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:
commit
f6cfd4028e
11 changed files with 58 additions and 35 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(",") }));
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue