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}