Deep review of the debt burn: - `refusal_reason` told the user to reconnect on ANY token-endpoint failure — a 5xx, a 429, a misconfigured client — while the grant stayed and the account still read as connected, with no connect button. Only `invalid_grant` (the one that drops the grant) means reconnect now. - `pins.reorder` writes only the rows that change, so two overlapping reorders each skipped rows that matched in their own stale snapshot and interleaved into two pins on one slot. It locks the user's pins first; the second waits and deals from the first's result. - Adding a whitelist entry committed the invite and the activation it implies separately, and its audit row was written before a new invite had an id (target NULL). One transaction, flushed for the id. - The mutation convention test skipped async routes and dicts returned by name: request-access, purge-discovery and the subscription sync answered without `ok`. They do now, and the test covers both. The helper-commit check looked only at `_`-names; it reads every non-route function, which listed two deliberate committers (upsert_pending_invite, ensure_welcome) — now allowlisted with their reasons.
163 lines
6.8 KiB
Python
163 lines
6.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.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(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 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 (E9 F1).
|
|
db.rollback()
|
|
raise youtube_refusal(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, 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 {"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}
|