release: 0.99.2 (bug/b1-e34-defects)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+18
-2
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+59
-10
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
@@ -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 == []
|
||||
@@ -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<typeof import("react-i18next")>()),
|
||||
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(<Header me={me} onYtSearch={() => {}} />);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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={<YouTubeIcon className="w-4 h-4" />}
|
||||
goDisabled={!trimmed || me.is_demo}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 újraszkennel — 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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user