diff --git a/CHANGELOG.md b/CHANGELOG.md index bd49e11e..4901bb32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ edit the release notes and re-run the generator. Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). +## 0.99.2 — 2026-10-04 + +### Fixed + +- Settings: the download worker's concurrency and the log level now take effect within seconds — before, the worker ignored both until it was rebuilt. +- The YouTube quota history no longer grows forever: the daily cleanup prunes it after a year (Configuration → Quota → Quota history kept). +- The live-search button no longer says a search spends the API quota: with the default website source the search itself is free. It names the cost when the official API is selected. + ## 0.99.1 — 2026-10-04 ### Fixed diff --git a/VERSION b/VERSION index 626c498f..3b66e07e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.99.1 +0.99.2 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/routes/me.py b/backend/app/routes/me.py index 935c3331..52cd8f3f 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -148,6 +148,9 @@ def get_me( "google_enabled": google_enabled(db), # Whether the optional Plex module is enabled (UI shows the Plex feed source when so). "plex_enabled": sysconfig.get_bool(db, "plex_enabled"), + # Which live-search source runs, so the search button can say what a search costs: "scrape" + # spends no API quota, "api" spends 100 units a page. + "search_source": sysconfig.get_str(db, "search_source"), "can_read": has_read_scope(user), "can_write": has_write_scope(user), "pending_invites": pending_invites, 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/app/worker.py b/backend/app/worker.py index e2006bc9..4536cb04 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -5,7 +5,8 @@ from the API so long yt-dlp downloads + ffmpeg postprocessing never block web re Design: * N loop threads (download_worker_concurrency) each claim a `queued` DownloadJob with - FOR UPDATE SKIP LOCKED and process it. + FOR UPDATE SKIP LOCKED and process it. N and the log level follow the settings page while the + worker runs (`_sync_knobs`), not only the env it booted with. * The shared MediaAsset is the dedup unit. A job whose asset is already `ready` finishes instantly (no re-download); a fresh asset is claimed pending→downloading by exactly one worker (a DB compare-and-set), so two jobs for the same video+format never double-download. @@ -48,12 +49,60 @@ from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset log = logs.get_logger(__name__) _stop = threading.Event() +# How many loop threads may claim jobs. A thread whose index is at or above it stops after its +# current job, so lowering the knob never abandons a download halfway. +_target = 1 +# How often the worker re-reads the knobs it obeys; the settings page says nothing about a restart. +_KNOB_SECONDS = 10.0 def _handle_signal(signum, frame): _stop.set() +def _read_knobs() -> tuple[int, str] | None: + """The stored concurrency and log level, or None when the database cannot answer — the caller + then keeps what already runs.""" + try: + with SessionLocal() as db: + return ( + max(1, sysconfig.get_int(db, "download_worker_concurrency")), + sysconfig.get_str(db, "log_level"), + ) + except Exception: # noqa: BLE001 — any DB or config failure keeps the knobs already in force + log.warning( + "could not read the worker's settings; keeping the ones in force", + exc_info=True, + extra=logs.event("download.worker.knobs_unreadable"), + ) + return None + + +def _sync_knobs(threads: dict[int, threading.Thread], level: str | None) -> str | None: + """Apply the stored knobs: run loop threads up to the concurrency (surplus ones retire after + their job, see `_target`) and re-apply the log level when it changed. Returns the level in force.""" + global _target + knobs = _read_knobs() + if knobs is not None: + target, stored_level = knobs + if target != _target: + log.info( + "download concurrency is now %d", + target, + extra=logs.event("download.worker.concurrency", before=_target, after=target), + ) + _target = target + if stored_level != level: + logs.set_level(stored_level) + level = stored_level + for i in range(_target): + t = threads.get(i) + if t is None or not t.is_alive(): + threads[i] = threading.Thread(target=_worker_loop, args=(i,), daemon=True) + threads[i].start() + return level + + # --- claim + recovery ----------------------------------------------------------------------- @@ -576,7 +625,7 @@ def _finish_from_cache(job_id: int, asset_id: int) -> None: def _worker_loop(idx: int) -> None: - while not _stop.is_set(): + while not _stop.is_set() and idx < _target: try: job_id = _claim_job() except Exception: @@ -634,15 +683,15 @@ def main() -> None: # has migrated — those few seconds of batches fail and say so on stderr; nothing else breaks. logsink.start() _recover_orphans() - n = max(1, settings.download_worker_concurrency) - log.info("Download worker started (concurrency=%d, root=%s).", n, settings.download_root) - threads = [threading.Thread(target=_worker_loop, args=(i,), daemon=True) for i in range(n)] - for t in threads: - t.start() - while not _stop.is_set(): - _stop.wait(1.0) + global _target + _target = max(1, settings.download_worker_concurrency) # used only if the DB can't answer + threads: dict[int, threading.Thread] = {} + level = _sync_knobs(threads, None) + log.info("Download worker started (concurrency=%d, root=%s).", _target, settings.download_root) + while not _stop.wait(_KNOB_SECONDS): + level = _sync_knobs(threads, level) log.info("Download worker stopping…") - for t in threads: + for t in threads.values(): t.join(timeout=5) diff --git a/backend/tests/test_me_search_source.py b/backend/tests/test_me_search_source.py new file mode 100644 index 00000000..7f5bb2f3 --- /dev/null +++ b/backend/tests/test_me_search_source.py @@ -0,0 +1,26 @@ +"""`/api/me` names the live-search source, so the search button's tooltip can say what a search costs +under the source that actually runs (the default website source spends no API quota).""" + +from app import sysconfig + +_PW = "correct horse battery" + + +def _me(client): + r = client.get("/api/me") + assert r.status_code == 200 and r.json()["authenticated"] is True + return r.json() + + +def test_me_names_the_running_search_source(client, db, make_user): + # /api/me resolves its user through optional_current_user, which the as_user override does not + # cover — so sign in for real (see test_me_email_flag). + user = make_user(password=_PW) + login = client.post("/auth/password-login", json={"email": user.email, "password": _PW}) + assert login.status_code == 200 + + assert _me(client)["search_source"] == "scrape" # the default + + sysconfig.set_value(db, "search_source", "api") + db.commit() + assert _me(client)["search_source"] == "api" 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/backend/tests/test_worker_knobs.py b/backend/tests/test_worker_knobs.py new file mode 100644 index 00000000..b423510d --- /dev/null +++ b/backend/tests/test_worker_knobs.py @@ -0,0 +1,109 @@ +"""The worker obeys the two admin knobs that concern it — `download_worker_concurrency` and +`log_level` — as stored in the settings page, not only the env it booted with. It used to read +`settings.download_worker_concurrency` and `settings.log_level` once at start, so the admin page +showed values the worker never applied. No DB or real threads of work here: the stored values come +from a patched `sysconfig`, and the loop threads are stand-ins that only record their index.""" + +import threading + +import pytest + +from app import worker + + +class _NoSession: + def __enter__(self): + return None + + def __exit__(self, *exc): + return False + + +@pytest.fixture +def stored(monkeypatch): + """The settings page's values, served to the worker instead of a database.""" + values = {"download_worker_concurrency": 3, "log_level": "DEBUG"} + monkeypatch.setattr(worker, "SessionLocal", _NoSession) + monkeypatch.setattr(worker.sysconfig, "get_int", lambda _db, key: int(values[key])) + monkeypatch.setattr(worker.sysconfig, "get_str", lambda _db, key: str(values[key])) + return values + + +@pytest.fixture +def levels(monkeypatch): + applied: list[str] = [] + monkeypatch.setattr(worker.logs, "set_level", applied.append) + return applied + + +@pytest.fixture +def loops(monkeypatch): + """Stand-in loop threads: each records its index and parks until the worker stops.""" + started: list[int] = [] + lock = threading.Lock() + + def fake_loop(idx: int) -> None: + with lock: + started.append(idx) + worker._stop.wait() + + monkeypatch.setattr(worker, "_worker_loop", fake_loop) + yield started + worker._stop.set() + + +@pytest.fixture(autouse=True) +def _clean_stop(): + worker._stop.clear() + yield + worker._stop.clear() + + +def test_worker_boots_with_the_stored_concurrency_and_log_level(monkeypatch, stored, levels, loops): + monkeypatch.setattr(worker.settings, "download_worker_concurrency", 2) + monkeypatch.setattr(worker.settings, "worker_enabled", True) + monkeypatch.setattr(worker.logs, "configure_logging", lambda _source: None) + monkeypatch.setattr(worker.signal, "signal", lambda *_a: None) + monkeypatch.setattr(worker, "_wait_for_schema", lambda: True) + monkeypatch.setattr(worker.logsink, "start", lambda: None) + monkeypatch.setattr(worker, "_recover_orphans", lambda: None) + + threading.Timer(0.5, worker._stop.set).start() + worker.main() + + assert sorted(loops) == [0, 1, 2] # the stored 3, not the env's 2 + assert levels and levels[-1] == "DEBUG" + + +def test_a_changed_knob_applies_without_a_restart(stored, levels, loops): + threads: dict[int, threading.Thread] = {} + level = worker._sync_knobs(threads, None) + assert level == "DEBUG" + assert worker._target == 3 + + stored["download_worker_concurrency"] = 5 + stored["log_level"] = "WARNING" + level = worker._sync_knobs(threads, level) + + assert worker._target == 5 + assert sorted(threads) == [0, 1, 2, 3, 4] + assert level == "WARNING" and levels[-1] == "WARNING" + + # Lowering it retires the surplus threads after their current job: they see the lower target. + stored["download_worker_concurrency"] = 1 + worker._sync_knobs(threads, level) + assert worker._target == 1 + + +def test_an_unreadable_database_keeps_what_runs(monkeypatch, levels, loops): + def boom(): + raise RuntimeError("db down") + + monkeypatch.setattr(worker, "SessionLocal", boom) + monkeypatch.setattr(worker, "_target", 2) + threads: dict[int, threading.Thread] = {} + + assert worker._sync_knobs(threads, "INFO") == "INFO" + assert worker._target == 2 + assert sorted(threads) == [0, 1] # the loops still run on the boot value + assert levels == [] diff --git a/frontend/src/components/Header.searchTip.dom.test.tsx b/frontend/src/components/Header.searchTip.dom.test.tsx new file mode 100644 index 00000000..8b120412 --- /dev/null +++ b/frontend/src/components/Header.searchTip.dom.test.tsx @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +// +// The live-search button's tooltip says what a search costs under the search source the instance +// actually runs. It used to say "uses shared API quota" always, while the default source (the +// website's own endpoint) spends none — so the owner avoided a free search for months. +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }), +})); +vi.mock("./navigationContext", () => ({ useNavigation: () => ({ page: "feed" }) })); +vi.mock("./feedFiltersContext", () => ({ + useFeedFilters: () => ({ q: "cats", sort: "newest" }), + useFeedFiltersActions: () => ({ setFilters: vi.fn() }), +})); +vi.mock("./playlistsContext", () => ({ + usePlaylists: () => ({ search: "" }), + usePlaylistsActions: () => ({ setSearch: vi.fn() }), +})); +vi.mock("./channelsContext", () => ({ + useChannels: () => ({ search: "" }), + useChannelsActions: () => ({ setSearch: vi.fn(), goToFullHistory: vi.fn() }), +})); +vi.mock("./SyncStatus", () => ({ default: () => null })); + +import Header from "./Header"; +import type { Me } from "../lib/api"; + +afterEach(cleanup); + +function tipFor(source: Me["search_source"]): string | null { + const me = { is_demo: false, plex_enabled: false, search_source: source } as Me; + render(
{}} />); + return screen.getByRole("button", { name: /header\.searchYoutube$/ }).getAttribute("title"); +} + +describe("the live-search tooltip", () => { + it("does not claim a quota cost under the free website source", () => { + expect(tipFor("scrape")).toBe("header.searchYoutubeTip"); + }); + + it("names the quota cost when the instance searches through the official API", () => { + expect(tipFor("api")).toBe("header.searchYoutubeTipApi"); + }); +}); diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 8729df4a..0fbe3651 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -60,7 +60,9 @@ const Header = forwardRef< }} onGo={() => onYtSearch(trimmed)} goLabel={t("header.searchYoutube")} - goTitle={t("header.searchYoutubeTip")} + goTitle={t( + me.search_source === "api" ? "header.searchYoutubeTipApi" : "header.searchYoutubeTip", + )} goIcon={} goDisabled={!trimmed || me.is_demo} /> diff --git a/frontend/src/content/release-notes/en.json b/frontend/src/content/release-notes/en.json index e5d79148..eb8b480c 100644 --- a/frontend/src/content/release-notes/en.json +++ b/frontend/src/content/release-notes/en.json @@ -1,4 +1,14 @@ [ + { + "version": "0.99.2", + "date": "2026-10-04", + "new": [], + "fixed": [ + "Settings: the download worker's concurrency and the log level now take effect within seconds — before, the worker ignored both until it was rebuilt.", + "The YouTube quota history no longer grows forever: the daily cleanup prunes it after a year (Configuration → Quota → Quota history kept).", + "The live-search button no longer says a search spends the API quota: with the default website source the search itself is free. It names the cost when the official API is selected." + ] + }, { "version": "0.99.1", "date": "2026-10-04", diff --git a/frontend/src/content/release-notes/hu.json b/frontend/src/content/release-notes/hu.json index eaf6c810..86b8e971 100644 --- a/frontend/src/content/release-notes/hu.json +++ b/frontend/src/content/release-notes/hu.json @@ -1,4 +1,14 @@ [ + { + "version": "0.99.2", + "date": "2026-10-04", + "new": [], + "fixed": [ + "Beállítások: a letöltő párhuzamossága és a naplószint pár másodpercen belül érvénybe lép — eddig a letöltő mindkettőt figyelmen kívül hagyta az újraépítéséig.", + "A YouTube-kvótaelőzmény többé nem nő a végtelenségig: a napi takarítás egy év után törli (Konfiguráció → Kvóta → Kvótaelőzmények megőrzése).", + "Az élő keresés gombja többé nem állítja, hogy a keresés API-kvótát fogyaszt: az alapértelmezett, weboldalas forrással maga a keresés ingyenes. A hivatalos API kiválasztásakor kiírja a költséget." + ] + }, { "version": "0.99.1", "date": "2026-10-04", 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/header.json b/frontend/src/i18n/locales/en/header.json index c03b081f..ac80bdd2 100644 --- a/frontend/src/i18n/locales/en/header.json +++ b/frontend/src/i18n/locales/en/header.json @@ -2,7 +2,8 @@ "searchPlaceholder": "Search your subscriptions…", "clearSearch": "Clear search", "searchYoutube": "Search YouTube", - "searchYoutubeTip": "Search live on YouTube for this term (uses shared API quota). You can also press Enter.", + "searchYoutubeTip": "Search live on YouTube for this term — the search itself spends no API quota; adding the new videos it finds costs a unit or two. You can also press Enter.", + "searchYoutubeTipApi": "Search live on YouTube for this term — uses the shared API quota (100 units a page). You can also press Enter.", "go": "Go", "goTip": "Search (or press Enter)", "channelManager": "Channels", diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json index b9ce0990..655e74e8 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)" }, @@ -172,6 +172,7 @@ "countries_coded": "countries coded", "sessions_closed": "sessions closed", "sessions_reaped": "sessions cleaned up", + "quota_reaped": "quota history cleaned up", "recent": "recent", "deep": "deep", "bios": "biographies", 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/header.json b/frontend/src/i18n/locales/hu/header.json index ed782b2e..f19bca06 100644 --- a/frontend/src/i18n/locales/hu/header.json +++ b/frontend/src/i18n/locales/hu/header.json @@ -2,7 +2,8 @@ "searchPlaceholder": "Keresés a feliratkozásaid között…", "clearSearch": "Keresés törlése", "searchYoutube": "Keresés a YouTube-on", - "searchYoutubeTip": "Élő keresés a YouTube-on erre a kifejezésre (a közös API-kvótát fogyasztja). Entert is nyomhatsz.", + "searchYoutubeTip": "Élő keresés a YouTube-on erre a kifejezésre — maga a keresés nem fogyaszt API-kvótát, a talált új videók behúzása is csak egy-két egységet. Entert is nyomhatsz.", + "searchYoutubeTipApi": "Élő keresés a YouTube-on erre a kifejezésre — a közös API-kvótát fogyasztja (oldalanként 100 egység). Entert is nyomhatsz.", "go": "Mehet", "goTip": "Keresés (vagy Enter)", "channelManager": "Csatornák", diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index 8daa8884..eff5d3e1 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)" }, @@ -172,6 +172,7 @@ "countries_coded": "országkód pótolva", "sessions_closed": "lezárt munkamenet", "sessions_reaped": "eltakarított munkamenet", + "quota_reaped": "eltakarított kvótaelőzmény", "recent": "friss", "deep": "mély", "bios": "életrajzok", diff --git a/frontend/src/lib/api/account.ts b/frontend/src/lib/api/account.ts index 171f5f1c..47574547 100644 --- a/frontend/src/lib/api/account.ts +++ b/frontend/src/lib/api/account.ts @@ -20,6 +20,7 @@ export interface Me { has_password: boolean; google_enabled: boolean; plex_enabled: boolean; + search_source: "scrape" | "api"; can_read: boolean; can_write: boolean; pending_invites: number; diff --git a/frontend/src/lib/gates.snapshot.json b/frontend/src/lib/gates.snapshot.json index 00f878f1..e208ef36 100644 --- a/frontend/src/lib/gates.snapshot.json +++ b/frontend/src/lib/gates.snapshot.json @@ -1794,6 +1794,7 @@ "src/components/Feed.ytsort.dom.test.tsx", "src/components/FeedFiltersProvider.dom.test.ts", "src/components/FeedFiltersProvider.seed.dom.test.tsx", + "src/components/Header.searchTip.dom.test.tsx", "src/components/Home.dom.test.tsx", "src/components/Home.pin.dom.test.tsx", "src/components/IssueComposer.dom.test.tsx",