Merge feature/scheduler-dashboard: live admin scheduler dashboard + reusable poll hook

This commit is contained in:
npeter83 2026-06-16 14:38:58 +02:00
commit c011972efb
16 changed files with 668 additions and 7 deletions

View file

@ -26,7 +26,19 @@ from starlette.middleware.sessions import SessionMiddleware
from app import auth from app import auth
from app.config import settings 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 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(channels.router)
app.include_router(playlists.router) app.include_router(playlists.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(scheduler_routes.router)
app.include_router(quota.router) app.include_router(quota.router)
app.include_router(version.router) app.include_router(version.router)

View file

@ -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,
}

View file

@ -1,6 +1,8 @@
"""Background scheduler: free RSS detection, enrichment of new videos, and quota-aware """Background scheduler: free RSS detection, enrichment of new videos, and quota-aware
recent-first / deep backfill. Runs inside the API process (single worker).""" recent-first / deep backfill. Runs inside the API process (single worker)."""
import logging import logging
import threading
from datetime import datetime, timezone
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
@ -23,22 +25,96 @@ logger = logging.getLogger("subfeed.scheduler")
_scheduler: BackgroundScheduler | None = None _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: def _job(name: str, fn) -> None:
db = SessionLocal() db = SessionLocal()
_record(name, running=True, last_started=_now_iso(), last_error=None)
try: try:
if is_sync_paused(db): if is_sync_paused(db):
logger.info("job %s skipped (sync paused)", name) logger.info("job %s skipped (sync paused)", name)
_record(name, running=False, status="skipped", last_finished=_now_iso(),
last_result="paused")
return return
result = fn(db) result = fn(db)
logger.info("job %s -> %s", name, result) 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() db.rollback()
logger.exception("job %s failed", name) 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: finally:
db.close() 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: def _rss_job() -> None:
_job("rss_poll", lambda db: run_rss_poll(db)) _job("rss_poll", lambda db: run_rss_poll(db))

View file

@ -27,6 +27,7 @@ import Feed from "./components/Feed";
import Channels, { type ChannelStatusFilter } from "./components/Channels"; import Channels, { type ChannelStatusFilter } from "./components/Channels";
import Playlists from "./components/Playlists"; import Playlists from "./components/Playlists";
import Stats from "./components/Stats"; import Stats from "./components/Stats";
import Scheduler from "./components/Scheduler";
import SettingsPanel from "./components/SettingsPanel"; import SettingsPanel from "./components/SettingsPanel";
import OnboardingWizard from "./components/OnboardingWizard"; import OnboardingWizard from "./components/OnboardingWizard";
import { shouldAutoOpenOnboarding } from "./lib/onboarding"; import { shouldAutoOpenOnboarding } from "./lib/onboarding";
@ -62,7 +63,8 @@ function loadInitialPage(): Page {
stored === "channels" || stored === "channels" ||
stored === "stats" || stored === "stats" ||
stored === "playlists" || stored === "playlists" ||
stored === "settings" stored === "settings" ||
stored === "scheduler"
) )
return stored; return stored;
return "feed"; return "feed";
@ -271,6 +273,8 @@ export default function App() {
/> />
) : page === "stats" && meQuery.data!.role === "admin" ? ( ) : page === "stats" && meQuery.data!.role === "admin" ? (
<Stats /> <Stats />
) : page === "scheduler" && meQuery.data!.role === "admin" ? (
<Scheduler />
) : page === "playlists" ? ( ) : page === "playlists" ? (
<Playlists canWrite={meQuery.data!.can_write} /> <Playlists canWrite={meQuery.data!.can_write} />
) : page === "settings" ? ( ) : page === "settings" ? (

View file

@ -78,6 +78,8 @@ export default function Header({
? t("header.account.playlists") ? t("header.account.playlists")
: page === "settings" : page === "settings"
? t("settings.title") ? t("settings.title")
: page === "scheduler"
? t("header.scheduler")
: t("header.channelManager")} : t("header.channelManager")}
</div> </div>
)} )}

View file

@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
Activity,
BarChart3, BarChart3,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
@ -109,8 +110,10 @@ export default function NavSidebar({
{ page: "channels", icon: Tv, label: t("header.account.channels") }, { page: "channels", icon: Tv, label: t("header.account.channels") },
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") }, { 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: "stats", icon: BarChart3, label: t("header.account.stats") });
items.push({ page: "scheduler", icon: Activity, label: t("header.account.scheduler") });
}
const rowBase = const rowBase =
"w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition"; "w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";

View file

@ -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 <span className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${color}`} />;
}
function JobRow({ job }: { job: SchedulerJob }) {
const { t } = useTranslation();
const next = secsUntil(job.next_run);
return (
<div className="glass-card rounded-xl p-3 flex items-center gap-3">
<StatusDot job={job} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{t(`scheduler.jobs.${job.id}`, job.id)}
<span className="text-muted font-normal">
{" "}
· {t("scheduler.everyMin", { count: job.interval_minutes })}
</span>
</div>
<div className="text-[11px] text-muted truncate">
{job.running
? t("scheduler.runningNow")
: job.last_finished
? t("scheduler.lastRun", { time: relativeTime(job.last_finished) })
: t("scheduler.neverRun")}
{job.last_error ? (
<span className="text-red-400"> · {job.last_error}</span>
) : job.last_result ? (
<span> · {job.last_result}</span>
) : null}
</div>
</div>
<div className="shrink-0 text-right text-[11px] text-muted tabular-nums">
{job.running ? (
<span className="text-accent">{t("scheduler.active")}</span>
) : next != null ? (
<>
<div className="uppercase tracking-wide text-[10px]">{t("scheduler.nextRun")}</div>
<div>{fmtCountdown(next)}</div>
</>
) : (
"—"
)}
</div>
</div>
);
}
function Stat({
icon: Icon,
label,
value,
hint,
sub,
}: {
icon: typeof Database;
label: string;
value: string | number;
hint?: string;
sub?: string;
}) {
return (
<div className="glass-card rounded-xl p-3">
<div className="flex items-center gap-2 text-muted text-xs">
<Icon className="w-4 h-4 shrink-0" />
<Tooltip hint={hint ?? ""}>
<span className={hint ? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help" : ""}>
{label}
</span>
</Tooltip>
</div>
<div className="text-xl font-semibold mt-1 tabular-nums">{value}</div>
{sub && <div className="text-[11px] text-muted">{sub}</div>}
</div>
);
}
export default function Scheduler() {
const { t } = useTranslation();
const qc = useQueryClient();
const q = useLiveQuery<SchedulerStatus>(["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 <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
if (!data)
return <div className="p-8 text-muted">{t("common.somethingWrong")}</div>;
const quotaPct = data.quota.daily_budget
? Math.min(100, Math.round((data.quota.used_today / data.quota.daily_budget) * 100))
: 0;
return (
<div className="p-4 max-w-4xl w-full mx-auto space-y-4">
{/* Status strip */}
<div className="glass rounded-2xl p-4 flex flex-wrap items-center gap-3">
<Activity className="w-5 h-5 text-accent shrink-0" />
<div className="min-w-0">
<div className="font-semibold">{t("scheduler.title")}</div>
<div className="text-xs text-muted flex items-center gap-2 flex-wrap">
<span className="inline-flex items-center gap-1">
<span
className={`w-2 h-2 rounded-full ${
data.paused ? "bg-amber-500" : data.running_here ? "bg-emerald-500" : "bg-border"
}`}
/>
{data.paused
? t("scheduler.paused")
: data.running_here
? t("scheduler.running")
: t("scheduler.notHere")}
</span>
{q.isFetching && <span className="text-accent">· {t("scheduler.updating")}</span>}
</div>
</div>
<div className="flex-1" />
<Tooltip hint={t("scheduler.pauseHint")}>
<button
onClick={() => pauseResume.mutate(data.paused)}
disabled={pauseResume.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
{data.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
{data.paused ? t("scheduler.resume") : t("scheduler.pause")}
</button>
</Tooltip>
</div>
{!data.running_here && (
<div className="flex items-center gap-2 text-sm bg-amber-500/15 border border-amber-500/30 rounded-xl px-3 py-2">
<AlertTriangle className="w-4 h-4 text-amber-500 shrink-0" />
<span className="text-muted">{t("scheduler.notHereNote")}</span>
</div>
)}
{/* Jobs */}
<div>
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.jobsTitle")}</div>
<div className="space-y-1.5">
{data.jobs.map((job) => (
<JobRow key={job.id} job={job} />
))}
</div>
</div>
{/* Queue */}
<div>
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.queueTitle")}</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<Stat
icon={Database}
label={t("scheduler.queue.recentPending")}
hint={t("scheduler.queue.recentPendingHint")}
value={data.queue.channels_recent_pending.toLocaleString()}
/>
<Stat
icon={Database}
label={t("scheduler.queue.deepPending")}
hint={t("scheduler.queue.deepPendingHint")}
value={data.queue.channels_deep_pending.toLocaleString()}
/>
<Stat
icon={Clock}
label={t("scheduler.queue.enrichPending")}
hint={t("scheduler.queue.enrichPendingHint")}
value={data.queue.videos_pending_enrich.toLocaleString()}
/>
<Stat
icon={Clock}
label={t("scheduler.queue.shortsPending")}
hint={t("scheduler.queue.shortsPendingHint")}
value={data.queue.videos_pending_shorts.toLocaleString()}
/>
<Stat
icon={RadioTower}
label={t("scheduler.queue.liveRefresh")}
hint={t("scheduler.queue.liveRefreshHint")}
value={data.queue.videos_live_refresh.toLocaleString()}
/>
</div>
</div>
{/* Quota */}
<div>
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.quotaTitle")}</div>
<div className="glass-card rounded-xl p-4">
<div className="flex items-center gap-2 text-sm">
<Gauge className="w-4 h-4 text-muted shrink-0" />
<span>
{t("scheduler.quotaUsed", {
used: data.quota.used_today.toLocaleString(),
budget: data.quota.daily_budget.toLocaleString(),
})}
</span>
<span className="flex-1" />
<span className="text-muted text-xs">
{t("scheduler.quotaRemaining", {
count: data.quota.remaining_today,
formatted: data.quota.remaining_today.toLocaleString(),
})}
</span>
</div>
<div className="mt-2 h-2 rounded-full bg-border overflow-hidden">
<div
className={`h-full rounded-full ${quotaPct > 90 ? "bg-red-500" : "bg-accent"}`}
style={{ width: `${quotaPct}%` }}
/>
</div>
<div className="text-[11px] text-muted mt-1.5">{t("scheduler.quotaNote")}</div>
</div>
</div>
</div>
);
}

View file

@ -3,6 +3,7 @@
"searchPlaceholder": "Deine Abos durchsuchen…", "searchPlaceholder": "Deine Abos durchsuchen…",
"channelManager": "Kanalverwaltung", "channelManager": "Kanalverwaltung",
"usageStats": "Nutzung & Statistik", "usageStats": "Nutzung & Statistik",
"scheduler": "Planer",
"scope": { "scope": {
"label": "Feed-Quelle", "label": "Feed-Quelle",
"my": "Meine", "my": "Meine",
@ -16,6 +17,7 @@
"channels": "Kanäle", "channels": "Kanäle",
"playlists": "Wiedergabelisten", "playlists": "Wiedergabelisten",
"stats": "Statistik", "stats": "Statistik",
"scheduler": "Planer",
"settings": "Einstellungen", "settings": "Einstellungen",
"about": "Über", "about": "Über",
"signOut": "Abmelden", "signOut": "Abmelden",

View file

@ -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."
}
}

View file

@ -3,6 +3,7 @@
"searchPlaceholder": "Search your subscriptions…", "searchPlaceholder": "Search your subscriptions…",
"channelManager": "Channel manager", "channelManager": "Channel manager",
"usageStats": "Usage & stats", "usageStats": "Usage & stats",
"scheduler": "Scheduler",
"scope": { "scope": {
"label": "Feed source", "label": "Feed source",
"my": "Mine", "my": "Mine",
@ -16,6 +17,7 @@
"channels": "Channels", "channels": "Channels",
"playlists": "Playlists", "playlists": "Playlists",
"stats": "Stats", "stats": "Stats",
"scheduler": "Scheduler",
"settings": "Settings", "settings": "Settings",
"about": "About", "about": "About",
"signOut": "Sign out", "signOut": "Sign out",

View file

@ -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."
}
}

View file

@ -3,6 +3,7 @@
"searchPlaceholder": "Keresés a feliratkozásaid között…", "searchPlaceholder": "Keresés a feliratkozásaid között…",
"channelManager": "Csatornakezelő", "channelManager": "Csatornakezelő",
"usageStats": "Használat és statisztika", "usageStats": "Használat és statisztika",
"scheduler": "Ütemező",
"scope": { "scope": {
"label": "Hírfolyam forrása", "label": "Hírfolyam forrása",
"my": "Sajátom", "my": "Sajátom",
@ -16,6 +17,7 @@
"channels": "Csatornák", "channels": "Csatornák",
"playlists": "Lejátszási listák", "playlists": "Lejátszási listák",
"stats": "Statisztika", "stats": "Statisztika",
"scheduler": "Ütemező",
"settings": "Beállítások", "settings": "Beállítások",
"about": "Névjegy", "about": "Névjegy",
"signOut": "Kijelentkezés", "signOut": "Kijelentkezés",

View file

@ -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."
}
}

View file

@ -303,6 +303,39 @@ export interface ManagedChannel {
backfill_done: boolean; 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 { export interface Account {
id: number; id: number;
email: string; email: string;
@ -400,6 +433,7 @@ export const api = {
// --- quota usage --- // --- quota usage ---
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"), myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`), adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"),
// --- onboarding / admin --- // --- onboarding / admin ---
requestAccess: (email: string): Promise<{ status: string }> => requestAccess: (email: string): Promise<{ status: string }> =>

View file

@ -78,11 +78,15 @@ export function hasFilterParams(params: URLSearchParams): boolean {
return KEYS.some((k) => params.has(k)); 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 { export function readPage(): Page {
const p = new URLSearchParams(window.location.search).get("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 ? p
: "feed"; : "feed";
} }

View file

@ -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<T>(
key: QueryKey,
queryFn: () => Promise<T>,
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,
});
}