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:
_job("rss_poll", lambda db: run_rss_poll(db))
_job("rss_poll", run_rss_poll)
def _enrich_job() -> None:

View file

@ -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):

View file

@ -31,17 +31,21 @@ 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()
)
.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:
@ -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)

View file

@ -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)

View file

@ -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<SaveState>("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(",") }));
};

View file

@ -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<SchedulerStatus>(["scheduler"], api.schedulerStatus, {
@ -411,7 +413,15 @@ export default function Scheduler() {
</Tooltip>
<Tooltip hint={t("scheduler.purgeDiscoveryHint")}>
<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}
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 Tooltip from "./Tooltip";
import { Section, SettingRow, Switch } from "./ui/form";
import { DraftSaveBar } from "./ui/DraftSaveBar";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { useConfirm } from "./ConfirmProvider";
// 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).
// 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.
export type PrefsSaveState = "idle" | "saving" | "saved" | "error";
export type PrefsSaveState = SaveState;
export interface PrefsController {
theme: ThemePrefs;
setTheme: (t: ThemePrefs) => void;

View file

@ -103,5 +103,6 @@
},
"purgeDiscovery": "Entdeckung leeren",
"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"
}

View file

@ -103,5 +103,6 @@
},
"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.",
"purgeConfirmTitle": "Purge discovery content?",
"purgedDiscovery": "Removed {{count}} discovery items"
}

View file

@ -103,5 +103,6 @@
},
"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.",
"purgeConfirmTitle": "Felfedezés-tartalom törlése?",
"purgedDiscovery": "{{count}} felfedezés-elem eltávolítva"
}

View file

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