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).
|
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
|
## 0.99.1 — 2026-10-04
|
||||||
|
|
||||||
### Fixed
|
### 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.
|
# Admin audit log: how long to keep rows (days; 0 = keep forever) + how often the GC runs.
|
||||||
audit_retention_days: int = 180
|
audit_retention_days: int = 180
|
||||||
audit_gc_minutes: int = 1440
|
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".
|
# On-disk layout: "plex" (Channel/Season YYYY/… [id].ext + .nfo/poster) or "flat".
|
||||||
download_layout: DownloadLayout = "plex"
|
download_layout: DownloadLayout = "plex"
|
||||||
# Default SponsorBlock (skip sponsor segments) for new custom profiles.
|
# 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 contextlib
|
||||||
import contextvars
|
import contextvars
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, cast
|
||||||
from zoneinfo import ZoneInfo
|
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.dialects.postgresql import insert as pg_insert
|
||||||
|
from sqlalchemy.engine import CursorResult
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
@@ -96,6 +98,20 @@ def pacific_today():
|
|||||||
return datetime.now(_PACIFIC).date()
|
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:
|
def pacific_day_start_utc() -> datetime:
|
||||||
"""UTC instant of the current Pacific day's midnight — aligns 'today' with the budget reset."""
|
"""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)
|
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),
|
"google_enabled": google_enabled(db),
|
||||||
# Whether the optional Plex module is enabled (UI shows the Plex feed source when so).
|
# Whether the optional Plex module is enabled (UI shows the Plex feed source when so).
|
||||||
"plex_enabled": sysconfig.get_bool(db, "plex_enabled"),
|
"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_read": has_read_scope(user),
|
||||||
"can_write": has_write_scope(user),
|
"can_write": has_write_scope(user),
|
||||||
"pending_invites": pending_invites,
|
"pending_invites": pending_invites,
|
||||||
|
|||||||
@@ -425,12 +425,15 @@ def _people_enrich_job() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _audit_gc_job() -> None:
|
def _audit_gc_job() -> None:
|
||||||
# Prune audit-log rows older than the admin-set retention (audit_retention_days; 0 = keep
|
# The history cleanup: audit-log rows past audit_retention_days and YouTube quota history past
|
||||||
# forever). Pure DB, no quota.
|
# quota_retention_days (0 = keep forever, each). Pure DB, no quota.
|
||||||
def work(db):
|
def work(db):
|
||||||
from app import sysconfig
|
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)
|
_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).
|
# Live-search source: "scrape" (InnerTube, zero API quota) or "api" (search.list, 100u/page).
|
||||||
ConfigSpec("search_source", "choice", "quota", "search_source"),
|
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) ---
|
# --- Backfill (recent-first first pass per channel) ---
|
||||||
ConfigSpec(
|
ConfigSpec(
|
||||||
"backfill_recent_max_videos",
|
"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:
|
Design:
|
||||||
* N loop threads (download_worker_concurrency) each claim a `queued` DownloadJob with
|
* 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
|
* 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
|
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.
|
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__)
|
log = logs.get_logger(__name__)
|
||||||
_stop = threading.Event()
|
_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):
|
def _handle_signal(signum, frame):
|
||||||
_stop.set()
|
_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 -----------------------------------------------------------------------
|
# --- claim + recovery -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -576,7 +625,7 @@ def _finish_from_cache(job_id: int, asset_id: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _worker_loop(idx: int) -> None:
|
def _worker_loop(idx: int) -> None:
|
||||||
while not _stop.is_set():
|
while not _stop.is_set() and idx < _target:
|
||||||
try:
|
try:
|
||||||
job_id = _claim_job()
|
job_id = _claim_job()
|
||||||
except Exception:
|
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.
|
# has migrated — those few seconds of batches fail and say so on stderr; nothing else breaks.
|
||||||
logsink.start()
|
logsink.start()
|
||||||
_recover_orphans()
|
_recover_orphans()
|
||||||
n = max(1, settings.download_worker_concurrency)
|
global _target
|
||||||
log.info("Download worker started (concurrency=%d, root=%s).", n, settings.download_root)
|
_target = max(1, settings.download_worker_concurrency) # used only if the DB can't answer
|
||||||
threads = [threading.Thread(target=_worker_loop, args=(i,), daemon=True) for i in range(n)]
|
threads: dict[int, threading.Thread] = {}
|
||||||
for t in threads:
|
level = _sync_knobs(threads, None)
|
||||||
t.start()
|
log.info("Download worker started (concurrency=%d, root=%s).", _target, settings.download_root)
|
||||||
while not _stop.is_set():
|
while not _stop.wait(_KNOB_SECONDS):
|
||||||
_stop.wait(1.0)
|
level = _sync_knobs(threads, level)
|
||||||
log.info("Download worker stopping…")
|
log.info("Download worker stopping…")
|
||||||
for t in threads:
|
for t in threads.values():
|
||||||
t.join(timeout=5)
|
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)}
|
onGo={() => onYtSearch(trimmed)}
|
||||||
goLabel={t("header.searchYoutube")}
|
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" />}
|
goIcon={<YouTubeIcon className="w-4 h-4" />}
|
||||||
goDisabled={!trimmed || me.is_demo}
|
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",
|
"version": "0.99.1",
|
||||||
"date": "2026-10-04",
|
"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",
|
"version": "0.99.1",
|
||||||
"date": "2026-10-04",
|
"date": "2026-10-04",
|
||||||
|
|||||||
@@ -62,6 +62,10 @@
|
|||||||
"label": "Live search source",
|
"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."
|
"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": {
|
"backfill_recent_max_videos": {
|
||||||
"label": "Recent backfill — max videos",
|
"label": "Recent backfill — max videos",
|
||||||
"hint": "Newest videos fetched per channel on the first pass."
|
"hint": "Newest videos fetched per channel on the first pass."
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"searchPlaceholder": "Search your subscriptions…",
|
"searchPlaceholder": "Search your subscriptions…",
|
||||||
"clearSearch": "Clear search",
|
"clearSearch": "Clear search",
|
||||||
"searchYoutube": "Search YouTube",
|
"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",
|
"go": "Go",
|
||||||
"goTip": "Search (or press Enter)",
|
"goTip": "Search (or press Enter)",
|
||||||
"channelManager": "Channels",
|
"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_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_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.",
|
"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.",
|
"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.",
|
"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."
|
"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_sync": "Plex watch sync (incremental)",
|
||||||
"plex_watch_reconcile": "Plex watch reconcile (full)",
|
"plex_watch_reconcile": "Plex watch reconcile (full)",
|
||||||
"tmdb_enrich": "TMDB enrichment (Plex)",
|
"tmdb_enrich": "TMDB enrichment (Plex)",
|
||||||
"audit_gc": "Audit-log cleanup",
|
"audit_gc": "Audit-log and quota-history cleanup",
|
||||||
"log_gc": "Log cleanup",
|
"log_gc": "Log cleanup",
|
||||||
"people_enrich": "People details and portraits (Plex)"
|
"people_enrich": "People details and portraits (Plex)"
|
||||||
},
|
},
|
||||||
@@ -172,6 +172,7 @@
|
|||||||
"countries_coded": "countries coded",
|
"countries_coded": "countries coded",
|
||||||
"sessions_closed": "sessions closed",
|
"sessions_closed": "sessions closed",
|
||||||
"sessions_reaped": "sessions cleaned up",
|
"sessions_reaped": "sessions cleaned up",
|
||||||
|
"quota_reaped": "quota history cleaned up",
|
||||||
"recent": "recent",
|
"recent": "recent",
|
||||||
"deep": "deep",
|
"deep": "deep",
|
||||||
"bios": "biographies",
|
"bios": "biographies",
|
||||||
|
|||||||
@@ -62,6 +62,10 @@
|
|||||||
"label": "Élő keresés forrása",
|
"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."
|
"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": {
|
"backfill_recent_max_videos": {
|
||||||
"label": "Friss behúzás — max videó",
|
"label": "Friss behúzás — max videó",
|
||||||
"hint": "Csatornánként az első körben behúzott legújabb videók száma."
|
"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…",
|
"searchPlaceholder": "Keresés a feliratkozásaid között…",
|
||||||
"clearSearch": "Keresés törlése",
|
"clearSearch": "Keresés törlése",
|
||||||
"searchYoutube": "Keresés a YouTube-on",
|
"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",
|
"go": "Mehet",
|
||||||
"goTip": "Keresés (vagy Enter)",
|
"goTip": "Keresés (vagy Enter)",
|
||||||
"channelManager": "Csatornák",
|
"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_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_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.",
|
"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.",
|
"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.",
|
"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."
|
"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_sync": "Plex megtekintés-szinkron (inkrementális)",
|
||||||
"plex_watch_reconcile": "Plex megtekintés-egyeztetés (teljes)",
|
"plex_watch_reconcile": "Plex megtekintés-egyeztetés (teljes)",
|
||||||
"tmdb_enrich": "TMDB-dúsítás (Plex)",
|
"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",
|
"log_gc": "Naplótakarítás",
|
||||||
"people_enrich": "Személyadatok és portrék (Plex)"
|
"people_enrich": "Személyadatok és portrék (Plex)"
|
||||||
},
|
},
|
||||||
@@ -172,6 +172,7 @@
|
|||||||
"countries_coded": "országkód pótolva",
|
"countries_coded": "országkód pótolva",
|
||||||
"sessions_closed": "lezárt munkamenet",
|
"sessions_closed": "lezárt munkamenet",
|
||||||
"sessions_reaped": "eltakarított munkamenet",
|
"sessions_reaped": "eltakarított munkamenet",
|
||||||
|
"quota_reaped": "eltakarított kvótaelőzmény",
|
||||||
"recent": "friss",
|
"recent": "friss",
|
||||||
"deep": "mély",
|
"deep": "mély",
|
||||||
"bios": "életrajzok",
|
"bios": "életrajzok",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface Me {
|
|||||||
has_password: boolean;
|
has_password: boolean;
|
||||||
google_enabled: boolean;
|
google_enabled: boolean;
|
||||||
plex_enabled: boolean;
|
plex_enabled: boolean;
|
||||||
|
search_source: "scrape" | "api";
|
||||||
can_read: boolean;
|
can_read: boolean;
|
||||||
can_write: boolean;
|
can_write: boolean;
|
||||||
pending_invites: number;
|
pending_invites: number;
|
||||||
|
|||||||
@@ -1794,6 +1794,7 @@
|
|||||||
"src/components/Feed.ytsort.dom.test.tsx",
|
"src/components/Feed.ytsort.dom.test.tsx",
|
||||||
"src/components/FeedFiltersProvider.dom.test.ts",
|
"src/components/FeedFiltersProvider.dom.test.ts",
|
||||||
"src/components/FeedFiltersProvider.seed.dom.test.tsx",
|
"src/components/FeedFiltersProvider.seed.dom.test.tsx",
|
||||||
|
"src/components/Header.searchTip.dom.test.tsx",
|
||||||
"src/components/Home.dom.test.tsx",
|
"src/components/Home.dom.test.tsx",
|
||||||
"src/components/Home.pin.dom.test.tsx",
|
"src/components/Home.pin.dom.test.tsx",
|
||||||
"src/components/IssueComposer.dom.test.tsx",
|
"src/components/IssueComposer.dom.test.tsx",
|
||||||
|
|||||||
Reference in New Issue
Block a user