Merge chore/code-hygiene: Phase 2 #6 Admin/Settings/Scheduler (cleanup + bugs)

Fixes: env-vs-DB config drift for download layout/retention; Plex library toggle
inversion from the all-state; Purge-discovery now behind a confirm dialog.
Cleanup: token_users dedup, rss lambda, LS.configTab, SaveState type reuse.
Verified: ruff, tsc, re-review clean, localdev boots, focused E2E green (Plex
toggle + purge confirm).
This commit is contained in:
npeter83 2026-07-11 19:45:09 +02:00
commit f6cfd4028e
11 changed files with 58 additions and 35 deletions

View file

@ -209,7 +209,7 @@ def scheduler_snapshot() -> dict:
def _rss_job() -> None: def _rss_job() -> None:
_job("rss_poll", lambda db: run_rss_poll(db)) _job("rss_poll", run_rss_poll)
def _enrich_job() -> None: def _enrich_job() -> None:

View file

@ -14,7 +14,8 @@ from sqlalchemy.orm import Session
from app import progress, quota from app import progress, quota
from app.auth import has_read_scope 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.sync.videos import apply_video_details, parse_dt
from app.youtube.client import YouTubeClient, YouTubeError 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: def sync_all_playlists(db: Session) -> dict:
"""Scheduler entry point: mirror playlists for every user with a read scope + token.""" """Scheduler entry point: mirror playlists for every user with a read scope + token."""
users = ( users = token_users(db)
db.execute(
select(User).join(OAuthToken).where(OAuthToken.refresh_token_enc.is_not(None))
)
.scalars()
.all()
)
total = 0 total = 0
for i, user in enumerate(users, 1): for i, user in enumerate(users, 1):
if not has_read_scope(user): if not has_read_scope(user):

View file

@ -31,19 +31,23 @@ from app.youtube.rss import fetch_channel_feed
RSS_POLL_WORKERS = 16 RSS_POLL_WORKERS = 16
def get_service_user(db: Session) -> User | None: def token_users(db: Session) -> list[User]:
return ( """Every user with a stored YouTube refresh token (the read-scope service accounts), by id."""
return list(
db.execute( db.execute(
select(User) select(User)
.join(OAuthToken) .join(OAuthToken)
.where(OAuthToken.refresh_token_enc.is_not(None)) .where(OAuthToken.refresh_token_enc.is_not(None))
.order_by(User.id) .order_by(User.id)
) ).scalars()
.scalars()
.first()
) )
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: def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> int:
if channels is None: if channels is None:
channels = db.execute(select(Channel)).scalars().all() channels = db.execute(select(Channel)).scalars().all()
@ -102,15 +106,7 @@ def run_shorts(db: Session) -> dict:
def run_subscription_resync(db: Session) -> dict: def run_subscription_resync(db: Session) -> dict:
"""Re-import every user's subscriptions so unsubscribes and new subscriptions on """Re-import every user's subscriptions so unsubscribes and new subscriptions on
YouTube are reflected automatically.""" YouTube are reflected automatically."""
users = ( users = token_users(db)
db.execute(
select(User)
.join(OAuthToken)
.where(OAuthToken.refresh_token_enc.is_not(None))
)
.scalars()
.all()
)
for i, user in enumerate(users, 1): for i, user in enumerate(users, 1):
try: try:
import_subscriptions(db, user) import_subscriptions(db, user)

View file

@ -26,6 +26,7 @@ from pathlib import Path
import yt_dlp import yt_dlp
from sqlalchemy import text from sqlalchemy import text
from app import sysconfig
from app.config import settings from app.config import settings
from app.db import SessionLocal from app.db import SessionLocal
from app.downloads import edit as editmod 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) 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: def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spec: dict) -> None:
staging = _staging_dir(asset_id) staging = _staging_dir(asset_id)
try: 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"), 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) thumb = _find_thumbnail(staging, produced)
storage.place_file(produced, settings.download_root, rel) storage.place_file(produced, settings.download_root, rel)
nfo_ok = storage.write_sidecars(settings.download_root, rel, meta, thumb) nfo_ok = storage.write_sidecars(settings.download_root, rel, meta, thumb)
final = storage.abs_path(settings.download_root, rel) final = storage.abs_path(settings.download_root, rel)
size = final.stat().st_size if final.exists() else None 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: with SessionLocal() as db:
asset = db.get(MediaAsset, asset_id) 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) storage.place_file(dest_staging, settings.download_root, rel)
final = storage.abs_path(settings.download_root, rel) final = storage.abs_path(settings.download_root, rel)
size = final.stat().st_size if final.exists() else None 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: with SessionLocal() as db:
asset = db.get(MediaAsset, asset_id) asset = db.get(MediaAsset, asset_id)

View file

@ -7,7 +7,8 @@ import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import Tabs, { usePersistedTab } from "./Tabs"; import Tabs, { usePersistedTab } from "./Tabs";
import { Switch } from "./ui/form"; 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 // 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 // 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. // the server until Save. Group order is fixed where known so logically related settings (e.g.
// Email/SMTP) stay together. // Email/SMTP) stay together.
const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch", "downloads", "plex"]; const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch", "downloads", "plex"];
type SaveState = "idle" | "saving" | "saved" | "error";
export default function ConfigPanel() { export default function ConfigPanel() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -40,7 +40,7 @@ export default function ConfigPanel() {
const [saveState, setSaveState] = useState<SaveState>("idle"); const [saveState, setSaveState] = useState<SaveState>("idle");
// One tab per config group (persisted). Called before the loading guard so hook order is // 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. // 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). // Re-seed the draft whenever the server data changes (initial load + after a save/reset).
useEffect(() => { useEffect(() => {
@ -110,9 +110,14 @@ export default function ConfigPanel() {
}); });
const plexLibs = (draft["plex_libraries"] ?? "").split(",").map((s) => s.trim()).filter(Boolean); const plexLibs = (draft["plex_libraries"] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const togglePlexLib = (key: string) => { const togglePlexLib = (key: string) => {
const next = plexLibs.includes(key) // An empty list means "all libraries" — every box renders checked. Unchecking one from that
? plexLibs.filter((k) => k !== key) // state must select all-except-this, so expand "all" to the explicit section set first;
: [...plexLibs, key]; // 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(",") })); setDraft((d) => ({ ...d, plex_libraries: next.join(",") }));
}; };

View file

@ -18,6 +18,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api"; import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { useConfirm } from "./ConfirmProvider";
import { relativeTime } from "../lib/format"; import { relativeTime } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
@ -309,6 +310,7 @@ function Stat({
export default function Scheduler() { export default function Scheduler() {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm();
// Poll faster while any job is running so live progress is smooth, then ease back when // 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). // idle. Derived from the freshest data by react-query itself (see useLiveQuery).
const q = useLiveQuery<SchedulerStatus>(["scheduler"], api.schedulerStatus, { const q = useLiveQuery<SchedulerStatus>(["scheduler"], api.schedulerStatus, {
@ -411,7 +413,15 @@ export default function Scheduler() {
</Tooltip> </Tooltip>
<Tooltip hint={t("scheduler.purgeDiscoveryHint")}> <Tooltip hint={t("scheduler.purgeDiscoveryHint")}>
<button <button
onClick={() => purgeMut.mutate()} onClick={async () => {
const ok = await confirm({
title: t("scheduler.purgeConfirmTitle"),
message: t("scheduler.purgeDiscoveryHint"),
confirmLabel: t("scheduler.purgeDiscovery"),
danger: true,
});
if (ok) purgeMut.mutate();
}}
disabled={purgeMut.isPending} disabled={purgeMut.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition" className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
> >

View file

@ -9,14 +9,14 @@ import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications"; import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import { Section, SettingRow, Switch } from "./ui/form"; import { Section, SettingRow, Switch } from "./ui/form";
import { DraftSaveBar } from "./ui/DraftSaveBar"; import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
// The Settings page edits server-persisted preferences as a draft: changes apply locally // The Settings page edits server-persisted preferences as a draft: changes apply locally
// for instant preview but reach the server only on an explicit Save (or revert on Discard). // for instant preview but reach the server only on an explicit Save (or revert on Discard).
// App owns the draft + baseline (so the leave-the-page guard can see it); this controller is // App owns the draft + baseline (so the leave-the-page guard can see it); this controller is
// how the panel reads/writes it. See App.tsx. // how the panel reads/writes it. See App.tsx.
export type PrefsSaveState = "idle" | "saving" | "saved" | "error"; export type PrefsSaveState = SaveState;
export interface PrefsController { export interface PrefsController {
theme: ThemePrefs; theme: ThemePrefs;
setTheme: (t: ThemePrefs) => void; setTheme: (t: ThemePrefs) => void;

View file

@ -103,5 +103,6 @@
}, },
"purgeDiscovery": "Entdeckung leeren", "purgeDiscovery": "Entdeckung leeren",
"purgeDiscoveryHint": "Alle nicht behaltenen Entdeckungsinhalte jetzt entfernen (von niemandem angesehene/gespeicherte Suchergebnisse und erkundete, nicht abonnierte Kanäle) — ohne Karenzzeit.", "purgeDiscoveryHint": "Alle nicht behaltenen Entdeckungsinhalte jetzt entfernen (von niemandem angesehene/gespeicherte Suchergebnisse und erkundete, nicht abonnierte Kanäle) — ohne Karenzzeit.",
"purgeConfirmTitle": "Entdeckungsinhalte löschen?",
"purgedDiscovery": "{{count}} Entdeckungselemente entfernt" "purgedDiscovery": "{{count}} Entdeckungselemente entfernt"
} }

View file

@ -103,5 +103,6 @@
}, },
"purgeDiscovery": "Purge discovery", "purgeDiscovery": "Purge discovery",
"purgeDiscoveryHint": "Remove all un-kept discovery content now (search results nobody watched/saved, and explored-but-unsubscribed channels) — ignoring the grace period.", "purgeDiscoveryHint": "Remove all un-kept discovery content now (search results nobody watched/saved, and explored-but-unsubscribed channels) — ignoring the grace period.",
"purgeConfirmTitle": "Purge discovery content?",
"purgedDiscovery": "Removed {{count}} discovery items" "purgedDiscovery": "Removed {{count}} discovery items"
} }

View file

@ -103,5 +103,6 @@
}, },
"purgeDiscovery": "Felfedezés ürítése", "purgeDiscovery": "Felfedezés ürítése",
"purgeDiscoveryHint": "Most eltávolítja az összes meg nem tartott felfedezés-tartalmat (senki által meg nem nézett/mentett keresési találatok és felfedezett, de nem követett csatornák) — a türelmi időt figyelmen kívül hagyva.", "purgeDiscoveryHint": "Most eltávolítja az összes meg nem tartott felfedezés-tartalmat (senki által meg nem nézett/mentett keresési találatok és felfedezett, de nem követett csatornák) — a türelmi időt figyelmen kívül hagyva.",
"purgeConfirmTitle": "Felfedezés-tartalom törlése?",
"purgedDiscovery": "{{count}} felfedezés-elem eltávolítva" "purgedDiscovery": "{{count}} felfedezés-elem eltávolítva"
} }

View file

@ -19,6 +19,7 @@ export const LS = {
channelsTable: "siftlode.channelsTable", channelsTable: "siftlode.channelsTable",
channelDiscoveryTable: "siftlode.channelDiscoveryTable", channelDiscoveryTable: "siftlode.channelDiscoveryTable",
settingsTab: "siftlode.settingsTab", settingsTab: "siftlode.settingsTab",
configTab: "siftlode.configTab",
statsTab: "siftlode.statsTab", statsTab: "siftlode.statsTab",
adminUsersTab: "siftlode.adminUsersTab", adminUsersTab: "siftlode.adminUsersTab",
navCollapsed: "siftlode.navCollapsed", navCollapsed: "siftlode.navCollapsed",