// THE ONE READER AND THE ONE WRITER OF settings.json. // // The shape — every field, its default, its clamp, its documentation — is // `siteSettingsSchema` in ./settingsSchema.ts (one-core phase 3 slice 4a), and // everything that module exports is re-exported here, so the ~200 importers of // `lib/settings` (types, constants, clamps, sanitizers) did not move. // // What is left in this file is exactly what a schema cannot do: // // - READ STAYS LENIENT. A settings.json is whatever an operator, an older // build, or a half-finished write left behind. getSettings never throws: // an unreadable or non-object file reads as the empty one, and every field // is total. Three migrations are keyed on a field's ABSENCE in the raw file // — which a parsed object cannot see, because parsing folds the default in // — so they run around the parse, each handed the RAW object. // - WRITE STAYS STRICT. writeSettings derives the worker shadow, runs the two // validators that THROW (a worker list that cannot transcribe, a social // link whose SVG is unsafe), parses through the same schema — which is what // drops every key it does not name, retired fields included — and writes // atomically (tmp + rename). // // One schema, both directions: the only differences between what a read and a // write produce are those migrations and those two validators. import { readJsonFileSync, writeJsonAtomic } from "./jsonFile-server"; import path from "node:path"; import { getPaths } from "./paths"; import { type AppInstanceConfig, DEFAULT_TRANSCRIBE_ARGS, DEFAULT_TRANSCRIPTION_APP_ID, TRANSCRIPTION_APPS, } from "./transcriptionApps"; import { defaultWorkersFromApps, sanitizeWorkers, validateWorkers, } from "./workers"; import { migrateSweepsToLanes } from "./laneMigration"; import { migrateMediaRootToLocations } from "./storageLocations"; import { socialLinksForSave } from "./socialLinks"; import { clampParallelTranscriptions, defaultStorage, parseSocialLinks, sanitizeTranscriptionApps, siteSettingsSchema, type SiteSettings, type SocialLink, } from "./settingsSchema"; export * from "./settingsSchema"; type RawSettings = Record; // The file as JSON, or `undefined` when it is missing or not JSON. Never // throws: a settings read is on every request path. function readRawSettings(file: string): unknown { const read = readJsonFileSync(file); return read.ok ? read.value : undefined; } // Only a plain object is a settings file. `null`, `[]`, `3` and a truncated // write all read as the empty file — every field its default — rather than as // an exception. (Before slice 4a a file containing `null` threw here.) function rawObject(raw: unknown): RawSettings { return raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as RawSettings) : {}; } // THE TWO ABSENCE-KEYED MIGRATIONS THAT REWRITE AN INPUT BLOCK, applied to the // raw object BEFORE the parse. Each is handed the raw file, never a parsed one, // because "the file does not spell this key" is the whole trigger. // // - autoQueue: `migrateSweepsToLanes` fills `autoQueue.digest` / `.backfill` // from the retired sweep fields when — and only when — the file does not // already carry that lane. It never enables a lane the sweep flag did not. // See lib/laneMigration.ts. // - storage: `storage.mediaRoot` (one absolute string) becomes a one-entry // location list when the file has no `locations` key. An absent `storage` // block is the default block, which has a `locations` key and so does not // migrate. See lib/storageLocations.ts. // // Both return RAW values; the schema's sanitizers then normalize them exactly as // they normalize a hand-edited file. function premigrateRaw(raw: RawSettings): RawSettings { return { ...raw, autoQueue: migrateSweepsToLanes(raw), storage: migrateMediaRootToLocations(raw.storage ?? defaultStorage()), }; } // THE TWO ABSENCE-KEYED MIGRATIONS THAT NEED THE PARSED RESULT, in this order: // // 1. A pre-multi-app file (transcribeBin/transcribeArgs/transcribeModel, no // `transcriptionApp` key) is migrated onto the app registry. It reads the // already-sanitized `transcriptionApps`, so it runs after the parse. // 2. A file predating the worker model (no `workers` key) gets a worker list // synthesized from the now-settled active app, so existing installs behave // identically. A file that HAS the key keeps its sanitized list, even an // empty one. function finishRawMigrations( parsed: SiteSettings, raw: RawSettings, ): SiteSettings { if (raw.transcriptionApp === undefined) { migrateLegacyTranscription(raw as LegacyTranscribeFields, parsed); } if (raw.workers === undefined) { parsed.workers = defaultWorkersFromApps( parsed.transcriptionApp, parsed.transcriptionApps, parsed.parallelTranscriptions, ); } return parsed; } export function getSettings(): SiteSettings { return settingsFromFile(getPaths().settingsFile); } // getSettings for a named file: the same read, the same migrations, no write. // `archilyzer doctor` reads the file its (injectable) paths name through this. export function settingsFromFile(file: string): SiteSettings { const raw = rawObject(readRawSettings(file)); return finishRawMigrations(siteSettingsSchema.parse(premigrateRaw(raw)), raw); } type LegacyTranscribeFields = { transcribeBin?: unknown; transcribeArgs?: unknown; transcribeModel?: unknown; }; function argsAreDefault(args: string[]): boolean { return ( args.length === DEFAULT_TRANSCRIBE_ARGS.length && args.every((a, i) => a === DEFAULT_TRANSCRIBE_ARGS[i]) ); } function migrateLegacyTranscription( raw: LegacyTranscribeFields, merged: SiteSettings, ): void { const bin = typeof raw.transcribeBin === "string" ? raw.transcribeBin.trim() : ""; const model = typeof raw.transcribeModel === "string" ? raw.transcribeModel : ""; const legacyArgs = Array.isArray(raw.transcribeArgs) ? raw.transcribeArgs.map(String) : []; if (!bin && !model && legacyArgs.length === 0) return; // nothing to migrate // If the legacy binary names a known app (e.g. "chough"), adopt that app so // its native arg/output handling applies; otherwise fall back to whisper-cpp, // which preserves the old placeholder-template behavior. const binBase = bin ? path.basename(bin).toLowerCase() : ""; const appId = binBase && TRANSCRIPTION_APPS[binBase] ? binBase : DEFAULT_TRANSCRIPTION_APP_ID; const cfg: AppInstanceConfig = { ...merged.transcriptionApps[appId] }; if (bin) cfg.bin = bin; if (model) cfg.model = model; // Carry a customArgs template only for whisper-cpp and only when it differs // from the default — a default install migrates to a clean config. The chough // app builds its own argv, so legacy args are dropped for it. if ( appId === DEFAULT_TRANSCRIPTION_APP_ID && legacyArgs.length > 0 && !argsAreDefault(legacyArgs) ) { cfg.customArgs = legacyArgs; } merged.transcriptionApp = appId; merged.transcriptionApps = { ...merged.transcriptionApps, [appId]: cfg }; } // THE WORKER SHADOW, derived on every save. // // Workers are the source of truth. A caller that still sets only the deprecated // transcriptionApp/transcriptionApps (no `workers`) gets a list synthesized from // them, so old call sites keep working. The list is then VALIDATED — this throws // — and the deprecated pair is rewritten as a faithful shadow of it (used only // for rollback to a pre-worker build; a file with a `workers` key is never // re-migrated): the active app is the first enabled local worker, and the per-app // map mirrors each local worker's config. function deriveWorkerShadow(next: SiteSettings): SiteSettings { let workers = sanitizeWorkers(next.workers); if (workers.length === 0) { const fallbackAppId = typeof next.transcriptionApp === "string" && TRANSCRIPTION_APPS[next.transcriptionApp] ? next.transcriptionApp : DEFAULT_TRANSCRIPTION_APP_ID; workers = defaultWorkersFromApps( fallbackAppId, sanitizeTranscriptionApps(next.transcriptionApps), clampParallelTranscriptions(next.parallelTranscriptions), ); } const workersErr = validateWorkers(workers); if (workersErr) throw new Error(workersErr); const firstLocal = workers.find((w) => w.enabled && w.kind === "local") ?? workers.find((w) => w.kind === "local"); const transcriptionApp = firstLocal?.appId && TRANSCRIPTION_APPS[firstLocal.appId] ? firstLocal.appId : DEFAULT_TRANSCRIPTION_APP_ID; const transcriptionApps = sanitizeTranscriptionApps(next.transcriptionApps); for (const w of workers) { if (w.kind === "local" && w.appId) { transcriptionApps[w.appId] = w.config ?? {}; } } return { ...next, workers, transcriptionApp, transcriptionApps }; } // The social links to store: each new or edited SVG normalized for inline use, // or a THROW naming the first that is refused; a link whose SVG is unchanged // from the file is kept as it is (lib/socialLinks.ts socialLinksForSave). The // schema's own `parseSocialLinks` only checks shape; this is the write-side // half. function validatedSocialLinks(value: unknown, file: string): SocialLink[] { const stored = parseSocialLinks(rawObject(readRawSettings(file)).socialLinks); const r = socialLinksForSave(parseSocialLinks(value), stored); if ("refused" in r) { throw new Error(`Social link "${r.refused.label}" has an invalid SVG: ${r.refused.problem}`); } return r.links; } export async function writeSettings(next: SiteSettings): Promise { // THE SCHEMA IS ALSO WHAT RETIRES A FIELD. It names only the known // operational fields and strips the rest, so a settings.json still carrying // pre-multi-site keys (siteTitle/groups) or the four retired pause flags // (`transcriptionsPaused`, `downloadsPaused`, `digest.digestsPaused`, the // inverted `backfill.enabled`) loses them on this write. The gate is // `autoQueue[lane].held` and nothing else — see lib/pauseGates.ts. const file = getPaths().settingsFile; const merged = siteSettingsSchema.parse({ ...deriveWorkerShadow(next), socialLinks: validatedSocialLinks(next.socialLinks, file), }); await writeJsonAtomic(file, merged); }