diff --git a/backend/alembic/versions/0018_maintenance_batch_setting.py b/backend/alembic/versions/0018_maintenance_batch_setting.py new file mode 100644 index 0000000..07b9f9e --- /dev/null +++ b/backend/alembic/versions/0018_maintenance_batch_setting.py @@ -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") diff --git a/backend/app/models.py b/backend/app/models.py index dfe3e13..fca20f3 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -339,6 +339,9 @@ class AppState(Base): sync_paused: Mapped[bool] = mapped_column( 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): diff --git a/backend/app/routes/scheduler.py b/backend/app/routes/scheduler.py index 82a4c82..66e4f8f 100644 --- a/backend/app/routes/scheduler.py +++ b/backend/app/routes/scheduler.py @@ -71,6 +71,29 @@ def run_all_now(user: User = Depends(admin_user)) -> dict: 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("") def get_scheduler( _: User = Depends(admin_user), db: Session = Depends(get_db) @@ -138,4 +161,10 @@ def get_scheduler( "paused": state.is_sync_paused(db), "queue": queue, "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, + }, } diff --git a/backend/app/state.py b/backend/app/state.py index ad85e19..34f56fa 100644 --- a/backend/app/state.py +++ b/backend/app/state.py @@ -3,10 +3,15 @@ import logging from sqlalchemy.orm import Session +from app.config import settings from app.models import AppState 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: row = db.get(AppState, 1) @@ -27,3 +32,20 @@ def set_sync_paused(db: Session, paused: bool) -> None: db.add(row) db.commit() 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 diff --git a/backend/app/sync/maintenance.py b/backend/app/sync/maintenance.py index 7e308df..47e3569 100644 --- a/backend/app/sync/maintenance.py +++ b/backend/app/sync/maintenance.py @@ -29,6 +29,7 @@ from app import progress, quota from app.config import settings from app.models import Playlist, PlaylistItem, Video, VideoState from app.notifications import create_notification +from app.state import get_maintenance_batch from app.sync.runner import get_service_user from app.sync.videos import apply_video_details, parse_dt 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: """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).""" - batch = settings.maintenance_revalidate_batch + batch = get_maintenance_batch(db) # admin override, else config default flagged_new = 0 checked = 0 now = _now() diff --git a/frontend/src/components/NotificationCenter.tsx b/frontend/src/components/NotificationCenter.tsx deleted file mode 100644 index 146bf14..0000000 --- a/frontend/src/components/NotificationCenter.tsx +++ /dev/null @@ -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(null); - const btnRef = useRef(null); - const panelRef = useRef(null); - // Rail variant anchors the panel right + above the button (fixed, viewport coords) and - // portals it to 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( -
- {el} -
, - document.body - ); - } - - return ( -
- - - {open && - renderPanel( -
-
-
{t("notifications.title")}
- {notifications.length > 0 && ( - - )} -
- -
- {notifications.length === 0 ? ( -
{t("notifications.empty")}
- ) : ( - 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 ( -
- -
- {n.title &&
{n.title}
} -
{n.message}
-
- {relTime(n.ts, t)} - {needsAction && ( - - {t("notifications.actionNeeded")} - - )} -
- - {hidden ? ( -
- - -
- ) : watched ? ( -
- -
- ) : ( - n.action && - !n.dismissed && ( - - ) - )} -
-
- ); - }) - )} -
-
- )} -
- ); -} diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 697722b..549916e 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -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 ( +
+
+ + + + {t("scheduler.maintenance.batchLabel")} + + + + 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" + /> + +
+
+ {t("scheduler.maintenance.batchNote", { default: m.revalidate_batch_default.toLocaleString() })} +
+
+ ); +} + function Stat({ icon: Icon, label, @@ -285,6 +339,10 @@ export default function Scheduler() { qc.invalidateQueries({ queryKey: ["scheduler"] }); }, }); + const batchMut = useMutation({ + mutationFn: (v: number) => api.updateMaintenanceBatch(v), + onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }), + }); if (q.isLoading && !data) return
{t("scheduler.loading")}
; @@ -432,6 +490,16 @@ export default function Scheduler() {
{t("scheduler.quotaNote")}
+ + {/* Maintenance settings */} +
+
{t("scheduler.maintenance.title")}
+ batchMut.mutate(batch)} + saving={batchMut.isPending} + /> +
); } diff --git a/frontend/src/i18n/locales/de/scheduler.json b/frontend/src/i18n/locales/de/scheduler.json index 28fa613..8c30032 100644 --- a/frontend/src/i18n/locales/de/scheduler.json +++ b/frontend/src/i18n/locales/de/scheduler.json @@ -40,6 +40,12 @@ "backfill_deep": "Ganze Historie nachladen", "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": { "running": "läuft gerade", "ok": "letzter Lauf OK", diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json index 6c45ed5..b33bcf5 100644 --- a/frontend/src/i18n/locales/en/scheduler.json +++ b/frontend/src/i18n/locales/en/scheduler.json @@ -40,6 +40,12 @@ "backfill_deep": "Backfilling full history", "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": { "running": "running now", "ok": "last run OK", diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index 7c7fac4..a3a1b9a 100644 --- a/frontend/src/i18n/locales/hu/scheduler.json +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -40,6 +40,12 @@ "backfill_deep": "Teljes előzmény behúzása", "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": { "running": "most fut", "ok": "utolsó futás OK", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4dbb9b6..c316c70 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -349,6 +349,12 @@ export interface SchedulerStatus { daily_budget: number; backfill_reserve: number; }; + maintenance: { + revalidate_batch: number; + revalidate_batch_default: number; + min: number; + max: number; + }; } export interface Account { @@ -474,6 +480,11 @@ export const api = { req(`/api/admin/scheduler/jobs/${jobId}/run`, { method: "POST" }), runAllSchedulerJobs: (): Promise<{ started: string[] }> => 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 --- requestAccess: (email: string): Promise<{ status: string }> =>