+ {/* 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,
+ });
+}