Channels, search and sync do no database work in their routes any more: reads go through catalog/queries, writes through catalog/service, and a live search's ingest is catalog/search. What a catalog route needs from Reader (a playlist's channels, a user's tag links, the feed projection of results) it asks reader/queries and reader/service. The scheduler and the runs page read channel titles and the work queue through catalog/queries. Catalog is fenced below its HTTP layer: its owner filters go through userscope, it reads no other unit's tables, and its ingest jobs commit only where they are named.
124 lines
5.6 KiB
Python
124 lines
5.6 KiB
Python
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import audit, quota, state
|
|
from app.audit import AuditAction
|
|
from app.auth import admin_user, current_user, has_read_scope
|
|
from app.db import get_db
|
|
from app.models import User
|
|
from app.catalog import queries, service
|
|
from app.routes._common import youtube_refusal
|
|
from app.ratelimit import RateLimiter
|
|
from app.sync.runner import estimate_deep_backfill
|
|
from app.sync.subscriptions import import_subscriptions
|
|
from app.scheduler import running_job_ids
|
|
from app.youtube.client import YouTubeError
|
|
from app.refusal import refused
|
|
|
|
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
|
|
|
# SEC-14: the YouTube API budget is SHARED across the whole instance, and a subscription import
|
|
# walks every page of the caller's subscriptions — so one user holding down a manual sync button can
|
|
# spend the day's units for everyone. Keyed by user id, not client IP: the cost belongs to the
|
|
# account, and the same person on a second device is the same drain. 3 per 10 minutes leaves manual
|
|
# re-syncing entirely usable (the scheduler does the routine work anyway) while removing the loop.
|
|
_sync_limiter = RateLimiter(max_events=3, window_seconds=600)
|
|
|
|
|
|
@router.post("/subscriptions")
|
|
def sync_subscriptions(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
|
if not has_read_scope(user):
|
|
# No YouTube read grant yet — the onboarding wizard hasn't been completed.
|
|
raise refused(403, "youtube_read_scope")
|
|
if not _sync_limiter.allow(str(user.id)):
|
|
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
|
try:
|
|
with quota.measured(db, user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL) as m:
|
|
result = import_subscriptions(db, user)
|
|
except YouTubeError as exc:
|
|
# The onboarding wizard's first call: an unguarded failure here was a raw 500 and the
|
|
# blocking "Server error" dialog, with nothing the user could act on.
|
|
db.rollback()
|
|
raise youtube_refusal(exc) from exc
|
|
result["quota_used_estimate"] = m.used
|
|
result["quota_remaining_today"] = m.remaining
|
|
return {"ok": True, **result}
|
|
|
|
|
|
@router.get("/status")
|
|
def sync_status(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
|
return {
|
|
**queries.catalog_totals(db, user),
|
|
"quota_used_today": quota.units_used_today(db),
|
|
"quota_remaining_today": quota.remaining_today(db),
|
|
"paused": state.is_sync_paused(db),
|
|
"is_admin": user.role == "admin",
|
|
}
|
|
|
|
|
|
@router.get("/my-status")
|
|
def my_status(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
|
"""Per-user sync progress: how far the channels *this* user subscribes to have synced."""
|
|
total, details_synced, recent_synced, all_deep_done = queries.my_progress(db, user)
|
|
|
|
# Per-user full-history (deep) backfill: channels this user has opted in, and an ETA
|
|
# for the still-pending ones (quota-bound estimate).
|
|
deep_requested_channels = queries.my_deep_requested(db, user)
|
|
deep_eta = estimate_deep_backfill(db, deep_requested_channels)
|
|
# "done / requested" is shown as one ratio, so both halves count the SAME channels: the ones this
|
|
# user asked full history for. Counting every subscribed channel whose back-catalog happens to be
|
|
# complete (another user asked, or it was short) gave "342/329".
|
|
deep_done = sum(1 for c in deep_requested_channels if c.backfill_done)
|
|
|
|
my_videos = queries.my_video_count(db, user)
|
|
# Whole-catalog total (every video any user's subscriptions have pulled in), so the
|
|
# header can show "yours" against the shared library size.
|
|
total_videos = queries.video_total(db)
|
|
return {
|
|
"channels_total": total,
|
|
"channels_details_synced": details_synced,
|
|
"channels_recent_synced": recent_synced,
|
|
"channels_deep_done": deep_done,
|
|
"channels_recent_pending": total - recent_synced,
|
|
"channels_deep_pending": total - all_deep_done,
|
|
"channels_deep_requested": len(deep_requested_channels),
|
|
"deep_pending_count": deep_eta["channels_pending"],
|
|
"deep_eta_seconds": deep_eta["eta_seconds"],
|
|
"my_videos": my_videos,
|
|
"total_videos": total_videos,
|
|
"quota_used_today": quota.units_used_today(db),
|
|
"quota_remaining_today": quota.remaining_today(db),
|
|
"paused": state.is_sync_paused(db),
|
|
# True only while a job that advances this view (channel sync) is actually running,
|
|
# so the header shows live activity rather than a perpetual spinner over pending work.
|
|
"sync_active": bool({"backfill", "rss_poll"} & running_job_ids()),
|
|
}
|
|
|
|
|
|
@router.post("/deep-all")
|
|
def deep_all(
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
on: bool = True,
|
|
) -> dict:
|
|
"""Opt every one of the user's channels into (or out of) full-history backfill."""
|
|
updated = service.set_deep_all(db, user, on)
|
|
db.commit()
|
|
return {"ok": True, "updated": updated, "deep_requested": on}
|
|
|
|
|
|
@router.post("/pause")
|
|
def pause_sync(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
|
state.set_sync_paused(db, True)
|
|
audit.record(db, admin.id, AuditAction.SYNC_PAUSE, summary="Background sync paused")
|
|
db.commit()
|
|
return {"ok": True, "paused": True}
|
|
|
|
|
|
@router.post("/resume")
|
|
def resume_sync(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
|
state.set_sync_paused(db, False)
|
|
audit.record(db, admin.id, AuditAction.SYNC_RESUME, summary="Background sync resumed")
|
|
db.commit()
|
|
return {"ok": True, "paused": False}
|