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:
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(",") }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue