feat(scheduler): admin-tunable maintenance re-validation batch size

Expose the maintenance job's rolling re-validation batch (videos re-checked per
run) as an admin control on the Scheduler dashboard, stored in app_state
(migration 0018; NULL = the env/config default). The job reads the effective
value each run via a state helper, clamped to a sane range. Trilingual.
This commit is contained in:
npeter83 2026-06-18 04:37:08 +02:00
parent 264a3c74af
commit dd83718304
11 changed files with 183 additions and 232 deletions

View file

@ -0,0 +1,30 @@
"""admin override for the maintenance re-validation batch size
Revision ID: 0018_maintenance_batch_setting
Revises: 0017_notifications
Create Date: 2026-06-18
Adds app_state.maintenance_revalidate_batch: an admin-tunable override (from the Scheduler
dashboard) for how many videos the maintenance job re-validates per run. NULL = use the
env/config default. Purely additive.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0018_maintenance_batch_setting"
down_revision: Union[str, None] = "0017_notifications"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"app_state",
sa.Column("maintenance_revalidate_batch", sa.Integer(), nullable=True),
)
def downgrade() -> None:
op.drop_column("app_state", "maintenance_revalidate_batch")

View file

@ -339,6 +339,9 @@ class AppState(Base):
sync_paused: Mapped[bool] = mapped_column( sync_paused: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false" Boolean, default=False, server_default="false"
) )
# Admin override for the maintenance job's rolling re-validation batch (videos re-checked
# per run). NULL = use the config/env default (settings.maintenance_revalidate_batch).
maintenance_revalidate_batch: Mapped[int | None] = mapped_column(Integer)
class SchedulerSetting(Base): class SchedulerSetting(Base):

View file

@ -71,6 +71,29 @@ def run_all_now(user: User = Depends(admin_user)) -> dict:
return {"started": trigger_all(actor_id=user.id)} return {"started": trigger_all(actor_id=user.id)}
@router.patch("/maintenance")
def set_maintenance_settings(
payload: dict,
_: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Admin-tune the maintenance job's rolling re-validation batch (videos re-checked per
run). Persisted to app_state; the job reads it each run."""
try:
value = int(payload.get("revalidate_batch"))
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="revalidate_batch must be a number")
if not (state.MAINTENANCE_BATCH_MIN <= value <= state.MAINTENANCE_BATCH_MAX):
raise HTTPException(
status_code=400,
detail=(
f"revalidate_batch must be between {state.MAINTENANCE_BATCH_MIN} and "
f"{state.MAINTENANCE_BATCH_MAX}"
),
)
return {"revalidate_batch": state.set_maintenance_batch(db, value)}
@router.get("") @router.get("")
def get_scheduler( def get_scheduler(
_: User = Depends(admin_user), db: Session = Depends(get_db) _: User = Depends(admin_user), db: Session = Depends(get_db)
@ -138,4 +161,10 @@ def get_scheduler(
"paused": state.is_sync_paused(db), "paused": state.is_sync_paused(db),
"queue": queue, "queue": queue,
"quota": quota_info, "quota": quota_info,
"maintenance": {
"revalidate_batch": state.get_maintenance_batch(db),
"revalidate_batch_default": settings.maintenance_revalidate_batch,
"min": state.MAINTENANCE_BATCH_MIN,
"max": state.MAINTENANCE_BATCH_MAX,
},
} }

View file

@ -3,10 +3,15 @@ import logging
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import settings
from app.models import AppState from app.models import AppState
log = logging.getLogger("subfeed.state") log = logging.getLogger("subfeed.state")
# Bounds for the admin-set maintenance re-validation batch (videos re-checked per run).
MAINTENANCE_BATCH_MIN = 100
MAINTENANCE_BATCH_MAX = 100_000
def _row(db: Session) -> AppState: def _row(db: Session) -> AppState:
row = db.get(AppState, 1) row = db.get(AppState, 1)
@ -27,3 +32,20 @@ def set_sync_paused(db: Session, paused: bool) -> None:
db.add(row) db.add(row)
db.commit() db.commit()
log.info("Background sync %s", "paused" if paused else "resumed") log.info("Background sync %s", "paused" if paused else "resumed")
def get_maintenance_batch(db: Session) -> int:
"""Effective maintenance re-validation batch: the admin override if set, else the
env/config default."""
return _row(db).maintenance_revalidate_batch or settings.maintenance_revalidate_batch
def set_maintenance_batch(db: Session, value: int) -> int:
"""Clamp and persist the maintenance batch override. Returns the applied value."""
value = max(MAINTENANCE_BATCH_MIN, min(MAINTENANCE_BATCH_MAX, int(value)))
row = _row(db)
row.maintenance_revalidate_batch = value
db.add(row)
db.commit()
log.info("Maintenance re-validation batch set to %s", value)
return value

View file

@ -29,6 +29,7 @@ from app import progress, quota
from app.config import settings from app.config import settings
from app.models import Playlist, PlaylistItem, Video, VideoState from app.models import Playlist, PlaylistItem, Video, VideoState
from app.notifications import create_notification from app.notifications import create_notification
from app.state import get_maintenance_batch
from app.sync.runner import get_service_user from app.sync.runner import get_service_user
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 from app.youtube.client import YouTubeClient
@ -189,7 +190,7 @@ def _recheck_flagged(db: Session, yt: YouTubeClient) -> dict:
def _revalidate_rolling(db: Session, yt: YouTubeClient) -> dict: def _revalidate_rolling(db: Session, yt: YouTubeClient) -> dict:
"""Phase 2: re-check the least-recently-checked currently-available videos and flag any """Phase 2: re-check the least-recently-checked currently-available videos and flag any
that have become unplayable (hidden immediately; deleted later if they don't recover).""" that have become unplayable (hidden immediately; deleted later if they don't recover)."""
batch = settings.maintenance_revalidate_batch batch = get_maintenance_batch(db) # admin override, else config default
flagged_new = 0 flagged_new = 0
checked = 0 checked = 0
now = _now() now = _now()

View file

@ -1,231 +0,0 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Bell, Eye, RotateCcw, Search, Trash2 } from "lucide-react";
import { api, type FeedFilters } from "../lib/api";
import {
clearAll,
dismiss,
getNotifications,
getUnreadCount,
markAllRead,
notify,
subscribe,
type VideoHiddenMeta,
type VideoWatchedMeta,
} from "../lib/notifications";
import { LEVEL_STYLE } from "./Toaster";
function relTime(ts: number, t: TFunction): string {
const s = Math.round((Date.now() - ts) / 1000);
if (s < 60) return t("notifications.time.justNow");
const m = Math.round(s / 60);
if (m < 60) return t("notifications.time.minutes", { count: m });
const h = Math.round(m / 60);
if (h < 24) return t("notifications.time.hours", { count: h });
const d = Math.round(h / 24);
return t("notifications.time.days", { count: d });
}
export default function NotificationCenter({
filters,
setFilters,
variant = "header",
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
variant?: "header" | "rail";
}) {
const { t } = useTranslation();
const notifications = useSyncExternalStore(subscribe, getNotifications, getNotifications);
const unread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
const [open, setOpen] = useState(false);
const wrap = useRef<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
// Rail variant anchors the panel right + above the button (fixed, viewport coords) and
// portals it to <body> so it escapes the nav's backdrop-filter.
const [pos, setPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 });
const qc = useQueryClient();
// Close when clicking anywhere outside the button + panel.
useEffect(() => {
if (!open) return;
function onDown(e: MouseEvent) {
const target = e.target as Node;
if (btnRef.current?.contains(target) || panelRef.current?.contains(target)) return;
if (variant === "header" && wrap.current?.contains(target)) return;
setOpen(false);
}
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open, variant]);
function toggle() {
if (!open && variant === "rail") {
const r = btnRef.current?.getBoundingClientRect();
if (r) setPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom });
}
setOpen((o) => !o);
}
// Opening the center marks everything read.
useEffect(() => {
if (open) markAllRead();
}, [open, notifications.length]);
function locateHidden(meta: VideoHiddenMeta) {
setFilters({
...filters,
show: "hidden",
channelId: meta.channelId || undefined,
channelName: meta.channelName || undefined,
});
setOpen(false);
}
function revertState(
meta: VideoHiddenMeta | VideoWatchedMeta,
messageKey: "notifications.unhidden" | "notifications.unwatched"
) {
api
.setState(meta.videoId, "new")
.then(() => {
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["feed-count"] });
notify({ level: "success", message: t(messageKey, { title: meta.title }) });
})
.catch(() => {});
}
function renderPanel(el: JSX.Element) {
if (variant !== "rail") return el;
return createPortal(
<div style={{ position: "fixed", left: pos.left, bottom: pos.bottom, zIndex: 40 }}>
{el}
</div>,
document.body
);
}
return (
<div className="relative" ref={wrap}>
<button
ref={btnRef}
onClick={toggle}
title={t("notifications.title")}
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Bell className="w-5 h-5" />
{unread > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-accent text-accent-fg text-[10px] font-bold grid place-items-center">
{unread > 9 ? "9+" : unread}
</span>
)}
</button>
{open &&
renderPanel(
<div
ref={panelRef}
className={`glass-menu w-80 max-w-[calc(100vw-2rem)] rounded-xl flex flex-col max-h-[70vh] animate-[popIn_0.16s_ease] ${
variant === "rail" ? "" : "absolute right-0 mt-2 z-30"
}`}
>
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<div className="text-sm font-semibold">{t("notifications.title")}</div>
{notifications.length > 0 && (
<button
onClick={clearAll}
className="flex items-center gap-1 text-xs text-muted hover:text-accent transition"
title={t("notifications.clearAll")}
>
<Trash2 className="w-3.5 h-3.5" />
{t("notifications.clear")}
</button>
)}
</div>
<div className="overflow-y-auto">
{notifications.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-muted">{t("notifications.empty")}</div>
) : (
notifications.map((n) => {
const { icon: Icon, color } = LEVEL_STYLE[n.level];
const needsAction = n.requiresInteraction && !n.dismissed;
const hidden = n.meta?.kind === "video-hidden" ? n.meta : null;
const watched = n.meta?.kind === "video-watched" ? n.meta : null;
return (
<div
key={n.id}
className={`flex items-start gap-2.5 px-3 py-2.5 border-b border-border/60 ${
needsAction ? "bg-accent/5" : ""
}`}
>
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1">
{n.title && <div className="text-sm font-semibold">{n.title}</div>}
<div className="text-sm break-words">{n.message}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[11px] text-muted">{relTime(n.ts, t)}</span>
{needsAction && (
<span className="text-[10px] uppercase tracking-wide font-semibold text-accent">
{t("notifications.actionNeeded")}
</span>
)}
</div>
{hidden ? (
<div className="flex items-center gap-3 mt-1">
<button
onClick={() => locateHidden(hidden)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Search className="w-3.5 h-3.5" />
{t("notifications.findInFeed")}
</button>
<button
onClick={() => revertState(hidden, "notifications.unhidden")}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Eye className="w-3.5 h-3.5" />
{t("notifications.unhide")}
</button>
</div>
) : watched ? (
<div className="flex items-center gap-3 mt-1">
<button
onClick={() => revertState(watched, "notifications.unwatched")}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("notifications.unwatch")}
</button>
</div>
) : (
n.action &&
!n.dismissed && (
<button
onClick={() => {
n.action!.onClick();
dismiss(n.id);
}}
className="mt-1 text-accent text-sm font-semibold hover:underline"
>
{n.action.label}
</button>
)
)}
</div>
</div>
);
})
)}
</div>
</div>
)}
</div>
);
}

View file

@ -212,6 +212,60 @@ function JobRow({
); );
} }
function MaintenanceSettings({
m,
onSave,
saving,
}: {
m: SchedulerStatus["maintenance"];
onSave: (batch: number) => void;
saving: boolean;
}) {
const { t } = useTranslation();
const [val, setVal] = useState(String(m.revalidate_batch));
useEffect(() => setVal(String(m.revalidate_batch)), [m.revalidate_batch]);
function save() {
const n = parseInt(val, 10);
if (Number.isFinite(n) && n >= m.min && n <= m.max && n !== m.revalidate_batch) onSave(n);
}
const dirty = String(m.revalidate_batch) !== val;
return (
<div className="glass-card rounded-xl p-4">
<div className="flex items-center gap-2 text-sm flex-wrap">
<Database className="w-4 h-4 text-muted shrink-0" />
<Tooltip hint={t("scheduler.maintenance.batchHint")}>
<span className="underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help">
{t("scheduler.maintenance.batchLabel")}
</span>
</Tooltip>
<span className="flex-1" />
<input
type="number"
min={m.min}
max={m.max}
step={1000}
value={val}
onChange={(e) => setVal(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && save()}
className="w-28 bg-card border border-border rounded px-2 py-1 text-sm tabular-nums outline-none focus:border-accent"
/>
<button
onClick={save}
disabled={!dirty || saving}
className="glass-card glass-hover px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
>
{t("common.save")}
</button>
</div>
<div className="text-[11px] text-muted mt-1.5">
{t("scheduler.maintenance.batchNote", { default: m.revalidate_batch_default.toLocaleString() })}
</div>
</div>
);
}
function Stat({ function Stat({
icon: Icon, icon: Icon,
label, label,
@ -285,6 +339,10 @@ export default function Scheduler() {
qc.invalidateQueries({ queryKey: ["scheduler"] }); qc.invalidateQueries({ queryKey: ["scheduler"] });
}, },
}); });
const batchMut = useMutation({
mutationFn: (v: number) => api.updateMaintenanceBatch(v),
onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }),
});
if (q.isLoading && !data) if (q.isLoading && !data)
return <div className="p-8 text-muted">{t("scheduler.loading")}</div>; return <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
@ -432,6 +490,16 @@ export default function Scheduler() {
<div className="text-[11px] text-muted mt-1.5">{t("scheduler.quotaNote")}</div> <div className="text-[11px] text-muted mt-1.5">{t("scheduler.quotaNote")}</div>
</div> </div>
</div> </div>
{/* Maintenance settings */}
<div>
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.maintenance.title")}</div>
<MaintenanceSettings
m={data.maintenance}
onSave={(batch) => batchMut.mutate(batch)}
saving={batchMut.isPending}
/>
</div>
</div> </div>
); );
} }

View file

@ -40,6 +40,12 @@
"backfill_deep": "Ganze Historie nachladen", "backfill_deep": "Ganze Historie nachladen",
"probe": "Shorts klassifizieren" "probe": "Shorts klassifizieren"
}, },
"maintenance": {
"title": "Wartung",
"batchLabel": "Pro Lauf erneut geprüfte Videos",
"batchHint": "Wie viele am längsten nicht geprüfte Videos der Wartungs-Job pro Lauf erneut validiert. Höher = schnellerer Gesamtzyklus, aber mehr API-Kontingent (1 Einheit je 50 Videos).",
"batchNote": "Standard: {{default}}. ~233k Videos ÷ dieser Wert = Tage für einen vollen Prüfzyklus."
},
"dot": { "dot": {
"running": "läuft gerade", "running": "läuft gerade",
"ok": "letzter Lauf OK", "ok": "letzter Lauf OK",

View file

@ -40,6 +40,12 @@
"backfill_deep": "Backfilling full history", "backfill_deep": "Backfilling full history",
"probe": "Classifying Shorts" "probe": "Classifying Shorts"
}, },
"maintenance": {
"title": "Maintenance",
"batchLabel": "Videos re-checked per run",
"batchHint": "How many least-recently-checked videos the maintenance job re-validates each run. Higher = faster full cycle but more API quota (1 unit per 50 videos).",
"batchNote": "Default: {{default}}. ~233k videos ÷ this = days for a full re-check cycle."
},
"dot": { "dot": {
"running": "running now", "running": "running now",
"ok": "last run OK", "ok": "last run OK",

View file

@ -40,6 +40,12 @@
"backfill_deep": "Teljes előzmény behúzása", "backfill_deep": "Teljes előzmény behúzása",
"probe": "Shorts besorolás" "probe": "Shorts besorolás"
}, },
"maintenance": {
"title": "Karbantartás",
"batchLabel": "Futásonként újraellenőrzött videók",
"batchHint": "Hány, legrégebben ellenőrzött videót ellenőriz újra a karbantartó job futásonként. Magasabb = gyorsabb teljes ciklus, de több API-kvóta (50 videónként 1 egység).",
"batchNote": "Alapérték: {{default}}. ~233k videó ÷ ez = a teljes újraellenőrzési ciklus napokban."
},
"dot": { "dot": {
"running": "most fut", "running": "most fut",
"ok": "utolsó futás OK", "ok": "utolsó futás OK",

View file

@ -349,6 +349,12 @@ export interface SchedulerStatus {
daily_budget: number; daily_budget: number;
backfill_reserve: number; backfill_reserve: number;
}; };
maintenance: {
revalidate_batch: number;
revalidate_batch_default: number;
min: number;
max: number;
};
} }
export interface Account { export interface Account {
@ -474,6 +480,11 @@ export const api = {
req(`/api/admin/scheduler/jobs/${jobId}/run`, { method: "POST" }), req(`/api/admin/scheduler/jobs/${jobId}/run`, { method: "POST" }),
runAllSchedulerJobs: (): Promise<{ started: string[] }> => runAllSchedulerJobs: (): Promise<{ started: string[] }> =>
req("/api/admin/scheduler/run-all", { method: "POST" }), req("/api/admin/scheduler/run-all", { method: "POST" }),
updateMaintenanceBatch: (revalidateBatch: number): Promise<{ revalidate_batch: number }> =>
req("/api/admin/scheduler/maintenance", {
method: "PATCH",
body: JSON.stringify({ revalidate_batch: revalidateBatch }),
}),
// --- onboarding / admin --- // --- onboarding / admin ---
requestAccess: (email: string): Promise<{ status: string }> => requestAccess: (email: string): Promise<{ status: string }> =>