Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mcommon/lib/operations.test.ts | 28++++++++++++++++++++++++++++
Mcommon/lib/operations.ts | 20++++++++++++++++++++
Aeditor/app/components/forms/Field.tsx | 45+++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/operations/settingsActions.ts | 269+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/settings/components/SettingsForm.tsx | 38+-------------------------------------
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> - ); -}