feat: M2 (part 1) — subscription import, YouTube client, quota guard

- Channel/Subscription/Video/ApiQuotaUsage models + migration 0002
- Synchronous YouTube Data API client with OAuth token refresh and per-call
  quota accounting (API key preferred for public reads when configured)
- Subscription import: upsert channels + subscription links, prune unsubscribed,
  fetch channel details (uploads playlist, topics, stats, handle, country)
- Central quota guard tracking units per US-Pacific day against a daily budget
- POST /api/sync/subscriptions and GET /api/sync/status endpoints
This commit is contained in:
npeter83 2026-06-11 01:22:07 +02:00
parent 3a774cf7d6
commit 24b6e0026d
10 changed files with 603 additions and 3 deletions

50
backend/app/quota.py Normal file
View file

@ -0,0 +1,50 @@
"""Central YouTube Data API quota guard.
The whole app shares one daily budget (the API resets at midnight US Pacific time).
Steady-state work (RSS detection is free; enrichment of new videos is cheap) should
always be allowed; expensive backfill must yield to the remaining budget.
"""
from datetime import datetime
from zoneinfo import ZoneInfo
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from app.config import settings
from app.models import ApiQuotaUsage
_PACIFIC = ZoneInfo("America/Los_Angeles")
def pacific_today():
return datetime.now(_PACIFIC).date()
def units_used_today(db: Session) -> int:
row = db.get(ApiQuotaUsage, pacific_today())
return row.units_used if row else 0
def remaining_today(db: Session) -> int:
return max(0, settings.quota_daily_budget - units_used_today(db))
def can_spend(db: Session, units: int) -> bool:
return remaining_today(db) >= units
def record_usage(db: Session, units: int) -> None:
"""Atomically add `units` to today's counter (upsert)."""
if units <= 0:
return
day = pacific_today()
stmt = (
pg_insert(ApiQuotaUsage)
.values(day=day, units_used=units)
.on_conflict_do_update(
index_elements=[ApiQuotaUsage.day],
set_={"units_used": ApiQuotaUsage.units_used + units},
)
)
db.execute(stmt)
db.commit()