From ce8ccfc3c0a0d9361fdde4c912fa39e01bdd73c8 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 4 Oct 2026 17:53:19 +0200 Subject: [PATCH] fix(quota): prune quota history past a retention window quota_events had no retention: one row per API spend plus a zero-unit row per live search, kept forever. The daily history cleanup (audit_gc) now also prunes it past quota_retention_days (a new Quota setting, default 365 so the admin usage view's longest window stays whole; 0 = forever). The floor of two days keeps today's rows, which the per-user search cap counts. --- backend/app/config.py | 3 ++ backend/app/quota.py | 20 ++++++++- backend/app/scheduler.py | 9 ++-- backend/app/sysconfig.py | 1 + backend/tests/test_quota_retention.py | 46 +++++++++++++++++++++ frontend/src/i18n/locales/en/config.json | 4 ++ frontend/src/i18n/locales/en/scheduler.json | 4 +- frontend/src/i18n/locales/hu/config.json | 4 ++ frontend/src/i18n/locales/hu/scheduler.json | 4 +- 9 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 backend/tests/test_quota_retention.py diff --git a/backend/app/config.py b/backend/app/config.py index 6869bf32..02ce10d1 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -260,6 +260,9 @@ class Settings(BaseSettings): # Admin audit log: how long to keep rows (days; 0 = keep forever) + how often the GC runs. audit_retention_days: int = 180 audit_gc_minutes: int = 1440 + # YouTube quota history (quota_events): days kept (0 = forever). A year, so the admin usage view's + # longest window (365 days) stays whole; the same daily cleanup job prunes it. + quota_retention_days: int = 365 # On-disk layout: "plex" (Channel/Season YYYY/… [id].ext + .nfo/poster) or "flat". download_layout: DownloadLayout = "plex" # Default SponsorBlock (skip sponsor segments) for new custom profiles. diff --git a/backend/app/quota.py b/backend/app/quota.py index c7d91b38..c0ef1db5 100644 --- a/backend/app/quota.py +++ b/backend/app/quota.py @@ -7,11 +7,13 @@ always be allowed; expensive backfill must yield to the remaining budget. import contextlib import contextvars -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone +from typing import Any, cast from zoneinfo import ZoneInfo -from sqlalchemy import func, select +from sqlalchemy import delete, func, select from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.engine import CursorResult from sqlalchemy.orm import Session from app import sysconfig @@ -96,6 +98,20 @@ def pacific_today(): return datetime.now(_PACIFIC).date() +def gc(db: Session, retention_days: int) -> int: + """Delete quota-history rows older than `retention_days` (<= 0 keeps them forever) and return how + many went. Flushes only; the cleanup job commits. Today's rows are never in reach: the per-user + search cap counts them, and the floor of two days outlasts any Pacific day (25 h at most).""" + if retention_days <= 0: + return 0 + cutoff = datetime.now(timezone.utc) - timedelta(days=max(2, retention_days)) + # A DELETE answers with a CursorResult; `execute` is typed as the general Result, without rowcount. + result = cast( + CursorResult[Any], db.execute(delete(QuotaEvent).where(QuotaEvent.created_at < cutoff)) + ) + return result.rowcount or 0 + + 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) diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index f54dad64..e49f5aa6 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -425,12 +425,15 @@ def _people_enrich_job() -> None: def _audit_gc_job() -> None: - # Prune audit-log rows older than the admin-set retention (audit_retention_days; 0 = keep - # forever). Pure DB, no quota. + # The history cleanup: audit-log rows past audit_retention_days and YouTube quota history past + # quota_retention_days (0 = keep forever, each). Pure DB, no quota. def work(db): from app import sysconfig - return {"reaped": audit.gc(db, sysconfig.get_int(db, "audit_retention_days"))} + reaped = audit.gc(db, sysconfig.get_int(db, "audit_retention_days")) + quota_reaped = quota.gc(db, sysconfig.get_int(db, "quota_retention_days")) + db.commit() + return {"reaped": reaped, "quota_reaped": quota_reaped} _job("audit_gc", work) diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py index cef6ee07..0822fad1 100644 --- a/backend/app/sysconfig.py +++ b/backend/app/sysconfig.py @@ -80,6 +80,7 @@ SPECS: tuple[ConfigSpec, ...] = ( ), # Live-search source: "scrape" (InnerTube, zero API quota) or "api" (search.list, 100u/page). ConfigSpec("search_source", "choice", "quota", "search_source"), + ConfigSpec("quota_retention_days", "int", "quota", "quota_retention_days", min=0, max=3650), # --- Backfill (recent-first first pass per channel) --- ConfigSpec( "backfill_recent_max_videos", diff --git a/backend/tests/test_quota_retention.py b/backend/tests/test_quota_retention.py new file mode 100644 index 00000000..e3ecb794 --- /dev/null +++ b/backend/tests/test_quota_retention.py @@ -0,0 +1,46 @@ +"""`quota_events` has a retention: the daily history-cleanup job (`audit_gc`) prunes rows older than +`quota_retention_days`, as it does the audit log. Without it the table only ever grew — one row per +API spend plus a zero-unit row per live search.""" + +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select + +from app import scheduler, sysconfig +from app.models import QuotaEvent + + +def _event(days_ago: int) -> QuotaEvent: + return QuotaEvent( + user_id=None, + action="videos.list", + units=1, + created_at=datetime.now(timezone.utc) - timedelta(days=days_ago), + ) + + +def _remaining(db) -> list[int]: + db.expire_all() + return sorted(e.units for e in db.execute(select(QuotaEvent)).scalars()) + + +def test_the_history_cleanup_prunes_quota_events_past_the_retention(db): + old, recent = _event(400), _event(10) + recent.units = 7 + db.add_all([old, recent]) + db.commit() + + scheduler.JOB_FUNCS["audit_gc"]() + + assert _remaining(db) == [7] # the default keeps a year, which the admin usage view can show + + +def test_a_zero_retention_keeps_quota_history_forever(db): + db.add(_event(4000)) + db.commit() + sysconfig.set_value(db, "quota_retention_days", 0) + db.commit() + + scheduler.JOB_FUNCS["audit_gc"]() + + assert _remaining(db) == [1] diff --git a/frontend/src/i18n/locales/en/config.json b/frontend/src/i18n/locales/en/config.json index 4ae176cf..eed76f55 100644 --- a/frontend/src/i18n/locales/en/config.json +++ b/frontend/src/i18n/locales/en/config.json @@ -62,6 +62,10 @@ "label": "Live search source", "hint": "Where live YouTube search results come from. “Website” reads YouTube's own search endpoint and spends no API quota (recommended). “Official API” uses search.list (100 units/page) — switch to it if the website source ever breaks or is blocked." }, + "quota_retention_days": { + "label": "Quota history kept (days)", + "hint": "How long the per-action YouTube quota history (who spent what) is kept before the history cleanup job prunes it. 0 = keep forever. Default 365, so the usage view's longest window stays whole." + }, "backfill_recent_max_videos": { "label": "Recent backfill — max videos", "hint": "Newest videos fetched per channel on the first pass." diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json index b9ce0990..78066090 100644 --- a/frontend/src/i18n/locales/en/scheduler.json +++ b/frontend/src/i18n/locales/en/scheduler.json @@ -76,7 +76,7 @@ "plex_sync": "Mirrors the enabled Plex libraries into the app's catalogue — the metadata behind Plex browsing, search, filters and the info pages (genres, cast, ratings, artwork). If it stops, newly added or changed Plex titles don't appear until the next sync.", "plex_watch_sync": "Pulls recent Plex watch changes (finished episodes, resume positions) into Siftlode and re-pushes any local watch changes that didn't reach Plex. The cheap two-way keep-in-sync between full reconciles. If it stops, watched/resume made in the Plex app takes longer to show here.", "plex_watch_reconcile": "A full watch-state reconcile that rescans every library — catches what the incremental pass can't, notably an episode un-marked as watched on the Plex side. Heavier, so it runs about once a day. If it stops, un-watches done in Plex aren't mirrored back.", - "audit_gc": "Prunes admin audit-log entries older than the configured retention window (Configuration → Audit log). If it stops, the audit log just keeps growing — harmless but unbounded.", + "audit_gc": "Prunes admin audit-log entries and YouTube quota history older than their retention windows (Configuration → Audit log, Quota). If it stops, both just keep growing — harmless but unbounded.", "log_gc": "Deletes stored log events older than the retention window (Configuration → Diagnostics). Runs hourly so nothing outlives the promised limit by more than an hour.", "tmdb_enrich": "Enriches the mirrored Plex catalogue from TMDB (keywords, crew, budget/revenue, TMDb score, recommendations, artwork), matched by each title's canonical id and served from the app's own database. Optional — a no-op until a TMDB API key is set. If it stops, new Plex titles just miss their TMDB extras until it runs again.", "people_enrich": "Makes the people in the Plex library local: fetches each person's biography from TMDB and caches their portrait, resized through Plex, the most-credited people first. A person page and a cast strip then open from the app's own data. The TMDB half does nothing without a TMDB API key, the portrait half nothing without Plex. If it stops, a person page fetches its own bio when opened, a moment later." @@ -97,7 +97,7 @@ "plex_watch_sync": "Plex watch sync (incremental)", "plex_watch_reconcile": "Plex watch reconcile (full)", "tmdb_enrich": "TMDB enrichment (Plex)", - "audit_gc": "Audit-log cleanup", + "audit_gc": "Audit-log and quota-history cleanup", "log_gc": "Log cleanup", "people_enrich": "People details and portraits (Plex)" }, diff --git a/frontend/src/i18n/locales/hu/config.json b/frontend/src/i18n/locales/hu/config.json index 01f20190..a3e3fc5c 100644 --- a/frontend/src/i18n/locales/hu/config.json +++ b/frontend/src/i18n/locales/hu/config.json @@ -62,6 +62,10 @@ "label": "Élő keresés forrása", "hint": "Honnan jönnek az élő YouTube-keresés találatai. A „Weboldal” a YouTube saját keresővégpontját olvassa, és nem fogyaszt API-kvótát (ajánlott). A „Hivatalos API” a search.list-et használja (100 egység/oldal) — válts rá, ha a weboldalas forrás elromlik vagy letiltják." }, + "quota_retention_days": { + "label": "Kvótaelőzmények megőrzése (nap)", + "hint": "Meddig marad meg a YouTube-kvóta tételes előzménye (ki mit fogyasztott), mielőtt az előzmény-takarító feladat törli. 0 = örökre. Alapérték 365, így a használati nézet leghosszabb ablaka is teljes marad." + }, "backfill_recent_max_videos": { "label": "Friss behúzás — max videó", "hint": "Csatornánként az első körben behúzott legújabb videók száma." diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index 8daa8884..9c3d2ba4 100644 --- a/frontend/src/i18n/locales/hu/scheduler.json +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -76,7 +76,7 @@ "plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infóoldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő szinkron után jelennek meg.", "plex_watch_sync": "Áthozza a Plex-oldali friss megtekintés-változásokat (befejezett epizódok, folytatás-pozíciók) a Siftlode-ba, és felnyomja a Plexet el nem érő helyi változásokat. Ez az olcsó kétirányú szinkron a teljes egyeztetések között. Ha leáll, a Plex-appban jelölt megtekintés/folytatás lassabban jelenik meg itt.", "plex_watch_reconcile": "A megtekintési állapot teljes egyeztetése, amely minden könyvtárat újraszken­nel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben levett megtekintve jel nem tükröződik vissza.", - "audit_gc": "Törli a beállított megőrzési időnél (Konfiguráció → Eseménynapló) régebbi admin-naplóbejegyzéseket. Ha leáll, az eseménynapló csak nő — ártalmatlan, de korlátlanul.", + "audit_gc": "Törli a megőrzési időnél régebbi admin-naplóbejegyzéseket és YouTube-kvótaelőzményeket (Konfiguráció → Eseménynapló, Kvóta). Ha leáll, mindkettő csak nő — ártalmatlan, de korlátlanul.", "log_gc": "Törli a megőrzési időnél (Konfiguráció → Diagnosztika) régebbi tárolt naplóeseményeket. Óránként fut, így semmi sem marad meg egy óránál tovább az ígért határidőn túl.", "tmdb_enrich": "A tükrözött Plex-katalógust dúsítja a TMDB-ből (kulcsszavak, stáb, költségvetés/bevétel, TMDb-pontszám, ajánlások, artwork), a címek kanonikus azonosítója alapján párosítva, és az app saját adatbázisából szolgálja ki. Opcionális — TMDB API-kulcs nélkül nem csinál semmit. Ha leáll, az új Plex-címek csak a következő futásig maradnak TMDB-extrák nélkül.", "people_enrich": "Helyivé teszi a Plex-könyvtár szereplőit: a TMDB-ből letölti az életrajzukat, a portréjukat pedig a Plexszel kicsinyítve elmenti, a legtöbbet szereplőkkel kezdve. A személyoldal és a szereplősáv így az app saját adataiból nyílik meg. A TMDB-rész TMDB API-kulcs nélkül nem csinál semmit, a portrérész Plex nélkül. Ha leáll, a személyoldal megnyitáskor maga kéri le az életrajzot, egy pillanattal később." @@ -97,7 +97,7 @@ "plex_watch_sync": "Plex megtekintés-szinkron (inkrementális)", "plex_watch_reconcile": "Plex megtekintés-egyeztetés (teljes)", "tmdb_enrich": "TMDB-dúsítás (Plex)", - "audit_gc": "Eseménynapló-takarítás", + "audit_gc": "Eseménynapló- és kvótaelőzmény-takarítás", "log_gc": "Naplótakarítás", "people_enrich": "Személyadatok és portrék (Plex)" },