commit b352dde505625cf8fd21b3dff8d7d25e4af1ff4f
parent 43417f039a1e8af571d4fe480e01cc42cf7c2a55
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 29 Aug 2026 18:11:36 -0400
workers: the worker list is configured where the workers are
/workers showed the live pool and sent you to /settings to change it; /settings
showed the list with a link back. The list is a form on /workers now, under the
workers it describes, with its own Save — and WorkersField moves with it.
saveWorkersAction is the one writer of settings.workers: read the file, replace
that block, preserve every other verbatim, then reconfigure the live pool with
applyEnabled, exactly as the whole-object form did. Nothing else on the page is
a form, so a save here cannot read another block's checkbox as off — the
structural property slice 3 bought for the operation blocks.
The tag vocabulary is built here now and FILTERED ON `scope === "video"`: a tag
names a per-video operation a delegate can take a unit of, and no worker will
ever be handed a channel. That is the first consumer to use the field for the
reason it exists.
Every worker aria-label is byte-identical. The three list tests move from
settings.spec.ts to workers.spec.ts; the migration and parakeet specs point at
/workers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
12 files changed, 809 insertions(+), 723 deletions(-)
diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts
@@ -28,11 +28,6 @@ import {
isCookieMode,
} from "yt-dlp-transcript-common/lib/cookiePolicy";
import { isDownloadFormatPreset } from "yt-dlp-transcript-common/ytdlp/downloadFormat";
-import {
- sanitizeWorkers,
- validateWorkers,
-} from "yt-dlp-transcript-common/lib/workers";
-import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool";
export type SaveResult = { ok: true } | { ok: false; error: string };
@@ -85,18 +80,6 @@ export async function saveSettingsAction(
if (!adminTitle) return { ok: false, error: "Admin title is required" };
- // Workers are submitted as a JSON array by the WorkersField client component.
- // Sanitize + validate here for a friendly error; writeSettings re-validates.
- let workersInput: unknown;
- try {
- workersInput = JSON.parse(String(formData.get("workersJson") ?? "[]"));
- } catch {
- return { ok: false, error: "Workers payload is malformed" };
- }
- const workers = sanitizeWorkers(workersInput);
- const workersErr = validateWorkers(workers);
- if (workersErr) return { ok: false, error: workersErr };
-
const parsed = Number.parseInt(maxBytesRaw, 10);
if (!Number.isFinite(parsed)) {
return { ok: false, error: "maxTranscriptPageBytes must be a number" };
@@ -223,7 +206,8 @@ export async function saveSettingsAction(
// is vestigial (kept only for rollback) so pass the default.
transcriptionApp: DEFAULT_TRANSCRIPTION_APP_ID,
transcriptionApps: {},
- workers,
+ // workers: edited on /workers, beside the live list, by saveWorkersAction.
+ workers: getSettings().workers,
cookiesFromBrowser,
cookieMode,
sleepBetweenDownloadsSeconds: sleepParsed,
@@ -270,11 +254,6 @@ export async function saveSettingsAction(
} catch (e) {
return { ok: false, error: (e as Error).message };
}
- // Apply the saved worker list to the live pool immediately so the Workers page
- // and the next batch see it without waiting for a batch-start reconfigure.
- // applyEnabled: a saved Enabled change is an explicit intent and takes effect now.
- getWorkerPool().reconfigure(next.workers, { applyEnabled: true });
revalidatePath("/settings");
- revalidatePath("/workers");
return { ok: true };
}
diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx
@@ -10,24 +10,18 @@ import {
DOWNLOAD_FORMAT_LABELS,
DOWNLOAD_FORMAT_PRESETS,
} from "yt-dlp-transcript-common/ytdlp/downloadFormat";
-import type { TranscriptionAppDescriptor } from "yt-dlp-transcript-common/lib/transcriptionApps";
import { Field } from "../../components/forms/Field";
import {
SocialLinksField,
toSocialRow,
type SocialRow,
} from "../../components/SocialLinksField";
-import { WorkersField } from "./WorkersField";
type Props = {
initial: SiteSettings;
- apps: TranscriptionAppDescriptor[];
- // Known worker-tag vocabulary (operation ids + resources), built on the
- // server for the same bundle reason — the catalog lives in operations.ts.
- workerTags?: string[];
};
-export function SettingsForm({ initial, apps, workerTags }: Props) {
+export function SettingsForm({ initial }: Props) {
const [state, formAction] = useActionState<SaveResult | undefined, FormData>(
saveSettingsAction,
undefined,
@@ -59,29 +53,6 @@ export function SettingsForm({ initial, apps, workerTags }: Props) {
hint="Bytes per generated transcript page (256 KB – 20 MB). Affects pagination next build."
type="number"
/>
- <fieldset className="flex flex-col gap-3 border border-border rounded p-3">
- <legend className="px-1 text-sm font-medium">
- Transcription workers
- </legend>
- <p className="text-xs text-muted-foreground">
- Each worker is one processing slot — one transcription at a time. To run
- several in parallel on the same engine, add more workers (use{" "}
- <strong>Copy</strong> to duplicate one). List order is priority (top =
- preferred); each video goes to the highest-priority free worker, so a
- fast GPU worker and a CPU worker run side by side. Enable, disable, and
- drain individual workers live on the{" "}
- <a href="/workers" className="underline">
- Workers
- </a>{" "}
- page — handy for freeing one core/GPU while the rest keep going.
- </p>
- <WorkersField
- initial={initial.workers}
- apps={apps}
- name="workersJson"
- knownTags={workerTags}
- />
- </fieldset>
<Field
label="Cookies from browser"
name="cookiesFromBrowser"
diff --git a/editor/app/settings/components/WorkersField.tsx b/editor/app/settings/components/WorkersField.tsx
@@ -1,556 +0,0 @@
-"use client";
-
-import { useId, useState } from "react";
-import type { Worker } from "yt-dlp-transcript-common/lib/workers";
-import type { TranscriptionAppDescriptor } from "yt-dlp-transcript-common/lib/transcriptionApps";
-
-// Editor for the transcription worker list. Manages an ordered array of workers
-// (list order == priority, top = highest) and serializes it to a hidden JSON
-// input the save action reads (mirrors SocialLinksField/socialLinksJson). The
-// server re-sanitizes and validates, so this component only needs to produce a
-// reasonable shape.
-
-type Row = Worker & { _key: number };
-
-let keyCounter = 0;
-function withKeys(workers: Worker[]): Row[] {
- return workers.map((w) => ({ ...w, _key: keyCounter++ }));
-}
-
-function blankLocal(appId: string): Row {
- return {
- _key: keyCounter++,
- id: "",
- name: "",
- kind: "local",
- enabled: true,
- priority: 0,
- appId,
- config: {},
- };
-}
-
-function blankRemote(): Row {
- return {
- _key: keyCounter++,
- id: "",
- name: "",
- kind: "remote",
- enabled: true,
- priority: 0,
- remote: { baseUrl: "" },
- };
-}
-
-function blankLlm(): Row {
- return {
- _key: keyCounter++,
- id: "",
- name: "",
- kind: "llm",
- enabled: true,
- priority: 0,
- llm: { baseUrl: "" },
- };
-}
-
-// Duplicate a worker into a new slot: same config, fresh id, "(copy)" name.
-function copyOf(row: Row): Row {
- const { _key, id, name, ...rest } = row;
- void _key;
- void id;
- return {
- ...rest,
- _key: keyCounter++,
- id: "",
- name: name ? `${name} (copy)` : "",
- config: row.config ? { ...row.config } : undefined,
- remote: row.remote ? { ...row.remote } : undefined,
- llm: row.llm ? { ...row.llm } : undefined,
- };
-}
-
-// Strip the React-only _key and set priority from list order before serializing.
-function serialize(rows: Row[]): string {
- return JSON.stringify(
- rows.map(({ _key, ...w }, i) => {
- void _key;
- return { ...w, priority: i };
- }),
- );
-}
-
-type Props = {
- initial: Worker[];
- apps: TranscriptionAppDescriptor[];
- name: string;
- // The known tag vocabulary (operation ids + resource names), built on the
- // server: the catalog lives in operations.ts, whose import graph must not
- // reach the client bundle. Used only to WARN about a tag the scheduler will
- // never match — an unknown tag saves fine.
- knownTags?: string[];
-};
-
-export function WorkersField({ initial, apps, name, knownTags }: Props) {
- const [rows, setRows] = useState<Row[]>(() => withKeys(initial));
- const defaultAppId = apps[0]?.id ?? "whisper-cpp";
-
- function update(next: Row[]) {
- setRows(next);
- }
- function patch(key: number, change: Partial<Row>) {
- update(rows.map((r) => (r._key === key ? { ...r, ...change } : r)));
- }
- function patchConfig(key: number, change: Partial<NonNullable<Worker["config"]>>) {
- update(
- rows.map((r) =>
- r._key === key ? { ...r, config: { ...r.config, ...change } } : r,
- ),
- );
- }
- function patchRemote(key: number, change: Partial<NonNullable<Worker["remote"]>>) {
- update(
- rows.map((r) =>
- r._key === key
- ? { ...r, remote: { baseUrl: "", ...r.remote, ...change } }
- : r,
- ),
- );
- }
- function patchLlm(key: number, change: Partial<NonNullable<Worker["llm"]>>) {
- update(
- rows.map((r) =>
- r._key === key
- ? { ...r, llm: { baseUrl: "", ...r.llm, ...change } }
- : r,
- ),
- );
- }
- function remove(key: number) {
- update(rows.filter((r) => r._key !== key));
- }
- function copy(index: number) {
- const next = [...rows];
- next.splice(index + 1, 0, copyOf(rows[index]));
- update(next);
- }
- function move(index: number, dir: -1 | 1) {
- const j = index + dir;
- if (j < 0 || j >= rows.length) return;
- const next = [...rows];
- [next[index], next[j]] = [next[j], next[index]];
- update(next);
- }
-
- return (
- <div className="flex flex-col gap-3">
- <input type="hidden" name={name} value={serialize(rows)} readOnly />
- {rows.length === 0 && (
- <p className="text-xs text-destructive">
- No workers defined — add at least one so transcription can run.
- </p>
- )}
- {rows.map((row, i) => (
- <WorkerCard
- key={row._key}
- row={row}
- index={i}
- total={rows.length}
- apps={apps}
- knownTags={knownTags}
- onPatch={(c) => patch(row._key, c)}
- onPatchConfig={(c) => patchConfig(row._key, c)}
- onPatchRemote={(c) => patchRemote(row._key, c)}
- onPatchLlm={(c) => patchLlm(row._key, c)}
- onRemove={() => remove(row._key)}
- onCopy={() => copy(i)}
- onMove={(dir) => move(i, dir)}
- />
- ))}
- <div className="flex gap-2">
- <button
- type="button"
- onClick={() => update([...rows, blankLocal(defaultAppId)])}
- className="px-2 py-1 rounded border border-border text-xs hover:bg-muted"
- >
- + Local worker
- </button>
- <button
- type="button"
- onClick={() => update([...rows, blankRemote()])}
- className="px-2 py-1 rounded border border-border text-xs hover:bg-muted"
- >
- + Remote worker
- </button>
- <button
- type="button"
- onClick={() => update([...rows, blankLlm()])}
- className="px-2 py-1 rounded border border-border text-xs hover:bg-muted"
- >
- + LLM endpoint
- </button>
- </div>
- </div>
- );
-}
-
-function WorkerCard({
- row,
- index,
- total,
- apps,
- knownTags,
- onPatch,
- onPatchConfig,
- onPatchRemote,
- onPatchLlm,
- onRemove,
- onCopy,
- onMove,
-}: {
- row: Row;
- index: number;
- total: number;
- apps: TranscriptionAppDescriptor[];
- knownTags?: string[];
- onPatch: (c: Partial<Row>) => void;
- onPatchConfig: (c: Partial<NonNullable<Worker["config"]>>) => void;
- onPatchRemote: (c: Partial<NonNullable<Worker["remote"]>>) => void;
- onPatchLlm: (c: Partial<NonNullable<Worker["llm"]>>) => void;
- onRemove: () => void;
- onCopy: () => void;
- onMove: (dir: -1 | 1) => void;
-}) {
- const uid = useId();
- const app = apps.find((a) => a.id === row.appId);
- const cfg = row.config ?? {};
- const remote = row.remote ?? { baseUrl: "" };
- const llmCfg = row.llm ?? { baseUrl: "" };
- return (
- <div
- aria-label={`worker ${index + 1}`}
- className="flex flex-col gap-2 rounded border border-border p-3"
- >
- <div className="flex flex-wrap items-center gap-2">
- <span className="text-xs text-muted-foreground" title="priority (lower = preferred)">
- #{index + 1}
- </span>
- <input
- type="text"
- aria-label={`worker ${index + 1} name`}
- placeholder="Worker name"
- value={row.name}
- onChange={(e) => onPatch({ name: e.target.value })}
- className="flex-1 min-w-40 rounded border border-border bg-card px-2 py-1 text-sm"
- />
- <label className="flex items-center gap-1 text-xs">
- <input
- type="checkbox"
- checked={row.enabled}
- onChange={(e) => onPatch({ enabled: e.target.checked })}
- aria-label={`worker ${index + 1} enabled`}
- />
- Enabled
- </label>
- <div className="flex items-center gap-1">
- <button
- type="button"
- onClick={() => onMove(-1)}
- disabled={index === 0}
- aria-label={`move worker ${index + 1} up`}
- className="px-1.5 py-0.5 rounded border border-border text-xs disabled:opacity-40"
- >
- ↑
- </button>
- <button
- type="button"
- onClick={() => onMove(1)}
- disabled={index === total - 1}
- aria-label={`move worker ${index + 1} down`}
- className="px-1.5 py-0.5 rounded border border-border text-xs disabled:opacity-40"
- >
- ↓
- </button>
- <button
- type="button"
- onClick={onCopy}
- aria-label={`copy worker ${index + 1}`}
- title="Duplicate this worker into another slot"
- className="px-1.5 py-0.5 rounded border border-border text-xs hover:bg-muted"
- >
- Copy
- </button>
- <button
- type="button"
- onClick={onRemove}
- aria-label={`remove worker ${index + 1}`}
- className="px-1.5 py-0.5 rounded border border-destructive/30 text-xs text-destructive hover:bg-destructive-soft"
- >
- Remove
- </button>
- </div>
- </div>
-
- <div className="flex flex-wrap items-end gap-3">
- {row.kind === "local" && (
- <label className="flex flex-col gap-1 text-xs">
- <span className="font-medium">Engine</span>
- <select
- value={row.appId ?? ""}
- onChange={(e) => onPatch({ appId: e.target.value })}
- aria-label={`worker ${index + 1} engine`}
- className="rounded border border-border bg-card px-2 py-1 text-sm"
- >
- {apps.map((a) => (
- <option key={a.id} value={a.id}>
- {a.label}
- </option>
- ))}
- </select>
- </label>
- )}
- <span className="text-xs text-muted-foreground">
- {row.kind === "local"
- ? "Local · one slot"
- : row.kind === "llm"
- ? `LLM endpoint · ${llmCfg.slots ?? 1} slot${(llmCfg.slots ?? 1) === 1 ? "" : "s"}`
- : remote.slots !== undefined
- ? `Remote · ${remote.slots} slot${remote.slots === 1 ? "" : "s"}`
- : "Remote · slots probed from the remote"}
- </span>
- <TagsField
- tags={row.tags ?? []}
- knownTags={knownTags}
- index={index}
- onChange={(tags) =>
- onPatch({ tags: tags.length > 0 ? tags : undefined })
- }
- />
- </div>
-
- {row.kind === "llm" ? (
- <div className="flex flex-col gap-2 border-t border-border pt-2">
- <CardField
- label="Base URL"
- value={llmCfg.baseUrl}
- onChange={(v) => onPatchLlm({ baseUrl: v })}
- hint="A bare ollama endpoint (e.g. http://macbook.lan:11434) — the box runs nothing but `ollama serve`. It must serve the primary's exact model tag; the scheduler checks and skips it if not."
- id={`${uid}-llmurl`}
- />
- <CardField
- label="Slots"
- type="number"
- value={llmCfg.slots !== undefined ? String(llmCfg.slots) : ""}
- onChange={(v) =>
- onPatchLlm({ slots: v.trim() === "" ? undefined : Number(v) })
- }
- hint="Concurrent generations to send this endpoint. Blank = 1."
- id={`${uid}-llmslots`}
- />
- <p className="text-xs text-muted-foreground">
- Takes digest and speaker-attribution model calls only — never a
- transcription. Tags narrow which of those operations it serves;
- blank tags mean all of them.
- </p>
- </div>
- ) : row.kind === "local" ? (
- <div className="flex flex-col gap-2 border-t border-border pt-2">
- <CardField
- label="Binary"
- value={cfg.bin ?? ""}
- onChange={(v) => onPatchConfig({ bin: v })}
- hint="Path/name of the executable. Blank = the engine's default."
- id={`${uid}-bin`}
- />
- {app?.fields.model && (
- <CardField
- label="Model"
- value={cfg.model ?? ""}
- onChange={(v) => onPatchConfig({ model: v })}
- hint="whisper.cpp: {model}. chough: CHOUGH_MODEL. parakeet: .gguf path. Blank = default."
- id={`${uid}-model`}
- />
- )}
- {app?.fields.remoteUrl && (
- <CardField
- label="chough server URL"
- value={cfg.remoteUrl ?? ""}
- onChange={(v) => onPatchConfig({ remoteUrl: v })}
- hint="chough only: transcribe via a chough --server (sets CHOUGH_URL). Point two copies of this worker at the same server for two togglable slots. Blank = run chough locally."
- id={`${uid}-remoteurl`}
- />
- )}
- {app?.fields.device && (
- <CardField
- label="Device"
- value={cfg.device ?? ""}
- onChange={(v) => onPatchConfig({ device: v })}
- hint="parakeet compute device (sets PARAKEET_DEVICE). e.g. cpu to force CPU, or CUDA0 / Vulkan1 for a specific GPU. Blank = parakeet-cli's default (first GPU)."
- id={`${uid}-device`}
- />
- )}
- {app?.fields.chunkSize && (
- <CardField
- label="Chunk size (seconds)"
- type="number"
- value={cfg.chunkSize !== undefined ? String(cfg.chunkSize) : ""}
- onChange={(v) =>
- onPatchConfig({
- chunkSize: v.trim() === "" ? undefined : Number(v),
- })
- }
- hint="parakeet: per-window seconds. chough: -c. Blank = default."
- id={`${uid}-chunk`}
- />
- )}
- {app?.fields.customArgs && (
- <label className="flex flex-col gap-1 text-xs">
- <span className="font-medium">Custom args (one per line)</span>
- <textarea
- value={(cfg.customArgs ?? []).join("\n")}
- onChange={(e) =>
- onPatchConfig({
- customArgs: e.target.value
- .split("\n")
- .map((s) => s.trim())
- .filter(Boolean),
- })
- }
- rows={4}
- className="rounded border border-border bg-card px-2 py-1 text-sm font-mono"
- />
- <span className="text-muted-foreground">
- Blank = default. Placeholders: <code>{"{audioFile}"}</code>,{" "}
- <code>{"{outputBase}"}</code> (both required),{" "}
- <code>{"{model}"}</code>.
- </span>
- </label>
- )}
- </div>
- ) : (
- <div className="flex flex-col gap-2 border-t border-border pt-2">
- <CardField
- label="Base URL"
- value={remote.baseUrl}
- onChange={(v) => onPatchRemote({ baseUrl: v })}
- hint="Another instance of this app, e.g. http://gpu-box.lan:3001. It runs its own worker pool."
- id={`${uid}-url`}
- />
- <CardField
- label="Token"
- value={remote.token ?? ""}
- onChange={(v) => onPatchRemote({ token: v })}
- hint="Bearer token sent to the remote (must match its WORKER_TOKEN). Blank if the remote has no token."
- id={`${uid}-token`}
- />
- <CardField
- label="Slots"
- type="number"
- value={remote.slots !== undefined ? String(remote.slots) : ""}
- onChange={(v) =>
- onPatchRemote({
- slots: v.trim() === "" ? undefined : Number(v),
- })
- }
- hint="Concurrent units to send this remote. Blank = ask the remote itself (its enabled worker count, re-checked about once a minute)."
- id={`${uid}-slots`}
- />
- <label className="flex items-center gap-2 text-xs">
- <input
- type="checkbox"
- checked={remote.sharedFs === true}
- onChange={(e) => onPatchRemote({ sharedFs: e.target.checked })}
- />
- <span>
- Shared filesystem — send the video path instead of uploading audio
- (only if the remote mounts the same transcripts dir).
- </span>
- </label>
- </div>
- )}
- </div>
- );
-}
-
-// Comma-separated capability tags. Local text state so a trailing comma or
-// space survives typing; the parsed list is pushed up on every change. Unknown
-// tags WARN and still save — the scheduler tolerates them, it just never
-// matches them.
-function TagsField({
- tags,
- knownTags,
- index,
- onChange,
-}: {
- tags: string[];
- knownTags?: string[];
- index: number;
- onChange: (tags: string[]) => void;
-}) {
- const [text, setText] = useState(tags.join(", "));
- const unknown = knownTags ? tags.filter((t) => !knownTags.includes(t)) : [];
- return (
- <label className="flex flex-col gap-1 text-xs flex-1 min-w-48">
- <span className="font-medium">Tags</span>
- <input
- type="text"
- aria-label={`worker ${index + 1} tags`}
- value={text}
- placeholder="e.g. cpu, diarization"
- onChange={(e) => {
- setText(e.target.value);
- onChange([
- ...new Set(
- e.target.value
- .split(",")
- .map((s) => s.trim())
- .filter(Boolean),
- ),
- ]);
- }}
- className="rounded border border-border bg-card px-2 py-1 text-sm"
- />
- <span className="text-muted-foreground">
- Capability routing: operation ids (e.g. <code>diarization</code>) or
- resources (<code>gpu</code>, <code>cpu</code>, <code>network</code>),
- comma-separated. Blank = this worker takes anything.
- </span>
- {unknown.length > 0 && (
- <span className="text-warning">
- Unknown tag{unknown.length > 1 ? "s" : ""}: {unknown.join(", ")} —
- saved, but the scheduler will never match {unknown.length > 1 ? "them" : "it"}.
- </span>
- )}
- </label>
- );
-}
-
-function CardField({
- label,
- value,
- onChange,
- hint,
- type = "text",
- id,
-}: {
- label: string;
- value: string;
- onChange: (v: string) => void;
- hint?: string;
- type?: string;
- id: string;
-}) {
- return (
- <label className="flex flex-col gap-1 text-xs" htmlFor={id}>
- <span className="font-medium">{label}</span>
- <input
- id={id}
- type={type}
- value={value}
- onChange={(e) => onChange(e.target.value)}
- className="rounded border border-border bg-card px-2 py-1 text-sm"
- />
- {hint && <span className="text-muted-foreground">{hint}</span>}
- </label>
- );
-}
diff --git a/editor/app/settings/page.tsx b/editor/app/settings/page.tsx
@@ -2,9 +2,6 @@ import type { Metadata } from "next";
import Link from "next/link";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
-import { listTranscriptionApps } from "yt-dlp-transcript-common/lib/transcriptionApps";
-import { operationCatalog } from "yt-dlp-transcript-common/lib/operations";
-import { WORKER_RESOURCE_TAGS } from "yt-dlp-transcript-common/lib/workers";
import { readXSessionStatus } from "yt-dlp-transcript-common/social/xSessionBroker";
import { SettingsForm } from "./components/SettingsForm";
import { XSessionSection } from "./components/XSessionSection";
@@ -77,19 +74,15 @@ export default async function SettingsPage() {
<Link href="/operations/sync" className={POINTER_LINK}>
Sync
</Link>{" "}
- carries the whole block, below the schedule it drives.
+ carries the whole block, below the schedule it drives. The
+ transcription worker list is configured on{" "}
+ <Link href="/workers" className={POINTER_LINK}>
+ Workers
+ </Link>
+ , beside the workers it describes.
</p>
</div>
- <SettingsForm
- initial={settings}
- apps={listTranscriptionApps()}
- workerTags={[
- ...new Set([
- ...operationCatalog().map((o) => o.id),
- ...WORKER_RESOURCE_TAGS,
- ]),
- ]}
- />
+ <SettingsForm initial={settings} />
</section>
<section className="flex flex-col gap-3 border-t border-border pt-6">
diff --git a/editor/app/workers/actions.ts b/editor/app/workers/actions.ts
@@ -3,19 +3,62 @@
import { revalidatePath } from "next/cache";
import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { getSettings, writeSettings } from "yt-dlp-transcript-common/lib/settings";
+import {
+ sanitizeWorkers,
+ validateWorkers,
+} from "yt-dlp-transcript-common/lib/workers";
import { writeWorkerDefaults } from "yt-dlp-transcript-common/jobs/workerDefaults";
-// Runtime worker controls for the Workers page. Most of these are transient
-// operator overrides on the live pool — they are NOT written to settings.json
-// (a restart returns workers to their configured enabled state). The settings
-// form is where the persisted default-enabled state lives.
+// Worker controls for the Workers page, of three kinds.
//
-// The one exception is setDefaultWorkersAction: it snapshots the currently
-// enabled workers to a small persisted file (workerDefaults.ts) that the pool
-// re-applies on the next launch — "Set as default" on the Workers page.
+// Most are transient operator overrides on the LIVE POOL — they are NOT written
+// to settings.json (a restart returns workers to their configured enabled
+// state).
+//
+// setDefaultWorkersAction snapshots the currently enabled workers to a small
+// persisted file (workerDefaults.ts) that the pool re-applies on the next
+// launch — "Set as default" on this page.
+//
+// saveWorkersAction is the PERSISTED LIST itself, which used to be a fieldset
+// in the settings form. It lives here now, beside the workers it describes.
export type WorkerActionResult = { ok: boolean; error?: string };
+export type SaveWorkersResult = { ok: true } | { ok: false; error: string };
+
+// THE ONE WRITER OF settings.workers. Reads the current file and replaces this
+// block only — every other block is preserved verbatim, the contract
+// cadence-ui.spec.ts holds every settings writer to. Then the live pool: a
+// saved Enabled change is an explicit intent and takes effect now
+// (applyEnabled), exactly as the whole-object settings form did before this
+// moved here.
+export async function saveWorkersAction(
+ _prev: SaveWorkersResult | undefined,
+ formData: FormData,
+): Promise<SaveWorkersResult> {
+ // Workers are submitted as a JSON array by the WorkersField client component.
+ // Sanitize + validate here for a friendly error; writeSettings re-validates.
+ let workersInput: unknown;
+ try {
+ workersInput = JSON.parse(String(formData.get("workersJson") ?? "[]"));
+ } catch {
+ return { ok: false, error: "Workers payload is malformed" };
+ }
+ const workers = sanitizeWorkers(workersInput);
+ const workersErr = validateWorkers(workers);
+ if (workersErr) return { ok: false, error: workersErr };
+
+ try {
+ await writeSettings({ ...getSettings(), workers });
+ } catch (e) {
+ return { ok: false, error: (e as Error).message };
+ }
+ getWorkerPool().reconfigure(workers, { applyEnabled: true });
+ revalidatePath("/workers");
+ return { ok: true };
+}
+
function refresh() {
revalidatePath("/workers");
}
diff --git a/editor/app/workers/components/WorkersConfigForm.tsx b/editor/app/workers/components/WorkersConfigForm.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useActionState } from "react";
+import type { Worker } from "yt-dlp-transcript-common/lib/workers";
+import type { TranscriptionAppDescriptor } from "yt-dlp-transcript-common/lib/transcriptionApps";
+import { saveWorkersAction, type SaveWorkersResult } from "../actions";
+import { WorkersField } from "./WorkersField";
+
+// THE PERSISTED WORKER LIST, configured beside the workers it describes.
+//
+// It was a fieldset inside the one big settings form, which is the shape slice
+// 3 spent a whole commit undoing: this form writes ONE block and nothing else
+// on this page is a form, which is what lets a save here never read another
+// block's checkbox as off.
+//
+// Both type imports are load-bearing as types: workers.ts reaches node:fs
+// through transcriptionApps.ts. The app list and the tag vocabulary are built
+// on the server in page.tsx and travel as props.
+export function WorkersConfigForm({
+ initial,
+ apps,
+ knownTags,
+}: {
+ initial: Worker[];
+ apps: TranscriptionAppDescriptor[];
+ knownTags: string[];
+}) {
+ const [state, formAction, pending] = useActionState<
+ SaveWorkersResult | undefined,
+ FormData
+ >(saveWorkersAction, undefined);
+
+ return (
+ <form
+ action={formAction}
+ data-settings-block="workers"
+ className="flex flex-col gap-3 rounded border border-border p-3"
+ >
+ <h2 className="text-sm font-semibold">Configuration</h2>
+ <p className="text-xs text-muted-foreground">
+ Each worker is one processing slot — one transcription at a time. To run
+ several in parallel on the same engine, add more workers (use{" "}
+ <strong>Copy</strong> to duplicate one). List order is priority (top =
+ preferred); each video goes to the highest-priority free worker, so a
+ fast GPU worker and a CPU worker run side by side. Enable, disable and
+ drain individual workers live in the list above.
+ </p>
+ <WorkersField
+ initial={initial}
+ apps={apps}
+ name="workersJson"
+ knownTags={knownTags}
+ />
+ <div className="flex items-center gap-3">
+ <button
+ type="submit"
+ disabled={pending}
+ className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50"
+ >
+ {pending ? "Saving…" : "Save workers"}
+ </button>
+ {state?.ok === true && (
+ <span role="status" className="text-sm text-success">
+ Saved.
+ </span>
+ )}
+ {state?.ok === false && (
+ <span role="alert" className="text-sm text-destructive">
+ {state.error}
+ </span>
+ )}
+ </div>
+ </form>
+ );
+}
diff --git a/editor/app/workers/components/WorkersField.tsx b/editor/app/workers/components/WorkersField.tsx
@@ -0,0 +1,560 @@
+"use client";
+
+import { useId, useState } from "react";
+import type { Worker } from "yt-dlp-transcript-common/lib/workers";
+import type { TranscriptionAppDescriptor } from "yt-dlp-transcript-common/lib/transcriptionApps";
+
+// Editor for the transcription worker list. Manages an ordered array of workers
+// (list order == priority, top = highest) and serializes it to a hidden JSON
+// input saveWorkersAction reads (mirrors SocialLinksField/socialLinksJson). The
+// server re-sanitizes and validates, so this component only needs to produce a
+// reasonable shape.
+//
+// BOTH IMPORTS BELOW ARE `import type` AND MUST STAY SO: workers.ts reaches
+// transcriptionApps.ts, which reaches paths.ts and node:fs. The tag vocabulary
+// and the app list are built on the server and passed down as props.
+
+type Row = Worker & { _key: number };
+
+let keyCounter = 0;
+function withKeys(workers: Worker[]): Row[] {
+ return workers.map((w) => ({ ...w, _key: keyCounter++ }));
+}
+
+function blankLocal(appId: string): Row {
+ return {
+ _key: keyCounter++,
+ id: "",
+ name: "",
+ kind: "local",
+ enabled: true,
+ priority: 0,
+ appId,
+ config: {},
+ };
+}
+
+function blankRemote(): Row {
+ return {
+ _key: keyCounter++,
+ id: "",
+ name: "",
+ kind: "remote",
+ enabled: true,
+ priority: 0,
+ remote: { baseUrl: "" },
+ };
+}
+
+function blankLlm(): Row {
+ return {
+ _key: keyCounter++,
+ id: "",
+ name: "",
+ kind: "llm",
+ enabled: true,
+ priority: 0,
+ llm: { baseUrl: "" },
+ };
+}
+
+// Duplicate a worker into a new slot: same config, fresh id, "(copy)" name.
+function copyOf(row: Row): Row {
+ const { _key, id, name, ...rest } = row;
+ void _key;
+ void id;
+ return {
+ ...rest,
+ _key: keyCounter++,
+ id: "",
+ name: name ? `${name} (copy)` : "",
+ config: row.config ? { ...row.config } : undefined,
+ remote: row.remote ? { ...row.remote } : undefined,
+ llm: row.llm ? { ...row.llm } : undefined,
+ };
+}
+
+// Strip the React-only _key and set priority from list order before serializing.
+function serialize(rows: Row[]): string {
+ return JSON.stringify(
+ rows.map(({ _key, ...w }, i) => {
+ void _key;
+ return { ...w, priority: i };
+ }),
+ );
+}
+
+type Props = {
+ initial: Worker[];
+ apps: TranscriptionAppDescriptor[];
+ name: string;
+ // The known tag vocabulary (operation ids + resource names), built on the
+ // server: the catalog lives in operations.ts, whose import graph must not
+ // reach the client bundle. Used only to WARN about a tag the scheduler will
+ // never match — an unknown tag saves fine.
+ knownTags?: string[];
+};
+
+export function WorkersField({ initial, apps, name, knownTags }: Props) {
+ const [rows, setRows] = useState<Row[]>(() => withKeys(initial));
+ const defaultAppId = apps[0]?.id ?? "whisper-cpp";
+
+ function update(next: Row[]) {
+ setRows(next);
+ }
+ function patch(key: number, change: Partial<Row>) {
+ update(rows.map((r) => (r._key === key ? { ...r, ...change } : r)));
+ }
+ function patchConfig(key: number, change: Partial<NonNullable<Worker["config"]>>) {
+ update(
+ rows.map((r) =>
+ r._key === key ? { ...r, config: { ...r.config, ...change } } : r,
+ ),
+ );
+ }
+ function patchRemote(key: number, change: Partial<NonNullable<Worker["remote"]>>) {
+ update(
+ rows.map((r) =>
+ r._key === key
+ ? { ...r, remote: { baseUrl: "", ...r.remote, ...change } }
+ : r,
+ ),
+ );
+ }
+ function patchLlm(key: number, change: Partial<NonNullable<Worker["llm"]>>) {
+ update(
+ rows.map((r) =>
+ r._key === key
+ ? { ...r, llm: { baseUrl: "", ...r.llm, ...change } }
+ : r,
+ ),
+ );
+ }
+ function remove(key: number) {
+ update(rows.filter((r) => r._key !== key));
+ }
+ function copy(index: number) {
+ const next = [...rows];
+ next.splice(index + 1, 0, copyOf(rows[index]));
+ update(next);
+ }
+ function move(index: number, dir: -1 | 1) {
+ const j = index + dir;
+ if (j < 0 || j >= rows.length) return;
+ const next = [...rows];
+ [next[index], next[j]] = [next[j], next[index]];
+ update(next);
+ }
+
+ return (
+ <div className="flex flex-col gap-3">
+ <input type="hidden" name={name} value={serialize(rows)} readOnly />
+ {rows.length === 0 && (
+ <p className="text-xs text-destructive">
+ No workers defined — add at least one so transcription can run.
+ </p>
+ )}
+ {rows.map((row, i) => (
+ <WorkerCard
+ key={row._key}
+ row={row}
+ index={i}
+ total={rows.length}
+ apps={apps}
+ knownTags={knownTags}
+ onPatch={(c) => patch(row._key, c)}
+ onPatchConfig={(c) => patchConfig(row._key, c)}
+ onPatchRemote={(c) => patchRemote(row._key, c)}
+ onPatchLlm={(c) => patchLlm(row._key, c)}
+ onRemove={() => remove(row._key)}
+ onCopy={() => copy(i)}
+ onMove={(dir) => move(i, dir)}
+ />
+ ))}
+ <div className="flex gap-2">
+ <button
+ type="button"
+ onClick={() => update([...rows, blankLocal(defaultAppId)])}
+ className="px-2 py-1 rounded border border-border text-xs hover:bg-muted"
+ >
+ + Local worker
+ </button>
+ <button
+ type="button"
+ onClick={() => update([...rows, blankRemote()])}
+ className="px-2 py-1 rounded border border-border text-xs hover:bg-muted"
+ >
+ + Remote worker
+ </button>
+ <button
+ type="button"
+ onClick={() => update([...rows, blankLlm()])}
+ className="px-2 py-1 rounded border border-border text-xs hover:bg-muted"
+ >
+ + LLM endpoint
+ </button>
+ </div>
+ </div>
+ );
+}
+
+function WorkerCard({
+ row,
+ index,
+ total,
+ apps,
+ knownTags,
+ onPatch,
+ onPatchConfig,
+ onPatchRemote,
+ onPatchLlm,
+ onRemove,
+ onCopy,
+ onMove,
+}: {
+ row: Row;
+ index: number;
+ total: number;
+ apps: TranscriptionAppDescriptor[];
+ knownTags?: string[];
+ onPatch: (c: Partial<Row>) => void;
+ onPatchConfig: (c: Partial<NonNullable<Worker["config"]>>) => void;
+ onPatchRemote: (c: Partial<NonNullable<Worker["remote"]>>) => void;
+ onPatchLlm: (c: Partial<NonNullable<Worker["llm"]>>) => void;
+ onRemove: () => void;
+ onCopy: () => void;
+ onMove: (dir: -1 | 1) => void;
+}) {
+ const uid = useId();
+ const app = apps.find((a) => a.id === row.appId);
+ const cfg = row.config ?? {};
+ const remote = row.remote ?? { baseUrl: "" };
+ const llmCfg = row.llm ?? { baseUrl: "" };
+ return (
+ <div
+ aria-label={`worker ${index + 1}`}
+ className="flex flex-col gap-2 rounded border border-border p-3"
+ >
+ <div className="flex flex-wrap items-center gap-2">
+ <span className="text-xs text-muted-foreground" title="priority (lower = preferred)">
+ #{index + 1}
+ </span>
+ <input
+ type="text"
+ aria-label={`worker ${index + 1} name`}
+ placeholder="Worker name"
+ value={row.name}
+ onChange={(e) => onPatch({ name: e.target.value })}
+ className="flex-1 min-w-40 rounded border border-border bg-card px-2 py-1 text-sm"
+ />
+ <label className="flex items-center gap-1 text-xs">
+ <input
+ type="checkbox"
+ checked={row.enabled}
+ onChange={(e) => onPatch({ enabled: e.target.checked })}
+ aria-label={`worker ${index + 1} enabled`}
+ />
+ Enabled
+ </label>
+ <div className="flex items-center gap-1">
+ <button
+ type="button"
+ onClick={() => onMove(-1)}
+ disabled={index === 0}
+ aria-label={`move worker ${index + 1} up`}
+ className="px-1.5 py-0.5 rounded border border-border text-xs disabled:opacity-40"
+ >
+ ↑
+ </button>
+ <button
+ type="button"
+ onClick={() => onMove(1)}
+ disabled={index === total - 1}
+ aria-label={`move worker ${index + 1} down`}
+ className="px-1.5 py-0.5 rounded border border-border text-xs disabled:opacity-40"
+ >
+ ↓
+ </button>
+ <button
+ type="button"
+ onClick={onCopy}
+ aria-label={`copy worker ${index + 1}`}
+ title="Duplicate this worker into another slot"
+ className="px-1.5 py-0.5 rounded border border-border text-xs hover:bg-muted"
+ >
+ Copy
+ </button>
+ <button
+ type="button"
+ onClick={onRemove}
+ aria-label={`remove worker ${index + 1}`}
+ className="px-1.5 py-0.5 rounded border border-destructive/30 text-xs text-destructive hover:bg-destructive-soft"
+ >
+ Remove
+ </button>
+ </div>
+ </div>
+
+ <div className="flex flex-wrap items-end gap-3">
+ {row.kind === "local" && (
+ <label className="flex flex-col gap-1 text-xs">
+ <span className="font-medium">Engine</span>
+ <select
+ value={row.appId ?? ""}
+ onChange={(e) => onPatch({ appId: e.target.value })}
+ aria-label={`worker ${index + 1} engine`}
+ className="rounded border border-border bg-card px-2 py-1 text-sm"
+ >
+ {apps.map((a) => (
+ <option key={a.id} value={a.id}>
+ {a.label}
+ </option>
+ ))}
+ </select>
+ </label>
+ )}
+ <span className="text-xs text-muted-foreground">
+ {row.kind === "local"
+ ? "Local · one slot"
+ : row.kind === "llm"
+ ? `LLM endpoint · ${llmCfg.slots ?? 1} slot${(llmCfg.slots ?? 1) === 1 ? "" : "s"}`
+ : remote.slots !== undefined
+ ? `Remote · ${remote.slots} slot${remote.slots === 1 ? "" : "s"}`
+ : "Remote · slots probed from the remote"}
+ </span>
+ <TagsField
+ tags={row.tags ?? []}
+ knownTags={knownTags}
+ index={index}
+ onChange={(tags) =>
+ onPatch({ tags: tags.length > 0 ? tags : undefined })
+ }
+ />
+ </div>
+
+ {row.kind === "llm" ? (
+ <div className="flex flex-col gap-2 border-t border-border pt-2">
+ <CardField
+ label="Base URL"
+ value={llmCfg.baseUrl}
+ onChange={(v) => onPatchLlm({ baseUrl: v })}
+ hint="A bare ollama endpoint (e.g. http://macbook.lan:11434) — the box runs nothing but `ollama serve`. It must serve the primary's exact model tag; the scheduler checks and skips it if not."
+ id={`${uid}-llmurl`}
+ />
+ <CardField
+ label="Slots"
+ type="number"
+ value={llmCfg.slots !== undefined ? String(llmCfg.slots) : ""}
+ onChange={(v) =>
+ onPatchLlm({ slots: v.trim() === "" ? undefined : Number(v) })
+ }
+ hint="Concurrent generations to send this endpoint. Blank = 1."
+ id={`${uid}-llmslots`}
+ />
+ <p className="text-xs text-muted-foreground">
+ Takes digest and speaker-attribution model calls only — never a
+ transcription. Tags narrow which of those operations it serves;
+ blank tags mean all of them.
+ </p>
+ </div>
+ ) : row.kind === "local" ? (
+ <div className="flex flex-col gap-2 border-t border-border pt-2">
+ <CardField
+ label="Binary"
+ value={cfg.bin ?? ""}
+ onChange={(v) => onPatchConfig({ bin: v })}
+ hint="Path/name of the executable. Blank = the engine's default."
+ id={`${uid}-bin`}
+ />
+ {app?.fields.model && (
+ <CardField
+ label="Model"
+ value={cfg.model ?? ""}
+ onChange={(v) => onPatchConfig({ model: v })}
+ hint="whisper.cpp: {model}. chough: CHOUGH_MODEL. parakeet: .gguf path. Blank = default."
+ id={`${uid}-model`}
+ />
+ )}
+ {app?.fields.remoteUrl && (
+ <CardField
+ label="chough server URL"
+ value={cfg.remoteUrl ?? ""}
+ onChange={(v) => onPatchConfig({ remoteUrl: v })}
+ hint="chough only: transcribe via a chough --server (sets CHOUGH_URL). Point two copies of this worker at the same server for two togglable slots. Blank = run chough locally."
+ id={`${uid}-remoteurl`}
+ />
+ )}
+ {app?.fields.device && (
+ <CardField
+ label="Device"
+ value={cfg.device ?? ""}
+ onChange={(v) => onPatchConfig({ device: v })}
+ hint="parakeet compute device (sets PARAKEET_DEVICE). e.g. cpu to force CPU, or CUDA0 / Vulkan1 for a specific GPU. Blank = parakeet-cli's default (first GPU)."
+ id={`${uid}-device`}
+ />
+ )}
+ {app?.fields.chunkSize && (
+ <CardField
+ label="Chunk size (seconds)"
+ type="number"
+ value={cfg.chunkSize !== undefined ? String(cfg.chunkSize) : ""}
+ onChange={(v) =>
+ onPatchConfig({
+ chunkSize: v.trim() === "" ? undefined : Number(v),
+ })
+ }
+ hint="parakeet: per-window seconds. chough: -c. Blank = default."
+ id={`${uid}-chunk`}
+ />
+ )}
+ {app?.fields.customArgs && (
+ <label className="flex flex-col gap-1 text-xs">
+ <span className="font-medium">Custom args (one per line)</span>
+ <textarea
+ value={(cfg.customArgs ?? []).join("\n")}
+ onChange={(e) =>
+ onPatchConfig({
+ customArgs: e.target.value
+ .split("\n")
+ .map((s) => s.trim())
+ .filter(Boolean),
+ })
+ }
+ rows={4}
+ className="rounded border border-border bg-card px-2 py-1 text-sm font-mono"
+ />
+ <span className="text-muted-foreground">
+ Blank = default. Placeholders: <code>{"{audioFile}"}</code>,{" "}
+ <code>{"{outputBase}"}</code> (both required),{" "}
+ <code>{"{model}"}</code>.
+ </span>
+ </label>
+ )}
+ </div>
+ ) : (
+ <div className="flex flex-col gap-2 border-t border-border pt-2">
+ <CardField
+ label="Base URL"
+ value={remote.baseUrl}
+ onChange={(v) => onPatchRemote({ baseUrl: v })}
+ hint="Another instance of this app, e.g. http://gpu-box.lan:3001. It runs its own worker pool."
+ id={`${uid}-url`}
+ />
+ <CardField
+ label="Token"
+ value={remote.token ?? ""}
+ onChange={(v) => onPatchRemote({ token: v })}
+ hint="Bearer token sent to the remote (must match its WORKER_TOKEN). Blank if the remote has no token."
+ id={`${uid}-token`}
+ />
+ <CardField
+ label="Slots"
+ type="number"
+ value={remote.slots !== undefined ? String(remote.slots) : ""}
+ onChange={(v) =>
+ onPatchRemote({
+ slots: v.trim() === "" ? undefined : Number(v),
+ })
+ }
+ hint="Concurrent units to send this remote. Blank = ask the remote itself (its enabled worker count, re-checked about once a minute)."
+ id={`${uid}-slots`}
+ />
+ <label className="flex items-center gap-2 text-xs">
+ <input
+ type="checkbox"
+ checked={remote.sharedFs === true}
+ onChange={(e) => onPatchRemote({ sharedFs: e.target.checked })}
+ />
+ <span>
+ Shared filesystem — send the video path instead of uploading audio
+ (only if the remote mounts the same transcripts dir).
+ </span>
+ </label>
+ </div>
+ )}
+ </div>
+ );
+}
+
+// Comma-separated capability tags. Local text state so a trailing comma or
+// space survives typing; the parsed list is pushed up on every change. Unknown
+// tags WARN and still save — the scheduler tolerates them, it just never
+// matches them.
+function TagsField({
+ tags,
+ knownTags,
+ index,
+ onChange,
+}: {
+ tags: string[];
+ knownTags?: string[];
+ index: number;
+ onChange: (tags: string[]) => void;
+}) {
+ const [text, setText] = useState(tags.join(", "));
+ const unknown = knownTags ? tags.filter((t) => !knownTags.includes(t)) : [];
+ return (
+ <label className="flex flex-col gap-1 text-xs flex-1 min-w-48">
+ <span className="font-medium">Tags</span>
+ <input
+ type="text"
+ aria-label={`worker ${index + 1} tags`}
+ value={text}
+ placeholder="e.g. cpu, diarization"
+ onChange={(e) => {
+ setText(e.target.value);
+ onChange([
+ ...new Set(
+ e.target.value
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean),
+ ),
+ ]);
+ }}
+ className="rounded border border-border bg-card px-2 py-1 text-sm"
+ />
+ <span className="text-muted-foreground">
+ Capability routing: operation ids (e.g. <code>diarization</code>) or
+ resources (<code>gpu</code>, <code>cpu</code>, <code>network</code>),
+ comma-separated. Blank = this worker takes anything.
+ </span>
+ {unknown.length > 0 && (
+ <span className="text-warning">
+ Unknown tag{unknown.length > 1 ? "s" : ""}: {unknown.join(", ")} —
+ saved, but the scheduler will never match {unknown.length > 1 ? "them" : "it"}.
+ </span>
+ )}
+ </label>
+ );
+}
+
+function CardField({
+ label,
+ value,
+ onChange,
+ hint,
+ type = "text",
+ id,
+}: {
+ label: string;
+ value: string;
+ onChange: (v: string) => void;
+ hint?: string;
+ type?: string;
+ id: string;
+}) {
+ return (
+ <label className="flex flex-col gap-1 text-xs" htmlFor={id}>
+ <span className="font-medium">{label}</span>
+ <input
+ id={id}
+ type={type}
+ value={value}
+ onChange={(e) => onChange(e.target.value)}
+ className="rounded border border-border bg-card px-2 py-1 text-sm"
+ />
+ {hint && <span className="text-muted-foreground">{hint}</span>}
+ </label>
+ );
+}
diff --git a/editor/app/workers/page.tsx b/editor/app/workers/page.tsx
@@ -1,6 +1,10 @@
import type { Metadata } from "next";
-import Link from "next/link";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { listTranscriptionApps } from "yt-dlp-transcript-common/lib/transcriptionApps";
+import { operationCatalog } from "yt-dlp-transcript-common/lib/operations";
+import { WORKER_RESOURCE_TAGS } from "yt-dlp-transcript-common/lib/workers";
import { WorkersView } from "./components/WorkersView";
+import { WorkersConfigForm } from "./components/WorkersConfigForm";
import { buildWorkersPayload } from "./buildWorkers";
export const dynamic = "force-dynamic";
@@ -9,26 +13,34 @@ export const metadata: Metadata = { title: "Workers" };
export default async function WorkersPage() {
const initial = buildWorkersPayload();
+ const settings = getSettings();
+ // The tag vocabulary a worker can be matched on: every PER-VIDEO operation (a
+ // delegate takes a UNIT of one — lanes.ts, runsOnFor) plus the resources.
+ // `scope` is the filter, not the id: sync is catalogued and channel-scoped,
+ // and no worker will ever be handed a channel.
+ const knownTags = [
+ ...new Set([
+ ...operationCatalog()
+ .filter((o) => o.scope === "video")
+ .map((o) => o.id),
+ ...WORKER_RESOURCE_TAGS,
+ ]),
+ ];
return (
<div className="flex flex-col gap-4">
- <div className="flex items-center justify-between">
- <h1 className="text-2xl font-semibold">Workers</h1>
- <Link href="/settings" className="text-sm underline">
- Configure workers
- </Link>
- </div>
+ <h1 className="text-2xl font-semibold">Workers</h1>
<p className="text-sm text-muted-foreground">
Transcription workers — one slot each. Enable, disable, or drain a worker
to free up a CPU/GPU for other programs, then turn it back on when done.
Disabling every worker pauses running batches (they wait for a worker)
- instead of failing. Worker config (engines, priority, copies) lives on
- the{" "}
- <Link href="/settings" className="underline">
- Settings
- </Link>{" "}
- page.
+ instead of failing.
</p>
<WorkersView initial={initial} />
+ <WorkersConfigForm
+ initial={settings.workers}
+ apps={listTranscriptionApps()}
+ knownTags={knownTags}
+ />
</div>
);
}
diff --git a/editor/e2e/parakeet-partial.spec.ts b/editor/e2e/parakeet-partial.spec.ts
@@ -206,14 +206,13 @@ test("Stop & keep progress on a force-killed parakeet run is NOT recorded as a f
test("a parakeet worker's device is configurable and persists", async ({
page,
}) => {
- await page.goto("/settings");
+ await page.goto("/workers");
+ const form = page.locator('form[data-settings-block="workers"]');
// Migration makes worker 1 whisper-cpp; switch it to parakeet to reveal Device.
- await page.getByLabel("worker 1 engine").selectOption("parakeet");
- await page.getByLabel(/^Device/).fill("CUDA0");
- await page.getByRole("button", { name: /save settings/i }).click();
- await expect(
- page.getByRole("status").filter({ hasText: "Saved" }),
- ).toBeVisible();
+ await form.getByLabel("worker 1 engine").selectOption("parakeet");
+ await form.getByLabel(/^Device/).fill("CUDA0");
+ await form.getByRole("button", { name: "Save workers" }).click();
+ await expect(form.getByRole("status")).toHaveText("Saved.");
const saved = await readJson<{
workers?: { appId?: string; config?: { device?: string } }[];
diff --git a/editor/e2e/settings.spec.ts b/editor/e2e/settings.spec.ts
@@ -57,75 +57,6 @@ test("saves global default social links", async ({ page }) => {
expect(saved.socialLinks?.[0].svg).toContain("currentColor");
});
-test("shows the migrated default workers and persists an added worker", async ({
- page,
-}) => {
- await page.goto("/settings");
- // An empty settings.json migrates to one whisper-cpp worker PER slot — the old
- // default parallelTranscriptions of 2 becomes two enabled whisper workers.
- await expect(page.getByLabel("worker 1 name")).toHaveValue(/whisper/i);
- await expect(page.getByLabel("worker 2 name")).toHaveValue(/whisper/i);
-
- // Add a third worker on a different engine.
- await page.getByRole("button", { name: "+ Local worker" }).click();
- await page.getByLabel("worker 3 name").fill("CPU chough");
- await page.getByLabel("worker 3 engine").selectOption("chough");
-
- await page.getByRole("button", { name: /save settings/i }).click();
- await expect(
- page.getByRole("status").filter({ hasText: "Saved" }),
- ).toBeVisible();
-
- const saved = await readJson<{
- workers?: { name: string; appId?: string; priority: number }[];
- }>("test-settings.json");
- expect(saved.workers).toHaveLength(3);
- expect(saved.workers?.[2].name).toBe("CPU chough");
- expect(saved.workers?.[2].appId).toBe("chough");
- // List order is priority.
- expect(saved.workers?.[2].priority).toBe(2);
-
- await page.reload();
- await expect(page.getByLabel("worker 3 name")).toHaveValue("CPU chough");
-});
-
-test("copy duplicates a worker into a new slot", async ({ page }) => {
- await page.goto("/settings");
- // Configure worker 1 as a chough-server worker, then copy it — the pattern for
- // running two togglable slots against one chough --server.
- await page.getByLabel("worker 1 engine").selectOption("chough");
- await page.getByLabel("worker 1 name").fill("Chough server");
- await page.getByLabel(/chough server URL/i).fill("http://localhost:8165");
- await page.getByRole("button", { name: "copy worker 1" }).click();
-
- await expect(page.getByLabel("worker 2 name")).toHaveValue("Chough server (copy)");
-
- await page.getByRole("button", { name: /save settings/i }).click();
- await expect(
- page.getByRole("status").filter({ hasText: "Saved" }),
- ).toBeVisible();
-
- const saved = await readJson<{
- workers?: { appId?: string; config?: { remoteUrl?: string } }[];
- }>("test-settings.json");
- const choughs = (saved.workers ?? []).filter((w) => w.appId === "chough");
- expect(choughs.length).toBe(2);
- expect(choughs.every((w) => w.config?.remoteUrl === "http://localhost:8165")).toBe(
- true,
- );
-});
-
-test("rejects saving with no enabled workers", async ({ page }) => {
- await page.goto("/settings");
- // Migration produced two enabled workers; disable both.
- await page.getByLabel("worker 1 enabled").uncheck();
- await page.getByLabel("worker 2 enabled").uncheck();
- await page.getByRole("button", { name: /save settings/i }).click();
- await expect(page.locator("form").getByRole("alert")).toContainText(
- /at least one worker must be enabled/i,
- );
-});
-
test("rejects out-of-range maxTranscriptPageBytes", async ({ page }) => {
await page.goto("/settings");
await page.getByLabel(/max transcript page bytes/i).fill("42");
diff --git a/editor/e2e/transcription-app-migration.spec.ts b/editor/e2e/transcription-app-migration.spec.ts
@@ -3,7 +3,7 @@ import { resetData, writeSettings } from "./helpers";
// A pre-multi-app settings.json (transcribeBin/transcribeArgs/transcribeModel,
// no transcriptionApp/workers keys) must migrate onto the worker model when
-// read, so the Settings page shows a worker on the matching engine rather than
+// read, so the Workers page shows a worker on the matching engine rather than
// crashing on missing fields.
test("legacy whisper settings migrate onto a whisper-cpp worker", async ({
@@ -18,7 +18,7 @@ test("legacy whisper settings migrate onto a whisper-cpp worker", async ({
transcribeModel: "/models/ggml.bin",
transcribeArgs: ["-ojf", "-l", "en", "-m", "{model}", "-of", "{outputBase}", "{audioFile}"],
});
- await page.goto("/settings");
+ await page.goto("/workers");
await expect(page.getByLabel("worker 1 engine")).toHaveValue("whisper-cpp");
});
@@ -34,6 +34,6 @@ test("legacy settings whose binary is chough migrate onto a chough worker", asyn
transcribeModel: "",
transcribeArgs: ["-f", "json", "-o", "{outputBase}", "{audioFile}"],
});
- await page.goto("/settings");
+ await page.goto("/workers");
await expect(page.getByLabel("worker 1 engine")).toHaveValue("chough");
});
diff --git a/editor/e2e/workers.spec.ts b/editor/e2e/workers.spec.ts
@@ -1,6 +1,7 @@
// The Workers page: live per-worker status plus the runtime enable/disable/drain
// controls and the temporary pause-all/resume-all. These are transient operator
-// overrides on the global worker pool — they don't touch settings.json.
+// overrides on the global worker pool — they don't touch settings.json — plus
+// the persisted worker list, which is configured here now.
import { mkdir, writeFile } from "node:fs/promises";
import { test, expect } from "@playwright/test";
@@ -263,3 +264,81 @@ test("a worker not in the saved default starts disabled on the next launch", asy
await expect(gpu).toContainText(/idle|busy/);
await expect(extra).toContainText("disabled");
});
+
+// --- The persisted worker list, configured here ------------------------------
+//
+// Moved from settings.spec.ts: the list was a fieldset in the one big settings
+// form and is now its own form on this page, beside the live workers it
+// describes. Every worker aria-label is byte-identical; the page and the save
+// button are what changed. The file-level beforeEach already does the
+// resetData("empty") these tests used to bring with them.
+
+test.describe("worker configuration", () => {
+ test("shows the migrated default workers and persists an added worker", async ({
+ page,
+ }) => {
+ await page.goto("/workers");
+ const form = page.locator('form[data-settings-block="workers"]');
+ // An empty settings.json migrates to one whisper-cpp worker PER slot — the old
+ // default parallelTranscriptions of 2 becomes two enabled whisper workers.
+ await expect(form.getByLabel("worker 1 name")).toHaveValue(/whisper/i);
+ await expect(form.getByLabel("worker 2 name")).toHaveValue(/whisper/i);
+
+ // Add a third worker on a different engine.
+ await form.getByRole("button", { name: "+ Local worker" }).click();
+ await form.getByLabel("worker 3 name").fill("CPU chough");
+ await form.getByLabel("worker 3 engine").selectOption("chough");
+
+ await form.getByRole("button", { name: "Save workers" }).click();
+ await expect(form.getByRole("status")).toHaveText("Saved.");
+
+ const saved = await readJson<{
+ workers?: { name: string; appId?: string; priority: number }[];
+ }>("test-settings.json");
+ expect(saved.workers).toHaveLength(3);
+ expect(saved.workers?.[2].name).toBe("CPU chough");
+ expect(saved.workers?.[2].appId).toBe("chough");
+ // List order is priority.
+ expect(saved.workers?.[2].priority).toBe(2);
+
+ await page.reload();
+ await expect(form.getByLabel("worker 3 name")).toHaveValue("CPU chough");
+ });
+
+ test("copy duplicates a worker into a new slot", async ({ page }) => {
+ await page.goto("/workers");
+ const form = page.locator('form[data-settings-block="workers"]');
+ // Configure worker 1 as a chough-server worker, then copy it — the pattern for
+ // running two togglable slots against one chough --server.
+ await form.getByLabel("worker 1 engine").selectOption("chough");
+ await form.getByLabel("worker 1 name").fill("Chough server");
+ await form.getByLabel(/chough server URL/i).fill("http://localhost:8165");
+ await form.getByRole("button", { name: "copy worker 1" }).click();
+
+ await expect(form.getByLabel("worker 2 name")).toHaveValue("Chough server (copy)");
+
+ await form.getByRole("button", { name: "Save workers" }).click();
+ await expect(form.getByRole("status")).toHaveText("Saved.");
+
+ const saved = await readJson<{
+ workers?: { appId?: string; config?: { remoteUrl?: string } }[];
+ }>("test-settings.json");
+ const choughs = (saved.workers ?? []).filter((w) => w.appId === "chough");
+ expect(choughs.length).toBe(2);
+ expect(choughs.every((w) => w.config?.remoteUrl === "http://localhost:8165")).toBe(
+ true,
+ );
+ });
+
+ test("rejects saving with no enabled workers", async ({ page }) => {
+ await page.goto("/workers");
+ const form = page.locator('form[data-settings-block="workers"]');
+ // Migration produced two enabled workers; disable both.
+ await form.getByLabel("worker 1 enabled").uncheck();
+ await form.getByLabel("worker 2 enabled").uncheck();
+ await form.getByRole("button", { name: "Save workers" }).click();
+ await expect(form.getByRole("alert")).toContainText(
+ /at least one worker must be enabled/i,
+ );
+ });
+});