From 4e68bdf9203afd4e3ed461fc046724820489a4ce Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 11 Jul 2026 19:44:53 +0200 Subject: [PATCH 1/3] fix(config): honor DB-overridable download layout + retention (env-vs-DB drift) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit download_layout and download_retention_days are registered ConfigSpec keys (admin-editable on the Configuration page), but worker.py read them from settings.* (env) at download/edit finalize time — so an admin's edit was a silent no-op (files kept the old layout; TTL stayed at the env default). Read them via sysconfig (_download_settings), which falls back to the env default when there's no DB override. Same class as the Downloads B3 fix. ruff clean, localdev boots, re-review clean. --- backend/app/worker.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/backend/app/worker.py b/backend/app/worker.py index ab06b3f..b35d6f1 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -26,6 +26,7 @@ from pathlib import Path import yt_dlp from sqlalchemy import text +from app import sysconfig from app.config import settings from app.db import SessionLocal from app.downloads import edit as editmod @@ -371,6 +372,16 @@ def _extract_with_fallback(job_id: int, asset_id: int, url: str, spec: dict, sta raise last_exc # unreachable (loop either returns or raises) +def _download_settings() -> tuple[str, int]: + """Layout + retention days from the DB config (admin-overridable on the Configuration page), + falling back to the env defaults. Read here, not from `settings`, so an admin edit takes effect.""" + with SessionLocal() as db: + return ( + sysconfig.get_str(db, "download_layout"), + sysconfig.get_int(db, "download_retention_days"), + ) + + def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spec: dict) -> None: staging = _staging_dir(asset_id) try: @@ -402,14 +413,15 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe thumbnail_url=info.get("thumbnail"), ) - rel = storage.rel_path(meta, ext, settings.download_layout) + layout, retention_days = _download_settings() + rel = storage.rel_path(meta, ext, layout) thumb = _find_thumbnail(staging, produced) storage.place_file(produced, settings.download_root, rel) nfo_ok = storage.write_sidecars(settings.download_root, rel, meta, thumb) final = storage.abs_path(settings.download_root, rel) size = final.stat().st_size if final.exists() else None - expires = datetime.now(timezone.utc) + timedelta(days=settings.download_retention_days) + expires = datetime.now(timezone.utc) + timedelta(days=retention_days) with SessionLocal() as db: asset = db.get(MediaAsset, asset_id) @@ -554,7 +566,8 @@ def _process_edit(job_id: int, asset_id: int) -> None: storage.place_file(dest_staging, settings.download_root, rel) final = storage.abs_path(settings.download_root, rel) size = final.stat().st_size if final.exists() else None - expires = datetime.now(timezone.utc) + timedelta(days=settings.download_retention_days) + _, retention_days = _download_settings() + expires = datetime.now(timezone.utc) + timedelta(days=retention_days) with SessionLocal() as db: asset = db.get(MediaAsset, asset_id) From a6ffcf95772be76e354a792a19b04ad5c3f93f98 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 11 Jul 2026 19:44:53 +0200 Subject: [PATCH 2/3] chore(scheduler): dedup token-user query + drop pointless rss lambda MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract runner.token_users(db): the "all users with a stored refresh token" query was spelled 3× (get_service_user, run_subscription_resync, sync_all_playlists). get_service_user now reuses it ([0]); sync_all_playlists imports it from runner (no cycle — runner doesn't import playlists); the now- unused OAuthToken import is dropped there. - scheduler.py: _job("rss_poll", lambda db: run_rss_poll(db)) → _job("rss_poll", run_rss_poll) — the lambda was dead indirection; all sibling jobs pass the callable directly and _job calls fn(db). Behavior-neutral. ruff clean, localdev boots, re-review clean. --- backend/app/scheduler.py | 2 +- backend/app/sync/playlists.py | 11 +++-------- backend/app/sync/runner.py | 24 ++++++++++-------------- 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index 5e001b6..e5e97b2 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -209,7 +209,7 @@ def scheduler_snapshot() -> dict: def _rss_job() -> None: - _job("rss_poll", lambda db: run_rss_poll(db)) + _job("rss_poll", run_rss_poll) def _enrich_job() -> None: diff --git a/backend/app/sync/playlists.py b/backend/app/sync/playlists.py index ca82b27..9634a99 100644 --- a/backend/app/sync/playlists.py +++ b/backend/app/sync/playlists.py @@ -14,7 +14,8 @@ from sqlalchemy.orm import Session from app import progress, quota from app.auth import has_read_scope -from app.models import Channel, OAuthToken, Playlist, PlaylistItem, User, Video +from app.models import Channel, Playlist, PlaylistItem, User, Video +from app.sync.runner import token_users from app.sync.videos import apply_video_details, parse_dt from app.youtube.client import YouTubeClient, YouTubeError @@ -370,13 +371,7 @@ def repull_playlist(db: Session, user: User, pl: Playlist) -> dict: def sync_all_playlists(db: Session) -> dict: """Scheduler entry point: mirror playlists for every user with a read scope + token.""" - users = ( - db.execute( - select(User).join(OAuthToken).where(OAuthToken.refresh_token_enc.is_not(None)) - ) - .scalars() - .all() - ) + users = token_users(db) total = 0 for i, user in enumerate(users, 1): if not has_read_scope(user): diff --git a/backend/app/sync/runner.py b/backend/app/sync/runner.py index 7239a5d..e325a50 100644 --- a/backend/app/sync/runner.py +++ b/backend/app/sync/runner.py @@ -31,19 +31,23 @@ from app.youtube.rss import fetch_channel_feed RSS_POLL_WORKERS = 16 -def get_service_user(db: Session) -> User | None: - return ( +def token_users(db: Session) -> list[User]: + """Every user with a stored YouTube refresh token (the read-scope service accounts), by id.""" + return list( db.execute( select(User) .join(OAuthToken) .where(OAuthToken.refresh_token_enc.is_not(None)) .order_by(User.id) - ) - .scalars() - .first() + ).scalars() ) +def get_service_user(db: Session) -> User | None: + users = token_users(db) + return users[0] if users else None + + def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> int: if channels is None: channels = db.execute(select(Channel)).scalars().all() @@ -102,15 +106,7 @@ def run_shorts(db: Session) -> dict: def run_subscription_resync(db: Session) -> dict: """Re-import every user's subscriptions so unsubscribes and new subscriptions on YouTube are reflected automatically.""" - users = ( - db.execute( - select(User) - .join(OAuthToken) - .where(OAuthToken.refresh_token_enc.is_not(None)) - ) - .scalars() - .all() - ) + users = token_users(db) for i, user in enumerate(users, 1): try: import_subscriptions(db, user) From 3d00d75863ed28dbb75a171e6c67d86ccb767cbf Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 11 Jul 2026 19:44:53 +0200 Subject: [PATCH 3/3] fix(admin-ui): Plex library toggle inversion + Purge-discovery confirm (+ cleanups) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugs: - ConfigPanel (CB1): unchecking a Plex library from the "all" (empty) state made that library the ONLY selected one instead of all-except-it, because the toggle ADDED the key to an empty list. Expand "all" to the explicit section set before toggling. E2E-verified: uncheck from all → all-except-one. - Scheduler (SB1): "Purge discovery" bulk-deleted search videos/videos/channels immediately with no confirm (every AdminUsers destructive action uses useConfirm). Wrapped in a danger useConfirm dialog (+ trilingual purgeConfirmTitle key). E2E-verified: dialog appears, Cancel aborts. Cleanups (behavior-neutral): - Register LS.configTab; ConfigPanel uses it instead of the bare "siftlode.configTab". - ConfigPanel + SettingsPanel import SaveState from ui/DraftSaveBar instead of redeclaring the union (PrefsSaveState is now an alias). tsc green, re-review clean, localdev boots. --- frontend/src/components/ConfigPanel.tsx | 17 +++++++++++------ frontend/src/components/Scheduler.tsx | 12 +++++++++++- frontend/src/components/SettingsPanel.tsx | 4 ++-- frontend/src/i18n/locales/de/scheduler.json | 1 + frontend/src/i18n/locales/en/scheduler.json | 1 + frontend/src/i18n/locales/hu/scheduler.json | 1 + frontend/src/lib/storage.ts | 1 + 7 files changed, 28 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index 70f2b3c..680586a 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -7,7 +7,8 @@ import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import Tabs, { usePersistedTab } from "./Tabs"; import { Switch } from "./ui/form"; -import { DraftSaveBar } from "./ui/DraftSaveBar"; +import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar"; +import { LS } from "../lib/storage"; // Admin Configuration page: edit DB-overridable operational settings (registry-driven from the // backend — adding a key there makes it appear here automatically). Edits are drafted and @@ -15,7 +16,6 @@ import { DraftSaveBar } from "./ui/DraftSaveBar"; // the server until Save. Group order is fixed where known so logically related settings (e.g. // Email/SMTP) stay together. const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch", "downloads", "plex"]; -type SaveState = "idle" | "saving" | "saved" | "error"; export default function ConfigPanel() { const { t } = useTranslation(); @@ -40,7 +40,7 @@ export default function ConfigPanel() { const [saveState, setSaveState] = useState("idle"); // One tab per config group (persisted). Called before the loading guard so hook order is // stable; the active id is clamped to a real group at render time once data has loaded. - const [tab, setTab] = usePersistedTab("siftlode.configTab"); + const [tab, setTab] = usePersistedTab(LS.configTab); // Re-seed the draft whenever the server data changes (initial load + after a save/reset). useEffect(() => { @@ -110,9 +110,14 @@ export default function ConfigPanel() { }); const plexLibs = (draft["plex_libraries"] ?? "").split(",").map((s) => s.trim()).filter(Boolean); const togglePlexLib = (key: string) => { - const next = plexLibs.includes(key) - ? plexLibs.filter((k) => k !== key) - : [...plexLibs, key]; + // An empty list means "all libraries" — every box renders checked. Unchecking one from that + // state must select all-except-this, so expand "all" to the explicit section set first; + // otherwise the toggle would ADD the key and leave it as the ONLY selected library. + const allKeys = plexResult?.sections?.map((s) => s.key) ?? []; + const current = plexLibs.length === 0 ? allKeys : plexLibs; + const next = current.includes(key) + ? current.filter((k) => k !== key) + : [...current, key]; setDraft((d) => ({ ...d, plex_libraries: next.join(",") })); }; diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index f57eddf..e399ccd 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -18,6 +18,7 @@ import { } from "lucide-react"; import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; +import { useConfirm } from "./ConfirmProvider"; import { relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; @@ -309,6 +310,7 @@ function Stat({ export default function Scheduler() { const { t } = useTranslation(); const qc = useQueryClient(); + const confirm = useConfirm(); // Poll faster while any job is running so live progress is smooth, then ease back when // idle. Derived from the freshest data by react-query itself (see useLiveQuery). const q = useLiveQuery(["scheduler"], api.schedulerStatus, { @@ -411,7 +413,15 @@ export default function Scheduler() {