None are referenced by the SPA or tests; the Scheduler dashboard's "run now"
covers every manual trigger. Removed: POST /api/sync/{rss,backfill,enrich}
(+ the now-dead _user_channels helper and three sync.runner imports),
POST /api/tags/recompute (+ its admin_user / run_autotag_all imports), and
GET /auth/me (a lesser duplicate of the canonical /api/me in routes/me.py).
This also retires a latent bug: POST /api/sync/backfill reset backfill_done /
backfill_cursor on already-fully-backfilled channels (tipping them back to
"needs full history") for up to 25 channels per curl. Full lane 365 pass.
159 lines
5.8 KiB
Python
159 lines
5.8 KiB
Python
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, require_human
|
|
from app.db import get_db
|
|
from app.models import Channel, Subscription, User, Video
|
|
from app.sync.runner import estimate_deep_backfill
|
|
from app.sync.subscriptions import import_subscriptions
|
|
from app.scheduler import running_job_ids
|
|
|
|
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
|
|
|
|
|
@router.post("/subscriptions")
|
|
def sync_subscriptions(
|
|
user: User = Depends(require_human), 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 HTTPException(
|
|
status_code=403,
|
|
detail="Connect your YouTube account (read access) to import subscriptions.",
|
|
)
|
|
with quota.measured(db, user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL) as m:
|
|
result = import_subscriptions(db, user)
|
|
result["quota_used_estimate"] = m.used
|
|
result["quota_remaining_today"] = m.remaining
|
|
return 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, 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),
|
|
),
|
|
)
|
|
)
|
|
# 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 - 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(require_human),
|
|
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(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 {"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 {"paused": False}
|