feat(scheduler): admin run-now/run-all triggers + live progress + completion notices

Add per-job "Run now" buttons and a "Start all now" button to the admin Scheduler
dashboard (admin-gated endpoints). Triggers run the job in a background thread
independent of its interval, refusing a concurrent run (409). While running, the
long jobs (maintenance, enrich, backfill, shorts) report live progress through a
decoupled contextvar sink, shown as a progress bar on the job row via the existing
4s poll. A manually-triggered run posts a completion notification to the triggering
admin's inbox (scheduled runs stay silent to avoid spam); the inbox renders the
"scheduler" type trilingually from type+data. While here, give the maintenance job
its missing dashboard label/description in all three languages.
This commit is contained in:
npeter83 2026-06-18 04:01:10 +02:00
parent 22e3c2fdd8
commit 5988769cda
15 changed files with 344 additions and 30 deletions

40
backend/app/progress.py Normal file
View file

@ -0,0 +1,40 @@
"""Lightweight, decoupled progress reporting for long-running scheduler jobs.
A job's work function calls `report(current, total, phase)`; the scheduler binds a sink (via
`bind()`) that forwards it into the in-memory activity the admin dashboard polls. A contextvar
holds the sink, so the sync code stays unaware of the scheduler and concurrent jobs (each in
its own thread/context) never cross wires. When no sink is bound (e.g. a manual sync endpoint),
`report` is a cheap no-op.
"""
import contextvars
from typing import Callable, Optional
Sink = Callable[[int, Optional[int], Optional[str]], None]
_sink: contextvars.ContextVar[Optional[Sink]] = contextvars.ContextVar(
"progress_sink", default=None
)
def report(current: int, total: int | None = None, phase: str | None = None) -> None:
"""Report job progress to the bound sink, if any. `total=None` means indeterminate."""
sink = _sink.get()
if sink is not None:
sink(current, total, phase)
class bind:
"""Context manager that binds a progress sink for the duration of a job run."""
def __init__(self, sink: Sink):
self._sink = sink
self._token = None
def __enter__(self) -> "bind":
self._token = _sink.set(self._sink)
return self
def __exit__(self, *exc) -> bool:
if self._token is not None:
_sink.reset(self._token)
return False

View file

@ -10,7 +10,15 @@ from app.config import settings
from app.db import get_db from app.db import get_db
from app.models import Channel, Subscription, User, Video from app.models import Channel, Subscription, User, Video
from app.routes.admin import admin_user from app.routes.admin import admin_user
from app.scheduler import JOB_INTERVALS, MAX_INTERVAL, MIN_INTERVAL, apply_interval, scheduler_snapshot from app.scheduler import (
JOB_INTERVALS,
MAX_INTERVAL,
MIN_INTERVAL,
apply_interval,
scheduler_snapshot,
trigger_all,
trigger_job,
)
from app.sync.runner import estimate_deep_backfill from app.sync.runner import estimate_deep_backfill
router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"]) router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"])
@ -40,6 +48,29 @@ def set_job_interval(
return {"id": job_id, "interval_minutes": applied} return {"id": job_id, "interval_minutes": applied}
@router.post("/jobs/{job_id}/run")
def run_job_now(
job_id: str,
user: User = Depends(admin_user),
) -> dict:
"""Trigger a job to run immediately (in a background thread), independent of its
interval. 409 if it's already running, 404 for an unknown job. On completion the
triggering admin gets an inbox notification with the result."""
try:
result = trigger_job(job_id, actor_id=user.id)
except KeyError:
raise HTTPException(status_code=404, detail="Unknown job")
if result == "already_running":
raise HTTPException(status_code=409, detail="Job is already running")
return {"id": job_id, "started": True}
@router.post("/run-all")
def run_all_now(user: User = Depends(admin_user)) -> dict:
"""Trigger every job that isn't already running. Returns the ids actually started."""
return {"started": trigger_all(actor_id=user.id)}
@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)

View file

@ -1,16 +1,19 @@
"""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 contextvars
import logging import logging
import threading import threading
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Callable
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from sqlalchemy import select from sqlalchemy import select
from app import quota from app import progress, quota
from app.config import settings from app.config import settings
from app.db import SessionLocal from app.db import SessionLocal
from app.models import SchedulerSetting from app.models import SchedulerSetting
from app.notifications import create_notification
from app.state import is_sync_paused from app.state import is_sync_paused
from app.sync.autotag import run_autotag_all from app.sync.autotag import run_autotag_all
from app.sync.maintenance import run_maintenance from app.sync.maintenance import run_maintenance
@ -35,6 +38,13 @@ _scheduler: BackgroundScheduler | None = None
_activity: dict[str, dict] = {} _activity: dict[str, dict] = {}
_activity_lock = threading.Lock() _activity_lock = threading.Lock()
# Set (per-thread) by a manual "run now" trigger to the id of the admin who clicked, so that
# run — and only that run, not the recurring scheduled ones — posts a completion notification
# to their inbox. Unset (None) for scheduled runs.
_manual_actor: contextvars.ContextVar[int | None] = contextvars.ContextVar(
"manual_actor", default=None
)
# Each interval job's env/config default period (minutes). The admin can override any of # Each interval job's env/config default period (minutes). The admin can override any of
# these at runtime (stored in scheduler_settings, applied live); see load_intervals. # these at runtime (stored in scheduler_settings, applied live); see load_intervals.
JOB_INTERVALS: dict[str, int] = { JOB_INTERVALS: dict[str, int] = {
@ -90,24 +100,55 @@ def _record(name: str, **fields) -> None:
_activity.setdefault(name, {}).update(fields) _activity.setdefault(name, {}).update(fields)
def _notify_done(db, actor_id: int, job_id: str, status: str, summary: str | None) -> None:
"""Post a completion notice to the triggering admin's inbox (manual runs only). Best
effort a notification failure must never mask the job's own outcome."""
try:
create_notification(
db,
user_id=actor_id,
type="scheduler",
title=f"{job_id}: {status}", # English fallback; UI renders translated from data
body=summary,
data={"job_id": job_id, "status": status, "summary": summary},
)
except Exception:
db.rollback()
logger.exception("failed to write completion notification for job %s", job_id)
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) actor_id = _manual_actor.get() # set only for a manual "run now" trigger
_record(name, running=True, last_started=_now_iso(), last_error=None, progress=None)
def sink(current, total, phase):
_record(name, progress={"current": current, "total": total, "phase": phase})
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(), _record(name, running=False, status="skipped", last_finished=_now_iso(),
last_result="paused") last_result="paused", progress=None)
if actor_id is not None:
_notify_done(db, actor_id, name, "skipped", "sync paused")
return return
with progress.bind(sink):
result = fn(db) result = fn(db)
summary = _summarize(result)
logger.info("job %s -> %s", name, result) logger.info("job %s -> %s", name, result)
_record(name, running=False, status="ok", last_finished=_now_iso(), _record(name, running=False, status="ok", last_finished=_now_iso(),
last_result=_summarize(result)) last_result=summary, progress=None)
if actor_id is not None:
_notify_done(db, actor_id, name, "ok", summary)
except Exception as exc: except Exception as exc:
db.rollback() db.rollback()
logger.exception("job %s failed", name) logger.exception("job %s failed", name)
err = str(exc) or exc.__class__.__name__
_record(name, running=False, status="error", last_finished=_now_iso(), _record(name, running=False, status="error", last_finished=_now_iso(),
last_error=str(exc) or exc.__class__.__name__) last_error=err, progress=None)
if actor_id is not None:
_notify_done(db, actor_id, name, "error", err)
finally: finally:
db.close() db.close()
@ -151,6 +192,7 @@ def scheduler_snapshot() -> dict:
"last_finished": a.get("last_finished"), "last_finished": a.get("last_finished"),
"last_result": a.get("last_result"), "last_result": a.get("last_result"),
"last_error": a.get("last_error"), "last_error": a.get("last_error"),
"progress": a.get("progress"),
} }
) )
return {"running_here": running_here, "enabled": settings.scheduler_enabled, "jobs": jobs} return {"running_here": running_here, "enabled": settings.scheduler_enabled, "jobs": jobs}
@ -211,6 +253,58 @@ def _maintenance_job() -> None:
_job("maintenance", work) _job("maintenance", work)
# job_id -> wrapper. The single source of truth for which jobs exist and how to run one,
# shared by start_scheduler (recurring registration) and trigger_job (manual "run now").
JOB_FUNCS: dict[str, Callable[[], None]] = {
"rss_poll": _rss_job,
"enrich": _enrich_job,
"backfill": _backfill_job,
"autotag": _autotag_job,
"shorts": _shorts_job,
"subscriptions": _subscriptions_job,
"playlist_sync": _playlist_sync_job,
"maintenance": _maintenance_job,
}
def _is_running(job_id: str) -> bool:
with _activity_lock:
return bool(_activity.get(job_id, {}).get("running"))
def trigger_job(job_id: str, actor_id: int | None = None) -> str:
"""Run a job immediately in a background thread, independent of its interval schedule.
The wrapper handles its own DB session, activity tracking and pause-skip, so the live
dashboard reflects the run. Returns "started", "already_running", or raises KeyError for
an unknown job. Refusing a concurrent run keeps a manual trigger from overlapping a
scheduled run (APScheduler enforces max_instances=1 for the scheduled side).
`actor_id` (the admin who clicked "run now") is stashed in a contextvar inside the new
thread so the run posts a completion notification to that admin's inbox."""
fn = JOB_FUNCS.get(job_id)
if fn is None:
raise KeyError(job_id)
if _is_running(job_id):
return "already_running"
def run() -> None:
if actor_id is not None:
_manual_actor.set(actor_id)
fn()
threading.Thread(target=run, name=f"trigger-{job_id}", daemon=True).start()
return "started"
def trigger_all(actor_id: int | None = None) -> list[str]:
"""Trigger every job that isn't already running; returns the ids actually started."""
started = []
for job_id in JOB_FUNCS:
if trigger_job(job_id, actor_id) == "started":
started.append(job_id)
return started
def start_scheduler() -> None: def start_scheduler() -> None:
global _scheduler global _scheduler
if not settings.scheduler_enabled or _scheduler is not None: if not settings.scheduler_enabled or _scheduler is not None:
@ -221,18 +315,8 @@ def start_scheduler() -> None:
iv = load_intervals(db) iv = load_intervals(db)
finally: finally:
db.close() db.close()
fns = {
"rss_poll": _rss_job,
"enrich": _enrich_job,
"backfill": _backfill_job,
"autotag": _autotag_job,
"shorts": _shorts_job,
"subscriptions": _subscriptions_job,
"playlist_sync": _playlist_sync_job,
"maintenance": _maintenance_job,
}
scheduler = BackgroundScheduler(timezone="UTC") scheduler = BackgroundScheduler(timezone="UTC")
for job_id, fn in fns.items(): for job_id, fn in JOB_FUNCS.items():
scheduler.add_job(fn, "interval", minutes=iv[job_id], id=job_id) scheduler.add_job(fn, "interval", minutes=iv[job_id], id=job_id)
scheduler.start() scheduler.start()
_scheduler = scheduler _scheduler = scheduler

View file

@ -25,7 +25,7 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import or_, select from sqlalchemy import or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import quota 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
@ -147,6 +147,7 @@ def _recheck_flagged(db: Session, yt: YouTubeClient) -> dict:
) )
if not flagged: if not flagged:
return {"checked": 0, "recovered": 0, "deleted": 0, "notified": 0} return {"checked": 0, "recovered": 0, "deleted": 0, "notified": 0}
progress.report(0, len(flagged), "recheck")
items = {it["id"]: it for it in yt.get_videos([v.id for v in flagged])} items = {it["id"]: it for it in yt.get_videos([v.id for v in flagged])}
now = _now() now = _now()
recovered = 0 recovered = 0
@ -221,6 +222,7 @@ def _revalidate_rolling(db: Session, yt: YouTubeClient) -> dict:
flagged_new += 1 flagged_new += 1
db.commit() db.commit()
checked += len(page) checked += len(page)
progress.report(checked, batch, "revalidate")
return {"checked": checked, "flagged": flagged_new} return {"checked": checked, "flagged": flagged_new}

View file

@ -9,7 +9,7 @@ import logging
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import quota from app import progress, quota
from app.config import settings from app.config import settings
log = logging.getLogger("subfeed.sync") log = logging.getLogger("subfeed.sync")
@ -64,6 +64,7 @@ def run_enrich(db: Session, max_batches: int = 200, floor: int = 200) -> int:
break break
n = enrich_pending(db, yt) n = enrich_pending(db, yt)
total += n total += n
progress.report(total, None, "enrich") # total unknown (drains until empty)
if n == 0: if n == 0:
break break
# Revisit transient live/upcoming videos (enrich_pending is one-shot) so an ended # Revisit transient live/upcoming videos (enrich_pending is one-shot) so an ended
@ -121,6 +122,7 @@ def run_recent_backfill(
try: try:
videos_added += backfill_channel_recent(db, yt, channel) videos_added += backfill_channel_recent(db, yt, channel)
processed += 1 processed += 1
progress.report(processed, len(channels), "backfill_recent")
except Exception: except Exception:
db.rollback() db.rollback()
log.exception("Recent backfill failed for channel %s", channel.id) log.exception("Recent backfill failed for channel %s", channel.id)
@ -166,6 +168,7 @@ def run_deep_backfill(db: Session, max_channels: int = 10, max_pages: int = 5) -
try: try:
videos_added += backfill_channel_deep(db, yt, channel, max_pages) videos_added += backfill_channel_deep(db, yt, channel, max_pages)
processed += 1 processed += 1
progress.report(processed, len(channels), "backfill_deep")
except Exception: except Exception:
db.rollback() db.rollback()
log.exception("Deep backfill failed for channel %s", channel.id) log.exception("Deep backfill failed for channel %s", channel.id)

View file

@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import progress
from app.config import settings from app.config import settings
from app.models import Channel, Video from app.models import Channel, Video
from app.youtube.client import YouTubeClient, best_thumbnail from app.youtube.client import YouTubeClient, best_thumbnail
@ -365,5 +366,6 @@ def run_shorts_classification(db: Session, limit: int | None = None) -> dict:
probed += 1 probed += 1
if result: if result:
shorts += 1 shorts += 1
progress.report(probed, len(candidates), "probe")
db.commit() db.commit()
return {"probed": probed, "shorts": shorts} return {"probed": probed, "shorts": shorts}

View file

@ -117,10 +117,21 @@ function NotificationRow({
// Known notification types are rendered from i18n (so they're trilingual) using the typed // Known notification types are rendered from i18n (so they're trilingual) using the typed
// payload; the server-stored title/body are an English fallback for any unknown type. // payload; the server-stored title/body are an English fallback for any unknown type.
const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number"; const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number";
const title = isMaintenance ? t("inbox.maintenance.title") : n.title; const isScheduler = n.type === "scheduler" && typeof n.data?.job_id === "string";
const body = isMaintenance let title = n.title;
? t("inbox.maintenance.body", { count: n.data!.count }) let body = n.body;
: n.body; if (isMaintenance) {
title = t("inbox.maintenance.title");
body = t("inbox.maintenance.body", { count: n.data!.count });
} else if (isScheduler) {
// "<Job label> finished/failed", with the raw result summary as the (technical) body.
const job = t(`scheduler.jobs.${n.data!.job_id}`, n.data!.job_id);
title = t(
n.data!.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk",
{ job }
);
body = n.data!.summary || n.body;
}
return ( return (
<div <div
className={`glass rounded-xl p-3.5 flex items-start gap-3 transition ${ className={`glass rounded-xl p-3.5 flex items-start gap-3 transition ${

View file

@ -73,14 +73,45 @@ function StatusLegend() {
); );
} }
function JobProgress({ p }: { p: NonNullable<SchedulerJob["progress"]> }) {
const { t } = useTranslation();
const phase = p.phase ? t(`scheduler.phase.${p.phase}`, p.phase) : null;
const pct =
p.total && p.total > 0 ? Math.min(100, Math.round((p.current / p.total) * 100)) : null;
return (
<div className="mt-1.5">
<div className="flex items-center justify-between text-[11px] text-muted mb-0.5">
<span className="truncate">{phase}</span>
<span className="tabular-nums shrink-0">
{p.total != null
? `${p.current.toLocaleString()} / ${p.total.toLocaleString()}`
: p.current.toLocaleString()}
</span>
</div>
<div className="h-1.5 rounded-full bg-border overflow-hidden">
{pct != null ? (
<div className="h-full rounded-full bg-accent transition-[width]" style={{ width: `${pct}%` }} />
) : (
// Indeterminate: total unknown, so a slim moving sliver instead of a fill.
<div className="h-full w-1/3 rounded-full bg-accent animate-pulse" />
)}
</div>
</div>
);
}
function JobRow({ function JobRow({
job, job,
onSave, onSave,
saving, saving,
onRun,
runDisabled,
}: { }: {
job: SchedulerJob; job: SchedulerJob;
onSave: (minutes: number) => void; onSave: (minutes: number) => void;
saving: boolean; saving: boolean;
onRun: () => void;
runDisabled: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
@ -153,6 +184,7 @@ function JobRow({
<span> · {job.last_result}</span> <span> · {job.last_result}</span>
) : null} ) : null}
</div> </div>
{job.running && job.progress && <JobProgress p={job.progress} />}
</div> </div>
<div className="shrink-0 text-right text-[11px] text-muted tabular-nums"> <div className="shrink-0 text-right text-[11px] text-muted tabular-nums">
{job.running ? ( {job.running ? (
@ -166,6 +198,16 @@ function JobRow({
"—" "—"
)} )}
</div> </div>
<Tooltip hint={t("scheduler.runNow")}>
<button
onClick={onRun}
disabled={job.running || runDisabled}
aria-label={t("scheduler.runNow")}
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-accent hover:bg-card transition disabled:opacity-30 disabled:hover:text-muted disabled:hover:bg-transparent"
>
<Play className="w-4 h-4" />
</button>
</Tooltip>
</div> </div>
); );
} }
@ -227,6 +269,23 @@ export default function Scheduler() {
onError: () => notify({ level: "error", message: t("scheduler.intervalFailed") }), onError: () => notify({ level: "error", message: t("scheduler.intervalFailed") }),
}); });
// Manual "run now" triggers. The wrapper runs in a background thread server-side; the
// live poll surfaces the "running" state within a tick, so we just nudge a refetch.
const runMut = useMutation({
mutationFn: (jobId: string) => api.runSchedulerJob(jobId),
onSuccess: (_d, jobId) => {
notify({ level: "success", message: t("scheduler.triggered", { job: t(`scheduler.jobs.${jobId}`, jobId) }) });
qc.invalidateQueries({ queryKey: ["scheduler"] });
},
});
const runAllMut = useMutation({
mutationFn: () => api.runAllSchedulerJobs(),
onSuccess: (res) => {
notify({ level: "success", message: t("scheduler.triggeredAll", { count: res.started.length }) });
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>;
if (!data) if (!data)
@ -260,6 +319,16 @@ export default function Scheduler() {
</div> </div>
</div> </div>
<div className="flex-1" /> <div className="flex-1" />
<Tooltip hint={data.paused ? t("scheduler.runAllPausedHint") : t("scheduler.runAllHint")}>
<button
onClick={() => runAllMut.mutate()}
disabled={runAllMut.isPending || data.paused}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Play className="w-4 h-4" />
{t("scheduler.runAll")}
</button>
</Tooltip>
<Tooltip hint={t("scheduler.pauseHint")}> <Tooltip hint={t("scheduler.pauseHint")}>
<button <button
onClick={() => pauseResume.mutate(data.paused)} onClick={() => pauseResume.mutate(data.paused)}
@ -290,6 +359,8 @@ export default function Scheduler() {
job={job} job={job}
saving={intervalMut.isPending} saving={intervalMut.isPending}
onSave={(minutes) => intervalMut.mutate({ jobId: job.id, minutes })} onSave={(minutes) => intervalMut.mutate({ jobId: job.id, minutes })}
onRun={() => runMut.mutate(job.id)}
runDisabled={data.paused || runAllMut.isPending}
/> />
))} ))}
</div> </div>

View file

@ -12,5 +12,9 @@
"title": "Videos entfernt", "title": "Videos entfernt",
"body_one": "{{count}} gespeichertes oder in einer Playlist befindliches Video wurde entfernt, weil es auf YouTube nicht mehr verfügbar ist.", "body_one": "{{count}} gespeichertes oder in einer Playlist befindliches Video wurde entfernt, weil es auf YouTube nicht mehr verfügbar ist.",
"body_other": "{{count}} gespeicherte oder in Playlists befindliche Videos wurden entfernt, weil sie auf YouTube nicht mehr verfügbar sind." "body_other": "{{count}} gespeicherte oder in Playlists befindliche Videos wurden entfernt, weil sie auf YouTube nicht mehr verfügbar sind."
},
"jobDone": {
"titleOk": "{{job}} abgeschlossen",
"titleError": "{{job}} fehlgeschlagen"
} }
} }

View file

@ -25,6 +25,21 @@
"minutes": "Min", "minutes": "Min",
"editInterval": "Klicken, um das Intervall zu ändern", "editInterval": "Klicken, um das Intervall zu ändern",
"intervalFailed": "Intervall konnte nicht geändert werden", "intervalFailed": "Intervall konnte nicht geändert werden",
"runNow": "Jetzt ausführen",
"runAll": "Alle jetzt starten",
"runAllHint": "Alle Jobs jetzt ausführen, zusätzlich zu ihren Zeitplänen.",
"runAllPausedHint": "Setze den Planer fort, um Jobs auszuführen (pausiert werden sie übersprungen).",
"triggered": "{{job}} gestartet",
"triggeredAll_one": "{{count}} Job gestartet",
"triggeredAll_other": "{{count}} Jobs gestartet",
"phase": {
"recheck": "Markierte Videos erneut prüfen",
"revalidate": "Videos erneut validieren",
"enrich": "Videos anreichern",
"backfill_recent": "Neue Uploads nachladen",
"backfill_deep": "Ganze Historie nachladen",
"probe": "Shorts klassifizieren"
},
"dot": { "dot": {
"running": "läuft gerade", "running": "läuft gerade",
"ok": "letzter Lauf OK", "ok": "letzter Lauf OK",
@ -39,7 +54,8 @@
"autotag": "Erkennt Sprache/Themen jedes Kanals und vergibt automatische Tags. Fällt er aus, bekommen neue Kanäle keine Auto-Tags, sodass Sprach-/Themenfilter sie verfehlen.", "autotag": "Erkennt Sprache/Themen jedes Kanals und vergibt automatische Tags. Fällt er aus, bekommen neue Kanäle keine Auto-Tags, sodass Sprach-/Themenfilter sie verfehlen.",
"shorts": "Prüft youtube.com/shorts, um Shorts zu markieren. Fällt er aus, werden Shorts im Feed nicht von normalen Videos getrennt.", "shorts": "Prüft youtube.com/shorts, um Shorts zu markieren. Fällt er aus, werden Shorts im Feed nicht von normalen Videos getrennt.",
"subscriptions": "Importiert die YouTube-Abos jedes Nutzers neu. Fällt er aus, werden auf YouTube hinzugefügte oder entfernte Abos hier nicht übernommen.", "subscriptions": "Importiert die YouTube-Abos jedes Nutzers neu. Fällt er aus, werden auf YouTube hinzugefügte oder entfernte Abos hier nicht übernommen.",
"playlist_sync": "Spiegelt die YouTube-Wiedergabelisten jedes Nutzers in die App. Fällt er aus, erscheinen Änderungen an deinen YouTube-Listen lokal nicht." "playlist_sync": "Spiegelt die YouTube-Wiedergabelisten jedes Nutzers in die App. Fällt er aus, erscheinen Änderungen an deinen YouTube-Listen lokal nicht.",
"maintenance": "Findet Videos, die nicht mehr abspielbar sind (gelöscht, auf privat gesetzt, abgebrochene Premieren), blendet sie aus dem Feed aus und entfernt sie nach einer Schonfrist; prüft die am längsten nicht geprüften Videos erneut. Fällt er aus, bleiben tote Videos im Katalog."
}, },
"jobs": { "jobs": {
"rss_poll": "RSS-Abfrage (neue Uploads)", "rss_poll": "RSS-Abfrage (neue Uploads)",
@ -48,7 +64,8 @@
"autotag": "Automatisches Tagging", "autotag": "Automatisches Tagging",
"shorts": "Shorts-Klassifizierung", "shorts": "Shorts-Klassifizierung",
"subscriptions": "Abo-Resync", "subscriptions": "Abo-Resync",
"playlist_sync": "YouTube-Wiedergabelisten-Sync" "playlist_sync": "YouTube-Wiedergabelisten-Sync",
"maintenance": "Wartung + Validierung"
}, },
"queue": { "queue": {
"recentPending": "Zu synchronisierende Kanäle", "recentPending": "Zu synchronisierende Kanäle",

View file

@ -12,5 +12,9 @@
"title": "Videos removed", "title": "Videos removed",
"body_one": "{{count}} saved or playlisted video was removed because it's no longer available on YouTube.", "body_one": "{{count}} saved or playlisted video was removed because it's no longer available on YouTube.",
"body_other": "{{count}} saved or playlisted videos were removed because they're no longer available on YouTube." "body_other": "{{count}} saved or playlisted videos were removed because they're no longer available on YouTube."
},
"jobDone": {
"titleOk": "{{job}} finished",
"titleError": "{{job}} failed"
} }
} }

View file

@ -25,6 +25,21 @@
"minutes": "min", "minutes": "min",
"editInterval": "Click to change how often this runs", "editInterval": "Click to change how often this runs",
"intervalFailed": "Couldn't change the interval", "intervalFailed": "Couldn't change the interval",
"runNow": "Run now",
"runAll": "Start all now",
"runAllHint": "Run every job now, in addition to their schedules.",
"runAllPausedHint": "Resume the scheduler to run jobs (they're skipped while paused).",
"triggered": "Started {{job}}",
"triggeredAll_one": "Started {{count}} job",
"triggeredAll_other": "Started {{count}} jobs",
"phase": {
"recheck": "Re-checking flagged videos",
"revalidate": "Re-validating videos",
"enrich": "Enriching videos",
"backfill_recent": "Backfilling recent uploads",
"backfill_deep": "Backfilling full history",
"probe": "Classifying Shorts"
},
"dot": { "dot": {
"running": "running now", "running": "running now",
"ok": "last run OK", "ok": "last run OK",
@ -39,7 +54,8 @@
"autotag": "Detects each channel's language/topics and applies automatic tags. If it stops, new channels get no auto tags, so language/topic filters miss them.", "autotag": "Detects each channel's language/topics and applies automatic tags. If it stops, new channels get no auto tags, so language/topic filters miss them.",
"shorts": "Probes youtube.com/shorts to mark which videos are Shorts. If it stops, Shorts aren't separated from normal videos in the feed.", "shorts": "Probes youtube.com/shorts to mark which videos are Shorts. If it stops, Shorts aren't separated from normal videos in the feed.",
"subscriptions": "Re-imports every user's YouTube subscriptions. If it stops, channels subscribed to or dropped on YouTube aren't reflected here.", "subscriptions": "Re-imports every user's YouTube subscriptions. If it stops, channels subscribed to or dropped on YouTube aren't reflected here.",
"playlist_sync": "Mirrors each user's YouTube playlists into the app. If it stops, changes to your YouTube playlists don't show up locally." "playlist_sync": "Mirrors each user's YouTube playlists into the app. If it stops, changes to your YouTube playlists don't show up locally.",
"maintenance": "Finds videos that can no longer be played (deleted, made private, abandoned premieres), hides them from the feed and removes them after a grace period; re-checks the least-recently-checked videos. If it stops, dead videos linger in the catalogue."
}, },
"jobs": { "jobs": {
"rss_poll": "RSS poll (new uploads)", "rss_poll": "RSS poll (new uploads)",
@ -48,7 +64,8 @@
"autotag": "Auto-tagging", "autotag": "Auto-tagging",
"shorts": "Shorts classification", "shorts": "Shorts classification",
"subscriptions": "Subscription resync", "subscriptions": "Subscription resync",
"playlist_sync": "YouTube playlist sync" "playlist_sync": "YouTube playlist sync",
"maintenance": "Maintenance + validation"
}, },
"queue": { "queue": {
"recentPending": "Channels to sync", "recentPending": "Channels to sync",

View file

@ -12,5 +12,9 @@
"title": "Videók eltávolítva", "title": "Videók eltávolítva",
"body_one": "{{count}} mentett vagy lejátszási listás videó eltávolítva, mert már nem elérhető a YouTube-on.", "body_one": "{{count}} mentett vagy lejátszási listás videó eltávolítva, mert már nem elérhető a YouTube-on.",
"body_other": "{{count}} mentett vagy lejátszási listás videó eltávolítva, mert már nem elérhetők a YouTube-on." "body_other": "{{count}} mentett vagy lejátszási listás videó eltávolítva, mert már nem elérhetők a YouTube-on."
},
"jobDone": {
"titleOk": "{{job}} kész",
"titleError": "{{job}} hibázott"
} }
} }

View file

@ -25,6 +25,21 @@
"minutes": "perc", "minutes": "perc",
"editInterval": "Kattints a gyakoriság módosításához", "editInterval": "Kattints a gyakoriság módosításához",
"intervalFailed": "Nem sikerült módosítani a gyakoriságot", "intervalFailed": "Nem sikerült módosítani a gyakoriságot",
"runNow": "Futtatás most",
"runAll": "Mind indítása most",
"runAllHint": "Minden feladat azonnali futtatása, az ütemezésükön felül.",
"runAllPausedHint": "Folytasd az ütemezőt a futtatáshoz (szüneteltetve a feladatok kimaradnak).",
"triggered": "Elindítva: {{job}}",
"triggeredAll_one": "{{count}} feladat elindítva",
"triggeredAll_other": "{{count}} feladat elindítva",
"phase": {
"recheck": "Megjelölt videók újraellenőrzése",
"revalidate": "Videók újraellenőrzése",
"enrich": "Videók dúsítása",
"backfill_recent": "Friss feltöltések behúzása",
"backfill_deep": "Teljes előzmény behúzása",
"probe": "Shorts besorolás"
},
"dot": { "dot": {
"running": "most fut", "running": "most fut",
"ok": "utolsó futás OK", "ok": "utolsó futás OK",
@ -39,7 +54,8 @@
"autotag": "Felismeri a csatornák nyelvét/témáit, és automatikus címkéket ad. Ha leáll, az új csatornák címke nélkül maradnak, így a nyelv/téma szűrők kihagyják őket.", "autotag": "Felismeri a csatornák nyelvét/témáit, és automatikus címkéket ad. Ha leáll, az új csatornák címke nélkül maradnak, így a nyelv/téma szűrők kihagyják őket.",
"shorts": "A youtube.com/shorts próbával jelöli, mely videók Shorts-ok. Ha leáll, a Shorts-ok nem különülnek el a normál videóktól a hírfolyamban.", "shorts": "A youtube.com/shorts próbával jelöli, mely videók Shorts-ok. Ha leáll, a Shorts-ok nem különülnek el a normál videóktól a hírfolyamban.",
"subscriptions": "Újraimportálja minden felhasználó YouTube-feliratkozásait. Ha leáll, a YouTube-on felvett vagy törölt feliratkozások nem tükröződnek itt.", "subscriptions": "Újraimportálja minden felhasználó YouTube-feliratkozásait. Ha leáll, a YouTube-on felvett vagy törölt feliratkozások nem tükröződnek itt.",
"playlist_sync": "Tükrözi a felhasználók YouTube lejátszási listáit az appba. Ha leáll, a YouTube-listák változásai nem jelennek meg lokálisan." "playlist_sync": "Tükrözi a felhasználók YouTube lejátszási listáit az appba. Ha leáll, a YouTube-listák változásai nem jelennek meg lokálisan.",
"maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a hírfolyamból, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak."
}, },
"jobs": { "jobs": {
"rss_poll": "RSS-lekérdezés (új feltöltések)", "rss_poll": "RSS-lekérdezés (új feltöltések)",
@ -48,7 +64,8 @@
"autotag": "Automatikus címkézés", "autotag": "Automatikus címkézés",
"shorts": "Shorts-besorolás", "shorts": "Shorts-besorolás",
"subscriptions": "Feliratkozások újraszinkronja", "subscriptions": "Feliratkozások újraszinkronja",
"playlist_sync": "YouTube lejátszási listák szinkronja" "playlist_sync": "YouTube lejátszási listák szinkronja",
"maintenance": "Karbantartás + ellenőrzés"
}, },
"queue": { "queue": {
"recentPending": "Szinkronra váró csatorna", "recentPending": "Szinkronra váró csatorna",

View file

@ -325,6 +325,9 @@ export interface SchedulerJob {
last_finished: string | null; last_finished: string | null;
last_result: string | null; last_result: string | null;
last_error: string | null; last_error: string | null;
// Live progress while running (null when idle or for jobs that don't report it).
// total is null for indeterminate progress (e.g. enrichment drains an unknown queue).
progress: { current: number; total: number | null; phase: string | null } | null;
} }
export interface SchedulerStatus { export interface SchedulerStatus {
@ -467,6 +470,10 @@ export const api = {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ interval_minutes: intervalMinutes }), body: JSON.stringify({ interval_minutes: intervalMinutes }),
}), }),
runSchedulerJob: (jobId: string): Promise<{ id: string; started: boolean }> =>
req(`/api/admin/scheduler/jobs/${jobId}/run`, { method: "POST" }),
runAllSchedulerJobs: (): Promise<{ started: string[] }> =>
req("/api/admin/scheduler/run-all", { method: "POST" }),
// --- onboarding / admin --- // --- onboarding / admin ---
requestAccess: (email: string): Promise<{ status: string }> => requestAccess: (email: string): Promise<{ status: string }> =>