from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import and_, case, func, select, update from sqlalchemy.orm import Session from app import quota, state from app.auth import current_user from app.db import get_db from app.models import Channel, Subscription, User, Video from app.sync.runner import ( estimate_deep_backfill, run_enrich, run_recent_backfill, run_rss_poll, ) from app.sync.subscriptions import import_subscriptions router = APIRouter(prefix="/api/sync", tags=["sync"]) def _user_channels(db: Session, user: User) -> list[Channel]: return ( db.execute( select(Channel) .join(Subscription, Subscription.channel_id == Channel.id) .where(Subscription.user_id == user.id) ) .scalars() .all() ) @router.post("/subscriptions") def sync_subscriptions( user: User = Depends(current_user), db: Session = Depends(get_db) ) -> dict: before = quota.units_used_today(db) result = import_subscriptions(db, user) result["quota_used_estimate"] = quota.units_used_today(db) - before result["quota_remaining_today"] = quota.remaining_today(db) return result @router.post("/rss") def sync_rss( user: User = Depends(current_user), db: Session = Depends(get_db) ) -> dict: new = run_rss_poll(db, _user_channels(db, user)) return {"new_videos": new} @router.post("/backfill") def sync_backfill( user: User = Depends(current_user), db: Session = Depends(get_db), max_channels: int = 25, ) -> dict: before = quota.units_used_today(db) result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels) result["quota_used_estimate"] = quota.units_used_today(db) - before return result @router.post("/enrich") def sync_enrich( user: User = Depends(current_user), db: Session = Depends(get_db) ) -> dict: before = quota.units_used_today(db) enriched = run_enrich(db) return { "enriched": enriched, "quota_used_estimate": quota.units_used_today(db) - before, } @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, 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 deep_done = int(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) 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), ), ) ) 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 - 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, "quota_used_today": quota.units_used_today(db), "quota_remaining_today": quota.remaining_today(db), "paused": state.is_sync_paused(db), } @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 {"updated": result.rowcount, "deep_requested": on} @router.post("/pause") def pause_sync( user: User = Depends(current_user), db: Session = Depends(get_db) ) -> dict: if user.role != "admin": raise HTTPException(status_code=403, detail="Admin only") state.set_sync_paused(db, True) return {"paused": True} @router.post("/resume") def resume_sync( user: User = Depends(current_user), db: Session = Depends(get_db) ) -> dict: if user.role != "admin": raise HTTPException(status_code=403, detail="Admin only") state.set_sync_paused(db, False) return {"paused": False}