From b23f805533743face85a90e67fc49b84b6d118d3 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 19 Jun 2026 11:48:11 +0200 Subject: [PATCH] refactor(quota): canonical _ taxonomy for quota events The quota-attribution action keys were inconsistent (mixed verbs, ad-hoc names, English-only labels). Introduce a QuotaAction constants holder (one source of truth), rename every attribution call site to it, and add migration 0020 to rename historical quota_events.action values. The display label now resolves from i18n (quotaActions., EN/HU/DE) instead of a hard-coded English map. Scheduler job ids and progress phase labels are a separate namespace and are left untouched. --- .../versions/0020_rename_quota_actions.py | 51 +++++++++++++++++++ backend/app/quota.py | 28 +++++++++- backend/app/routes/channels.py | 12 ++--- backend/app/routes/feed.py | 2 +- backend/app/routes/playlists.py | 10 ++-- backend/app/routes/sync.py | 6 +-- backend/app/scheduler.py | 10 ++-- backend/app/sync/playlists.py | 2 +- .../src/i18n/locales/de/quotaActions.json | 16 ++++++ .../src/i18n/locales/en/quotaActions.json | 16 ++++++ .../src/i18n/locales/hu/quotaActions.json | 16 ++++++ frontend/src/lib/format.ts | 15 ++---- 12 files changed, 151 insertions(+), 33 deletions(-) create mode 100644 backend/alembic/versions/0020_rename_quota_actions.py create mode 100644 frontend/src/i18n/locales/de/quotaActions.json create mode 100644 frontend/src/i18n/locales/en/quotaActions.json create mode 100644 frontend/src/i18n/locales/hu/quotaActions.json diff --git a/backend/alembic/versions/0020_rename_quota_actions.py b/backend/alembic/versions/0020_rename_quota_actions.py new file mode 100644 index 0000000..a427f6d --- /dev/null +++ b/backend/alembic/versions/0020_rename_quota_actions.py @@ -0,0 +1,51 @@ +"""rename quota_events.action to the canonical _ taxonomy + +Revision ID: 0020_rename_quota_actions +Revises: 0019_user_yt_channel_id +Create Date: 2026-06-19 + +The quota-attribution action keys were inconsistent (mixed verbs, ad-hoc names). Rename the +stored historical values to the canonical scheme defined in app.quota.QuotaAction: +``_``, snake_case, explicit pull/push. Pure data migration on quota_events; +fully reversible. (Scheduler job ids and progress phase labels are a separate namespace and +are NOT touched.) +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0020_rename_quota_actions" +down_revision: Union[str, None] = "0019_user_yt_channel_id" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# old action value -> new canonical key (mirrors app.quota.QuotaAction). +RENAMES: list[tuple[str, str]] = [ + ("sync_subscriptions", "subscriptions_pull"), + ("subscription_resync", "subscriptions_resync"), + ("playlist_sync", "playlists_pull"), + ("playlist_push", "playlists_push"), + ("playlist_delete", "playlists_delete"), + ("backfill_recent", "videos_backfill_recent"), + ("backfill_deep", "videos_backfill_full"), + ("enrich", "videos_enrich"), + ("video_lookup", "videos_lookup"), + ("discovery", "channels_discover"), + ("subscribe", "channels_subscribe"), + ("unsubscribe", "channels_unsubscribe"), + ("maintenance", "maintenance_revalidate"), + ("api", "other"), +] + +_SQL = sa.text("UPDATE quota_events SET action = :to WHERE action = :frm") + + +def upgrade() -> None: + for frm, to in RENAMES: + op.execute(_SQL.bindparams(frm=frm, to=to)) + + +def downgrade() -> None: + for frm, to in RENAMES: + op.execute(_SQL.bindparams(frm=to, to=frm)) diff --git a/backend/app/quota.py b/backend/app/quota.py index 3fe57cf..e2c7696 100644 --- a/backend/app/quota.py +++ b/backend/app/quota.py @@ -17,6 +17,32 @@ from app.models import ApiQuotaUsage, QuotaEvent _PACIFIC = ZoneInfo("America/Los_Angeles") + +class QuotaAction: + """Canonical quota-attribution action keys (one source of truth). + + Naming scheme: ``_``, all snake_case, explicit pull/push direction. + The stored value is this machine key; the user-facing label is resolved from i18n + (frontend ``quotaActions.``). 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" + CHANNELS_DISCOVER = "channels_discover" + CHANNELS_SUBSCRIBE = "channels_subscribe" + CHANNELS_UNSUBSCRIBE = "channels_unsubscribe" + MAINTENANCE_REVALIDATE = "maintenance_revalidate" + OTHER = "other" + + # 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. @@ -24,7 +50,7 @@ _actor_id: contextvars.ContextVar[int | None] = contextvars.ContextVar( "quota_actor_id", default=None ) _action: contextvars.ContextVar[str] = contextvars.ContextVar( - "quota_action", default="api" + "quota_action", default=QuotaAction.OTHER ) diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index d996047..3045e04 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -166,7 +166,7 @@ def discover_channels( # to yourself. Lazy + cached; needs OAuth, so demo / token-less users just skip it. if user.yt_channel_id is None and user.token is not None and not user.is_demo: try: - with quota.attribute(user.id, "discovery"), YouTubeClient(db, user) as yt: + with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), YouTubeClient(db, user) as yt: own = yt.get_my_channel_id() if own: user.yt_channel_id = own @@ -181,7 +181,7 @@ def discover_channels( need = [ch.id for ch, _, _ in rows if ch.details_synced_at is None][:DISCOVERY_ENRICH_CAP] if need: try: - with quota.attribute(user.id, "discovery"), YouTubeClient(db, user) as yt: + with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), YouTubeClient(db, user) as yt: apply_channel_details(db, yt.get_channels(need)) db.commit() rows = _discovery_rows(db, user) @@ -243,7 +243,7 @@ def update_channel( # fetched its recent uploads yet, do that one channel now (cheap) instead of waiting for # the scheduler. Deep paging still follows on the scheduler's next run (recent-then-deep). if deep_turned_on and channel is not None and channel.recent_synced_at is None: - with quota.attribute(user.id, "backfill_recent"): + with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT): run_recent_backfill(db, [channel], max_channels=1) return { @@ -274,7 +274,7 @@ def reset_backfill( channel.recent_synced_at = None sub.deep_requested = True db.commit() - with quota.attribute(user.id, "backfill_recent"): + with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT): run_recent_backfill(db, [channel], max_channels=1) return {"id": channel_id, "reset": True} @@ -306,7 +306,7 @@ def subscribe( if existing is not None: raise HTTPException(status_code=409, detail="Already subscribed to this channel.") try: - with quota.attribute(user.id, "subscribe"), YouTubeClient(db, user) as yt: + with quota.attribute(user.id, quota.QuotaAction.CHANNELS_SUBSCRIBE), YouTubeClient(db, user) as yt: yt_sub_id = yt.insert_subscription(channel_id) # Enrich a stub channel (title/thumbnail/subscriber count) so it shows up # properly right away; no video pull here. @@ -356,7 +356,7 @@ def unsubscribe( detail="No YouTube subscription id on record — sync subscriptions first.", ) try: - with quota.attribute(user.id, "unsubscribe"), YouTubeClient(db, user) as yt: + with quota.attribute(user.id, quota.QuotaAction.CHANNELS_UNSUBSCRIBE), YouTubeClient(db, user) as yt: yt.delete_subscription(sub.yt_subscription_id) except YouTubeError as exc: # 422 (a real, explainable error → speaking modal), not 502 which the client treats as diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index fb24597..caa9d87 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -549,7 +549,7 @@ def get_video_detail( } try: - with quota.attribute(user.id, "video_lookup"), YouTubeClient(db, user) as yt: + with quota.attribute(user.id, quota.QuotaAction.VIDEOS_LOOKUP), YouTubeClient(db, user) as yt: items = yt.get_videos([video_id]) except YouTubeError as exc: raise HTTPException(status_code=422, detail=f"YouTube lookup failed: {exc}") diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index 741b2ca..89228cb 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -245,7 +245,7 @@ def sync_youtube( status_code=403, detail="Connect YouTube (read access) to sync your playlists.", ) - with quota.attribute(user.id, "playlist_sync"): + with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL): return sync_user_playlists(db, user) @@ -266,7 +266,7 @@ def revert_youtube( status_code=403, detail="Connect YouTube (read access) to sync your playlists." ) try: - with quota.attribute(user.id, "playlist_sync"): + with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL): return repull_playlist(db, user, pl) except YouTubeError: db.rollback() @@ -298,7 +298,7 @@ def push_plan( status_code=403, detail="Enable YouTube editing in Settings first." ) try: - with quota.attribute(user.id, "playlist_push"): + with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH): plan = plan_push(db, user, pl) except YouTubeError: raise HTTPException(status_code=422, detail="Couldn't read the playlist on YouTube.") @@ -322,7 +322,7 @@ def push( status_code=403, detail="Enable YouTube editing in Settings first." ) try: - with quota.attribute(user.id, "playlist_push"): + with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH): plan = plan_push(db, user, pl) if plan["units_estimate"] > quota.remaining_today(db): raise HTTPException( @@ -418,7 +418,7 @@ def delete_playlist( status_code=403, detail="Enable YouTube editing in Settings first." ) try: - with quota.attribute(user.id, "playlist_delete"): + with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE): with YouTubeClient(db, user) as yt: yt.delete_playlist(pl.yt_playlist_id) except YouTubeError: diff --git a/backend/app/routes/sync.py b/backend/app/routes/sync.py index 02ffa91..e6fdcc2 100644 --- a/backend/app/routes/sync.py +++ b/backend/app/routes/sync.py @@ -41,7 +41,7 @@ def sync_subscriptions( detail="Connect your YouTube account (read access) to import subscriptions.", ) before = quota.units_used_today(db) - with quota.attribute(user.id, "sync_subscriptions"): + with quota.attribute(user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL): result = import_subscriptions(db, user) result["quota_used_estimate"] = quota.units_used_today(db) - before result["quota_remaining_today"] = quota.remaining_today(db) @@ -63,7 +63,7 @@ def sync_backfill( max_channels: int = 25, ) -> dict: before = quota.units_used_today(db) - with quota.attribute(user.id, "backfill_recent"): + with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT): 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 @@ -74,7 +74,7 @@ def sync_enrich( user: User = Depends(require_human), db: Session = Depends(get_db) ) -> dict: before = quota.units_used_today(db) - with quota.attribute(user.id, "enrich"): + with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH): enriched = run_enrich(db) return { "enriched": enriched, diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index abcb70f..8c08c40 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -204,7 +204,7 @@ def _rss_job() -> None: def _enrich_job() -> None: def work(db): - with quota.attribute(None, "enrich"): + with quota.attribute(None, quota.QuotaAction.VIDEOS_ENRICH): return run_enrich(db) _job("enrich", work) @@ -214,9 +214,9 @@ def _backfill_job() -> None: # Recent-first for not-yet-synced channels, then deep backfill for the rest. All # background spend is attributed to the system (no actor), split by action. def work(db): - with quota.attribute(None, "backfill_recent"): + with quota.attribute(None, quota.QuotaAction.VIDEOS_BACKFILL_RECENT): recent = run_recent_backfill(db, max_channels=25) - with quota.attribute(None, "backfill_deep"): + with quota.attribute(None, quota.QuotaAction.VIDEOS_BACKFILL_FULL): deep = run_deep_backfill(db, max_channels=10) return {"recent": recent, "deep": deep} @@ -233,7 +233,7 @@ def _shorts_job() -> None: def _subscriptions_job() -> None: def work(db): - with quota.attribute(None, "subscription_resync"): + with quota.attribute(None, quota.QuotaAction.SUBSCRIPTIONS_RESYNC): return run_subscription_resync(db) _job("subscriptions", work) @@ -247,7 +247,7 @@ def _playlist_sync_job() -> None: def _maintenance_job() -> None: def work(db): - with quota.attribute(None, "maintenance"): + with quota.attribute(None, quota.QuotaAction.MAINTENANCE_REVALIDATE): return run_maintenance(db) _job("maintenance", work) diff --git a/backend/app/sync/playlists.py b/backend/app/sync/playlists.py index f21ef4f..a5fe3a7 100644 --- a/backend/app/sync/playlists.py +++ b/backend/app/sync/playlists.py @@ -387,7 +387,7 @@ def sync_all_playlists(db: Session) -> dict: progress.report(i, len(users), "playlist_sync") continue try: - with quota.attribute(user.id, "playlist_sync"): + with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL): total += sync_user_playlists(db, user).get("synced", 0) except YouTubeError: db.rollback() diff --git a/frontend/src/i18n/locales/de/quotaActions.json b/frontend/src/i18n/locales/de/quotaActions.json new file mode 100644 index 0000000..e1c128e --- /dev/null +++ b/frontend/src/i18n/locales/de/quotaActions.json @@ -0,0 +1,16 @@ +{ + "subscriptions_pull": "Abonnements einlesen", + "subscriptions_resync": "Automatische Abo-Resynchronisierung", + "playlists_pull": "Playlists einlesen", + "playlists_push": "Playlists hochladen", + "playlists_delete": "Playlist löschen", + "videos_backfill_recent": "Aktuelle Uploads laden", + "videos_backfill_full": "Vollständigen Verlauf laden", + "videos_enrich": "Video-Anreicherung", + "videos_lookup": "Video-Abfrage", + "channels_discover": "Kanal-Entdeckung", + "channels_subscribe": "Abonnieren", + "channels_unsubscribe": "Abo beenden", + "maintenance_revalidate": "Wartungs-Neuvalidierung", + "other": "Sonstiges" +} diff --git a/frontend/src/i18n/locales/en/quotaActions.json b/frontend/src/i18n/locales/en/quotaActions.json new file mode 100644 index 0000000..4805e4e --- /dev/null +++ b/frontend/src/i18n/locales/en/quotaActions.json @@ -0,0 +1,16 @@ +{ + "subscriptions_pull": "Read subscriptions", + "subscriptions_resync": "Auto subscription resync", + "playlists_pull": "Read playlists", + "playlists_push": "Push playlists", + "playlists_delete": "Delete playlist", + "videos_backfill_recent": "Recent backfill", + "videos_backfill_full": "Full-history backfill", + "videos_enrich": "Video enrichment", + "videos_lookup": "Video lookup", + "channels_discover": "Channel discovery", + "channels_subscribe": "Subscribe", + "channels_unsubscribe": "Unsubscribe", + "maintenance_revalidate": "Maintenance re-validation", + "other": "Other" +} diff --git a/frontend/src/i18n/locales/hu/quotaActions.json b/frontend/src/i18n/locales/hu/quotaActions.json new file mode 100644 index 0000000..6be1891 --- /dev/null +++ b/frontend/src/i18n/locales/hu/quotaActions.json @@ -0,0 +1,16 @@ +{ + "subscriptions_pull": "Feliratkozások beolvasása", + "subscriptions_resync": "Automatikus feliratkozás-újraszinkron", + "playlists_pull": "Lejátszási listák beolvasása", + "playlists_push": "Lejátszási listák feltöltése", + "playlists_delete": "Lejátszási lista törlése", + "videos_backfill_recent": "Friss feltöltések betöltése", + "videos_backfill_full": "Teljes előzmény betöltése", + "videos_enrich": "Videó-gazdagítás", + "videos_lookup": "Videó-lekérdezés", + "channels_discover": "Csatorna-felfedezés", + "channels_subscribe": "Feliratkozás", + "channels_unsubscribe": "Leiratkozás", + "maintenance_revalidate": "Karbantartó újraellenőrzés", + "other": "Egyéb" +} diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 5aa8ac2..d7ebe17 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -44,18 +44,11 @@ export function formatDuration(sec: number | null): string { return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; } -const QUOTA_ACTION_LABELS: Record = { - sync_subscriptions: "Sync subscriptions", - backfill_recent: "Recent backfill", - backfill_deep: "Full-history backfill", - enrich: "Enrichment", - unsubscribe: "Unsubscribe", - subscription_resync: "Auto subscription resync", - api: "Other", -}; - +// Human label for a canonical quota-action key (see backend app.quota.QuotaAction). Resolved +// from i18n (quotaActions.) so it's translated in all UI languages; falls back to the raw +// key for any unmapped/legacy value. export function quotaActionLabel(action: string): string { - return QUOTA_ACTION_LABELS[action] ?? action; + return i18n.t(`quotaActions.${action}`, { defaultValue: action }); } /** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). */