feat(scheduler): each row shows its last three runs, an interrupted run as such, and the ad-hoc runs beside the jobs

A row's one dot said how the LAST run went, and an interrupted run fell through to "idle" — a grey dot
that read as "no run recorded". The row now leads with its last three runs, newest first, each named
for a screen reader and in its tooltip (how it went, when, who started it); an interrupted run is a
ring, set apart from a skipped run and a failure, and its line says the server restarted under it.

A channel's full-history backfill gets its own "Ad-hoc runs" block: its runs, its channel and what
it fetched, no interval and no run button — it needs its target. AD_HOC_JOBS names every such job;
the backend-id contract holds their names in both languages, and run_ad_hoc refuses any other id.
This commit is contained in:
2026-10-18 01:20:53 +02:00
parent 27c2fcbd2e
commit 1b1e3cdcf2
9 changed files with 404 additions and 89 deletions
+46 -13
View File
@@ -15,7 +15,7 @@ from app import audit, debugsession, jobruns, logs, logsink, progress, quota
from app.config import settings
from app.db import SessionLocal
from app.downloads.gc import run_download_gc
from app.models import SchedulerSetting
from app.models import Channel, SchedulerSetting
from app.plex.sync import sync as run_plex_sync
from app.plex.tmdb import run_enrich as run_tmdb_enrich
from app.plex.tmdb import run_people_enrich
@@ -253,16 +253,53 @@ def scheduler_snapshot(db) -> dict:
jobs = []
for job_id, default_interval in JOB_INTERVALS.items():
a = acts.get(job_id, {})
runs = recent.get(job_id, [])
run = runs[0] if runs else None
# A run still going has no outcome yet: only an ended run says how it went.
ended = run if run is not None and run.status != RunStatus.RUNNING else None
jobs.append(
{
"id": job_id,
"interval_minutes": live_intervals.get(job_id, default_interval),
"next_run": next_runs.get(job_id),
"running": a.get("running", False),
"progress": a.get("progress"),
**_last_runs(recent.get(job_id, [])),
}
)
# Work a user starts on one target: no interval, no "run now" (it needs its target), but its
# runs are shown beside the jobs' — it spends the same quota. Its one in-memory guard is the
# target's own (sync/runner), so "running" is what its newest row says.
backfills = recent.get(CHANNEL_BACKFILL, [])
newest = backfills[0] if backfills else None
ad_hoc = [
{
"id": CHANNEL_BACKFILL,
"running": newest is not None and newest.status == RunStatus.RUNNING,
"target": newest.target if newest else None,
"target_title": (
db.scalar(select(Channel.title).where(Channel.id == newest.target))
if newest is not None and newest.target
else None
),
**_last_runs(backfills),
}
]
return {
"running_here": running_here,
"enabled": settings.scheduler_enabled,
"jobs": jobs,
"ad_hoc": ad_hoc,
}
# How many past runs a Scheduler row shows as dots; the full history is the run list.
RECENT_RUNS = 3
def _last_runs(runs: list) -> dict:
"""A row's run fields from its newest runs (newest first): how the last one went, and the few
before it for the history dots."""
run = runs[0] if runs else None
# A run still going has no outcome yet: only an ended run says how it went.
ended = run if run is not None and run.status != RunStatus.RUNNING else None
return {
"status": ended.status if ended else None,
"last_started": _iso(run.started_at) if run else None,
"last_finished": _iso(run.finished_at) if run else None,
@@ -273,16 +310,8 @@ def scheduler_snapshot(db) -> dict:
"last_trigger": run.trigger if run else None,
"last_run_id": run.id if run else None,
"quota_units": ended.quota_units if ended else None,
"progress": a.get("progress"),
# The last few runs, newest first, for the row's history dots.
"recent": [_run_brief(r) for r in runs],
}
)
return {"running_here": running_here, "enabled": settings.scheduler_enabled, "jobs": jobs}
# How many past runs a Scheduler row shows as dots; the full history is the run list.
RECENT_RUNS = 3
def _run_brief(run) -> dict:
@@ -539,12 +568,16 @@ def trigger_all(actor_id: int | None = None) -> list[str]:
# one channel. It runs on the same scheduler as a recorded run (trigger `ad_hoc`), so what it did and
# spent is in `job_runs` like any job's, not a background task only the log ever heard of.
CHANNEL_BACKFILL = "channel_backfill"
# Every ad-hoc job id: the page names each one (a frontend contract test reads this tuple).
AD_HOC_JOBS = (CHANNEL_BACKFILL,)
def run_ad_hoc(job: str, fn: Callable[[], dict], *, actor_id: int, target: str) -> None:
"""Run `fn` now as `actor_id`'s run of `job` on `target`, a one-shot job on the scheduler. Its
own exclusion (if any) lives in `fn`, which says so in its result; one id per job and target, so
a second request before the first has even started is that same run."""
if job not in AD_HOC_JOBS:
raise KeyError(job) # an unnamed job would show on the page as its bare id
sched = _scheduler
if sched is None:
# Only between shutdown and exit: start_scheduler runs at every API startup.
+34 -1
View File
@@ -13,7 +13,7 @@ import pytest
from sqlalchemy import select
from app import jobruns, logs, logsink, quota, scheduler, state
from app.models import AuditLog, JobRun, Notification, QuotaEvent
from app.models import AuditLog, Channel, JobRun, Notification, QuotaEvent
from app.runstatus import RunStatus, RunTrigger
_JOB = "unit_test_claim" # not a real job id — exercises the primitive without touching live jobs
@@ -335,10 +335,43 @@ def test_an_ad_hoc_run_is_the_users_run_of_its_target_with_its_spend_and_failure
assert (failed.status, failed.error) == ("error", "quota exceeded")
def test_the_page_shows_the_ad_hoc_runs_beside_the_jobs_with_their_channel(db, make_user):
user = make_user("shown@example.com")
db.add(Channel(id="UC_shown", title="BBC Earth"))
db.commit()
scheduler._ad_hoc("channel_backfill", lambda: {"skipped": "at capacity"}, user.id, "UC_none")
scheduler._ad_hoc("channel_backfill", lambda: {"videos_added": 69}, user.id, "UC_shown")
(row,) = scheduler.scheduler_snapshot(db)["ad_hoc"]
assert (row["id"], row["running"], row["target"], row["target_title"]) == (
"channel_backfill",
False,
"UC_shown",
"BBC Earth",
)
assert (row["status"], row["result"], row["last_trigger"]) == (
"ok",
{"videos_added": 69},
"ad_hoc",
)
assert len(row["recent"]) == 2
assert all(j["id"] != "channel_backfill" for j in scheduler.scheduler_snapshot(db)["jobs"])
jobruns.start("channel_backfill", RunTrigger.AD_HOC, user.id, "UC_shown")
assert scheduler.scheduler_snapshot(db)["ad_hoc"][0]["running"] is True
def _finished(db, job) -> list[JobRun]:
return [r for r in _runs(db, job) if r.status != "running"]
def test_an_ad_hoc_job_outside_the_named_list_is_refused(live_scheduler):
"""The page names every ad-hoc job from AD_HOC_JOBS; one outside it would show as a bare id."""
with pytest.raises(KeyError):
scheduler.run_ad_hoc("unnamed_job", dict, actor_id=1, target="x")
assert live_scheduler.get_jobs() == []
def test_a_second_ad_hoc_request_before_the_first_starts_is_that_same_run(
db, make_user, live_scheduler
):
+84 -2
View File
@@ -6,7 +6,7 @@
// because this page polls: every few seconds a fresh status arrives, and a mirror is exactly the
// thing a poll can overwrite at the wrong moment.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { render, screen, fireEvent, cleanup, act, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
@@ -15,7 +15,7 @@ vi.mock("react-i18next", async (importOriginal) => {
});
import Scheduler from "./Scheduler";
import { api, type SchedulerStatus } from "../lib/api";
import { api, type RunBrief, type SchedulerAdHoc, type SchedulerStatus } from "../lib/api";
import { qk } from "../lib/queryKeys";
const status = (over: Partial<SchedulerStatus> = {}): SchedulerStatus => ({
@@ -34,9 +34,14 @@ const status = (over: Partial<SchedulerStatus> = {}): SchedulerStatus => ({
last_result: null,
result: null,
last_error: null,
last_trigger: null,
last_run_id: null,
quota_units: null,
recent: [],
progress: null,
},
],
ad_hoc: [],
queue: {
channels_recent_pending: 0,
channels_deep_pending: 0,
@@ -281,3 +286,80 @@ describe("a job's last result", () => {
expect(screen.queryByText(/new=3/)).toBeNull();
});
});
describe("a row's run history", () => {
const at = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60_000).toISOString();
const run = (id: number, status: RunBrief["status"], minutesAgo: number): RunBrief => ({
id,
status,
trigger: "scheduled",
started_at: at(minutesAgo),
finished_at: status === "running" ? null : at(minutesAgo),
});
const dots = (root: HTMLElement = document.body) =>
within(root)
.getAllByRole("img")
.map((d) => d.getAttribute("data-dot"));
// An interrupted run fell through to "idle" — a grey dot that said no run was ever recorded.
it("shows the last runs newest first, an interrupted one as such", async () => {
const base = status();
await mount({
...base,
jobs: [
{
...base.jobs[0]!,
status: "interrupted",
last_finished: at(2),
recent: [run(3, "interrupted", 2), run(2, "error", 12), run(1, "ok", 22)],
},
],
});
expect(dots()).toEqual(["interrupted", "error", "ok"]);
expect(screen.getByText(/scheduler\.interrupted/)).toBeTruthy();
expect(screen.getByText("scheduler.dot.interrupted")).toBeTruthy(); // the legend names it
});
it("shows one idle dot for a job that never ran", async () => {
await mount();
expect(dots()).toEqual(["idle"]);
});
it("gives a run the job has claimed, but not yet recorded, the newest dot", async () => {
const base = status();
await mount({
...base,
jobs: [
{
...base.jobs[0]!,
running: true,
recent: [run(3, "ok", 10), run(2, "ok", 20), run(1, "error", 30)],
},
],
});
expect(dots()).toEqual(["running", "ok", "ok"]);
});
// The one-channel full-history backfill ran unseen: a background task only the log heard of.
it("lists a user's ad-hoc run beside the jobs, with its channel and no run button", async () => {
const base = status();
const adHoc: SchedulerAdHoc = {
...base.jobs[0]!,
id: "channel_backfill",
running: false,
target: "UC_bbc",
target_title: "BBC Earth",
last_finished: at(60),
result: { videos_added: 69 },
recent: [run(9, "ok", 60), run(8, "ok", 120)],
};
await mount({ ...base, ad_hoc: [adHoc] });
expect(screen.getByText("scheduler.adHocTitle")).toBeTruthy();
const row = screen.getByText("scheduler.jobs.channel_backfill").closest<HTMLElement>(".p-3")!;
expect(dots(row)).toEqual(["ok", "ok"]);
expect(within(row).getByText(/BBC Earth/)).toBeTruthy();
expect(within(row).getByText(/scheduler\.result\.videos_added: 69/)).toBeTruthy();
expect(within(row).getByText("scheduler.adHocStarted")).toBeTruthy();
expect(within(row).queryByRole("button", { name: "scheduler.runNow" })).toBeNull();
});
});
@@ -32,9 +32,14 @@ const status = (over: Partial<SchedulerStatus> = {}): SchedulerStatus => ({
last_result: null,
result: null,
last_error: null,
last_trigger: null,
last_run_id: null,
quota_units: null,
recent: [],
progress: null,
},
],
ad_hoc: [],
queue: {
channels_recent_pending: 0,
channels_deep_pending: 0,
+142 -43
View File
@@ -17,7 +17,14 @@ import {
Trash2,
X,
} from "lucide-react";
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
import {
api,
type RunBrief,
type RunFields,
type SchedulerAdHoc,
type SchedulerJob,
type SchedulerStatus,
} from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
@@ -57,41 +64,40 @@ function fmtCountdown(t: TFunction, s: number, lang: string): string {
return `${formatUnit(h, "hour", lang)} ${formatUnit(m % 60, "minute", lang)}`;
}
const DOT_CLASS: Record<string, string> = {
// Interrupted is a ring, not a fill: the job did not fail, but its work did not finish either — set
// apart from a skipped run (the same colour, filled) and from a failure.
const DOT_CLASS = {
running: "bg-accent animate-pulse",
ok: "bg-success",
error: "bg-danger",
skipped: "bg-warn",
interrupted: "border-2 border-warn",
idle: "bg-border",
};
} as const;
function statusKey(job: SchedulerJob): keyof typeof DOT_CLASS {
if (job.running) return "running";
if (job.status === "ok") return "ok";
if (job.status === "error") return "error";
if (job.status === "skipped") return "skipped";
return "idle";
}
type DotKey = keyof typeof DOT_CLASS;
function StatusDot({
k,
withTooltip = true,
}: {
k: keyof typeof DOT_CLASS;
withTooltip?: boolean;
}) {
const { t } = useTranslation();
const dot = <span className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${DOT_CLASS[k]}`} />;
return withTooltip ? <Tooltip hint={t(`scheduler.dot.${k}`)}>{dot}</Tooltip> : dot;
/** A status dot. `label` names it for a screen reader (the tooltip shows only on hover, and only with
* hints on); a legend's dot has its words beside it and stays unnamed. */
function StatusDot({ k, label }: { k: DotKey; label?: string }) {
return (
<span
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
data-dot={k}
className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${DOT_CLASS[k]}`}
/>
);
}
function StatusLegend() {
const { t } = useTranslation();
return (
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap text-2xs text-muted mb-2">
{(["running", "ok", "idle", "error", "skipped"] as const).map((k) => (
{(["running", "ok", "idle", "error", "skipped", "interrupted"] as const).map((k) => (
<span key={k} className="inline-flex items-center gap-1.5">
<StatusDot k={k} withTooltip={false} />
<StatusDot k={k} />
{t(`scheduler.dot.${k}`)}
</span>
))}
@@ -99,6 +105,106 @@ function StatusLegend() {
);
}
// The server sends a row's newest runs, at most this many (RECENT_RUNS in app/scheduler.py).
const MAX_DOTS = 3;
/** A row's last runs, newest first (leftmost), each saying in its tooltip how it went, when, and who
* started it. A row with no run yet shows one idle dot. While a run goes on the newest dot pulses:
* the job's own claim says so a moment before its run row exists, so that moment gets a dot too. */
function RunDots({ runs, running }: { runs: RunBrief[]; running: boolean }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const claimed = running && runs[0]?.status !== "running";
const shown: (RunBrief | null)[] = claimed ? [null, ...runs].slice(0, MAX_DOTS) : runs;
const dots = shown.length === 0 ? [undefined] : shown;
return (
<span className="inline-flex items-center gap-1 w-10 shrink-0">
{dots.map((run) => {
const hint =
run === undefined
? t("scheduler.dot.idle")
: run === null
? t("scheduler.dot.running")
: t("scheduler.dotRun", {
state: t(`scheduler.dot.${run.status}`),
time: relativeTime(run.started_at, lang, t),
trigger: t(`scheduler.trigger.${run.trigger}`),
});
const k: DotKey = run === undefined ? "idle" : run === null ? "running" : run.status;
return (
<Tooltip key={run?.id ?? String(run)} hint={hint}>
<StatusDot k={k} label={hint} />
</Tooltip>
);
})}
</span>
);
}
/** "Last run 2 min ago · <what it did>", the line under a row's name: how the newest ended run went
* — its failure, its result, or that the process stopped under it. */
function LastRun({ row, running, target }: { row: RunFields; running: boolean; target?: string }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
return (
<div
className="text-2xs text-muted truncate"
title={(!running && formatDateTime(row.last_finished, lang)) || undefined}
>
{running
? t("scheduler.runningNow")
: row.last_finished
? t("scheduler.lastRun", { time: relativeTime(row.last_finished, lang, t) })
: t("scheduler.neverRun")}
{target && <span> · {target}</span>}
{row.last_error ? (
<span className="text-danger">
{" · "}
{jobFailureText(t, row.result, lang) ?? row.last_error}
</span>
) : row.status === "interrupted" ? (
<span className="text-warn"> · {t("scheduler.interrupted")}</span>
) : row.result ? (
<span> · {jobResultText(t, row.result, lang)}</span>
) : row.last_result ? (
<span> · {row.last_result}</span>
) : null}
</div>
);
}
/** A user's run on one target (a channel's full history): its runs as a job's are shown, but it has
* no interval and no "run now" — it needs its target, so it is started where that is chosen. */
function AdHocRow({ row }: { row: SchedulerAdHoc }) {
const { t } = useTranslation();
return (
<div className={`${SURFACE.card} p-3 flex items-center gap-3`}>
<RunDots runs={row.recent} running={row.running} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
<Tooltip hint={t(`scheduler.jobDesc.${row.id}`, "")}>
<span className="underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help">
{t(`scheduler.jobs.${row.id}`, row.id)}
</span>
</Tooltip>
</div>
<LastRun
row={row}
running={row.running}
target={row.target_title ?? row.target ?? undefined}
/>
</div>
<div className="shrink-0 text-right text-2xs text-muted">
{row.running ? (
<span className="text-accent">{t("scheduler.active")}</span>
) : (
t("scheduler.adHocStarted")
)}
</div>
</div>
);
}
// Shown for ANY running job. With reported counts it's a determinate bar; for a job that
// runs but doesn't report progress (or hasn't yet) it falls back to a generic "working"
// label and an indeterminate sliver — so every active run is visible, not just the ones
@@ -186,7 +292,7 @@ function JobRow({
return (
<div className={`${SURFACE.card} p-3 flex items-center gap-3`}>
<StatusDot k={statusKey(job)} />
<RunDots runs={job.recent} running={job.running} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium flex items-center gap-1.5 flex-wrap">
<Tooltip hint={t(`scheduler.jobDesc.${job.id}`, "")}>
@@ -247,26 +353,7 @@ function JobRow({
</Tooltip>
)}
</div>
<div
className="text-2xs text-muted truncate"
title={(!job.running && formatDateTime(job.last_finished, lang)) || undefined}
>
{job.running
? t("scheduler.runningNow")
: job.last_finished
? t("scheduler.lastRun", { time: relativeTime(job.last_finished, lang, t) })
: t("scheduler.neverRun")}
{job.last_error ? (
<span className="text-danger">
{" · "}
{jobFailureText(t, job.result, lang) ?? job.last_error}
</span>
) : job.result ? (
<span> · {jobResultText(t, job.result, lang)}</span>
) : job.last_result ? (
<span> · {job.last_result}</span>
) : null}
</div>
<LastRun row={job} running={job.running} />
{job.running && <JobProgress p={job.progress} />}
</div>
<div className="shrink-0 text-right text-2xs text-muted tabular-nums">
@@ -646,6 +733,18 @@ export default function Scheduler() {
</div>
</div>
{/* Ad-hoc runs: a user's work on one target, shown beside the jobs because it spends the same quota */}
{data.ad_hoc.length > 0 && (
<div>
<SectionLabel className="mb-2">{t("scheduler.adHocTitle")}</SectionLabel>
<div className="space-y-1.5">
{data.ad_hoc.map((row) => (
<AdHocRow key={row.id} row={row} />
))}
</div>
</div>
)}
{/* Queue */}
<div>
<SectionLabel className="mb-2">{t("scheduler.queueTitle")}</SectionLabel>
+10 -3
View File
@@ -10,7 +10,7 @@ import { vocabKey } from "../lib/vocab";
// the registries straight from the backend sources:
// - `sysconfig.SPECS` → `config.groups.<group>`, `config.fields.<key>.label` + `.hint`, and for a
// "choice" key `config.choices.<key>.<option>` — the options read from its Literal in config.py
// - `scheduler.JOB_INTERVALS` → `scheduler.jobs.<id>` + `scheduler.jobDesc.<id>`
// - `scheduler.JOB_INTERVALS` and `scheduler.AD_HOC_JOBS` → `scheduler.jobs.<id>` + `scheduler.jobDesc.<id>`
// - the `audit` action enum → `auditActions.<id>`; the `quota` action enum → `quotaActions.<id>`
// and the other way round: a name left behind for an id the backend dropped is dead weight that reads
// as a live setting to the next person (`plex_sync_interval_min` moved to the scheduler long ago).
@@ -56,11 +56,18 @@ const choicesOf = (attr: string): string[] => {
const choiceIds = specs
.filter((s) => s.type === "choice")
.flatMap((s) => choicesOf(s.attr).map((c) => `config.choices.${s.key}.${c}`));
const schedulerPy = backend("scheduler.py");
// The interval jobs, and the ad-hoc ones (`AD_HOC_JOBS`, a tuple of constants named beside it).
const jobs = [
...(backend("scheduler.py").split("JOB_INTERVALS")[1] ?? "")
...[
...(schedulerPy.split("JOB_INTERVALS")[1] ?? "")
.split("}")[0]!
.matchAll(/"(\w+)": settings\./g),
].map((m) => m[1]!);
].map((m) => m[1]!),
...[...(/^AD_HOC_JOBS = \(([^)]*)\)/m.exec(schedulerPy)?.[1] ?? "").matchAll(/\b[A-Z_]+\b/g)].map(
(m) => new RegExp(`^${m[0]} = "(\\w+)"$`, "m").exec(schedulerPy)?.[1] ?? m[0],
),
];
const enumValues = (file: string) =>
[...backend(file).matchAll(/^\s+[A-Z_]+ = "(\w+)"$/gm)].map((m) => m[1]!);
const auditActions = enumValues("audit.py");
+19 -7
View File
@@ -56,10 +56,11 @@
},
"dot": {
"running": "running now",
"ok": "last run OK",
"idle": "idle (no run recorded yet)",
"error": "last run failed",
"skipped": "skipped (sync paused)"
"ok": "OK",
"idle": "no run recorded yet",
"error": "failed",
"skipped": "skipped (sync paused)",
"interrupted": "interrupted (the server restarted)"
},
"jobDesc": {
"rss_poll": "Checks each channel's RSS feed for new uploads (free, no quota). If it stops, new videos only appear later when a backfill pass catches them, so your Feed lags behind YouTube.",
@@ -78,7 +79,8 @@
"audit_gc": "Prunes admin audit-log entries and YouTube quota history older than their retention windows (Configuration → Audit log, Quota). If it stops, both just keep growing — harmless but unbounded.",
"log_gc": "Deletes stored log events older than the retention window (Configuration → Diagnostics). Runs hourly so nothing outlives the promised limit by more than an hour.",
"tmdb_enrich": "Enriches the mirrored Plex catalogue from TMDB (keywords, crew, budget/revenue, TMDb score, recommendations, artwork), matched by each title's canonical id and served from the app's own database. Optional — a no-op until a TMDB API key is set. If it stops, new Plex titles just miss their TMDB extras until it runs again.",
"people_enrich": "Makes the people in the Plex library local: fetches each person's biography from TMDB and caches their portrait, resized through Plex, the most-credited people first. A person page and a cast strip then open from the app's own data. The TMDB half does nothing without a TMDB API key, the portrait half nothing without Plex. If it stops, a person page fetches its own bio when opened, a moment later."
"people_enrich": "Makes the people in the Plex library local: fetches each person's biography from TMDB and caches their portrait, resized through Plex, the most-credited people first. A person page and a cast strip then open from the app's own data. The TMDB half does nothing without a TMDB API key, the portrait half nothing without Plex. If it stops, a person page fetches its own bio when opened, a moment later.",
"channel_backfill": "Turning on full history for a channel fetches its whole back-catalogue at once, as its own run. The Backfill job finishes what this one could not (a very large channel, low quota, or two of these already running)."
},
"jobs": {
"rss_poll": "RSS poll (new uploads)",
@@ -97,7 +99,8 @@
"tmdb_enrich": "TMDB enrichment (Plex)",
"audit_gc": "Audit-log and quota-history cleanup",
"log_gc": "Log cleanup",
"people_enrich": "People details and portraits (Plex)"
"people_enrich": "People details and portraits (Plex)",
"channel_backfill": "A channel's full history"
},
"queue": {
"recentPending": "Channels to sync",
@@ -199,5 +202,14 @@
"transport": "unreachable",
"apply": "could not be saved",
"error": "unexpected error"
}
},
"dotRun": "{{state}} · {{time}} · {{trigger}}",
"trigger": {
"scheduled": "on schedule",
"manual": "started by an admin",
"ad_hoc": "started by a user"
},
"interrupted": "interrupted: the server restarted during the run",
"adHocTitle": "Ad-hoc runs",
"adHocStarted": "started by a user"
}
+19 -7
View File
@@ -56,10 +56,11 @@
},
"dot": {
"running": "most fut",
"ok": "utolsó futás OK",
"idle": "tétlen (még nincs rögzített futás)",
"error": "utolsó futás hibázott",
"skipped": "kihagyva (szinkron szüneteltetve)"
"ok": "rendben",
"idle": "még nincs rögzített futás",
"error": "hibával ért véget",
"skipped": "kihagyva (szinkron szüneteltetve)",
"interrupted": "megszakadt (újraindult a szerver)"
},
"jobDesc": {
"rss_poll": "Az egyes csatornák RSS-feedjét nézi új feltöltésekért (ingyenes, nem fogyaszt kvótát). Ha leáll, az új videók csak később jelennek meg, amikor a videók behúzása eléri őket — így a Feed lemarad a YouTube mögött.",
@@ -78,7 +79,8 @@
"audit_gc": "Törli a megőrzési időnél régebbi admin-naplóbejegyzéseket és YouTube-kvótaelőzményeket (Konfiguráció → Eseménynapló, Kvóta). Ha leáll, mindkettő csak nő — ártalmatlan, de korlátlanul.",
"log_gc": "Törli a megőrzési időnél (Konfiguráció → Diagnosztika) régebbi tárolt naplóeseményeket. Óránként fut, így semmi sem marad meg egy óránál tovább az ígért határidőn túl.",
"tmdb_enrich": "A tükrözött Plex-katalógust dúsítja a TMDB-ből (kulcsszavak, stáb, költségvetés/bevétel, TMDb-pontszám, ajánlások, artwork), a címek kanonikus azonosítója alapján párosítva, és az app saját adatbázisából szolgálja ki. Opcionális — TMDB API-kulcs nélkül nem csinál semmit. Ha leáll, az új Plex-címek csak a következő futásig maradnak TMDB-extrák nélkül.",
"people_enrich": "Helyivé teszi a Plex-könyvtár szereplőit: a TMDB-ből letölti az életrajzukat, a portréjukat pedig a Plexszel kicsinyítve elmenti, a legtöbbet szereplőkkel kezdve. A személyoldal és a szereplősáv így az app saját adataiból nyílik meg. A TMDB-rész TMDB API-kulcs nélkül nem csinál semmit, a portrérész Plex nélkül. Ha leáll, a személyoldal megnyitáskor maga kéri le az életrajzot, egy pillanattal később."
"people_enrich": "Helyivé teszi a Plex-könyvtár szereplőit: a TMDB-ből letölti az életrajzukat, a portréjukat pedig a Plexszel kicsinyítve elmenti, a legtöbbet szereplőkkel kezdve. A személyoldal és a szereplősáv így az app saját adataiból nyílik meg. A TMDB-rész TMDB API-kulcs nélkül nem csinál semmit, a portrérész Plex nélkül. Ha leáll, a személyoldal megnyitáskor maga kéri le az életrajzot, egy pillanattal később.",
"channel_backfill": "Ha egy csatornánál bekapcsolod a teljes előzményt, a teljes korábbi kínálata egyszerre, saját futásként töltődik le. Amit ez nem tud befejezni (egy nagyon nagy csatorna, kevés kvóta, vagy ha már két ilyen fut), azt a „Videók behúzása” feladat fejezi be."
},
"jobs": {
"rss_poll": "RSS-lekérdezés (új feltöltések)",
@@ -97,7 +99,8 @@
"tmdb_enrich": "TMDB-dúsítás (Plex)",
"audit_gc": "Eseménynapló- és kvótaelőzmény-takarítás",
"log_gc": "Naplótakarítás",
"people_enrich": "Személyadatok és portrék (Plex)"
"people_enrich": "Személyadatok és portrék (Plex)",
"channel_backfill": "Egy csatorna teljes előzménye"
},
"queue": {
"recentPending": "Szinkronra váró csatorna",
@@ -199,5 +202,14 @@
"transport": "nem érhető el",
"apply": "nem sikerült menteni",
"error": "váratlan hiba"
}
},
"dotRun": "{{state}} · {{time}} · {{trigger}}",
"trigger": {
"scheduled": "ütemezve",
"manual": "admin indította",
"ad_hoc": "felhasználó indította"
},
"interrupted": "megszakadt: a futás közben újraindult a szerver",
"adHocTitle": "Alkalmi futások",
"adHocStarted": "felhasználó indítja"
}
+38 -6
View File
@@ -184,12 +184,23 @@ function logParams(query: LogQuery, extra: Record<string, string | number | null
export type JobResult = { [key: string]: number | string | JobResult };
export interface SchedulerJob {
id: string;
interval_minutes: number;
next_run: string | null;
running: boolean;
status: "ok" | "error" | "skipped" | null;
/** How a run ended — the server's `RunStatus` (app/runstatus.py) minus `running`. `interrupted`:
* the process stopped mid-run (a deploy, a crash), and its next start closed the row. */
export type RunOutcome = "ok" | "error" | "skipped" | "interrupted";
/** One past run, as the row's history dots show it. */
export interface RunBrief {
id: number;
status: RunOutcome | "running";
trigger: "scheduled" | "manual" | "ad_hoc";
started_at: string;
finished_at: string | null;
}
/** The fields every Scheduler row has from its runs: how the newest ended one went, and the few
* before it. */
export interface RunFields {
status: RunOutcome | null;
last_started: string | null;
last_finished: string | null;
last_result: string | null; // the English one-liner — the fallback for a job with no `result`
@@ -197,16 +208,37 @@ export interface SchedulerJob {
// named in the reader's language by lib/jobResult.
result: JobResult | null;
last_error: string | null;
last_trigger: RunBrief["trigger"] | null;
last_run_id: number | null;
quota_units: number | null;
/** The newest runs, newest first (at most three). */
recent: RunBrief[];
}
export interface SchedulerJob extends RunFields {
id: string;
interval_minutes: number;
next_run: string | null;
running: boolean;
// 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;
}
/** Work a user starts on one target (a channel's full history): no interval, no "run now". */
export interface SchedulerAdHoc extends RunFields {
id: string;
running: boolean;
target: string | null;
target_title: string | null;
}
export interface SchedulerStatus {
running_here: boolean;
enabled: boolean;
paused: boolean;
jobs: SchedulerJob[];
ad_hoc: SchedulerAdHoc[];
queue: {
channels_recent_pending: number;
channels_deep_pending: number;