"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, and // `saveSettings` (../settings/saveSettings.ts) merges each patch onto the // rest of the file. // // 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, type SiteSettings, } from "yt-dlp-transcript-common/lib/settings"; import { saveSettings } from "../settings/saveSettings"; import { isDigestSectionKind, isDigestTimestampMode, } from "yt-dlp-transcript-common/lib/digest"; import { withGateHeld } from "yt-dlp-transcript-common/lib/pauseGates"; import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy"; import { sanitizePublish } from "yt-dlp-transcript-common/lib/settingsSchema"; import type { SaveResult } from "../settings/actions"; import { formValues } from "../lib/formState"; // 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 { const values = formValues(formData); 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", values, }; } } // Filtered to KNOWN kinds here rather than leaning on the settings schema's 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: Partial = { 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"], 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 saveSettings(next); } catch (e) { return { ok: false, error: (e as Error).message, values }; } 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 { const values = formValues(formData); const current = getSettings(); const dDiar = current.diarization; const next: Partial = { 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 saveSettings(next); } catch (e) { return { ok: false, error: (e as Error).message, values }; } 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 `allowRedownload` specifically, which an unrelated save // must never flip ON (it writes media to a 97%-full disk). // // "RUN THE BACKFILL LANE" IS THE LANE'S GATE, and it goes through // `withGateHeld` — a deliberate SECOND WRITER of `autoQueue.backfill.held`, // beside the pause buttons. It has always been two controls over one switch // (the checkbox wrote `backfill.enabled`, which `isGateHeld` read inverted); // slice 1.4 moved the switch onto the lane and S0-pause deleted the old field, // so there is exactly one key both controls write. // // 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 { const values = formValues(formData); const current = getSettings(); const dBack = current.backfill; const next: Partial = { backfill: { ...dBack, concurrency: num(formData, "backfillConcurrency", dBack.concurrency), allowRedownload: formData.get("backfillAllowRedownload") === "on", }, autoQueue: withGateHeld( current, "backfill", formData.get("backfillEnabled") !== "on", ).autoQueue, }; try { await saveSettings(next); } catch (e) { return { ok: false, error: (e as Error).message, values }; } 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 { const values = formValues(formData); const current = getSettings(); const dAttr = current.attribution; const next: Partial = { 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 saveSettings(next); } catch (e) { return { ok: false, error: (e as Error).message, values }; } revalidateOperations(); return { ok: true }; } // The publish lane (release 18): `settings.publish`, saved by // PublishSettingsForm on /operations/publish. `held` is not on the form — the // hold button writes it (operations/actions.ts setLaneHeld) — so it is carried // from the stored block. A preview branch name the sanitizer would silently // replace by the default is refused instead, with the sentence the deploy // buttons use. export async function savePublishSettingsAction( _prev: SaveResult | undefined, formData: FormData, ): Promise { const values = formValues(formData); const current = getSettings().publish; const text = (key: string) => String(formData.get(key) ?? "").trim(); const previewBranch = text("publishPreviewBranch") || current.previewBranch; const branchProblem = previewBranchProblem(previewBranch); if (branchProblem) return { ok: false, error: branchProblem, values }; const quietStart = text("publishQuietStart"); const quietEnd = text("publishQuietEnd"); if ((quietStart === "") !== (quietEnd === "")) { return { ok: false, error: "Quiet hours need both a start and an end hour (or neither).", values }; } const next = sanitizePublish({ ...current, enabled: formData.get("publishEnabled") === "on", checkEveryMinutes: num(formData, "publishCheckEveryMinutes", current.checkEveryMinutes), refreshEveryMinutes: num(formData, "publishRefreshEveryMinutes", current.refreshEveryMinutes), quietHours: quietStart === "" ? null : { start: Number(quietStart), end: Number(quietEnd) }, runner: text("publishRunner") || current.runner, previewBranch, hub: text("publishHub") || current.hub, homepage: text("publishHomepage") || current.homepage, }); if (quietStart !== "" && next.quietHours === null) { return { ok: false, error: "Quiet hours are two different hours from 0 to 23.", values }; } try { await saveSettings({ publish: next }); } catch (e) { return { ok: false, error: (e as Error).message, values }; } // Switched on: the runner starts (idempotent — one already up is kept). // Switched off: a running runner notices at its next tick and ends. if (next.enabled && !current.enabled) { const { startPublishRunner } = await import("yt-dlp-transcript-common/publish/publishRunner"); await startPublishRunner(); } revalidatePath("/operations/publish"); revalidatePath("/sites"); return { ok: true }; }