commit b94055b379541a2a1af878782fd8d28fc1e6f836
parent 8b5aa2cd6315d4fe1fa829cb66249cce08f7c183
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 23 Sep 2026 19:33:57 -0400
editor: one settings writer — saveSettings(patch), and the 11 callers pass patches
one-core phase 3 slice 4a, commit 4. editor/app/settings/saveSettings.ts
reads the current settings, merges a Partial<SiteSettings> ONE LEVEL DEEP
(object blocks merge over the stored block; arrays, scalars and objects
nested inside a block replace — the rule editor/e2e/helpers.ts already
applies) and calls writeSettings. It is now the only editor file that
imports writeSettings.
Deliberate deviation from plans/one-core.md's saveSettingsBlock(block,
patch): channels/actions.ts writes channelPriority and the four autoQueue
roots compiled from it in one write, which a one-block signature would split.
Callers (19 call sites in 11 files) drop their {...getSettings(), ...}
preamble. Two behaviour changes, both from the merge:
- storage/actions.ts: adding or editing a location no longer erases
storage.savedVideosLocationId (it rebuilt the block from two keys).
- settings/actions.ts: the /settings form patches only its own fields; it
no longer rewrites the rollback-only transcriptionApps shadow to {}
(the shadow is still re-derived from workers on every write).
saveSettings.test.ts pins the merge rule; editor unit 59 -> 63.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
13 files changed, 216 insertions(+), 118 deletions(-)
diff --git a/editor/app/channels/[slug]/incompleteTranscriptActions.ts b/editor/app/channels/[slug]/incompleteTranscriptActions.ts
@@ -7,7 +7,8 @@ import {
resolveQueueKey,
} from "yt-dlp-transcript-common/lib/queueKeys";
import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels";
-import { getSettings, writeSettings } from "yt-dlp-transcript-common/lib/settings";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { saveSettings } from "../../settings/saveSettings";
import { startAutoRunner } from "yt-dlp-transcript-common/controller/autoRunner";
import {
runManagedFunction,
@@ -40,8 +41,7 @@ export async function enableAutoRunners(): Promise<void> {
!current.autoQueue.transcription.enabled ||
!current.autoQueue.download.enabled
) {
- await writeSettings({
- ...current,
+ await saveSettings({
autoQueue: {
...current.autoQueue,
transcription: { ...current.autoQueue.transcription, enabled: true },
diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts
@@ -35,10 +35,8 @@ import {
siteChannelIndex,
type Site,
} from "yt-dlp-transcript-common/lib/site";
-import {
- getSettings,
- writeSettings,
-} from "yt-dlp-transcript-common/lib/settings";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { saveSettings } from "../settings/saveSettings";
import {
LANES,
type AutoQueueKind,
@@ -944,7 +942,10 @@ export async function saveChannelPriorityAction(
autoQueue[lane] = { ...autoQueue[lane], enabled: true };
}
try {
- await writeSettings({ ...settings, channelPriority: next, autoQueue });
+ // BOTH BLOCKS IN ONE WRITE — the reason saveSettings takes a whole-settings
+ // patch rather than one block. `channelPriority` is a full document (its
+ // `channels` map replaces), and `autoQueue` carries all four lanes.
+ await saveSettings({ channelPriority: next, autoQueue });
} catch (e) {
return { error: (e as Error).message };
}
diff --git a/editor/app/jobs/actions.ts b/editor/app/jobs/actions.ts
@@ -2,10 +2,8 @@
import { revalidatePath } from "next/cache";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import {
- getSettings,
- writeSettings,
-} from "yt-dlp-transcript-common/lib/settings";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { saveSettings } from "../settings/saveSettings";
import { laneRootFromScope } from "yt-dlp-transcript-common/lib/laneMigration";
import { isDefaultChannelPriority } from "yt-dlp-transcript-common/lib/channelPriority";
import { operationsForLane } from "yt-dlp-transcript-common/lib/operations";
@@ -216,8 +214,7 @@ export async function armLaneAction(
};
}
const policy = settings.autoQueue[lane];
- await writeSettings({
- ...settings,
+ await saveSettings({
autoQueue: {
...settings.autoQueue,
[lane]: {
@@ -276,8 +273,7 @@ export async function disarmLaneAction(
): Promise<ArmLaneResult> {
try {
const settings = getSettings();
- await writeSettings({
- ...settings,
+ await saveSettings({
autoQueue: {
...settings.autoQueue,
[lane]: { ...settings.autoQueue[lane], enabled: false },
diff --git a/editor/app/operations/actions.ts b/editor/app/operations/actions.ts
@@ -3,9 +3,9 @@
import { revalidatePath } from "next/cache";
import {
getSettings,
- writeSettings,
type SiteSettings,
} from "yt-dlp-transcript-common/lib/settings";
+import { saveSettings } from "../settings/saveSettings";
import {
isGateHeld,
withGateHeld,
@@ -94,8 +94,7 @@ export async function saveAutoQueueAction(
// in slice 1.4, is exactly what "forgotten here" would now mean. `undefined`
// is carried as undefined on purpose: that is what keeps a lane that has
// never been written falling back to its retired field.
- const next: SiteSettings = {
- ...current,
+ const next: Partial<SiteSettings> = {
autoQueue: {
...current.autoQueue,
[kind]: {
@@ -110,7 +109,7 @@ export async function saveAutoQueueAction(
},
};
try {
- await writeSettings(next);
+ await saveSettings(next);
} catch (e) {
return { ok: false, error: (e as Error).message };
}
@@ -153,15 +152,14 @@ export async function snoozeAutoQueueAction(
untilMs: number | null,
): Promise<SaveResult> {
const current = getSettings();
- const next: SiteSettings = {
- ...current,
+ const next: Partial<SiteSettings> = {
autoQueue: {
...current.autoQueue,
[kind]: { ...current.autoQueue[kind], snoozeUntil: untilMs },
},
};
try {
- await writeSettings(next);
+ await saveSettings(next);
} catch (e) {
return { ok: false, error: (e as Error).message };
}
@@ -199,7 +197,7 @@ async function setLaneHeld(
try {
const cur = getSettings();
if (isGateHeld(cur, lane) !== held) {
- await writeSettings(withGateHeld(cur, lane, held));
+ await saveSettings({ autoQueue: withGateHeld(cur, lane, held).autoQueue });
}
} catch (e) {
// REPORTED, not swallowed. The workers page's old best-effort persist
diff --git a/editor/app/operations/settingsActions.ts b/editor/app/operations/settingsActions.ts
@@ -6,7 +6,9 @@
// `*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.
+// 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.
@@ -14,9 +16,9 @@
import { revalidatePath } from "next/cache";
import {
getSettings,
- writeSettings,
type SiteSettings,
} from "yt-dlp-transcript-common/lib/settings";
+import { saveSettings } from "../settings/saveSettings";
import {
isDigestSectionKind,
isDigestTimestampMode,
@@ -62,7 +64,7 @@ export async function saveDigestSettingsAction(
return { ok: false, error: "Digest app config payload is malformed" };
}
}
- // Filtered to KNOWN kinds here rather than leaning on writeSettings' sanitizer.
+ // 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.
@@ -73,8 +75,7 @@ export async function saveDigestSettingsAction(
const digestTimestampModeRaw = String(
formData.get("digestTimestampMode") ?? "",
).trim();
- const next: SiteSettings = {
- ...current,
+ const next: Partial<SiteSettings> = {
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:
@@ -119,7 +120,7 @@ export async function saveDigestSettingsAction(
},
};
try {
- await writeSettings(next);
+ await saveSettings(next);
} catch (e) {
return { ok: false, error: (e as Error).message };
}
@@ -139,8 +140,7 @@ export async function saveDiarizationSettingsAction(
): Promise<SaveResult> {
const current = getSettings();
const dDiar = current.diarization;
- const next: SiteSettings = {
- ...current,
+ const next: Partial<SiteSettings> = {
diarization: {
...dDiar,
enabled: formData.get("diarizationEnabled") === "on",
@@ -179,7 +179,7 @@ export async function saveDiarizationSettingsAction(
},
};
try {
- await writeSettings(next);
+ await saveSettings(next);
} catch (e) {
return { ok: false, error: (e as Error).message };
}
@@ -210,20 +210,20 @@ export async function saveBackfillLaneSettingsAction(
): Promise<SaveResult> {
const current = getSettings();
const dBack = current.backfill;
- const next: SiteSettings = withGateHeld(
- {
- ...current,
- backfill: {
- ...dBack,
- concurrency: num(formData, "backfillConcurrency", dBack.concurrency),
- allowRedownload: formData.get("backfillAllowRedownload") === "on",
- },
+ const next: Partial<SiteSettings> = {
+ backfill: {
+ ...dBack,
+ concurrency: num(formData, "backfillConcurrency", dBack.concurrency),
+ allowRedownload: formData.get("backfillAllowRedownload") === "on",
},
- "backfill",
- formData.get("backfillEnabled") !== "on",
- );
+ autoQueue: withGateHeld(
+ current,
+ "backfill",
+ formData.get("backfillEnabled") !== "on",
+ ).autoQueue,
+ };
try {
- await writeSettings(next);
+ await saveSettings(next);
} catch (e) {
return { ok: false, error: (e as Error).message };
}
@@ -245,8 +245,7 @@ export async function saveAttributionSettingsAction(
): Promise<SaveResult> {
const current = getSettings();
const dAttr = current.attribution;
- const next: SiteSettings = {
- ...current,
+ const next: Partial<SiteSettings> = {
attribution: {
...dAttr,
enabled: formData.get("attributionEnabled") === "on",
@@ -265,7 +264,7 @@ export async function saveAttributionSettingsAction(
},
};
try {
- await writeSettings(next);
+ await saveSettings(next);
} catch (e) {
return { ok: false, error: (e as Error).message };
}
diff --git a/editor/app/saved-videos/backupActions.ts b/editor/app/saved-videos/backupActions.ts
@@ -2,7 +2,8 @@
import { revalidatePath } from "next/cache";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { getSettings, writeSettings } from "yt-dlp-transcript-common/lib/settings";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { saveSettings } from "../settings/saveSettings";
import { SYNC_INTERVAL_MAX_MINUTES } from "yt-dlp-transcript-common/lib/channelConfig";
import {
backupSavedVideos,
@@ -101,8 +102,7 @@ export async function saveSavedVideoBackupAction(
}
const settings = getSettings();
try {
- await writeSettings({
- ...settings,
+ await saveSettings({
savedVideoBackup: {
enabled,
dest,
diff --git a/editor/app/scheduler/actions.ts b/editor/app/scheduler/actions.ts
@@ -13,9 +13,9 @@ import {
} from "yt-dlp-transcript-common/lib/channelConfig";
import {
getSettings,
- writeSettings,
type SiteSettings,
} from "yt-dlp-transcript-common/lib/settings";
+import { saveSettings } from "../settings/saveSettings";
import { DURATION_KEEP } from "yt-dlp-transcript-common/lib/duration";
export type SaveResult = { ok: true } | { ok: false; error: string };
@@ -114,7 +114,7 @@ export async function setChannelCadencesAction(
// block is the sync operation's settings form now, on the sync operation's
// page, and this is what saves it.
//
-// Values are clamped/sanitized by sanitizeSyncScheduler inside writeSettings,
+// Values are clamped/sanitized by sanitizeSyncScheduler when saveSettings writes,
// so we only coerce here.
export async function saveSchedulerSettingsAction(
_prev: SaveResult | undefined,
@@ -143,8 +143,7 @@ export async function saveSchedulerSettingsAction(
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) ? n : null;
};
- const next: SiteSettings = {
- ...current,
+ const next: Partial<SiteSettings> = {
syncScheduler: {
...current.syncScheduler,
enabled: formData.get("syncSchedulerEnabled") === "on",
@@ -189,7 +188,7 @@ export async function saveSchedulerSettingsAction(
},
};
try {
- await writeSettings(next);
+ await saveSettings(next);
} catch (e) {
return { ok: false, error: (e as Error).message };
}
diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts
@@ -8,7 +8,6 @@ import {
defaultBuildPipeline,
isBuildMode,
isReportDebouncePreset,
- getSettings,
MIN_FREE_DISK_GB_MAX,
RESUME_MARGIN_GB_DEFAULT,
RESUME_MARGIN_GB_MAX,
@@ -18,11 +17,10 @@ import {
SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS,
TRANSCRIPT_PAGE_HARD_CAP_BYTES,
TRANSCRIPT_PAGE_MIN_BYTES,
- writeSettings,
type SiteSettings,
type SocialLink,
} from "yt-dlp-transcript-common/lib/settings";
-import { DEFAULT_TRANSCRIPTION_APP_ID } from "yt-dlp-transcript-common/lib/transcriptionApps";
+import { saveSettings } from "./saveSettings";
import {
DEFAULT_COOKIE_MODE,
isCookieMode,
@@ -182,7 +180,7 @@ export async function saveSettingsAction(
}
// Build pipeline. Values are clamped/coerced by sanitizeBuildPipeline inside
- // writeSettings, so we only read the form here (NaN/blank → default). The
+ // the settings schema on save, so we only read the form here (NaN/blank → default). The
// deploy-page toggle also writes `mode`; whichever saves last wins.
const dB = defaultBuildPipeline();
const buildModeRaw = String(formData.get("buildMode") ?? "").trim();
@@ -198,22 +196,25 @@ export async function saveSettingsAction(
String(formData.get("dockerfile") ?? "").trim() || dB.dockerfile,
};
- const next: SiteSettings = {
+ // ONLY WHAT THIS FORM EDITS. saveSettings merges the patch over the stored
+ // file, so every block edited elsewhere — workers (/workers), syncScheduler
+ // (/operations/sync), autoQueue and channelPriority (the operation pages and
+ // /channels), savedVideoBackup (/saved-videos), storage (/storage) and the
+ // four operation blocks (/operations/<id>) — survives a save here without
+ // being read and re-listed. Before slice 4a this literal named all 31 fields
+ // and carried each foreign block through `getSettings()`, which is how an
+ // unrelated save once disarmed a sweep.
+ const next: Partial<SiteSettings> = {
adminTitle,
maxTranscriptPageBytes: parsed,
- // Workers are the source of truth; writeSettings derives the deprecated
- // transcriptionApp/transcriptionApps shadow from them. parallelTranscriptions
- // is vestigial (kept only for rollback) so pass the default.
- transcriptionApp: DEFAULT_TRANSCRIPTION_APP_ID,
- transcriptionApps: {},
- // workers: edited on /workers, beside the live list, by saveWorkersAction.
- workers: getSettings().workers,
cookiesFromBrowser,
cookieMode,
sleepBetweenDownloadsSeconds: sleepParsed,
downloadFormat,
minFreeDiskGB: minFreeDiskParsed,
resumeMarginGB: resumeMarginParsed,
+ // Vestigial (the worker list is the parallelism; kept only for rollback),
+ // and this form has always reset it to the default.
parallelTranscriptions: PARALLEL_TRANSCRIPTIONS_DEFAULT,
inlineTranscribeOnFallback,
skipLiveDownloads,
@@ -222,43 +223,12 @@ export async function saveSettingsAction(
archiveStorage,
reportDebouncePreset,
autoRefreshIntervalSeconds: autoRefreshParsed,
- // syncScheduler: edited on /operations/sync, below the schedule, by
- // saveSchedulerSettingsAction.
- syncScheduler: getSettings().syncScheduler,
- // Preserve the existing auto-queue policy on an unrelated settings save
- // (this form doesn't edit it; the Auto-queue page does). writeSettings
- // re-sanitizes it regardless.
- autoQueue: getSettings().autoQueue,
- // Preserved for the same reason, and for one more: it is the SOURCE the
- // four roots above are compiled from, so rebuilding it here would silently
- // undo a focus. Edited on /channels by saveChannelPriorityAction, which is
- // its one writer.
- channelPriority: getSettings().channelPriority,
socialLinks,
homepageUrl,
- // Preserve the saved-video backup config on an unrelated settings save (the
- // Saved Videos page edits it). writeSettings re-sanitizes it regardless.
- savedVideoBackup: getSettings().savedVideoBackup,
- // NO LONGER THIS FORM'S. The storage block became a list of named
- // locations edited on /storage; preserved here the way autoQueue and
- // channelPriority are, so an unrelated settings save cannot erase it.
- // writeSettings re-sanitizes it regardless.
- storage: getSettings().storage,
buildPipeline,
- // Each edited on its own operation page; preserved here. Slice 3 moved
- // these four fieldsets to /operations/<id>, and with them the hidden
- // `*FormPresent` markers that used to gate them — a marker only existed
- // because one form saved everything, and reading an absent checkbox as
- // `false` could disarm a sweep, drop the diarization capture lane, flip
- // `allowRedownload` on, or arm ~194,000 model calls. Reading the CURRENT
- // block is now the whole protection, exactly as it is for autoQueue.
- digest: getSettings().digest,
- diarization: getSettings().diarization,
- backfill: getSettings().backfill,
- attribution: getSettings().attribution,
};
try {
- await writeSettings(next);
+ await saveSettings(next);
} catch (e) {
return { ok: false, error: (e as Error).message };
}
diff --git a/editor/app/settings/saveSettings.test.ts b/editor/app/settings/saveSettings.test.ts
@@ -0,0 +1,78 @@
+import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
+import { rm } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { test, after } from "node:test";
+import assert from "node:assert/strict";
+
+// Run with: pnpm -C editor exec tsx --test "app/**/*.test.ts"
+//
+// THE MERGE RULE of the editor's one settings writer: ONE LEVEL DEEP. An object
+// block merges over the stored block's keys; arrays and scalars replace; an
+// object nested INSIDE a block replaces. Same rule as editor/e2e/helpers.ts.
+//
+// SETTINGS SEAM as in common/lib/settingsWrite.test.ts: getPaths() memoizes, so
+// SETTINGS_FILE is set before the module under test is imported.
+
+const ROOT = mkdtempSync(path.join(os.tmpdir(), "save-settings-"));
+process.env.TRANSCRIPTS_DIR = ROOT;
+process.env.SETTINGS_FILE = path.join(ROOT, "settings.json");
+
+const { mergeSettingsPatch, saveSettings } = await import("./saveSettings");
+const { defaultSiteSettings, getSettings } = await import(
+ "yt-dlp-transcript-common/lib/settings"
+);
+
+after(() => rm(ROOT, { recursive: true, force: true }));
+
+test("an object block merges over the stored block, one level", () => {
+ const base = defaultSiteSettings();
+ base.syncScheduler.maxConcurrentSyncs = 7;
+ const out = mergeSettingsPatch(base, {
+ syncScheduler: { enabled: true } as typeof base.syncScheduler,
+ });
+ assert.equal(out.syncScheduler.enabled, true);
+ assert.equal(out.syncScheduler.maxConcurrentSyncs, 7);
+ // The input is not mutated.
+ assert.equal(base.syncScheduler.enabled, false);
+});
+
+test("arrays and scalars replace", () => {
+ const base = defaultSiteSettings();
+ base.socialLinks = [{ label: "a", url: "/a", svg: "<svg></svg>" }];
+ const out = mergeSettingsPatch(base, { socialLinks: [], adminTitle: "X" });
+ assert.deepEqual(out.socialLinks, []);
+ assert.equal(out.adminTitle, "X");
+ assert.equal(out.minFreeDiskGB, base.minFreeDiskGB);
+});
+
+test("an object nested inside a block replaces, it is not merged", () => {
+ const base = defaultSiteSettings();
+ base.digest.apps = { a: { model: "m1", numCtx: 4096 } };
+ base.channelPriority.channels = { keep: { tier: "low" } };
+ const out = mergeSettingsPatch(base, {
+ digest: { apps: { b: { model: "m2" } } } as unknown as typeof base.digest,
+ channelPriority: {
+ focus: { kind: "none" },
+ channels: { other: { tier: "paused" } },
+ },
+ });
+ assert.deepEqual(out.digest.apps, { b: { model: "m2" } });
+ assert.equal(out.digest.localAppId, base.digest.localAppId);
+ assert.deepEqual(out.channelPriority.channels, { other: { tier: "paused" } });
+});
+
+test("saveSettings writes the merged result and touches nothing else", async () => {
+ writeFileSync(
+ process.env.SETTINGS_FILE!,
+ JSON.stringify({ adminTitle: "Kept", minFreeDiskGB: 9 }),
+ );
+ await saveSettings({ minFreeDiskGB: 3, backfill: { concurrency: 4 } as never });
+ const s = getSettings();
+ assert.equal(s.adminTitle, "Kept");
+ assert.equal(s.minFreeDiskGB, 3);
+ assert.equal(s.backfill.concurrency, 4);
+ assert.equal(s.backfill.allowRedownload, false);
+ const onDisk = JSON.parse(readFileSync(process.env.SETTINGS_FILE!, "utf8"));
+ assert.equal(onDisk.adminTitle, "Kept");
+});
diff --git a/editor/app/settings/saveSettings.ts b/editor/app/settings/saveSettings.ts
@@ -0,0 +1,58 @@
+import {
+ getSettings,
+ writeSettings,
+ type SiteSettings,
+} from "yt-dlp-transcript-common/lib/settings";
+
+// THE EDITOR'S ONE SETTINGS WRITER (one-core phase 3 slice 4a).
+//
+// Every server action that changes settings.json calls `saveSettings(patch)`
+// with only what it changed; this reads the current settings, merges the
+// patch, and hands the result to `writeSettings` — which is imported by this
+// file and by no other file under editor/app.
+//
+// THE MERGE IS ONE LEVEL DEEP, and no deeper — the same rule e2e's
+// `writeSettings` helper (editor/e2e/helpers.ts) applies to the fixture:
+//
+// - a patch key whose value is a PLAIN OBJECT merges over the current block's
+// keys: `{ syncScheduler: { enabled: true } }` changes one flag and keeps the
+// rest of the scheduler;
+// - ARRAYS and SCALARS replace: `workers: []` means no workers;
+// - an object NESTED INSIDE a block replaces: `autoQueue: { digest: {…} }`
+// replaces the digest lane whole (a half-merged policy tree would be a
+// worse surprise than a replaced one), and `channelPriority: { channels }`
+// replaces the whole channel map.
+//
+// WHY A PATCH OF THE WHOLE SETTINGS OBJECT, NOT `saveSettingsBlock(block,
+// patch)` as plans/one-core.md sketched: the channel-priority action writes
+// `channelPriority` AND the four `autoQueue` roots compiled from it in ONE
+// write. A one-block signature would split that into two, and a reader between
+// them would see a priority model and trees that disagree.
+//
+// NOT a "use server" module: it is a helper the action files call, never an
+// action a client can invoke with an arbitrary patch.
+
+type Patch = Partial<SiteSettings>;
+
+function isPlainObject(v: unknown): v is Record<string, unknown> {
+ return typeof v === "object" && v !== null && !Array.isArray(v);
+}
+
+// Pure, so the merge rule is testable without a settings file.
+export function mergeSettingsPatch(
+ current: SiteSettings,
+ patch: Patch,
+): SiteSettings {
+ const merged: Record<string, unknown> = { ...current };
+ for (const [key, value] of Object.entries(patch)) {
+ if (value === undefined) continue;
+ const base = (current as Record<string, unknown>)[key];
+ merged[key] =
+ isPlainObject(value) && isPlainObject(base) ? { ...base, ...value } : value;
+ }
+ return merged as SiteSettings;
+}
+
+export async function saveSettings(patch: Patch): Promise<void> {
+ await writeSettings(mergeSettingsPatch(getSettings(), patch));
+}
diff --git a/editor/app/sites/lib/buildModeAction.ts b/editor/app/sites/lib/buildModeAction.ts
@@ -4,9 +4,9 @@ import { revalidatePath } from "next/cache";
import {
getSettings,
isBuildMode,
- writeSettings,
type BuildMode,
} from "yt-dlp-transcript-common/lib/settings";
+import { saveSettings } from "../../settings/saveSettings";
// Persist the build-mode choice (Basic vs Docker) from the family page (/sites)
// so it becomes the default for every subsequent build. Only the mode is touched
@@ -17,10 +17,8 @@ export async function setBuildModeAction(
): Promise<{ ok: boolean }> {
if (!isBuildMode(mode)) return { ok: false };
const settings = getSettings();
- await writeSettings({
- ...settings,
- buildPipeline: { ...settings.buildPipeline, mode },
- });
+ // One key of one block: saveSettings merges it over the stored pipeline.
+ await saveSettings({ buildPipeline: { ...settings.buildPipeline, mode } });
revalidatePath("/sites");
return { ok: true };
}
diff --git a/editor/app/storage/actions.ts b/editor/app/storage/actions.ts
@@ -3,10 +3,8 @@
import path from "node:path";
import { revalidatePath } from "next/cache";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import {
- getSettings,
- writeSettings,
-} from "yt-dlp-transcript-common/lib/settings";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { saveSettings } from "../settings/saveSettings";
import type { StorageLocation } from "yt-dlp-transcript-common/lib/storageLocations";
import {
mountByUuid,
@@ -105,8 +103,11 @@ export async function addStorageLocationAction(
autoRepoint: draft.autoRepoint,
};
const locations = [...settings.storage.locations, location];
- await writeSettings({
- ...settings,
+ // A PATCH of the storage block: saveSettings merges these keys over the
+ // stored block, so `savedVideosLocationId` — which this action does not
+ // edit — survives the save. (Before slice 4a it was rebuilt from these two
+ // keys alone, and adding or editing a location erased the store's record.)
+ await saveSettings({
storage: {
locations,
// The FIRST location is the default whether or not the box was ticked:
@@ -152,8 +153,8 @@ export async function editStorageLocationAction(
};
if (!keepVolume) delete (updated as { volume?: unknown }).volume;
- await writeSettings({
- ...settings,
+ // A patch of the storage block — `savedVideosLocationId` survives (see above).
+ await saveSettings({
storage: {
locations: settings.storage.locations.map((l) => (l.id === id ? updated : l)),
defaultLocationId: draft.makeDefault
@@ -214,8 +215,8 @@ export async function deleteStorageLocationAction(
};
}
const locations = settings.storage.locations.filter((l) => l.id !== id);
- await writeSettings({
- ...settings,
+ // A patch of the storage block — `savedVideosLocationId` survives (see above).
+ await saveSettings({
storage: {
locations,
defaultLocationId:
diff --git a/editor/app/workers/actions.ts b/editor/app/workers/actions.ts
@@ -3,7 +3,7 @@
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 { saveSettings } from "../settings/saveSettings";
import {
sanitizeWorkers,
validateWorkers,
@@ -38,7 +38,7 @@ export async function saveWorkersAction(
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.
+ // Sanitize + validate here for a friendly error; writeSettings re-validates on save.
let workersInput: unknown;
try {
workersInput = JSON.parse(String(formData.get("workersJson") ?? "[]"));
@@ -50,7 +50,7 @@ export async function saveWorkersAction(
if (workersErr) return { ok: false, error: workersErr };
try {
- await writeSettings({ ...getSettings(), workers });
+ await saveSettings({ workers });
} catch (e) {
return { ok: false, error: (e as Error).message };
}