commit 3cf0a6bc10850e7f11b0a462f32033db0fa4593f
parent 005734a2dbae46ff2ec7ca8894c416fc6933960b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 26 Aug 2026 16:44:48 -0400
operations: the seams for per-operation settings
A settings block is now something an operation DECLARES, the way slice 2 made
the runner and the lane declared rather than derived from the id:
settingsBlock?: digest | diarization | attribution
on `Operation` and copied through to `OperationDescriptor`, set on digest,
diarization and both attribution entries. Both attribution operations name the
same block on purpose — there is one `settings.attribution` and one form, drawn
on both pages. The backfill LANE's block is deliberately absent: it governs
BACKFILL_QUEUE, which three operations share, so it belongs to the lane and not
to any one operation.
Beside it, the four per-block actions the forms will post to
(`operations/settingsActions.ts`), each the existing block from
`saveSettingsAction` moved verbatim minus its hidden `*FormPresent` marker —
those markers exist ONLY because one form saves everything, and one form per
block retires them. `num()`, which was declared inside the diarization block and
reused by two others, is a module-level helper there now.
`Field` is promoted out of SettingsForm to `components/forms/Field.tsx` so the
new forms can use it; the three other file-local copies are explicitly left
alone, and the new file says why.
Nothing renders any of this yet and `saveSettingsAction` is untouched, so this
commit changes no behaviour. tsc in six packages, common 828/828, editor units
85/85.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
5 files changed, 363 insertions(+), 37 deletions(-)
diff --git a/common/lib/operations.test.ts b/common/lib/operations.test.ts
@@ -1240,6 +1240,34 @@ test("`appliesTo` is transcode's alone, and it reads the channel config", () =>
assert.equal(operationApplies("not-an-operation", { handling: "youtube" }), true);
});
+test("`settingsBlock` names the settings.json block, off the descriptor", () => {
+ // The operation page renders an operation's settings form by switching on
+ // THIS, not on a table keyed by operation id — the rule slice 2 set for
+ // `runner` and the lane. Both attribution operations name the same block
+ // because there is one `settings.attribution` and one form drawn on both
+ // pages; the backfill LANE's block is not an operation's and is not here.
+ const expected: Record<string, string> = {
+ digest: "digest",
+ diarization: "diarization",
+ "attribution-text": "attribution",
+ "attribution-diarized": "attribution",
+ };
+ for (const op of operationCatalog()) {
+ assert.equal(
+ op.settingsBlock,
+ expected[op.id],
+ `${op.id} declares settingsBlock ${String(op.settingsBlock)}`,
+ );
+ }
+ // Every id the map names is actually in the catalog — otherwise the loop
+ // above passes by never visiting a key that was renamed out from under it.
+ const ids = new Set(operationCatalog().map((o) => o.id));
+ for (const id of Object.keys(expected)) assert.ok(ids.has(id), `${id} is not catalogued`);
+ const attribution = operationCatalog().filter((o) => o.id.startsWith("attribution-"));
+ assert.equal(attribution.length, 2);
+ assert.equal(attribution[0].settingsBlock, attribution[1].settingsBlock);
+});
+
test("every catalogued operation declares a group and a cost basis", () => {
// Both are read unconditionally by the UI — the transit line groups stations
// by `group`, and every armed operation prints `costBasis` beside its
diff --git a/common/lib/operations.ts b/common/lib/operations.ts
@@ -317,6 +317,11 @@ export type OperationRunOutcome =
// members.
export type OperationGroup = "media" | "transcript" | "digest" | "speakers";
+// See Operation.settingsBlock. A closed union rather than `string` so the switch
+// that picks a settings form is exhaustive: a new block has to be named here and
+// then handled, instead of silently rendering nothing.
+export type OperationSettingsBlock = "digest" | "diarization" | "attribution";
+
// Group order: upstream first. The /channels columns and the transit line both
// lay their pipelines out in this order, so a reader moving between the two
// pages sees the same left-to-right sequence.
@@ -432,6 +437,13 @@ export type Operation = {
// otherwise every surface would advertise catch-up work for something the
// operator has switched off.
enabled(settings: SiteSettings): boolean;
+ // The settings.json block that configures this operation, when one does. Named
+ // here so the operation page can render that block's form without a table
+ // keyed by operation id. Digest owns `digest`; diarization owns `diarization`;
+ // both attribution operations share `attribution` (one block, one form, drawn
+ // on both pages). The backfill LANE's block (`settings.backfill`) is not an
+ // operation's and is not named here — it is rendered per lane, not per operation.
+ settingsBlock?: OperationSettingsBlock;
// The identity we would produce now, resolved ONCE per run rather than per
// video. `unknown` here is the one erasure point in the table: each entry
// narrows it back to its own type on the line below. The alternative — making
@@ -635,6 +647,7 @@ const diarization: Operation = {
// live answer, and diarizationLaneFor is where the rule actually lives.
lane: { queueKey: BACKFILL_QUEUE, contendsFor: "cpu" },
laneFor: (settings) => diarizationLaneFor(settings.diarization),
+ settingsBlock: "diarization",
enabled: (settings) =>
settings.diarization.enabled &&
!!settings.diarization.segModel &&
@@ -811,6 +824,7 @@ const attributionText: Operation = {
costBasis: "~1 model call per transcript chunk",
tier: "lane",
lane: { queueKey: BACKFILL_QUEUE, contendsFor: "network" },
+ settingsBlock: "attribution",
enabled: (settings) =>
settings.attribution.enabled && settings.attribution.textOnlyEnabled,
resolveTarget: ({ settings }) => attributionTargetFor(settings, "text-only"),
@@ -873,6 +887,7 @@ const attributionDiarized: Operation = {
// reads into something the scheduler can order by and a counter can name.
dependsOn: ["diarization"],
lane: { queueKey: BACKFILL_QUEUE, contendsFor: "network" },
+ settingsBlock: "attribution",
enabled: (settings) =>
settings.attribution.enabled && settings.attribution.diarizedEnabled,
resolveTarget: ({ settings }) => attributionTargetFor(settings, "diarized"),
@@ -1042,6 +1057,7 @@ const digest: Operation = {
// "enabled" flag, so the feature is on whenever an app is configured. The
// pause is honoured at DISPATCH (digestBatch's limit()), not here: a paused
// lane must still report how much work is outstanding.
+ settingsBlock: "digest",
enabled: () => true,
async resolveTarget({ paths, channelSlug }) {
// The existing resolver, verbatim. This is the async, channel-scoped case
@@ -1328,6 +1344,9 @@ export type OperationDescriptor = {
runner?: AutoQueueKind;
// See ExternalOperation.appliesTo. Absent means every channel.
appliesTo?: (config: ChannelConfig) => boolean;
+ // See Operation.settingsBlock. Absent for every external operation: nothing in
+ // settings.json configures a download or a transcode as an operation.
+ settingsBlock?: OperationSettingsBlock;
};
export function operationCatalog(): OperationDescriptor[] {
@@ -1343,6 +1362,7 @@ export function operationCatalog(): OperationDescriptor[] {
lane: k.lane,
dependsOn: k.dependsOn,
dispatch: "backfill" as const,
+ settingsBlock: k.settingsBlock,
})),
];
}
diff --git a/editor/app/components/forms/Field.tsx b/editor/app/components/forms/Field.tsx
@@ -0,0 +1,45 @@
+// The labelled text/number input the long settings forms are built out of,
+// lifted out of SettingsForm when slice 3 moved four of its fieldsets onto the
+// operation pages and two files needed the same helper.
+//
+// DELIBERATELY NOT A DEDUPLICATION SWEEP. Three other files still define their
+// own `Field` — ChannelForm.tsx, SiteForm.tsx and OverviewPanel.tsx — and they
+// are NOT this one: they differ in props (no `type`/`step`) and in markup, so
+// folding them in is a behaviour change to three forms that slice 3 does not
+// touch. Leave them until something actually needs them to be one.
+export function Field({
+ label,
+ name,
+ defaultValue,
+ hint,
+ required,
+ type = "text",
+ step,
+}: {
+ label: string;
+ name: string;
+ defaultValue?: string;
+ hint?: string;
+ required?: boolean;
+ type?: string;
+ // `type="number"` carries an IMPLICIT step of 1, so a decimal value fails
+ // constraint validation and the browser blocks the submit SILENTLY — no
+ // error, no request, the save just never happens. Any numeric field that
+ // accepts fractions must pass a step.
+ step?: string;
+}) {
+ return (
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">{label}</span>
+ <input
+ type={type}
+ name={name}
+ defaultValue={defaultValue}
+ required={required}
+ step={step}
+ className="rounded border border-border bg-card px-2 py-1 text-sm"
+ />
+ {hint && <span className="text-xs text-muted-foreground">{hint}</span>}
+ </label>
+ );
+}
diff --git a/editor/app/operations/settingsActions.ts b/editor/app/operations/settingsActions.ts
@@ -0,0 +1,269 @@
+"use server";
+
+// The four settings.json blocks that configure an OPERATION, each saved by its
+// own form on its own operation page. Split out of settings/actions.ts by
+// slice 3: while one form saved everything, each block needed a hidden
+// `*FormPresent` marker, because unchecked checkboxes are simply ABSENT from a
+// FormData and a submit from a form lacking the block would read every switch
+// as off. One form per block makes the marker unnecessary — the `...current`
+// spread at the top of each block is now the whole isolation story.
+//
+// Every field name is the one the old single form used, and the persisted keys
+// are unchanged: this is a move, not a redesign.
+
+import { revalidatePath } from "next/cache";
+import {
+ getSettings,
+ writeSettings,
+ type SiteSettings,
+} from "yt-dlp-transcript-common/lib/settings";
+import {
+ isDigestSectionKind,
+ isDigestTimestampMode,
+} from "yt-dlp-transcript-common/lib/digest";
+import type { SaveResult } from "../settings/actions";
+
+// Same helper as operations/actions.ts, and for the same reason — a "use server"
+// module may only EXPORT async functions, so this cannot be shared from there.
+// Every block below is drawn on /operations and on /operations/[id].
+function revalidateOperations(): void {
+ revalidatePath("/operations");
+ revalidatePath("/operations/[id]", "page");
+}
+
+// Read a number off the form, falling back to the current value. Was defined
+// inside the diarization block in settings/actions.ts and reused by the backfill
+// and attribution blocks; module-level here, where three actions share it.
+function num(formData: FormData, key: string, fallback: number): number {
+ const n = Number.parseFloat(String(formData.get(key) ?? "").trim());
+ return Number.isFinite(n) ? n : fallback;
+}
+
+// Digest. The metered lane's switch is a checkbox like any other, but note the
+// asymmetry: everything else here defaults to the CURRENT value on a partial
+// save, while `remoteEnabled` is read straight from the form so it can never be
+// turned on by an unrelated save. writeSettings re-sanitizes the whole block.
+//
+// DigestSettingsForm renders every field of this block and is the only form
+// that posts here.
+export async function saveDigestSettingsAction(
+ _prev: SaveResult | undefined,
+ formData: FormData,
+): Promise<SaveResult> {
+ const current = getSettings();
+ const dD = current.digest;
+ const digestAppsRaw = String(formData.get("digestAppsJson") ?? "").trim();
+ let digestApps: unknown = dD.apps;
+ if (digestAppsRaw) {
+ try {
+ digestApps = JSON.parse(digestAppsRaw);
+ } catch {
+ return { ok: false, error: "Digest app config payload is malformed" };
+ }
+ }
+ // Filtered to KNOWN kinds here rather than leaning on writeSettings' sanitizer.
+ // sanitizeDigest would drop an unknown value anyway, but typing it honestly is
+ // what lets the digest block below be checked against DigestSettings instead of
+ // cast — and the cast is what hid the dropped-fields bug.
+ const digestSectionsRaw = formData
+ .getAll("digestSections")
+ .map(String)
+ .filter(isDigestSectionKind);
+ const digestTimestampModeRaw = String(
+ formData.get("digestTimestampMode") ?? "",
+ ).trim();
+ const next: SiteSettings = {
+ ...current,
+ digest: {
+ // Spread the CURRENT block first. Every field this form does not render
+ // must survive a save untouched, and before this spread they did not:
+ // the block was built field-by-field and cast, so `yieldToTranscription`
+ // and — much worse — `sweepEnabled`/`sweepChannels` were absent from the
+ // object, and sanitizeDigest re-derived them from nothing. Saving any
+ // unrelated setting DISARMED AN ARMED CORPUS SWEEP. The cast is gone
+ // too, so the next added field is a type error rather than a silent
+ // reset.
+ ...dD,
+ remoteEnabled: formData.get("digestRemoteEnabled") === "on",
+ longTailSeconds: Number.parseInt(
+ String(formData.get("digestLongTailSeconds") ?? "").trim(),
+ 10,
+ ),
+ localAppId:
+ String(formData.get("digestLocalAppId") ?? "").trim() || dD.localAppId,
+ remoteAppId:
+ String(formData.get("digestRemoteAppId") ?? "").trim() ||
+ dD.remoteAppId,
+ // Cast, not sanitized here: this is hand-parsed JSON from the form and
+ // writeSettings runs sanitizeDigestApps over it. Narrowed at exactly
+ // this field so every OTHER field in the block stays type-checked.
+ apps: digestApps as SiteSettings["digest"]["apps"],
+ // Not edited by this form — the dashboard/channel controls own the pause.
+ digestsPaused: dD.digestsPaused,
+ spendCapUsd: Number.parseFloat(
+ String(formData.get("digestSpendCapUsd") ?? "").trim(),
+ ),
+ sections: digestSectionsRaw.length > 0 ? digestSectionsRaw : dD.sections,
+ // Prompt SHAPE — both freshness-affecting (see digestPromptVariant),
+ // so a silent reset here would invalidate every digest generated
+ // under a non-default shape. Each still FALLS BACK to the current
+ // value rather than to the default when the field is absent: a form
+ // that rebuilds this block but omits a field is exactly how the reset
+ // bug happens, and the fallback is what makes omission harmless.
+ timestampMode: isDigestTimestampMode(digestTimestampModeRaw)
+ ? digestTimestampModeRaw
+ : dD.timestampMode,
+ promptVariant: formData.has("digestPromptVariant")
+ ? String(formData.get("digestPromptVariant") ?? "").trim()
+ : dD.promptVariant,
+ yieldToCpuWorkers: formData.get("digestYieldToCpuWorkers") === "on",
+ },
+ };
+ try {
+ await writeSettings(next);
+ } catch (e) {
+ return { ok: false, error: (e as Error).message };
+ }
+ revalidateOperations();
+ return { ok: true };
+}
+
+// Diarization. The spread protects the CAPTURE LANE: losing `enabled` would let
+// the next Clean-audio sweep delete audio that was being held for diarization,
+// and every field this form does not render survives untouched.
+//
+// DiarizationSettingsForm renders every field of this block and is the only form
+// that posts here.
+export async function saveDiarizationSettingsAction(
+ _prev: SaveResult | undefined,
+ formData: FormData,
+): Promise<SaveResult> {
+ const current = getSettings();
+ const dDiar = current.diarization;
+ const next: SiteSettings = {
+ ...current,
+ diarization: {
+ ...dDiar,
+ enabled: formData.get("diarizationEnabled") === "on",
+ inlineAfterTranscribe:
+ formData.get("diarizationInlineAfterTranscribe") === "on",
+ threshold: num(formData, "diarizationThreshold", dDiar.threshold),
+ threads: num(formData, "diarizationThreads", dDiar.threads),
+ concurrency: num(formData, "diarizationConcurrency", dDiar.concurrency),
+ maxAudioHours: num(
+ formData,
+ "diarizationMaxAudioHours",
+ dDiar.maxAudioHours,
+ ),
+ python:
+ String(formData.get("diarizationPython") ?? "").trim() || dDiar.python,
+ segModel: String(formData.get("diarizationSegModel") ?? "").trim(),
+ embModel: String(formData.get("diarizationEmbModel") ?? "").trim(),
+ // Read as plain strings and left to sanitizeDiarization to validate:
+ // an unrecognized value there falls back to the DEFAULT engine, which
+ // is the one every sidecar on disk already matches. Narrowing here
+ // instead would mean this form and the sanitizer could disagree about
+ // what a valid engine is, and the corpus pays for that disagreement in
+ // weeks of regeneration.
+ engine: String(
+ formData.get("diarizationEngine") ?? dDiar.engine,
+ ) as typeof dDiar.engine,
+ backend: String(
+ formData.get("diarizationBackend") ?? dDiar.backend,
+ ) as typeof dDiar.backend,
+ sortformerBin: String(
+ formData.get("diarizationSortformerBin") ?? "",
+ ).trim(),
+ sortformerModel: String(
+ formData.get("diarizationSortformerModel") ?? "",
+ ).trim(),
+ },
+ };
+ try {
+ await writeSettings(next);
+ } catch (e) {
+ return { ok: false, error: (e as Error).message };
+ }
+ revalidateOperations();
+ return { ok: true };
+}
+
+// The backfill LANE — not an operation's block. It governs BACKFILL_QUEUE,
+// which diarization, attribution-text and attribution-diarized share, so its
+// form is drawn per LANE (beside the shared pause and sweep) rather than per
+// operation.
+//
+// The spread protects two things specifically: `allowRedownload`, which an
+// unrelated save must never flip ON (it writes media to a 97%-full disk), and
+// `sweepEnabled` + its scope, which this form does NOT render at all — those
+// are owned by the sweep controls above it on the same page, and reading them
+// from an absent form field would disarm a running multi-day sweep.
+//
+// LaneSettingsForm renders every field of this block and is the only form that
+// posts here.
+export async function saveBackfillLaneSettingsAction(
+ _prev: SaveResult | undefined,
+ formData: FormData,
+): Promise<SaveResult> {
+ const current = getSettings();
+ const dBack = current.backfill;
+ const next: SiteSettings = {
+ ...current,
+ backfill: {
+ ...dBack,
+ enabled: formData.get("backfillEnabled") === "on",
+ weight: num(formData, "backfillWeight", dBack.weight),
+ concurrency: num(formData, "backfillConcurrency", dBack.concurrency),
+ allowRedownload: formData.get("backfillAllowRedownload") === "on",
+ },
+ };
+ try {
+ await writeSettings(next);
+ } catch (e) {
+ return { ok: false, error: (e as Error).message };
+ }
+ revalidateOperations();
+ return { ok: true };
+}
+
+// Speaker attribution — ONE block behind TWO operations (attribution-text and
+// attribution-diarized), so this form is drawn on both their pages. It guards
+// the most expensive switch in the console: `textOnlyEnabled` arms a lane
+// costing roughly one model call per transcript chunk — ~194,000 across this
+// corpus.
+//
+// AttributionSettingsForm renders every field of this block and is the only form
+// that posts here.
+export async function saveAttributionSettingsAction(
+ _prev: SaveResult | undefined,
+ formData: FormData,
+): Promise<SaveResult> {
+ const current = getSettings();
+ const dAttr = current.attribution;
+ const next: SiteSettings = {
+ ...current,
+ attribution: {
+ ...dAttr,
+ enabled: formData.get("attributionEnabled") === "on",
+ appId: String(formData.get("attributionAppId") ?? dAttr.appId),
+ // Trimmed but NOT defaulted: empty is meaningful here ("use the
+ // engine's own model"), so an operator clearing the field must be able
+ // to clear it.
+ model: String(formData.get("attributionModel") ?? dAttr.model).trim(),
+ diarizedEnabled: formData.get("attributionDiarized") === "on",
+ textOnlyEnabled: formData.get("attributionTextOnly") === "on",
+ promptVersion: num(
+ formData,
+ "attributionPromptVersion",
+ dAttr.promptVersion,
+ ),
+ },
+ };
+ try {
+ await writeSettings(next);
+ } catch (e) {
+ return { ok: false, error: (e as Error).message };
+ }
+ revalidateOperations();
+ return { ok: true };
+}
diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx
@@ -27,6 +27,7 @@ import {
SYNC_INTERVAL_MIN_MINUTES,
} from "yt-dlp-transcript-common/lib/channelConfig";
import { DurationField } from "../../components/DurationField";
+import { Field } from "../../components/forms/Field";
import { FULL_SWEEP_PRESETS } from "../../scheduler/intervalPresets";
import { DigestAppsField } from "./DigestAppsField";
import {
@@ -1124,40 +1125,3 @@ export function SettingsForm({ initial, apps, digestApps, workerTags }: Props) {
</form>
);
}
-
-function Field({
- label,
- name,
- defaultValue,
- hint,
- required,
- type = "text",
- step,
-}: {
- label: string;
- name: string;
- defaultValue?: string;
- hint?: string;
- required?: boolean;
- type?: string;
- // `type="number"` carries an IMPLICIT step of 1, so a decimal value fails
- // constraint validation and the browser blocks the submit SILENTLY — no
- // error, no request, the save just never happens. Any numeric field that
- // accepts fractions must pass a step.
- step?: string;
-}) {
- return (
- <label className="flex flex-col gap-1 text-sm">
- <span className="font-medium">{label}</span>
- <input
- type={type}
- name={name}
- defaultValue={defaultValue}
- required={required}
- step={step}
- className="rounded border border-border bg-card px-2 py-1 text-sm"
- />
- {hint && <span className="text-xs text-muted-foreground">{hint}</span>}
- </label>
- );
-}