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) 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) 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() {