2026-06-11 01:22:07 +02:00
|
|
|
"""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.
|
|
|
|
|
"""
|
2026-06-12 02:47:55 +02:00
|
|
|
import contextlib
|
|
|
|
|
import contextvars
|
|
|
|
|
from datetime import datetime, timezone
|
2026-06-11 01:22:07 +02:00
|
|
|
from zoneinfo import ZoneInfo
|
|
|
|
|
|
|
|
|
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
feat(config): move operational params to DB (quota/backfill/shorts/batch)
Register 8 more keys in the sysconfig registry (quota daily budget + backfill
reserve, recent-backfill window, shorts-probe params, enrich/autotag batch sizes)
and route their reads through the DB-override-or-env resolver at every call site
(quota.py, sync/runner.py, sync/videos.py, sync/autotag.py, sync/maintenance.py,
routes/scheduler.py). All read sites already had a db session in scope. Reads are
read-through (no cache), matching app.state; admin can tune them live, no redeploy.
2026-06-19 13:07:45 +02:00
|
|
|
from app import sysconfig
|
2026-06-12 02:47:55 +02:00
|
|
|
from app.models import ApiQuotaUsage, QuotaEvent
|
2026-06-11 01:22:07 +02:00
|
|
|
|
|
|
|
|
_PACIFIC = ZoneInfo("America/Los_Angeles")
|
|
|
|
|
|
2026-06-19 11:48:11 +02:00
|
|
|
|
|
|
|
|
class QuotaAction:
|
|
|
|
|
"""Canonical quota-attribution action keys (one source of truth).
|
|
|
|
|
|
|
|
|
|
Naming scheme: ``<entity>_<action>``, all snake_case, explicit pull/push direction.
|
|
|
|
|
The stored value is this machine key; the user-facing label is resolved from i18n
|
|
|
|
|
(frontend ``quotaActions.<key>``). Distinct from scheduler job ids and progress phase
|
|
|
|
|
labels — do not conflate.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
SUBSCRIPTIONS_PULL = "subscriptions_pull"
|
|
|
|
|
SUBSCRIPTIONS_RESYNC = "subscriptions_resync"
|
|
|
|
|
PLAYLISTS_PULL = "playlists_pull"
|
|
|
|
|
PLAYLISTS_PUSH = "playlists_push"
|
|
|
|
|
PLAYLISTS_DELETE = "playlists_delete"
|
|
|
|
|
VIDEOS_BACKFILL_RECENT = "videos_backfill_recent"
|
|
|
|
|
VIDEOS_BACKFILL_FULL = "videos_backfill_full"
|
|
|
|
|
VIDEOS_ENRICH = "videos_enrich"
|
|
|
|
|
VIDEOS_LOOKUP = "videos_lookup"
|
2026-06-29 02:01:31 +02:00
|
|
|
VIDEOS_SEARCH = "videos_search"
|
2026-06-19 11:48:11 +02:00
|
|
|
CHANNELS_DISCOVER = "channels_discover"
|
feat(channels): channel-explore backend — about metadata, ephemeral provenance, cleanup
Adds the backend for a dedicated channel page + ephemeral browsing of un-subscribed
channels:
- migration 0033: channels.{total_view_count,published_at,banner_url,external_links,
from_explore}, videos.via_explore, explored_channels (per-user, grace-clocked).
- enrich channels with part=brandingSettings (banner/links) + statistics.viewCount +
snippet.publishedAt — no extra quota.
- GET /api/channels/{id}: About detail + this user's relationship; lazy-enriches the new
About fields (published_at sentinel).
- POST /api/channels/{id}/explore (require_human, quota-reserve guarded): records the
exploration, flags the channel ephemeral unless followed, ingests one page of uploads
(via_explore) + enriches; returns next_page_token for load-more.
- feed: per-explorer gate — a via_explore video is visible only to users who explored or
subscribe to its channel, so exploration never leaks into everyone's catalog.
- subscribe = keep: clears from_explore + via_explore on the channel's videos.
- scheduler explore_cleanup job + explore_grace_days config: hard-delete explored-but-
unkept channels and their untouched ephemeral videos after a grace period.
2026-06-30 02:52:44 +02:00
|
|
|
CHANNELS_EXPLORE = "channels_explore"
|
2026-06-19 11:48:11 +02:00
|
|
|
CHANNELS_SUBSCRIBE = "channels_subscribe"
|
|
|
|
|
CHANNELS_UNSUBSCRIBE = "channels_unsubscribe"
|
|
|
|
|
MAINTENANCE_REVALIDATE = "maintenance_revalidate"
|
|
|
|
|
OTHER = "other"
|
|
|
|
|
|
|
|
|
|
|
2026-06-12 02:47:55 +02:00
|
|
|
# Request/job-scoped attribution for quota spend: who triggered it and what kind of work.
|
|
|
|
|
# Set at entry points (route handlers, scheduler jobs) via attribute(); read by
|
|
|
|
|
# record_usage. Default = background/system, generic action.
|
|
|
|
|
_actor_id: contextvars.ContextVar[int | None] = contextvars.ContextVar(
|
|
|
|
|
"quota_actor_id", default=None
|
|
|
|
|
)
|
|
|
|
|
_action: contextvars.ContextVar[str] = contextvars.ContextVar(
|
2026-06-19 11:48:11 +02:00
|
|
|
"quota_action", default=QuotaAction.OTHER
|
2026-06-12 02:47:55 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@contextlib.contextmanager
|
|
|
|
|
def attribute(actor_id: int | None, action: str):
|
|
|
|
|
"""Attribute any quota spent in this block to `actor_id` (None = system) under `action`."""
|
|
|
|
|
t1 = _actor_id.set(actor_id)
|
|
|
|
|
t2 = _action.set(action)
|
|
|
|
|
try:
|
|
|
|
|
yield
|
|
|
|
|
finally:
|
|
|
|
|
_actor_id.reset(t1)
|
|
|
|
|
_action.reset(t2)
|
|
|
|
|
|
2026-06-11 01:22:07 +02:00
|
|
|
|
2026-06-26 03:15:53 +02:00
|
|
|
class Measure:
|
|
|
|
|
"""Result handle from `measured()` — read .used / .remaining after the block exits."""
|
|
|
|
|
|
|
|
|
|
used = 0
|
|
|
|
|
remaining = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@contextlib.contextmanager
|
|
|
|
|
def measured(db: Session, actor_id: int | None, action: str):
|
|
|
|
|
"""Like `attribute()`, but also reports how much quota the block spent. Yields a `Measure`
|
|
|
|
|
whose `.used` (units charged inside the block) and `.remaining` (today's remaining after)
|
|
|
|
|
are populated on exit — so manual sync routes don't each re-spell the before/after diff."""
|
|
|
|
|
m = Measure()
|
|
|
|
|
before = units_used_today(db)
|
|
|
|
|
with attribute(actor_id, action):
|
|
|
|
|
yield m
|
|
|
|
|
m.used = units_used_today(db) - before
|
|
|
|
|
m.remaining = remaining_today(db)
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 01:22:07 +02:00
|
|
|
def pacific_today():
|
|
|
|
|
return datetime.now(_PACIFIC).date()
|
|
|
|
|
|
|
|
|
|
|
2026-06-12 02:47:55 +02:00
|
|
|
def pacific_day_start_utc() -> datetime:
|
|
|
|
|
"""UTC instant of the current Pacific day's midnight — aligns 'today' with the budget reset."""
|
|
|
|
|
start = datetime.now(_PACIFIC).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
|
|
|
return start.astimezone(timezone.utc)
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 01:22:07 +02:00
|
|
|
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:
|
feat(config): move operational params to DB (quota/backfill/shorts/batch)
Register 8 more keys in the sysconfig registry (quota daily budget + backfill
reserve, recent-backfill window, shorts-probe params, enrich/autotag batch sizes)
and route their reads through the DB-override-or-env resolver at every call site
(quota.py, sync/runner.py, sync/videos.py, sync/autotag.py, sync/maintenance.py,
routes/scheduler.py). All read sites already had a db session in scope. Reads are
read-through (no cache), matching app.state; admin can tune them live, no redeploy.
2026-06-19 13:07:45 +02:00
|
|
|
return max(0, sysconfig.get_int(db, "quota_daily_budget") - units_used_today(db))
|
2026-06-11 01:22:07 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def can_spend(db: Session, units: int) -> bool:
|
|
|
|
|
return remaining_today(db) >= units
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 22:29:54 +02:00
|
|
|
def log_action(db: Session, user_id: int, action: str) -> None:
|
|
|
|
|
"""Record a zero-cost action event so per-user daily caps still count it.
|
|
|
|
|
|
|
|
|
|
`record_usage` only logs when units are actually charged; a scrape-based search spends no
|
|
|
|
|
quota yet must still be rate-limited per user, so it logs its event here directly."""
|
|
|
|
|
db.add(QuotaEvent(user_id=user_id, action=action, units=0))
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 02:01:31 +02:00
|
|
|
def actions_today(db: Session, user_id: int, action: str) -> int:
|
|
|
|
|
"""How many quota events of `action` this user has logged so far in the current Pacific
|
|
|
|
|
day — for per-user, per-action daily caps (e.g. the live-search limit). Counts events,
|
|
|
|
|
not units, so it only works for actions charged exactly once per user action."""
|
|
|
|
|
from sqlalchemy import func, select # local import: keep the module's import head lean
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
db.scalar(
|
|
|
|
|
select(func.count())
|
|
|
|
|
.select_from(QuotaEvent)
|
|
|
|
|
.where(
|
|
|
|
|
QuotaEvent.user_id == user_id,
|
|
|
|
|
QuotaEvent.action == action,
|
|
|
|
|
QuotaEvent.created_at >= pacific_day_start_utc(),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
or 0
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 01:22:07 +02:00
|
|
|
def record_usage(db: Session, units: int) -> None:
|
2026-06-12 02:47:55 +02:00
|
|
|
"""Atomically add `units` to today's counter (upsert) and log an attribution event."""
|
2026-06-11 01:22:07 +02:00
|
|
|
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)
|
2026-06-12 02:47:55 +02:00
|
|
|
# Audit detail: who/what spent it (per-user attribution; NULL actor = system).
|
|
|
|
|
db.add(QuotaEvent(user_id=_actor_id.get(), action=_action.get(), units=units))
|
2026-06-11 01:22:07 +02:00
|
|
|
db.commit()
|