from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import and_, case, func, select, update 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 Channel, Subscription, User, Video 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: sub_count = db.scalar( select(func.count()).select_from(Subscription).where(Subscription.user_id == user.id) ) return { "subscriptions": sub_count, "channels_total": db.scalar(select(func.count()).select_from(Channel)), "channels_backfilling": db.scalar( select(func.count()).select_from(Channel).where(Channel.backfill_done.is_(False)) ), "videos_total": db.scalar(select(func.count()).select_from(Video)), "pending_enrich": db.scalar( select(func.count()).select_from(Video).where(Video.enriched_at.is_(None)) ), "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.""" sub_join = and_(Subscription.channel_id == Channel.id, Subscription.user_id == user.id) total, details_synced, recent_synced, all_deep_done = db.execute( select( func.count(Channel.id), func.count(Channel.details_synced_at), func.count(Channel.recent_synced_at), func.sum(case((Channel.backfill_done.is_(True), 1), else_=0)), ) .select_from(Channel) .join(Subscription, sub_join) ).one() total = total or 0 all_deep_done = int(all_deep_done or 0) # 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 = ( db.execute( select(Channel).join( Subscription, and_(sub_join, Subscription.deep_requested.is_(True)), ) ) .scalars() .all() ) 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 = db.scalar( select(func.count(Video.id)).join( Subscription, and_( Subscription.channel_id == Video.channel_id, Subscription.user_id == user.id, Subscription.hidden.is_(False), ), ) ) # 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 = db.scalar(select(func.count(Video.id))) 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 or 0, "total_videos": total_videos or 0, "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.""" result = db.execute( update(Subscription).where(Subscription.user_id == user.id).values(deep_requested=on) ) db.commit() return {"ok": True, "updated": result.rowcount, "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}