From 02d913f13332038a76e9c285bd93a89a295375b4 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 16 Jun 2026 14:38:51 +0200 Subject: [PATCH 1/2] feat(scheduler): admin dashboard endpoint + per-job activity tracking Record each scheduler job's run activity in-process (running / last result / error / timestamps) and expose a snapshot. New admin-only GET /api/admin/scheduler returns the per-job activity (with APScheduler next-run times), the DB-derived work still queued (channels/videos pending, deep ETA, live-refresh backlog) and the shared quota picture. --- backend/app/main.py | 15 +++++- backend/app/routes/scheduler.py | 86 +++++++++++++++++++++++++++++++++ backend/app/scheduler.py | 78 +++++++++++++++++++++++++++++- 3 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 backend/app/routes/scheduler.py diff --git a/backend/app/main.py b/backend/app/main.py index 34fef9e..ca0b01d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -26,7 +26,19 @@ from starlette.middleware.sessions import SessionMiddleware from app import auth from app.config import settings -from app.routes import admin, channels, feed, health, me, playlists, quota, sync, tags, version +from app.routes import ( + admin, + channels, + feed, + health, + me, + playlists, + quota, + scheduler as scheduler_routes, + sync, + tags, + version, +) from app.scheduler import shutdown_scheduler, start_scheduler @@ -68,6 +80,7 @@ app.include_router(me.router) app.include_router(channels.router) app.include_router(playlists.router) app.include_router(admin.router) +app.include_router(scheduler_routes.router) app.include_router(quota.router) app.include_router(version.router) diff --git a/backend/app/routes/scheduler.py b/backend/app/routes/scheduler.py new file mode 100644 index 0000000..effa4c6 --- /dev/null +++ b/backend/app/routes/scheduler.py @@ -0,0 +1,86 @@ +"""Admin Scheduler dashboard: a live view of what the background scheduler is doing — +per-job run activity (from the in-process scheduler), the work still queued (DB-derived), +and the shared quota picture.""" +from fastapi import APIRouter, Depends +from sqlalchemy import and_, func, or_, select +from sqlalchemy.orm import Session + +from app import quota, state +from app.config import settings +from app.db import get_db +from app.models import Channel, Subscription, User, Video +from app.routes.admin import admin_user +from app.scheduler import scheduler_snapshot +from app.sync.runner import estimate_deep_backfill + +router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"]) + + +@router.get("") +def get_scheduler( + _: User = Depends(admin_user), db: Session = Depends(get_db) +) -> dict: + snap = scheduler_snapshot() + + # Work still queued (shared catalog, so these are instance-wide, not per-user). + deep_requested = ( + select(Subscription.id) + .where( + Subscription.channel_id == Channel.id, + Subscription.deep_requested.is_(True), + ) + .exists() + ) + deep_channels = ( + db.execute( + select(Channel).where(Channel.backfill_done.is_(False), deep_requested) + ) + .scalars() + .all() + ) + deep_eta = estimate_deep_backfill(db, deep_channels) + + queue = { + "channels_recent_pending": db.scalar( + select(func.count()).select_from(Channel).where(Channel.recent_synced_at.is_(None)) + ) + or 0, + "channels_deep_pending": deep_eta["channels_pending"], + "deep_eta_seconds": deep_eta["eta_seconds"], + "videos_pending_enrich": db.scalar( + select(func.count()).select_from(Video).where(Video.enriched_at.is_(None)) + ) + or 0, + "videos_pending_shorts": db.scalar( + select(func.count()) + .select_from(Video) + .where(Video.shorts_probed.is_(False), Video.enriched_at.is_not(None)) + ) + or 0, + "videos_live_refresh": db.scalar( + select(func.count()) + .select_from(Video) + .where( + or_( + Video.live_status.in_(("live", "upcoming")), + and_(Video.live_status == "was_live", Video.duration_seconds.is_(None)), + ) + ) + ) + or 0, + } + + used = quota.units_used_today(db) + quota_info = { + "used_today": used, + "remaining_today": quota.remaining_today(db), + "daily_budget": settings.quota_daily_budget, + "backfill_reserve": settings.backfill_quota_reserve, + } + + return { + **snap, + "paused": state.is_sync_paused(db), + "queue": queue, + "quota": quota_info, + } diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index 8d6d0c3..467910f 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -1,6 +1,8 @@ """Background scheduler: free RSS detection, enrichment of new videos, and quota-aware recent-first / deep backfill. Runs inside the API process (single worker).""" import logging +import threading +from datetime import datetime, timezone from apscheduler.schedulers.background import BackgroundScheduler @@ -23,22 +25,96 @@ logger = logging.getLogger("subfeed.scheduler") _scheduler: BackgroundScheduler | None = None +# Per-job run activity, kept in-memory for the admin Scheduler dashboard to read (the +# scheduler lives in the same single-worker process as the API, so a request can read it +# directly). Ephemeral by design — it reflects "what the scheduler is doing right now and +# how the last runs went" since this process started; durable history isn't the goal here. +_activity: dict[str, dict] = {} +_activity_lock = threading.Lock() + +# Each interval job's configured period, surfaced so the dashboard can show "every N min". +JOB_INTERVALS: dict[str, int] = { + "rss_poll": settings.rss_poll_minutes, + "enrich": settings.enrich_interval_minutes, + "backfill": settings.backfill_interval_minutes, + "autotag": settings.autotag_interval_minutes, + "shorts": settings.shorts_probe_interval_minutes, + "subscriptions": settings.subscriptions_resync_minutes, + "playlist_sync": settings.playlist_sync_minutes, +} + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _record(name: str, **fields) -> None: + with _activity_lock: + _activity.setdefault(name, {}).update(fields) + def _job(name: str, fn) -> None: db = SessionLocal() + _record(name, running=True, last_started=_now_iso(), last_error=None) try: if is_sync_paused(db): logger.info("job %s skipped (sync paused)", name) + _record(name, running=False, status="skipped", last_finished=_now_iso(), + last_result="paused") return result = fn(db) logger.info("job %s -> %s", name, result) - except Exception: + _record(name, running=False, status="ok", last_finished=_now_iso(), + last_result=_summarize(result)) + except Exception as exc: db.rollback() logger.exception("job %s failed", name) + _record(name, running=False, status="error", last_finished=_now_iso(), + last_error=str(exc) or exc.__class__.__name__) finally: db.close() +def _summarize(result) -> str | None: + """Compact one-line rendering of a job's return value for the dashboard.""" + if result is None: + return None + if isinstance(result, dict): + return ", ".join(f"{k}={v}" for k, v in result.items()) or None + return str(result) + + +def scheduler_snapshot() -> dict: + """What the scheduler is doing, for the admin dashboard. `running_here` is False in + environments where the scheduler is disabled (e.g. local dev shares the prod-ish DB but + runs no scheduler), so the UI can say so while still showing DB-derived queue/quota.""" + running_here = _scheduler is not None + next_runs: dict[str, str | None] = {} + if _scheduler is not None: + for job in _scheduler.get_jobs(): + nr = getattr(job, "next_run_time", None) + next_runs[job.id] = nr.astimezone(timezone.utc).isoformat() if nr else None + with _activity_lock: + acts = {k: dict(v) for k, v in _activity.items()} + jobs = [] + for job_id, interval in JOB_INTERVALS.items(): + a = acts.get(job_id, {}) + jobs.append( + { + "id": job_id, + "interval_minutes": interval, + "next_run": next_runs.get(job_id), + "running": a.get("running", False), + "status": a.get("status"), + "last_started": a.get("last_started"), + "last_finished": a.get("last_finished"), + "last_result": a.get("last_result"), + "last_error": a.get("last_error"), + } + ) + return {"running_here": running_here, "enabled": settings.scheduler_enabled, "jobs": jobs} + + def _rss_job() -> None: _job("rss_poll", lambda db: run_rss_poll(db)) From db9ee4beaba0a881f60902493d9f8b79b1e0e0a9 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 16 Jun 2026 14:38:51 +0200 Subject: [PATCH 2/2] feat(scheduler): live admin Scheduler dashboard + reusable poll hook (HU/EN/DE) New admin Scheduler page (left-nav entry) with a live, self-refreshing view of job activity, queued work and quota. Polling is factored into a reusable useLiveQuery hook (pauses when the tab is unfocused) that the notification bell and future yt-dlp job queue will reuse instead of re-implementing. --- frontend/src/App.tsx | 6 +- frontend/src/components/Header.tsx | 4 +- frontend/src/components/NavSidebar.tsx | 5 +- frontend/src/components/Scheduler.tsx | 273 ++++++++++++++++++++ frontend/src/i18n/locales/de/header.json | 2 + frontend/src/i18n/locales/de/scheduler.json | 46 ++++ frontend/src/i18n/locales/en/header.json | 2 + frontend/src/i18n/locales/en/scheduler.json | 46 ++++ frontend/src/i18n/locales/hu/header.json | 2 + frontend/src/i18n/locales/hu/scheduler.json | 46 ++++ frontend/src/lib/api.ts | 34 +++ frontend/src/lib/urlState.ts | 8 +- frontend/src/lib/useLiveQuery.ts | 22 ++ 13 files changed, 491 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/Scheduler.tsx create mode 100644 frontend/src/i18n/locales/de/scheduler.json create mode 100644 frontend/src/i18n/locales/en/scheduler.json create mode 100644 frontend/src/i18n/locales/hu/scheduler.json create mode 100644 frontend/src/lib/useLiveQuery.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 688a049..7501839 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,7 @@ import Feed from "./components/Feed"; import Channels, { type ChannelStatusFilter } from "./components/Channels"; import Playlists from "./components/Playlists"; import Stats from "./components/Stats"; +import Scheduler from "./components/Scheduler"; import SettingsPanel from "./components/SettingsPanel"; import OnboardingWizard from "./components/OnboardingWizard"; import { shouldAutoOpenOnboarding } from "./lib/onboarding"; @@ -62,7 +63,8 @@ function loadInitialPage(): Page { stored === "channels" || stored === "stats" || stored === "playlists" || - stored === "settings" + stored === "settings" || + stored === "scheduler" ) return stored; return "feed"; @@ -271,6 +273,8 @@ export default function App() { /> ) : page === "stats" && meQuery.data!.role === "admin" ? ( + ) : page === "scheduler" && meQuery.data!.role === "admin" ? ( + ) : page === "playlists" ? ( ) : page === "settings" ? ( diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index f5c3bdc..5afe782 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -78,7 +78,9 @@ export default function Header({ ? t("header.account.playlists") : page === "settings" ? t("settings.title") - : t("header.channelManager")} + : page === "scheduler" + ? t("header.scheduler") + : t("header.channelManager")} )} diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 0d8c75b..cb6d989 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -3,6 +3,7 @@ import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { + Activity, BarChart3, ChevronLeft, ChevronRight, @@ -109,8 +110,10 @@ export default function NavSidebar({ { page: "channels", icon: Tv, label: t("header.account.channels") }, { page: "playlists", icon: ListVideo, label: t("header.account.playlists") }, ]; - if (me.role === "admin") + if (me.role === "admin") { items.push({ page: "stats", icon: BarChart3, label: t("header.account.stats") }); + items.push({ page: "scheduler", icon: Activity, label: t("header.account.scheduler") }); + } const rowBase = "w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition"; diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx new file mode 100644 index 0000000..1226fb7 --- /dev/null +++ b/frontend/src/components/Scheduler.tsx @@ -0,0 +1,273 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + Activity, + AlertTriangle, + Clock, + Database, + Gauge, + Pause, + Play, + RadioTower, +} from "lucide-react"; +import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api"; +import { useLiveQuery } from "../lib/useLiveQuery"; +import { relativeTime } from "../lib/format"; +import { notify } from "../lib/notifications"; +import Tooltip from "./Tooltip"; + +const POLL_MS = 4000; + +// Seconds until an ISO instant (negative = past). +function secsUntil(iso: string | null): number | null { + if (!iso) return null; + return Math.round((new Date(iso).getTime() - Date.now()) / 1000); +} + +function fmtCountdown(s: number): string { + if (s <= 0) return "now"; + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ${s % 60}s`; + const h = Math.floor(m / 60); + return `${h}h ${m % 60}m`; +} + +function StatusDot({ job }: { job: SchedulerJob }) { + const color = job.running + ? "bg-accent animate-pulse" + : job.status === "ok" + ? "bg-emerald-500" + : job.status === "error" + ? "bg-red-500" + : job.status === "skipped" + ? "bg-amber-500" + : "bg-border"; + return ; +} + +function JobRow({ job }: { job: SchedulerJob }) { + const { t } = useTranslation(); + const next = secsUntil(job.next_run); + return ( +
+ +
+
+ {t(`scheduler.jobs.${job.id}`, job.id)} + + {" "} + · {t("scheduler.everyMin", { count: job.interval_minutes })} + +
+
+ {job.running + ? t("scheduler.runningNow") + : job.last_finished + ? t("scheduler.lastRun", { time: relativeTime(job.last_finished) }) + : t("scheduler.neverRun")} + {job.last_error ? ( + · {job.last_error} + ) : job.last_result ? ( + · {job.last_result} + ) : null} +
+
+
+ {job.running ? ( + {t("scheduler.active")} + ) : next != null ? ( + <> +
{t("scheduler.nextRun")}
+
{fmtCountdown(next)}
+ + ) : ( + "—" + )} +
+
+ ); +} + +function Stat({ + icon: Icon, + label, + value, + hint, + sub, +}: { + icon: typeof Database; + label: string; + value: string | number; + hint?: string; + sub?: string; +}) { + return ( +
+
+ + + + {label} + + +
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + +export default function Scheduler() { + const { t } = useTranslation(); + const qc = useQueryClient(); + const q = useLiveQuery(["scheduler"], api.schedulerStatus, { + intervalMs: POLL_MS, + }); + const data = q.data; + + // Tick once a second so the per-job countdowns move between polls. + const [, setTick] = useState(0); + useEffect(() => { + const id = setInterval(() => setTick((n) => n + 1), 1000); + return () => clearInterval(id); + }, []); + + const pauseResume = useMutation({ + mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()), + onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }), + onError: () => notify({ level: "error", message: t("scheduler.toggleFailed") }), + }); + + if (q.isLoading && !data) + return
{t("scheduler.loading")}
; + if (!data) + return
{t("common.somethingWrong")}
; + + const quotaPct = data.quota.daily_budget + ? Math.min(100, Math.round((data.quota.used_today / data.quota.daily_budget) * 100)) + : 0; + + return ( +
+ {/* Status strip */} +
+ +
+
{t("scheduler.title")}
+
+ + + {data.paused + ? t("scheduler.paused") + : data.running_here + ? t("scheduler.running") + : t("scheduler.notHere")} + + {q.isFetching && · {t("scheduler.updating")}} +
+
+
+ + + +
+ + {!data.running_here && ( +
+ + {t("scheduler.notHereNote")} +
+ )} + + {/* Jobs */} +
+
{t("scheduler.jobsTitle")}
+
+ {data.jobs.map((job) => ( + + ))} +
+
+ + {/* Queue */} +
+
{t("scheduler.queueTitle")}
+
+ + + + + +
+
+ + {/* Quota */} +
+
{t("scheduler.quotaTitle")}
+
+
+ + + {t("scheduler.quotaUsed", { + used: data.quota.used_today.toLocaleString(), + budget: data.quota.daily_budget.toLocaleString(), + })} + + + + {t("scheduler.quotaRemaining", { + count: data.quota.remaining_today, + formatted: data.quota.remaining_today.toLocaleString(), + })} + +
+
+
90 ? "bg-red-500" : "bg-accent"}`} + style={{ width: `${quotaPct}%` }} + /> +
+
{t("scheduler.quotaNote")}
+
+
+
+ ); +} diff --git a/frontend/src/i18n/locales/de/header.json b/frontend/src/i18n/locales/de/header.json index e220b88..6c24304 100644 --- a/frontend/src/i18n/locales/de/header.json +++ b/frontend/src/i18n/locales/de/header.json @@ -3,6 +3,7 @@ "searchPlaceholder": "Deine Abos durchsuchen…", "channelManager": "Kanalverwaltung", "usageStats": "Nutzung & Statistik", + "scheduler": "Planer", "scope": { "label": "Feed-Quelle", "my": "Meine", @@ -16,6 +17,7 @@ "channels": "Kanäle", "playlists": "Wiedergabelisten", "stats": "Statistik", + "scheduler": "Planer", "settings": "Einstellungen", "about": "Über", "signOut": "Abmelden", diff --git a/frontend/src/i18n/locales/de/scheduler.json b/frontend/src/i18n/locales/de/scheduler.json new file mode 100644 index 0000000..e54aa38 --- /dev/null +++ b/frontend/src/i18n/locales/de/scheduler.json @@ -0,0 +1,46 @@ +{ + "title": "Hintergrund-Planer", + "loading": "Planer wird geladen…", + "running": "Läuft", + "paused": "Pausiert", + "notHere": "Läuft in dieser Instanz nicht", + "notHereNote": "Der Planer läuft auf dem Server. Diese Instanz zeigt die gemeinsame Warteschlange und das Kontingent, aber die Live-Job-Aktivität erscheint nur dort, wo der Planer läuft.", + "updating": "wird aktualisiert…", + "pause": "Pausieren", + "resume": "Fortsetzen", + "pauseHint": "Die gesamte Hintergrund-Synchronisierung der App pausieren oder fortsetzen (für alle Nutzer).", + "toggleFailed": "Der Planer-Status konnte nicht geändert werden", + "jobsTitle": "Jobs", + "queueTitle": "Wartende Arbeit", + "quotaTitle": "Gemeinsames API-Kontingent", + "everyMin": "alle {{count}} Min", + "runningNow": "Läuft gerade…", + "lastRun": "Letzter Lauf {{time}}", + "neverRun": "In dieser Sitzung noch nicht gelaufen", + "active": "aktiv", + "nextRun": "nächster", + "quotaUsed": "{{used}} / {{budget}} Einheiten heute verbraucht", + "quotaRemaining": "{{formatted}} übrig", + "quotaNote": "Gemeinsames YouTube-Data-API-Budget; Reset um Mitternacht US-Pazifik. Backfill tritt zurück, um Spielraum für interaktive Nutzung zu lassen.", + "jobs": { + "rss_poll": "RSS-Abfrage (neue Uploads)", + "enrich": "Anreicherung + Live-Aktualisierung", + "backfill": "Backfill (aktuell + ganze Historie)", + "autotag": "Automatisches Tagging", + "shorts": "Shorts-Klassifizierung", + "subscriptions": "Abo-Resync", + "playlist_sync": "YouTube-Wiedergabelisten-Sync" + }, + "queue": { + "recentPending": "Zu synchronisierende Kanäle", + "recentPendingHint": "Kanäle, deren neueste Uploads noch nicht abgerufen wurden.", + "deepPending": "Ganze Historie ausstehend", + "deepPendingHint": "Für vollständigen Verlauf vorgemerkte Kanäle, die noch nicht fertig sind.", + "enrichPending": "Zu anreichernde Videos", + "enrichPendingHint": "Videos, die noch auf Details warten (Dauer, Statistiken, Live-/Short-Klassifizierung).", + "shortsPending": "Zu klassifizierende Shorts", + "shortsPendingHint": "Bereits angereicherte Videos, die noch auf die Shorts-Prüfung warten.", + "liveRefresh": "Live zu aktualisieren", + "liveRefreshHint": "Live-/bevorstehende Videos (und gerade beendete Streams ohne Dauer), bis sie sich stabilisieren." + } +} diff --git a/frontend/src/i18n/locales/en/header.json b/frontend/src/i18n/locales/en/header.json index e6f29dc..794deea 100644 --- a/frontend/src/i18n/locales/en/header.json +++ b/frontend/src/i18n/locales/en/header.json @@ -3,6 +3,7 @@ "searchPlaceholder": "Search your subscriptions…", "channelManager": "Channel manager", "usageStats": "Usage & stats", + "scheduler": "Scheduler", "scope": { "label": "Feed source", "my": "Mine", @@ -16,6 +17,7 @@ "channels": "Channels", "playlists": "Playlists", "stats": "Stats", + "scheduler": "Scheduler", "settings": "Settings", "about": "About", "signOut": "Sign out", diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json new file mode 100644 index 0000000..60bff5c --- /dev/null +++ b/frontend/src/i18n/locales/en/scheduler.json @@ -0,0 +1,46 @@ +{ + "title": "Background scheduler", + "loading": "Loading scheduler…", + "running": "Running", + "paused": "Paused", + "notHere": "Not running in this instance", + "notHereNote": "The scheduler runs on the server. This instance shows the shared queue and quota, but live job activity appears only where the scheduler runs.", + "updating": "updating…", + "pause": "Pause", + "resume": "Resume", + "pauseHint": "Pause or resume all background sync for the whole app (every user).", + "toggleFailed": "Couldn't change the scheduler state", + "jobsTitle": "Jobs", + "queueTitle": "Work queued", + "quotaTitle": "Shared API quota", + "everyMin": "every {{count}} min", + "runningNow": "Running now…", + "lastRun": "Last run {{time}}", + "neverRun": "Hasn't run yet this session", + "active": "active", + "nextRun": "next", + "quotaUsed": "{{used}} / {{budget}} units used today", + "quotaRemaining": "{{formatted}} left", + "quotaNote": "Shared YouTube Data API budget; resets at midnight US Pacific. Backfill yields to leave headroom for interactive use.", + "jobs": { + "rss_poll": "RSS poll (new uploads)", + "enrich": "Enrichment + live refresh", + "backfill": "Backfill (recent + full history)", + "autotag": "Auto-tagging", + "shorts": "Shorts classification", + "subscriptions": "Subscription resync", + "playlist_sync": "YouTube playlist sync" + }, + "queue": { + "recentPending": "Channels to sync", + "recentPendingHint": "Channels whose recent uploads haven't been fetched yet.", + "deepPending": "Full-history pending", + "deepPendingHint": "Channels opted into full-history backfill that aren't finished yet.", + "enrichPending": "Videos to enrich", + "enrichPendingHint": "Videos awaiting details (duration, stats, live/short classification).", + "shortsPending": "Shorts to classify", + "shortsPendingHint": "Enriched videos still awaiting the Shorts probe.", + "liveRefresh": "Live to refresh", + "liveRefreshHint": "Live/upcoming videos (and just-ended streams without a duration yet) re-checked until they settle." + } +} diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json index 16f3380..0e8b17f 100644 --- a/frontend/src/i18n/locales/hu/header.json +++ b/frontend/src/i18n/locales/hu/header.json @@ -3,6 +3,7 @@ "searchPlaceholder": "Keresés a feliratkozásaid között…", "channelManager": "Csatornakezelő", "usageStats": "Használat és statisztika", + "scheduler": "Ütemező", "scope": { "label": "Hírfolyam forrása", "my": "Sajátom", @@ -16,6 +17,7 @@ "channels": "Csatornák", "playlists": "Lejátszási listák", "stats": "Statisztika", + "scheduler": "Ütemező", "settings": "Beállítások", "about": "Névjegy", "signOut": "Kijelentkezés", diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json new file mode 100644 index 0000000..485bf0e --- /dev/null +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -0,0 +1,46 @@ +{ + "title": "Háttér-ütemező", + "loading": "Ütemező betöltése…", + "running": "Fut", + "paused": "Szüneteltetve", + "notHere": "Ebben a példányban nem fut", + "notHereNote": "Az ütemező a szerveren fut. Ez a példány a közös sort és kvótát mutatja, de az élő job-aktivitás csak ott látszik, ahol az ütemező fut.", + "updating": "frissítés…", + "pause": "Szüneteltetés", + "resume": "Folytatás", + "pauseHint": "A teljes app háttér-szinkronjának szüneteltetése vagy folytatása (minden felhasználóra).", + "toggleFailed": "Nem sikerült módosítani az ütemező állapotát", + "jobsTitle": "Feladatok", + "queueTitle": "Sorban álló munka", + "quotaTitle": "Közös API-kvóta", + "everyMin": "{{count}} percenként", + "runningNow": "Most fut…", + "lastRun": "Utolsó futás {{time}}", + "neverRun": "Ebben a munkamenetben még nem futott", + "active": "aktív", + "nextRun": "köv.", + "quotaUsed": "{{used}} / {{budget}} egység ma felhasználva", + "quotaRemaining": "{{formatted}} maradt", + "quotaNote": "Közös YouTube Data API-keret; éjfélkor (US Pacific) nullázódik. A backfill hátrébb sorol, hogy maradjon hely az interaktív használatnak.", + "jobs": { + "rss_poll": "RSS-lekérdezés (új feltöltések)", + "enrich": "Adatdúsítás + élő frissítés", + "backfill": "Backfill (friss + teljes előzmény)", + "autotag": "Automatikus címkézés", + "shorts": "Shorts-besorolás", + "subscriptions": "Feliratkozások újraszinkronja", + "playlist_sync": "YouTube lejátszási listák szinkronja" + }, + "queue": { + "recentPending": "Szinkronra váró csatorna", + "recentPendingHint": "Csatornák, amelyek friss feltöltéseit még nem kértük le.", + "deepPending": "Teljes előzmény függőben", + "deepPendingHint": "Teljes előzményre kijelölt csatornák, amelyek még nincsenek kész.", + "enrichPending": "Dúsításra váró videó", + "enrichPendingHint": "Videók, amelyek még adatokra várnak (hossz, statisztikák, live/short besorolás).", + "shortsPending": "Besorolásra váró Shorts", + "shortsPendingHint": "Már dúsított videók, amelyek még a Shorts-próbára várnak.", + "liveRefresh": "Frissítendő élő", + "liveRefreshHint": "Élő/közelgő videók (és a frissen véget ért, hossz nélküli adások), amíg le nem ülepednek." + } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 647102a..960bbe1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -303,6 +303,39 @@ export interface ManagedChannel { backfill_done: boolean; } +export interface SchedulerJob { + id: string; + interval_minutes: number; + next_run: string | null; + running: boolean; + status: "ok" | "error" | "skipped" | null; + last_started: string | null; + last_finished: string | null; + last_result: string | null; + last_error: string | null; +} + +export interface SchedulerStatus { + running_here: boolean; + enabled: boolean; + paused: boolean; + jobs: SchedulerJob[]; + queue: { + channels_recent_pending: number; + channels_deep_pending: number; + deep_eta_seconds: number; + videos_pending_enrich: number; + videos_pending_shorts: number; + videos_live_refresh: number; + }; + quota: { + used_today: number; + remaining_today: number; + daily_budget: number; + backfill_reserve: number; + }; +} + export interface Account { id: number; email: string; @@ -400,6 +433,7 @@ export const api = { // --- quota usage --- myUsage: (): Promise => req("/api/quota/my-usage"), adminQuota: (days = 30): Promise => req(`/api/quota/admin?days=${days}`), + schedulerStatus: (): Promise => req("/api/admin/scheduler"), // --- onboarding / admin --- requestAccess: (email: string): Promise<{ status: string }> => diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index a0ad9d2..ab2a38b 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -78,11 +78,15 @@ export function hasFilterParams(params: URLSearchParams): boolean { return KEYS.some((k) => params.has(k)); } -export type Page = "feed" | "channels" | "stats" | "playlists" | "settings"; +export type Page = "feed" | "channels" | "stats" | "playlists" | "settings" | "scheduler"; export function readPage(): Page { const p = new URLSearchParams(window.location.search).get("page"); - return p === "channels" || p === "stats" || p === "playlists" || p === "settings" + return p === "channels" || + p === "stats" || + p === "playlists" || + p === "settings" || + p === "scheduler" ? p : "feed"; } diff --git a/frontend/src/lib/useLiveQuery.ts b/frontend/src/lib/useLiveQuery.ts new file mode 100644 index 0000000..121045b --- /dev/null +++ b/frontend/src/lib/useLiveQuery.ts @@ -0,0 +1,22 @@ +import { useQuery, type QueryKey } from "@tanstack/react-query"; + +// Reusable "live" polling query: a thin wrapper over react-query that refetches on an +// interval and — by leaving refetchIntervalInBackground at its default false — pauses while +// the tab is unfocused, so it doesn't poll a server nobody's watching. This is the shared +// live-progress mechanism: the Scheduler dashboard uses it now; the notification bell and the +// future yt-dlp job queue reuse it rather than each re-implementing polling. +export function useLiveQuery( + key: QueryKey, + queryFn: () => Promise, + opts: { intervalMs?: number; enabled?: boolean } = {} +) { + const { intervalMs = 4000, enabled = true } = opts; + return useQuery({ + queryKey: key, + queryFn, + enabled, + refetchInterval: enabled ? intervalMs : false, + refetchIntervalInBackground: false, + staleTime: 0, + }); +}