commit 3681a992b226c9a0a3319746ed490506b45c068d
parent 67b405fc53ebd0af57a6693486243d57494b2943
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 18 Jun 2026 14:39:35 -0400
Merge branch 'monitor-widget'
Diffstat:
12 files changed, 879 insertions(+), 93 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,7 +1,7 @@
# Changelog
## [Unreleased]
-- **The channel video selector can bulk-delete directories and clear failure markers, and its action bar is now an action picker.** The selector pane's bulk bar (channel page → video list) previously stacked separate Transcribe / Retry / Mark-untranscribable buttons; it's now a single **Action** dropdown + **Apply** button that also exposes two new operations. **Delete directories** removes each checked video's directory outright (`fs.rm` recursive) — a pure filesystem op that queues no job, so it never triggers a transcode the way leaving failed downloads in place can; this is the quick way to clear a batch of failed downloads. It's gated by an inline *type `delete` to confirm* box (the Apply button stays disabled until matched), mirroring the Clean-extra-formats pattern. **Clear failed markers** prunes the selected ids from the channel's `failed-transcriptions` and `failed-transcodings` files so they're retried on the next pass. Two quick-select helpers, **Select failed** (every video listed in either failure file) and **Select filtered** (every row matching the current filter + search), make the cleanup one flow: filter **Failed** → **Select failed** → action **Delete directories** → type `delete` → **Apply**. Both new actions report a per-id success/failure summary and refresh the channel report like the existing bulk actions.
+- **New read-only monitor widget (`/widget`) plus a builder to compose and embed it.** A compact, chrome-less page shows worker status (a colored idle/busy/draining/disabled/degraded dot per worker) and active-job progress bars at a glance — no sidebar, no command palette, and no action controls — so it fits in a small pinned window or an `<iframe>` for at-a-glance monitoring. It reuses the existing `/api/jobs/active` and `/api/workers` endpoints (polled live), and its initial paint is server-rendered for no flicker. What it shows is driven entirely by GET params: `jobs`/`workers` (toggle each section), `channel` (filter active jobs to one slug), `poll` (refresh seconds), `compact` (drop per-task detail), `titles` (section headers), and `idle=hide` (collapse to a tiny "Idle" line when nothing is active). A new **Monitor** page under the sidebar's **Pool** group (`/widget/builder`) exposes all of those as form controls, builds the shareable link with a **Copy** button, and live-previews the real widget in a sized iframe. To strip the app shell on exactly the widget route, the root layout now renders its sidebar/command-palette/auto-refresh through a small `AppFrame` client wrapper that hides them when the path is `/widget` (the builder keeps the normal shell). The per-worker payload builder shared by the Workers page and `/api/workers` was extracted to `buildWorkersPayload()` so the widget reuses it too. The selector pane's bulk bar (channel page → video list) previously stacked separate Transcribe / Retry / Mark-untranscribable buttons; it's now a single **Action** dropdown + **Apply** button that also exposes two new operations. **Delete directories** removes each checked video's directory outright (`fs.rm` recursive) — a pure filesystem op that queues no job, so it never triggers a transcode the way leaving failed downloads in place can; this is the quick way to clear a batch of failed downloads. It's gated by an inline *type `delete` to confirm* box (the Apply button stays disabled until matched), mirroring the Clean-extra-formats pattern. **Clear failed markers** prunes the selected ids from the channel's `failed-transcriptions` and `failed-transcodings` files so they're retried on the next pass. Two quick-select helpers, **Select failed** (every video listed in either failure file) and **Select filtered** (every row matching the current filter + search), make the cleanup one flow: filter **Failed** → **Select failed** → action **Delete directories** → type `delete` → **Apply**. Both new actions report a per-id success/failure summary and refresh the channel report like the existing bulk actions.
- **Audio-integrity checks now pause the download while they run, cutting re-downloaded bytes and HTTP 429 risk.** With audio-integrity checking enabled, the downloader periodically snapshots the in-progress `.part` and validates it with ffmpeg. Previously yt-dlp was only paused for the brief *copy* of that snapshot and then resumed immediately, so it kept downloading throughout the (longer) ffmpeg probe — and if the probe came back malformed, every byte pulled during the probe, plus everything back to the last good checkpoint, was discarded and had to be re-fetched. That wasted, repeated fetching is a prime driver of rate-limit (429) responses. Now yt-dlp stays suspended (SIGSTOP) across the whole probe and only resumes on a clean verdict; on a corrupt verdict it's killed while still stopped and rolled back, having downloaded zero throwaway bytes. The trade-off is a briefly idle source connection during each probe (probes are seconds; if a held connection is ever dropped, yt-dlp's own `-c` resume recovers on the next launch). This is the new default; a per-channel **Resume during probe (legacy)** checkbox (channel editor → Audio-integrity checking, `audioCheck.resumeDuringProbe` in `config.json`) restores the old resume-immediately behavior for comparison, and the `AUDIO_CHECK_RESUME_DURING_PROBE` env var overrides it for one-off runs.
- **Channels can sync automatically on a schedule.** Each channel gained an **Auto-sync** setting (channel editor → Source): *Default* (inherit the global cadence), *Off*, or a concrete interval (every 10m / 30m / hourly / 6h / 12h / daily / weekly), stored as `syncIntervalMinutes` in `config.json`. Inspired by the Laravel scheduler, a single lightweight cron heartbeat (`pnpm sync:tick`, an ~30-line client) POSTs to the editor's new `/api/scheduler/tick`, and the **server** decides which channels are due — a channel is due when `now − lastSyncedAt ≥ its interval`, so a missed tick (server down, machine asleep) simply runs at the next one with no catch-up storm. All work runs **inside the editor** through the existing job queue and per-channel lock, so a scheduled sync can't collide with a manual **Sync** click, shows up live on `/jobs`, and feeds the same transcription worker pool — no second process, no new file locks. Global controls live in **Settings → Sync scheduler**: a master **enable** (off by default), a **default interval**, a **max concurrent syncs** cap (a tick queues at most `cap − running` channels, most-overdue first, rolling the rest to the next tick — which both bounds load and staggers a large due-batch so it doesn't hit the source all at once), an optional **quiet-hours** window, and **failure backoff** (after N consecutive failures a channel waits `base·2^(N-1)` minutes, capped, before retrying). Channels already marked **Exclude from sync** never auto-sync. A new **Schedule** page (`/scheduler`) shows each channel's interval, last sync, next-due time, last outcome, and any active backoff, plus a recent-ticks log and a **Run scheduler now** button; the same data is at `GET /api/scheduler/status`. The cron client targets the editor's port (3001) by default and is hardenable with a `SYNC_TICK_TOKEN` bearer token for installs that expose the editor — see `SCHEDULED_SYNC.md`.
- **Sites can link to each other.** A site's form gained a **Public URL** field (the absolute URL it's served at, e.g. `https://jeralyzer.com`) and a **Related sites** section. The export footer automatically links to every *other* site that has a Public URL, so filling these in is all that's needed for cross-site links; a site left without a URL is simply omitted from the lists. The **Related sites** editor lets a site pull closely-related siblings to the front under named groups (e.g. Jeralyzer featuring Rekietalyzer under "MTG drama") — add a group, give it an optional heading, and check which sibling sites belong; everything you don't feature falls into a trailing "Other sites" group on its own. Groups reorder with ↑/↓. The picker only lists sites that actually exist, and featured ids for sites that were since deleted are dropped on save (with a heads-up note). It's a subtle, secondary feature — see the matching note in the export changelog for how it renders.
diff --git a/editor/app/api/workers/route.ts b/editor/app/api/workers/route.ts
@@ -1,53 +1,12 @@
import { NextResponse } from "next/server";
-import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool";
-import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
+import { buildWorkersPayload } from "../../workers/buildWorkers";
export const dynamic = "force-dynamic";
-// Live worker status for the Workers page. Polled ~1s by WorkersView. Joins the
-// pool's per-worker slot/state summary with the registry's in-flight transcribe
-// tasks (which carry workerId) so each card can show what it's currently running.
+// Live worker status for the Workers page and the read-only monitor widget.
+// Polled ~1s by WorkersView/MonitorWidget. Joins the pool's per-worker
+// slot/state summary with the registry's in-flight transcribe tasks (which
+// carry workerId) so each card can show what it's currently running.
export async function GET() {
- const pool = getWorkerPool();
- const summary = pool.summary();
-
- // Collect running transcribe tasks grouped by their worker, with enough detail
- // for the Workers page to show what each worker is doing right now.
- const byWorker = new Map<
- string,
- {
- id: string;
- label: string;
- fraction?: number;
- detail?: string;
- startedAt: number;
- channelSlug?: string;
- }[]
- >();
- for (const job of getRegistry().list()) {
- if (job.status !== "running" || !job.tasks) continue;
- for (const t of job.tasks) {
- if (t.kind !== "transcribe" || !t.workerId) continue;
- const list = byWorker.get(t.workerId) ?? [];
- list.push({
- id: t.id,
- label: t.label,
- fraction: t.fraction,
- detail: t.detail,
- startedAt: t.startedAt,
- channelSlug: job.channelSlug,
- });
- byWorker.set(t.workerId, list);
- }
- }
-
- const workers = summary.map((w) => ({
- ...w,
- tasks: byWorker.get(w.id) ?? [],
- // True when the in-flight transcription can be stopped into a partial result
- // (parakeet). Drives the "Stop & keep partial" button on the Workers page.
- canStopPartial: pool.canStopPartial(w.id),
- }));
-
- return NextResponse.json({ paused: pool.isPaused(), workers });
+ return NextResponse.json(buildWorkersPayload());
}
diff --git a/editor/app/components/AppFrame.tsx b/editor/app/components/AppFrame.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { usePathname } from "next/navigation";
+
+// The root layout always renders the sidebar nav, command palette, and
+// auto-refresh. The read-only monitor widget (/widget) is meant to be embedded
+// in a small pinned window or iframe, so it needs none of that chrome. Since the
+// App Router root layout can't be skipped, this client wrapper hides the chrome
+// on exactly that route and renders a bare, full-bleed main instead.
+//
+// Matched exactly on "/widget" so the builder (/widget/builder) keeps the shell.
+export function AppFrame({
+ sidebar,
+ extras,
+ children,
+}: {
+ sidebar: ReactNode;
+ extras: ReactNode;
+ children: ReactNode;
+}) {
+ const pathname = usePathname();
+ const bare = pathname === "/widget";
+
+ if (bare) {
+ return <main className="flex-1 min-w-0 min-h-full">{children}</main>;
+ }
+
+ return (
+ <>
+ {sidebar}
+ <main className="flex-1 min-w-0 px-4 md:px-6 py-4 md:py-6">{children}</main>
+ {extras}
+ </>
+ );
+}
diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx
@@ -7,6 +7,7 @@ import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import { getLatestChangelogDate } from "yt-dlp-transcript-common/lib/changelog";
import { listSites } from "yt-dlp-transcript-common/lib/site";
+import { AppFrame } from "./components/AppFrame";
import { AutoRefresh } from "./components/AutoRefresh";
import { CommandPalette } from "./components/CommandPalette";
import { ChangelogNavLink } from "./components/ChangelogNavLink";
@@ -53,6 +54,7 @@ const NAV_GROUPS: NavGroup[] = [
{ href: "/jobs", label: "Jobs", badgeKey: "jobs" },
{ href: "/jobs/active", label: "Active", badgeKey: "running" },
{ href: "/workers", label: "Workers" },
+ { href: "/widget/builder", label: "Monitor" },
{ href: "/scheduler", label: "Schedule" },
{ href: "/build", label: "Build" },
{ href: "/actionable", label: "Actionable" },
@@ -139,42 +141,51 @@ export default async function RootLayout({
return (
<html lang="en" className="h-full antialiased">
<body className="min-h-full flex flex-col md:flex-row bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
- <aside className="md:w-56 md:shrink-0 md:sticky md:top-0 md:self-start md:h-screen md:overflow-y-auto border-b md:border-b-0 md:border-r border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 flex flex-col">
- <div className="px-4 py-3 md:py-4 md:border-b md:border-zinc-200 dark:md:border-zinc-800">
- <Link
- href="/"
- className="font-semibold tracking-tight block leading-tight"
- >
- {headerLabel}
- </Link>
- <div className="text-xs uppercase tracking-wide text-zinc-500 mt-0.5">
- editor
- </div>
- </div>
- <Suspense fallback={null}>
- <SiteScopeSelect sites={sites} />
- </Suspense>
- <nav className="flex flex-row md:flex-col p-2 gap-1 text-sm overflow-x-auto md:overflow-visible">
- {NAV_GROUPS.map((group) => (
- <div
- key={group.label}
- className="flex flex-row md:flex-col gap-1 md:gap-0.5 items-center md:items-stretch"
- >
- <div className="px-3 py-1 text-[10px] uppercase tracking-wider text-zinc-400 dark:text-zinc-600 whitespace-nowrap">
- {group.label}
+ <AppFrame
+ sidebar={
+ <aside className="md:w-56 md:shrink-0 md:sticky md:top-0 md:self-start md:h-screen md:overflow-y-auto border-b md:border-b-0 md:border-r border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 flex flex-col">
+ <div className="px-4 py-3 md:py-4 md:border-b md:border-zinc-200 dark:md:border-zinc-800">
+ <Link
+ href="/"
+ className="font-semibold tracking-tight block leading-tight"
+ >
+ {headerLabel}
+ </Link>
+ <div className="text-xs uppercase tracking-wide text-zinc-500 mt-0.5">
+ editor
</div>
- {group.links.map(renderLink)}
</div>
- ))}
- </nav>
- </aside>
- <main className="flex-1 min-w-0 px-4 md:px-6 py-4 md:py-6">{children}</main>
- <Suspense fallback={null}>
- <CommandPalette />
- </Suspense>
- <AutoRefresh
- intervalMs={settings.autoRefreshIntervalSeconds * 1000}
- />
+ <Suspense fallback={null}>
+ <SiteScopeSelect sites={sites} />
+ </Suspense>
+ <nav className="flex flex-row md:flex-col p-2 gap-1 text-sm overflow-x-auto md:overflow-visible">
+ {NAV_GROUPS.map((group) => (
+ <div
+ key={group.label}
+ className="flex flex-row md:flex-col gap-1 md:gap-0.5 items-center md:items-stretch"
+ >
+ <div className="px-3 py-1 text-[10px] uppercase tracking-wider text-zinc-400 dark:text-zinc-600 whitespace-nowrap">
+ {group.label}
+ </div>
+ {group.links.map(renderLink)}
+ </div>
+ ))}
+ </nav>
+ </aside>
+ }
+ extras={
+ <>
+ <Suspense fallback={null}>
+ <CommandPalette />
+ </Suspense>
+ <AutoRefresh
+ intervalMs={settings.autoRefreshIntervalSeconds * 1000}
+ />
+ </>
+ }
+ >
+ {children}
+ </AppFrame>
</body>
</html>
);
diff --git a/editor/app/widget/builder/components/WidgetBuilder.tsx b/editor/app/widget/builder/components/WidgetBuilder.tsx
@@ -0,0 +1,194 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import {
+ buildWidgetQuery,
+ WIDGET_DEFAULTS,
+ type WidgetConfig,
+} from "../../lib/config";
+
+const SIZE_PRESETS: { label: string; width: number; height: number }[] = [
+ { label: "Small", width: 320, height: 200 },
+ { label: "Medium", width: 380, height: 320 },
+ { label: "Tall", width: 360, height: 520 },
+];
+
+export function WidgetBuilder() {
+ const [config, setConfig] = useState<WidgetConfig>(WIDGET_DEFAULTS);
+ const [size, setSize] = useState(SIZE_PRESETS[0]);
+ const [origin, setOrigin] = useState("");
+ const [copied, setCopied] = useState(false);
+
+ // Resolve the absolute origin on the client to avoid an SSR/CSR mismatch.
+ useEffect(() => {
+ setOrigin(window.location.origin);
+ }, []);
+
+ const query = useMemo(() => buildWidgetQuery(config), [config]);
+ const relativeUrl = query ? `/widget?${query}` : "/widget";
+ const fullUrl = origin ? `${origin}${relativeUrl}` : relativeUrl;
+
+ function patch(p: Partial<WidgetConfig>) {
+ setConfig((c) => ({ ...c, ...p }));
+ setCopied(false);
+ }
+
+ async function copy() {
+ try {
+ await navigator.clipboard.writeText(fullUrl);
+ setCopied(true);
+ } catch {
+ // clipboard blocked — leave the field for manual copy
+ }
+ }
+
+ return (
+ <div className="flex flex-col gap-6 lg:flex-row lg:items-start">
+ <form className="flex flex-col gap-4 lg:w-80 lg:shrink-0">
+ <fieldset className="flex flex-col gap-2">
+ <legend className="text-sm font-medium mb-1">Sections</legend>
+ <Check
+ label="Workers"
+ checked={config.workers}
+ onChange={(v) => patch({ workers: v })}
+ />
+ <Check
+ label="Active jobs"
+ checked={config.jobs}
+ onChange={(v) => patch({ jobs: v })}
+ />
+ </fieldset>
+
+ <fieldset className="flex flex-col gap-2">
+ <legend className="text-sm font-medium mb-1">Display</legend>
+ <Check
+ label="Compact (hide per-task detail)"
+ checked={config.compact}
+ onChange={(v) => patch({ compact: v })}
+ />
+ <Check
+ label="Show section titles"
+ checked={config.showTitles}
+ onChange={(v) => patch({ showTitles: v })}
+ />
+ <Check
+ label="Hide when idle"
+ checked={config.hideIdle}
+ onChange={(v) => patch({ hideIdle: v })}
+ />
+ </fieldset>
+
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Channel filter (slug, optional)</span>
+ <input
+ type="text"
+ value={config.channel ?? ""}
+ onChange={(e) => patch({ channel: e.target.value || undefined })}
+ placeholder="all channels"
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1"
+ />
+ </label>
+
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Poll interval (seconds)</span>
+ <input
+ type="number"
+ min={1}
+ value={config.pollSeconds}
+ onChange={(e) =>
+ patch({
+ pollSeconds: Math.max(1, Math.round(Number(e.target.value) || 1)),
+ })
+ }
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 w-24"
+ />
+ </label>
+ </form>
+
+ <div className="flex flex-1 flex-col gap-3 min-w-0">
+ <div className="flex flex-col gap-1">
+ <span className="text-sm font-medium">Widget link</span>
+ <div className="flex gap-2">
+ <input
+ type="text"
+ readOnly
+ aria-label="widget URL"
+ value={fullUrl}
+ onFocus={(e) => e.currentTarget.select()}
+ className="flex-1 min-w-0 rounded border border-zinc-300 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-900 px-2 py-1 font-mono text-xs"
+ />
+ <button
+ type="button"
+ onClick={copy}
+ className="px-3 py-1.5 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 shrink-0"
+ >
+ {copied ? "Copied" : "Copy"}
+ </button>
+ </div>
+ <p className="text-xs text-zinc-500">
+ Open this in a small pinned window, or embed it with an{" "}
+ <code className="font-mono"><iframe></code>. It has no sidebar
+ and no controls — read-only.
+ </p>
+ </div>
+
+ <div className="flex flex-col gap-2">
+ <div className="flex items-center gap-2">
+ <span className="text-sm font-medium">Preview</span>
+ <div className="flex gap-1">
+ {SIZE_PRESETS.map((p) => (
+ <button
+ key={p.label}
+ type="button"
+ onClick={() => setSize(p)}
+ aria-pressed={size.label === p.label}
+ className={`px-2 py-0.5 rounded text-xs border ${
+ size.label === p.label
+ ? "bg-zinc-900 text-white border-zinc-900 dark:bg-zinc-100 dark:text-zinc-900 dark:border-zinc-100"
+ : "border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800"
+ }`}
+ >
+ {p.label}
+ </button>
+ ))}
+ </div>
+ </div>
+ <div className="inline-block rounded-lg border border-zinc-300 dark:border-zinc-700 overflow-hidden bg-white dark:bg-zinc-950 shadow-sm">
+ <iframe
+ title="widget preview"
+ src={relativeUrl}
+ width={size.width}
+ height={size.height}
+ className="block"
+ />
+ </div>
+ <p className="text-xs text-zinc-500">
+ Previewing at {size.width}×{size.height}px.
+ </p>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function Check({
+ label,
+ checked,
+ onChange,
+}: {
+ label: string;
+ checked: boolean;
+ onChange: (v: boolean) => void;
+}) {
+ return (
+ <label className="flex items-center gap-2 text-sm">
+ <input
+ type="checkbox"
+ checked={checked}
+ onChange={(e) => onChange(e.target.checked)}
+ className="h-4 w-4"
+ />
+ <span>{label}</span>
+ </label>
+ );
+}
diff --git a/editor/app/widget/builder/page.tsx b/editor/app/widget/builder/page.tsx
@@ -0,0 +1,24 @@
+import type { Metadata } from "next";
+import { WidgetBuilder } from "./components/WidgetBuilder";
+
+export const metadata: Metadata = { title: "Monitor widget" };
+
+// Lives inside the normal app shell (AppFrame strips chrome only on exactly
+// "/widget"). Lets you compose a read-only monitor link from form controls,
+// copy it, and preview the real bare widget in an iframe.
+export default function WidgetBuilderPage() {
+ return (
+ <div className="flex flex-col gap-4">
+ <div className="flex items-center justify-between">
+ <h1 className="text-2xl font-semibold">Monitor widget</h1>
+ </div>
+ <p className="text-sm text-zinc-500 max-w-2xl">
+ Build a read-only monitoring widget that shows worker status and active
+ jobs with no sidebar or controls — sized to sit in a small pinned window
+ or an embedded <code className="font-mono"><iframe></code>. Toggle
+ what it shows below, then copy the link.
+ </p>
+ <WidgetBuilder />
+ </div>
+ );
+}
diff --git a/editor/app/widget/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx
@@ -0,0 +1,348 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { formatDuration } from "yt-dlp-transcript-common/lib/format";
+import type { ActiveJobsPayload } from "../../jobs/active/buildActiveJobs";
+import type { RunningJobsListItem } from "../../jobs/components/RunningJobsList";
+import type { WorkersPayload, WorkerView } from "../../workers/components/WorkersView";
+import type { WidgetConfig } from "../lib/config";
+
+// Read-only monitor widget. Reuses the existing ~1s poll pattern from
+// ActiveJobsLive / WorkersView against the same /api endpoints, but renders a
+// compact, control-free view meant to fit in a small pinned window or iframe.
+
+// Live wall-clock that re-renders once a second; null until mounted so SSR and
+// the first client render agree (no Date.now() hydration mismatch).
+function useNow(): number | null {
+ const [now, setNow] = useState<number | null>(null);
+ useEffect(() => {
+ setNow(Date.now());
+ const id = setInterval(() => setNow(Date.now()), 1000);
+ return () => clearInterval(id);
+ }, []);
+ return now;
+}
+
+// Generic poller: fetches `url` every `pollMs` while enabled, swallowing
+// transient errors. Disabled (enabled=false) leaves the initial value as-is.
+function usePolledPayload<T>(
+ url: string,
+ enabled: boolean,
+ pollMs: number,
+ initial: T | null,
+): T | null {
+ const [data, setData] = useState<T | null>(initial);
+ useEffect(() => {
+ if (!enabled) return;
+ let cancelled = false;
+ let timer: ReturnType<typeof setTimeout> | null = null;
+ async function tick() {
+ try {
+ const res = await fetch(url, { cache: "no-store" });
+ if (res.ok && !cancelled) setData((await res.json()) as T);
+ } catch {
+ // transient — keep polling
+ } finally {
+ if (!cancelled) timer = setTimeout(tick, pollMs);
+ }
+ }
+ timer = setTimeout(tick, pollMs);
+ return () => {
+ cancelled = true;
+ if (timer) clearTimeout(timer);
+ };
+ }, [url, enabled, pollMs]);
+ return data;
+}
+
+export function MonitorWidget({
+ config,
+ initialJobs,
+ initialWorkers,
+}: {
+ config: WidgetConfig;
+ initialJobs: ActiveJobsPayload | null;
+ initialWorkers: WorkersPayload | null;
+}) {
+ const pollMs = config.pollSeconds * 1000;
+ const jobsPayload = usePolledPayload<ActiveJobsPayload>(
+ "/api/jobs/active",
+ config.jobs,
+ pollMs,
+ initialJobs,
+ );
+ const workersPayload = usePolledPayload<WorkersPayload>(
+ "/api/workers",
+ config.workers,
+ pollMs,
+ initialWorkers,
+ );
+
+ const jobs = (jobsPayload?.jobs ?? []).filter(
+ (j) => !config.channel || j.channelSlug === config.channel,
+ );
+ const workers = workersPayload?.workers ?? [];
+
+ const nothingActive =
+ (!config.jobs || jobs.length === 0) &&
+ (!config.workers || workers.every((w) => !w.busy));
+
+ if (config.hideIdle && nothingActive) {
+ return (
+ <div className="p-2 text-xs text-zinc-500" aria-label="monitor idle">
+ Idle
+ </div>
+ );
+ }
+
+ return (
+ <div className="flex flex-col gap-3 p-2 text-zinc-900 dark:text-zinc-100">
+ {config.workers && (
+ <WorkersStrip
+ workers={workers}
+ paused={workersPayload?.paused ?? false}
+ showTitle={config.showTitles}
+ />
+ )}
+ {config.jobs && (
+ <ActiveJobsStrip
+ jobs={jobs}
+ showTitle={config.showTitles}
+ compact={config.compact}
+ />
+ )}
+ </div>
+ );
+}
+
+// Compact state → dot color, mirroring stateBadge() in WorkersView.
+function workerDotClass(w: WorkerView): string {
+ if (w.degraded) return "bg-red-500";
+ switch (w.state) {
+ case "enabled":
+ return w.busy ? "bg-green-500" : "bg-green-500/40";
+ case "draining":
+ return "bg-amber-500";
+ default:
+ return "bg-zinc-400 dark:bg-zinc-600";
+ }
+}
+
+function workerStateLabel(w: WorkerView): string {
+ if (w.degraded) return "degraded";
+ if (w.state === "enabled") return w.busy ? "busy" : "idle";
+ return w.state; // draining | disabled
+}
+
+function WorkersStrip({
+ workers,
+ paused,
+ showTitle,
+}: {
+ workers: WorkerView[];
+ paused: boolean;
+ showTitle: boolean;
+}) {
+ const busy = workers.filter((w) => w.busy).length;
+ return (
+ <section aria-label="Workers" className="flex flex-col gap-1.5">
+ {showTitle && (
+ <div className="flex items-baseline gap-2">
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
+ Workers
+ </h2>
+ <span className="text-xs text-zinc-500">
+ {busy}/{workers.length} busy
+ </span>
+ {paused && (
+ <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 dark:bg-amber-950 text-amber-800 dark:text-amber-200 border border-amber-300 dark:border-amber-800">
+ paused
+ </span>
+ )}
+ </div>
+ )}
+ {workers.length === 0 ? (
+ <p className="text-xs text-zinc-500">No workers configured.</p>
+ ) : (
+ <ul className="flex flex-wrap gap-1.5">
+ {workers.map((w) => (
+ <li
+ key={w.id}
+ title={`${w.name} — ${workerStateLabel(w)}`}
+ aria-label={`worker ${w.name} ${workerStateLabel(w)}`}
+ className="flex items-center gap-1.5 rounded border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-1.5 py-0.5 text-xs"
+ >
+ <span
+ className={`inline-block h-2 w-2 shrink-0 rounded-full ${workerDotClass(w)}`}
+ />
+ <span className="truncate max-w-[10rem]">{w.name}</span>
+ </li>
+ ))}
+ </ul>
+ )}
+ </section>
+ );
+}
+
+function ActiveJobsStrip({
+ jobs,
+ showTitle,
+ compact,
+}: {
+ jobs: RunningJobsListItem[];
+ showTitle: boolean;
+ compact: boolean;
+}) {
+ const running = jobs.filter((j) => j.status === "running").length;
+ const queued = jobs.filter((j) => j.status === "queued").length;
+ return (
+ <section aria-label="Active jobs" className="flex flex-col gap-1.5">
+ {showTitle && (
+ <div className="flex items-baseline gap-2">
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
+ Active
+ </h2>
+ <span className="text-xs text-zinc-500">
+ {running} running, {queued} queued
+ </span>
+ </div>
+ )}
+ {jobs.length === 0 ? (
+ <p className="text-xs text-zinc-500">No active jobs.</p>
+ ) : (
+ <ul className="flex flex-col gap-2">
+ {jobs.map((j) => (
+ <li key={j.id}>
+ <JobRow job={j} compact={compact} />
+ </li>
+ ))}
+ </ul>
+ )}
+ </section>
+ );
+}
+
+function JobRow({
+ job,
+ compact,
+}: {
+ job: RunningJobsListItem;
+ compact: boolean;
+}) {
+ const statusClass =
+ job.status === "running"
+ ? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
+ : "bg-zinc-200 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-200";
+ return (
+ <div className="flex flex-col gap-1">
+ <div className="flex flex-wrap items-center gap-1.5 text-xs">
+ <span
+ className={`uppercase tracking-wide px-1.5 py-0.5 rounded text-[10px] ${statusClass}`}
+ >
+ {job.status}
+ </span>
+ <span className="font-mono">{job.kind}</span>
+ {job.channelSlug && (
+ <span className="font-mono text-zinc-500 truncate max-w-[12rem]">
+ {job.channelSlug}
+ </span>
+ )}
+ </div>
+ {job.progress && <JobProgressBar progress={job.progress} />}
+ {!compact &&
+ job.tasks &&
+ job.tasks.length > 0 &&
+ job.tasks.map((t) => <TaskBar key={t.id} task={t} />)}
+ </div>
+ );
+}
+
+function JobProgressBar({
+ progress,
+}: {
+ progress: NonNullable<RunningJobsListItem["progress"]>;
+}) {
+ const label =
+ progress.metric === "downloads"
+ ? `↓ ${progress.current}/${progress.target}`
+ : `${progress.current}/${progress.target}`;
+ const remaining = progress.target - progress.current;
+ const etaText =
+ remaining <= 0
+ ? null
+ : typeof progress.etaSeconds === "number"
+ ? `~${formatDuration(Math.max(1, Math.round(progress.etaSeconds)))} left`
+ : "…";
+ return (
+ <div className="flex flex-col gap-0.5">
+ <div
+ role="progressbar"
+ aria-valuemin={progress.initial}
+ aria-valuemax={progress.target}
+ aria-valuenow={progress.current}
+ aria-label={label}
+ className="relative h-1.5 w-full overflow-hidden rounded bg-zinc-200 dark:bg-zinc-800"
+ >
+ <div
+ className="absolute inset-y-0 left-0 bg-emerald-500"
+ style={{ width: `${progress.pct}%` }}
+ />
+ </div>
+ <div className="flex items-baseline justify-between gap-2 text-[11px] text-zinc-500">
+ <span>{label}</span>
+ {etaText && <span className="shrink-0">{etaText}</span>}
+ </div>
+ </div>
+ );
+}
+
+function TaskBar({
+ task,
+}: {
+ task: NonNullable<RunningJobsListItem["tasks"]>[number];
+}) {
+ const now = useNow();
+ const verb = task.kind === "download" ? "↓" : "✎";
+ const hasFraction = typeof task.fraction === "number";
+ const pct = hasFraction ? Math.round((task.fraction as number) * 100) : 0;
+ const fillClass =
+ task.kind === "download"
+ ? "bg-emerald-300 dark:bg-emerald-700"
+ : "bg-emerald-500";
+ const elapsed =
+ now === null
+ ? null
+ : formatDuration(Math.max(0, Math.round((now - task.startedAt) / 1000))) ||
+ "0:00";
+ const meta = [elapsed, hasFraction ? `${pct}%` : null, task.detail]
+ .filter(Boolean)
+ .join(" · ");
+ return (
+ <div className="flex flex-col gap-0.5 pl-2 border-l-2 border-zinc-200 dark:border-zinc-800">
+ <div className="flex items-baseline justify-between gap-2 text-[11px]">
+ <span className="truncate">
+ <span className="text-zinc-500">{verb} </span>
+ <span className="font-mono">{task.label}</span>
+ </span>
+ <span className="font-mono text-zinc-500 shrink-0">{meta}</span>
+ </div>
+ <div
+ role="progressbar"
+ aria-valuenow={hasFraction ? pct : undefined}
+ aria-label={`${task.label}`}
+ className="relative h-1 w-full overflow-hidden rounded bg-zinc-200 dark:bg-zinc-800"
+ >
+ {hasFraction ? (
+ <div
+ className={`absolute inset-y-0 left-0 ${fillClass}`}
+ style={{ width: `${pct}%` }}
+ />
+ ) : (
+ <div
+ className={`absolute inset-y-0 left-0 w-1/3 animate-pulse ${fillClass}`}
+ />
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/editor/app/widget/lib/config.ts b/editor/app/widget/lib/config.ts
@@ -0,0 +1,85 @@
+// Single source of truth for the read-only monitor widget's GET-param contract.
+// Used by the bare /widget page (parse) and the /widget/builder UI (parse +
+// serialize), so a link the builder copies always renders the way it previewed.
+
+export type WidgetConfig = {
+ // Which sections to render.
+ jobs: boolean;
+ workers: boolean;
+ // Optional: only show active jobs for this channel slug.
+ channel?: string;
+ // Poll cadence in seconds (clamped >= 1).
+ pollSeconds: number;
+ // Denser layout: drops per-task detail rows, keeps the job-level bars.
+ compact: boolean;
+ // Show the small "Workers" / "Active" section headers.
+ showTitles: boolean;
+ // When nothing is active, collapse to a tiny "Idle" line instead of the
+ // full empty-state boxes.
+ hideIdle: boolean;
+};
+
+export const WIDGET_DEFAULTS: WidgetConfig = {
+ jobs: true,
+ workers: true,
+ channel: undefined,
+ pollSeconds: 2,
+ compact: false,
+ showTitles: true,
+ hideIdle: false,
+};
+
+// Next's searchParams give each key as string | string[] | undefined.
+type RawParams = Record<string, string | string[] | undefined>;
+
+function first(v: string | string[] | undefined): string | undefined {
+ return Array.isArray(v) ? v[0] : v;
+}
+
+// Parse a "0"/"1" (also accepts "true"/"false") flag, falling back to a default.
+function parseBool(
+ v: string | string[] | undefined,
+ fallback: boolean,
+): boolean {
+ const s = first(v);
+ if (s === undefined) return fallback;
+ if (s === "1" || s === "true") return true;
+ if (s === "0" || s === "false") return false;
+ return fallback;
+}
+
+export function parseWidgetConfig(params: RawParams): WidgetConfig {
+ const channel = first(params.channel)?.trim();
+ const pollRaw = Number(first(params.poll));
+ const pollSeconds =
+ Number.isFinite(pollRaw) && pollRaw >= 1
+ ? Math.min(3600, Math.round(pollRaw))
+ : WIDGET_DEFAULTS.pollSeconds;
+ return {
+ jobs: parseBool(params.jobs, WIDGET_DEFAULTS.jobs),
+ workers: parseBool(params.workers, WIDGET_DEFAULTS.workers),
+ channel: channel || undefined,
+ pollSeconds,
+ compact: parseBool(params.compact, WIDGET_DEFAULTS.compact),
+ showTitles: parseBool(params.titles, WIDGET_DEFAULTS.showTitles),
+ hideIdle: first(params.idle) === "hide" ? true : WIDGET_DEFAULTS.hideIdle,
+ };
+}
+
+// Serialize a config to a query string, omitting anything left at its default so
+// shared links stay short. Returns "" when every value is default.
+export function buildWidgetQuery(config: WidgetConfig): string {
+ const sp = new URLSearchParams();
+ if (config.jobs !== WIDGET_DEFAULTS.jobs) sp.set("jobs", config.jobs ? "1" : "0");
+ if (config.workers !== WIDGET_DEFAULTS.workers)
+ sp.set("workers", config.workers ? "1" : "0");
+ if (config.channel) sp.set("channel", config.channel);
+ if (config.pollSeconds !== WIDGET_DEFAULTS.pollSeconds)
+ sp.set("poll", String(config.pollSeconds));
+ if (config.compact !== WIDGET_DEFAULTS.compact)
+ sp.set("compact", config.compact ? "1" : "0");
+ if (config.showTitles !== WIDGET_DEFAULTS.showTitles)
+ sp.set("titles", config.showTitles ? "1" : "0");
+ if (config.hideIdle !== WIDGET_DEFAULTS.hideIdle) sp.set("idle", "hide");
+ return sp.toString();
+}
diff --git a/editor/app/widget/page.tsx b/editor/app/widget/page.tsx
@@ -0,0 +1,31 @@
+import type { Metadata } from "next";
+import { buildActiveJobsPayload } from "../jobs/active/buildActiveJobs";
+import { buildWorkersPayload } from "../workers/buildWorkers";
+import { MonitorWidget } from "./components/MonitorWidget";
+import { parseWidgetConfig } from "./lib/config";
+
+export const dynamic = "force-dynamic";
+
+export const metadata: Metadata = { title: "Monitor" };
+
+// Bare, read-only monitor. The root layout strips its chrome (see AppFrame) so
+// it can be embedded in a small pinned window or iframe. What it shows is driven
+// entirely by GET params (see lib/config); the /widget/builder page composes
+// those links. Initial payloads are built server-side here for a flicker-free
+// first paint; MonitorWidget then polls the same /api endpoints for live data.
+export default async function WidgetPage({
+ searchParams,
+}: {
+ searchParams: Promise<Record<string, string | string[] | undefined>>;
+}) {
+ const config = parseWidgetConfig((await searchParams) ?? {});
+ const initialJobs = config.jobs ? await buildActiveJobsPayload() : null;
+ const initialWorkers = config.workers ? buildWorkersPayload() : null;
+ return (
+ <MonitorWidget
+ config={config}
+ initialJobs={initialJobs}
+ initialWorkers={initialWorkers}
+ />
+ );
+}
diff --git a/editor/app/workers/buildWorkers.ts b/editor/app/workers/buildWorkers.ts
@@ -0,0 +1,41 @@
+import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool";
+import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
+import type { WorkersPayload, WorkerTask } from "./components/WorkersView";
+
+// Builds the Workers screen payload: the pool's per-worker slot/state summary
+// joined with the registry's in-flight transcribe tasks (which carry workerId)
+// so each worker shows what it's currently running. Shared by the server-
+// rendered page (initial state), the /api/workers poll route, and the read-only
+// monitor widget.
+export function buildWorkersPayload(): WorkersPayload {
+ const pool = getWorkerPool();
+ const summary = pool.summary();
+
+ const byWorker = new Map<string, WorkerTask[]>();
+ for (const job of getRegistry().list()) {
+ if (job.status !== "running" || !job.tasks) continue;
+ for (const t of job.tasks) {
+ if (t.kind !== "transcribe" || !t.workerId) continue;
+ const list = byWorker.get(t.workerId) ?? [];
+ list.push({
+ id: t.id,
+ label: t.label,
+ fraction: t.fraction,
+ detail: t.detail,
+ startedAt: t.startedAt,
+ channelSlug: job.channelSlug,
+ });
+ byWorker.set(t.workerId, list);
+ }
+ }
+
+ const workers = summary.map((w) => ({
+ ...w,
+ tasks: byWorker.get(w.id) ?? [],
+ // True when the in-flight transcription can be stopped into a partial result
+ // (parakeet). Drives the "Stop & keep partial" button on the Workers page.
+ canStopPartial: pool.canStopPartial(w.id),
+ }));
+
+ return { paused: pool.isPaused(), workers };
+}
diff --git a/editor/app/workers/page.tsx b/editor/app/workers/page.tsx
@@ -1,22 +1,14 @@
import type { Metadata } from "next";
import Link from "next/link";
-import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool";
-import { WorkersView, type WorkersPayload } from "./components/WorkersView";
+import { WorkersView } from "./components/WorkersView";
+import { buildWorkersPayload } from "./buildWorkers";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Workers" };
export default async function WorkersPage() {
- const pool = getWorkerPool();
- const initial: WorkersPayload = {
- paused: pool.isPaused(),
- workers: pool.summary().map((w) => ({
- ...w,
- tasks: [],
- canStopPartial: pool.canStopPartial(w.id),
- })),
- };
+ const initial = buildWorkersPayload();
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
diff --git a/editor/e2e/widget.spec.ts b/editor/e2e/widget.spec.ts
@@ -0,0 +1,65 @@
+// The read-only monitor widget (/widget) and its builder (/widget/builder).
+// The widget is meant to embed in a small pinned window or iframe: the app shell
+// (sidebar, command palette) is stripped on exactly /widget, and it carries no
+// action controls. The builder lives inside the normal shell and composes links.
+
+import { test, expect } from "@playwright/test";
+import { resetData, writeSettings } from "./helpers";
+
+const TWO_WORKERS = {
+ workers: [
+ { id: "gpu", name: "GPU", kind: "local", enabled: true, priority: 0, appId: "whisper-cpp", config: {} },
+ { id: "cpu", name: "CPU", kind: "local", enabled: true, priority: 1, appId: "whisper-cpp", config: {} },
+ ],
+};
+
+test.beforeEach(async () => {
+ await resetData("empty");
+ await writeSettings(TWO_WORKERS);
+});
+
+test("the bare widget has no sidebar and no controls", async ({ page }) => {
+ await page.goto("/widget");
+
+ // Chrome is stripped: no sidebar nav.
+ await expect(page.locator("aside")).toHaveCount(0);
+
+ // Both sections render with the configured workers visible.
+ await expect(page.getByRole("heading", { name: "Workers" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Active" })).toBeVisible();
+ await expect(page.getByLabel("worker GPU idle")).toBeVisible();
+ await expect(page.getByLabel("worker CPU idle")).toBeVisible();
+
+ // Read-only: no action buttons of any kind.
+ await expect(page.getByRole("button")).toHaveCount(0);
+});
+
+test("section params gate what renders", async ({ page }) => {
+ await page.goto("/widget?jobs=0");
+ await expect(page.getByRole("heading", { name: "Workers" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Active" })).toHaveCount(0);
+
+ await page.goto("/widget?workers=0");
+ await expect(page.getByRole("heading", { name: "Active" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Workers" })).toHaveCount(0);
+});
+
+test("the builder keeps the shell and composes a link", async ({ page }) => {
+ await page.goto("/widget/builder");
+
+ // Builder lives inside the normal app shell.
+ await expect(page.locator("aside")).toBeVisible();
+
+ const url = page.getByLabel("widget URL");
+ await expect(url).toHaveValue(/\/widget$/);
+
+ const preview = page.locator('iframe[title="widget preview"]');
+ await expect(preview).toHaveAttribute("src", "/widget");
+
+ // Toggling a section updates both the link and the preview src.
+ await page.getByRole("checkbox", { name: "Workers" }).uncheck();
+ await expect(url).toHaveValue(/\/widget\?workers=0$/);
+ await expect(preview).toHaveAttribute("src", "/widget?workers=0");
+
+ await expect(page.getByRole("button", { name: /copy/i })).toBeVisible();
+});