commit 37ad056c9128834998fc6c454cd282c6cb3c6f71
parent 96798480c38e2a1cabc124ab080ca220c3071db1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 24 Sep 2026 13:29:52 -0400
controller: one channel-config reader, a strict writer, one patcher
controller/channels.ts: readChannelConfigFile(file) (readJsonFile +
channelConfigSchema) with readChannelConfig(paths, slug) on top, signature
unchanged; writeChannelConfig parses on write and THROWS on a non-channel
shape (strict, like writeSettings); patchChannelConfig(paths, slug, patch,
{unset}) re-reads the file at write time under a per-path lock
(withJsonFileLock, new in jsonFile-server), deletes `unset`, assigns, and
writes strictly — null and no write when there is no readable config.
Repointed onto the patch: runYtdlp's three sync-state stamps
(updateConfigField deleted), fetchPosts (the stale-spread bug: a social
fetch wrote back the config it read at its start), relocateChannelMedia's
two dataDir writes ({unset:["dataDir"]} on move-back), storageLocations'
re-point and rollback (only dataDir goes back), renameChannel,
socialActions' fetcher pick, the channel form ({unset:
CHANNEL_FORM_FIELDS}), the two exclude toggles, and the scheduler's bulk
cadence save. createChannel stays on writeChannelConfig. The duplicate
readers in buildIndex and buildStats use readChannelConfigFile.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
13 files changed, 255 insertions(+), 130 deletions(-)
diff --git a/common/controller/buildIndex.ts b/common/controller/buildIndex.ts
@@ -73,10 +73,10 @@ import {
siteSubsDir,
} from "../lib/site";
import {
- parseChannelConfig,
type ChannelConfig,
type ChannelHandling,
} from "../lib/channelConfig";
+import { readChannelConfigFile } from "./channels";
import { resolveChannelGroupId } from "../lib/channelGroups";
import type { Paths } from "../lib/paths";
import {
@@ -274,17 +274,6 @@ async function exists(p: string): Promise<boolean> {
}
}
-async function readChannelConfigFile(
- dir: string,
-): Promise<ChannelConfig | null> {
- try {
- const raw = await readFile(path.join(dir, "config.json"), "utf8");
- return parseChannelConfig(JSON.parse(raw));
- } catch {
- return null;
- }
-}
-
async function scanSource(
channelsDir: string,
log: (msg: string) => void,
@@ -304,7 +293,7 @@ async function scanSource(
for (const ch of channelEntries) {
if (!ch.isDirectory()) continue;
const channelDir = path.join(channelsDir, ch.name);
- const cfg = await readChannelConfigFile(channelDir);
+ const cfg = await readChannelConfigFile(path.join(channelDir, "config.json"));
if (!cfg) {
log(`Skipping channel ${ch.name}: missing or invalid config.json`);
continue;
diff --git a/common/controller/buildStats.ts b/common/controller/buildStats.ts
@@ -35,7 +35,8 @@ import type { VideoState } from "../lib/availability";
import { loadDownloadOutcome } from "../lib/downloadOutcome-server";
import { loadTranscribeOutcome } from "../lib/transcribeOutcome-server";
import type { VideoStatus } from "../lib/stats";
-import { parseChannelConfig, type ChannelConfig } from "../lib/channelConfig";
+import type { ChannelConfig } from "../lib/channelConfig";
+import { readChannelConfigFile } from "./channels";
import type { Paths } from "../lib/paths";
import { listSites, siteStatsDir } from "../lib/site";
import {
@@ -139,17 +140,6 @@ async function resolveAcquisitionDates(
return { downloadedDate, transcribedDate };
}
-async function readChannelConfigFile(
- dir: string,
-): Promise<ChannelConfig | null> {
- try {
- const raw = await readFile(path.join(dir, "config.json"), "utf8");
- return parseChannelConfig(JSON.parse(raw));
- } catch {
- return null;
- }
-}
-
async function scanSource(
channelsDir: string,
log: (msg: string) => void,
@@ -165,7 +155,7 @@ async function scanSource(
for (const ch of channelEntries) {
if (!ch.isDirectory()) continue;
const channelDir = path.join(channelsDir, ch.name);
- const cfg = await readChannelConfigFile(channelDir);
+ const cfg = await readChannelConfigFile(path.join(channelDir, "config.json"));
if (!cfg) {
log(`Skipping channel ${ch.name}: missing or invalid config.json`);
continue;
diff --git a/common/controller/channels.test.ts b/common/controller/channels.test.ts
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import {
mkdir,
mkdtemp,
+ readFile,
rm,
stat,
symlink,
@@ -16,7 +17,14 @@ import {
relocatedDataDir,
RELOCATION_MARKER_FILENAME,
} from "../lib/channelMedia";
-import { channelExists, deleteChannel, writeChannelConfig } from "./channels";
+import {
+ channelConfigPath,
+ channelExists,
+ deleteChannel,
+ patchChannelConfig,
+ readChannelConfig,
+ writeChannelConfig,
+} from "./channels";
// Run with:
// pnpm --filter yt-dlp-transcript-common exec tsx --test controller/channels.test.ts
@@ -130,3 +138,73 @@ test("an unmounted target does not make the channel undeletable", async () => {
assert.equal(await channelExists(paths, "alpha"), false);
});
});
+
+// ---------------------------------------------------------------------------
+// One reader, one strict writer, one patcher (one-core phase 3 slice 4b).
+// ---------------------------------------------------------------------------
+
+test("patchChannelConfig sets one key and leaves the rest", async () => {
+ await withPaths(async (paths) => {
+ await writeChannelConfig(paths, "a", { ...config, lastSyncedAt: "t0", keepLatest: 3 });
+ const out = await patchChannelConfig(paths, "a", { lastSyncedAt: "t1" });
+ assert.deepEqual(out, { handling: "youtube", name: "A channel", keepLatest: 3, lastSyncedAt: "t1" });
+ assert.deepEqual(await readChannelConfig(paths, "a"), out);
+ });
+});
+
+test("patchChannelConfig unsets before it assigns", async () => {
+ await withPaths(async (paths) => {
+ await writeChannelConfig(paths, "a", { ...config, dataDir: "/x", keepLatest: 3 });
+ await patchChannelConfig(paths, "a", {}, { unset: ["dataDir"] });
+ assert.deepEqual(await readChannelConfig(paths, "a"), { ...config, keepLatest: 3 });
+ // unset + set of the same key = set.
+ await patchChannelConfig(paths, "a", { keepLatest: 5 }, { unset: ["keepLatest"] });
+ assert.equal((await readChannelConfig(paths, "a"))?.keepLatest, 5);
+ });
+});
+
+test("patchChannelConfig on a missing or non-channel file writes nothing and answers null", async () => {
+ await withPaths(async (paths) => {
+ assert.equal(await patchChannelConfig(paths, "ghost", { lastSyncedAt: "t" }), null);
+ await assert.rejects(stat(channelConfigPath(paths, "ghost")));
+ await mkdir(path.join(paths.channelsDir, "junk"), { recursive: true });
+ await writeFile(channelConfigPath(paths, "junk"), "{ torn");
+ assert.equal(await patchChannelConfig(paths, "junk", { lastSyncedAt: "t" }), null);
+ assert.equal(await readFile(channelConfigPath(paths, "junk"), "utf8"), "{ torn");
+ });
+});
+
+test("writeChannelConfig is strict: a non-channel throws, unknown keys are dropped", async () => {
+ await withPaths(async (paths) => {
+ await assert.rejects(
+ writeChannelConfig(paths, "a", { handling: "x" } as unknown as ChannelConfig),
+ /not a channel config/,
+ );
+ await assert.rejects(stat(channelConfigPath(paths, "a")));
+ await writeChannelConfig(paths, "a", {
+ ...config,
+ bogus: 1,
+ excludeFromSync: true,
+ keepLatest: -4,
+ } as unknown as ChannelConfig);
+ assert.equal(
+ await readFile(channelConfigPath(paths, "a"), "utf8"),
+ JSON.stringify(config, null, 2) + "\n",
+ );
+ });
+});
+
+test("two concurrent patches to one channel both land", async () => {
+ await withPaths(async (paths) => {
+ await writeChannelConfig(paths, "a", config);
+ await Promise.all([
+ patchChannelConfig(paths, "a", { lastSyncedAt: "sync" }),
+ patchChannelConfig(paths, "a", { keepLatest: 7 }),
+ patchChannelConfig(paths, "a", { lastFullSweepAt: "sweep" }),
+ ]);
+ const out = await readChannelConfig(paths, "a");
+ assert.equal(out?.lastSyncedAt, "sync");
+ assert.equal(out?.keepLatest, 7);
+ assert.equal(out?.lastFullSweepAt, "sweep");
+ });
+});
diff --git a/common/controller/channels.ts b/common/controller/channels.ts
@@ -1,11 +1,13 @@
-import { writeJsonAtomic } from "../lib/jsonFile-server";
import path from "node:path";
-import { readdir, readFile, rm, stat, mkdir } from "node:fs/promises";
+import { readdir, readFile, rm, stat } from "node:fs/promises";
import type { Dirent } from "node:fs";
+import type { ChannelConfig } from "../lib/channelConfig";
+import { channelConfigSchema } from "../lib/channelConfigSchema";
import {
- parseChannelConfig,
- type ChannelConfig,
-} from "../lib/channelConfig";
+ readJsonFile,
+ withJsonFileLock,
+ writeJsonAtomic,
+} from "../lib/jsonFile-server";
import type { Paths } from "../lib/paths";
import { mapConcurrent } from "../lib/concurrency";
import {
@@ -147,19 +149,31 @@ export async function countPlaylist(p: string): Promise<number | null> {
return count;
}
+// THE CHANNEL config.json READER, WRITER AND PATCHER (one-core phase 3 slice
+// 4b). The shape is lib/channelConfigSchema.ts; everything that reads or
+// writes a config.json on the server goes through these three, except the two
+// places that read the RAW file on purpose (lib/channelMedia.ts's `dataDir`
+// guard, and the legacy `group` / `excludeFromSync` migrations, which need keys
+// the schema no longer names).
+
+export function channelConfigPath(paths: Paths, slug: string): string {
+ return path.join(paths.channelsDir, slug, "config.json");
+}
+
+// One config.json by path: the parsed config, or null when the file is absent,
+// unreadable, not JSON, or not a channel. Never throws.
+export async function readChannelConfigFile(
+ file: string,
+): Promise<ChannelConfig | null> {
+ const read = await readJsonFile(file);
+ return read.ok ? channelConfigSchema.parse(read.value) : null;
+}
+
export async function readChannelConfig(
paths: Paths,
slug: string,
): Promise<ChannelConfig | null> {
- try {
- const raw = await readFile(
- path.join(paths.channelsDir, slug, "config.json"),
- "utf8",
- );
- return parseChannelConfig(JSON.parse(raw));
- } catch {
- return null;
- }
+ return readChannelConfigFile(channelConfigPath(paths, slug));
}
export async function channelExists(
@@ -451,15 +465,56 @@ export async function listChannelStatsFromSnapshots(
}));
}
+// WRITE STAYS STRICT, like writeSettings: the config is parsed on the way out
+// — which is what drops an unknown or invalid key — and a value that is not a
+// channel at all (no valid `handling`) THROWS rather than writing a file every
+// reader would then treat as absent. Every caller passes a parsed config, so
+// this only ever fires on a bug. The e2e fixtures seed their configs raw, on
+// purpose, and are unaffected.
export async function writeChannelConfig(
paths: Paths,
slug: string,
config: ChannelConfig,
): Promise<void> {
- const dir = path.join(paths.channelsDir, slug);
- await mkdir(dir, { recursive: true });
- const file = path.join(dir, "config.json");
- await writeJsonAtomic(file, config);
+ const parsed = channelConfigSchema.parse(config);
+ if (!parsed) {
+ throw new Error(
+ `Refusing to write channels/${slug}/config.json: not a channel config ` +
+ `(handling ${JSON.stringify((config as { handling?: unknown })?.handling)})`,
+ );
+ }
+ await writeJsonAtomic(channelConfigPath(paths, slug), parsed, { mkdir: true });
+}
+
+export type PatchChannelConfigOptions = {
+ // Keys to delete before the patch is applied — how a caller says "clear
+ // this override" (a key absent from `patch` is left alone).
+ unset?: ReadonlyArray<keyof ChannelConfig>;
+};
+
+// READ-MODIFY-WRITE ONE CHANNEL'S CONFIG, and the only way to change some keys
+// of it: re-read the file NOW (not a copy the caller read earlier — that was
+// the stale-spread bug, where a fetch that took minutes wrote back the config
+// it had read at its start over every edit made meanwhile), delete `unset`,
+// assign `patch`, write strictly. A channel with no readable config is not
+// created: the answer is null and nothing is written (the old sync-stamp rule).
+// Two patches to one channel in this process serialise, so both land.
+export async function patchChannelConfig(
+ paths: Paths,
+ slug: string,
+ patch: Partial<ChannelConfig>,
+ opts: PatchChannelConfigOptions = {},
+): Promise<ChannelConfig | null> {
+ const file = channelConfigPath(paths, slug);
+ return withJsonFileLock(file, async () => {
+ const current = await readChannelConfigFile(file);
+ if (!current) return null;
+ const next: ChannelConfig = { ...current };
+ for (const key of opts.unset ?? []) delete next[key];
+ Object.assign(next, patch);
+ await writeChannelConfig(paths, slug, next);
+ return channelConfigSchema.parse(next);
+ });
}
export async function createChannel(
diff --git a/common/controller/fetchPosts.ts b/common/controller/fetchPosts.ts
@@ -7,7 +7,10 @@
// pages writes nothing new.
import path from "node:path";
-import { readChannelConfig, writeChannelConfig } from "./channels";
+import {
+ patchChannelConfig,
+ readChannelConfig,
+} from "./channels";
import type { Paths } from "../lib/paths";
import { isSocialChannel } from "../lib/channelConfig";
import {
@@ -180,8 +183,11 @@ export async function fetchPosts(
// it keys only off url / syncIntervalMinutes / lastSyncedAt, plus the
// channel-priority document's `sync` tier (`isChannelPaused(model, slug,
// "sync")`), which is where the retired `excludeFromSync` flag went.
- await writeChannelConfig(paths, slug, {
- ...config,
+ //
+ // A PATCH, re-read at write time. It used to spread the `config` this fetch
+ // read at its START, so any edit made during a fetch (minutes, for a long
+ // history) was silently reverted when the stamp landed.
+ await patchChannelConfig(paths, slug, {
lastSyncedAt: new Date().toISOString(),
});
diff --git a/common/controller/relocateChannelMedia.ts b/common/controller/relocateChannelMedia.ts
@@ -48,7 +48,11 @@ import {
type RelocationMarker,
type RelocationPhase,
} from "../lib/channelMedia";
-import { readChannelConfig, writeChannelConfig } from "./channels";
+import {
+ patchChannelConfig,
+ readChannelConfig,
+ writeChannelConfig,
+} from "./channels";
// MOVE A CHANNEL'S MEDIA TO ANOTHER DRIVE, AND BACK.
//
@@ -757,9 +761,13 @@ async function moveOut(args: {
// Written only now, on success: config.dataDir is a record of what is on
// disk, never an intention. Skipped when it already says so, so a rerun
// does not rewrite a file it agrees with.
- const fresh = (await readChannelConfig(paths, slug)) ?? args.config;
- if (fresh.dataDir?.trim() !== target) {
- await writeChannelConfig(paths, slug, { ...fresh, dataDir: target });
+ const fresh = await readChannelConfig(paths, slug);
+ if (!fresh) {
+ // No readable config.json (it vanished mid-move): the job's own copy is
+ // the best record there is, and the swap has already happened.
+ await writeChannelConfig(paths, slug, { ...args.config, dataDir: target });
+ } else if (fresh.dataDir?.trim() !== target) {
+ await patchChannelConfig(paths, slug, { dataDir: target });
}
log(`Swapped: ${dataDir} -> ${target}`);
await writeMarker(paths, slug, {
@@ -975,8 +983,7 @@ async function moveBack(args: {
const fresh = await readChannelConfig(paths, slug);
if (fresh?.dataDir !== undefined) {
- const { dataDir: _dropped, ...rest } = fresh;
- await writeChannelConfig(paths, slug, rest);
+ await patchChannelConfig(paths, slug, {}, { unset: ["dataDir"] });
}
await writeMarker(paths, slug, {
target,
diff --git a/common/controller/renameChannel.ts b/common/controller/renameChannel.ts
@@ -5,7 +5,7 @@ import type { ChannelConfig } from "../lib/channelConfig";
import {
channelExists,
isValidChannelSlug,
- readChannelConfig,
+ patchChannelConfig,
writeChannelConfig,
} from "./channels";
import { savedVideoRoot } from "../lib/savedVideo";
@@ -166,11 +166,14 @@ export async function renameChannel(
relinked = true;
// Re-read: the channel dir has already moved, so this is the file that
// will actually be on disk afterwards.
- const fresh = (await readChannelConfig(paths, newSlug)) ?? config;
- await writeChannelConfig(paths, newSlug, {
- ...fresh,
+ const patched = await patchChannelConfig(paths, newSlug, {
dataDir: newTarget,
});
+ if (!patched) {
+ // No readable config.json at the new slug: write the one this rename
+ // started from, so the moved data is not left unrecorded.
+ await writeChannelConfig(paths, newSlug, { ...config, dataDir: newTarget });
+ }
} catch (err) {
// The link goes back too, and before the directory under it moves: a
// failure between the symlink and the config write left `data/` pointing
diff --git a/common/controller/storageLocations.ts b/common/controller/storageLocations.ts
@@ -33,8 +33,8 @@ import {
import type { ChannelConfig } from "../lib/channelConfig";
import {
listChannelConfigs,
+ patchChannelConfig,
readChannelConfig,
- writeChannelConfig,
} from "./channels";
import { relocationRootProblem } from "./relocateChannelMedia";
@@ -630,8 +630,9 @@ async function rollbackChannel(
entry: ChannelLedgerEntry,
): Promise<void> {
if (entry.configWritten && entry.oldConfig) {
- await writeChannelConfig(paths, entry.slug, {
- ...entry.oldConfig,
+ // Only the field this job changed goes back; anything else edited since
+ // stays. (It used to rewrite the whole config it found at the start.)
+ await patchChannelConfig(paths, entry.slug, {
dataDir: entry.oldTarget,
}).catch(() => {});
}
@@ -725,10 +726,7 @@ export async function repointStorageLocation(opts: {
await symlink(newTarget, link);
entry.relinked = true;
}
- await writeChannelConfig(opts.paths, slug, {
- ...(fresh as ChannelConfig),
- dataDir: newTarget,
- });
+ await patchChannelConfig(opts.paths, slug, { dataDir: newTarget });
entry.configWritten = true;
} catch (err) {
const step = resuming
diff --git a/common/lib/jsonFile-server.ts b/common/lib/jsonFile-server.ts
@@ -20,9 +20,8 @@
// last rename wins.
//
// The chain serialises WRITES, not read-modify-write cycles. A caller that reads,
-// changes and writes back has to hold its read inside the same critical section
-// for the read to be current — see `patchChannelConfig` in
-// controller/channels.ts, which re-reads immediately before its write.
+// changes and writes back takes `withJsonFileLock` around the whole cycle, so
+// its read is current — see `patchChannelConfig` in controller/channels.ts.
//
// BYTES ARE PRESERVED, per caller. `indent` and `newline` are options, not a
// house style, because the bytes on disk are what the phase-3 numbers diff: the
@@ -170,6 +169,26 @@ export function writeJsonAtomicSync(
}
}
+// Run `fn` with exclusive use of `file` among callers of this function in this
+// process: a READ-MODIFY-WRITE cycle that must not interleave with another one
+// on the same path (two `patchChannelConfig`s, say, each of which would
+// otherwise read the file before the other wrote it and so drop its patch).
+// Callers that only write need not take it — writes are chained anyway. Per
+// process, like the write chain.
+const locks = new Map<string, Promise<unknown>>();
+
+export function withJsonFileLock<T>(file: string, fn: () => Promise<T>): Promise<T> {
+ const key = path.resolve(file);
+ const prev = locks.get(key) ?? Promise.resolve();
+ const next = prev.then(fn, fn);
+ locks.set(key, next);
+ const release = () => {
+ if (locks.get(key) === next) locks.delete(key);
+ };
+ next.then(release, release);
+ return next;
+}
+
// For tests: how many paths currently have a write in flight.
export function pendingJsonWrites(): number {
return chains.size;
diff --git a/common/ytdlp/runYtdlp.ts b/common/ytdlp/runYtdlp.ts
@@ -1,16 +1,15 @@
-import { writeJsonAtomic } from "../lib/jsonFile-server";
import path from "node:path";
import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises";
import { execa } from "execa";
import pLimit from "p-limit";
import { readArchive } from "../lib/archive";
import {
- parseChannelConfig,
type AudioFormat,
type ChannelConfig,
type ChannelHandling,
} from "../lib/channelConfig";
import { getSettings } from "../lib/settings";
+import { patchChannelConfig } from "../controller/channels";
import { diskGate } from "../lib/diskSpace";
import { detectPlatform } from "../lib/platform";
import { isRealAudioFile } from "../lib/videoStatus";
@@ -1808,38 +1807,27 @@ async function runChildAndStream(
}
}
+// The three sync-state stamps (CHANNEL_SYNC_STATE_KEYS). Each re-reads the
+// config at the moment it writes, so an edit made while the sync ran survives;
+// a channel whose config.json is gone or unreadable is not recreated.
async function touchLastSync(opts: RunYtdlpOpts): Promise<void> {
- const file = path.join(channelRoot(opts), "config.json");
- await updateConfigField(file, "lastSyncedAt", new Date().toISOString());
+ await patchChannelConfig(opts.paths, opts.channelSlug, {
+ lastSyncedAt: new Date().toISOString(),
+ });
}
async function touchLastFullDownload(opts: RunYtdlpOpts): Promise<void> {
- const file = path.join(channelRoot(opts), "config.json");
- await updateConfigField(file, "lastFullDownloadAt", new Date().toISOString());
+ await patchChannelConfig(opts.paths, opts.channelSlug, {
+ lastFullDownloadAt: new Date().toISOString(),
+ });
}
// Stamps when this channel last paid for a full enumeration, which is what the
// cadence gate reads to keep the next N syncs on the cheap paged walk.
async function touchLastFullSweep(opts: RunYtdlpOpts): Promise<void> {
- const file = path.join(channelRoot(opts), "config.json");
- await updateConfigField(file, "lastFullSweepAt", new Date().toISOString());
-}
-
-async function updateConfigField(
- configPath: string,
- field: "lastSyncedAt" | "lastFullDownloadAt" | "lastFullSweepAt",
- value: string,
-): Promise<void> {
- let raw: string;
- try {
- raw = await readFile(configPath, "utf8");
- } catch {
- return;
- }
- const parsed = parseChannelConfig(JSON.parse(raw));
- if (!parsed) return;
- parsed[field] = value;
- await writeJsonAtomic(configPath, parsed);
+ await patchChannelConfig(opts.paths, opts.channelSlug, {
+ lastFullSweepAt: new Date().toISOString(),
+ });
}
// The channel's archive file stores yt-dlp's native extractor ids (e.g.
diff --git a/editor/app/channels/[slug]/socialActions.ts b/editor/app/channels/[slug]/socialActions.ts
@@ -14,8 +14,8 @@ import {
resolveQueueKey,
} from "yt-dlp-transcript-common/lib/queueKeys";
import {
+ patchChannelConfig,
readChannelConfig,
- writeChannelConfig,
} from "yt-dlp-transcript-common/controller/channels";
import { isSocialChannel } from "yt-dlp-transcript-common/lib/channelConfig";
import { fetchPosts } from "yt-dlp-transcript-common/controller/fetchPosts";
@@ -56,7 +56,7 @@ export async function setPostFetcherAction(
error: `${fetcher.label} handles ${fetcher.platform}, not ${config.platform}.`,
};
}
- await writeChannelConfig(paths, slug, { ...config, postFetcher: wanted });
+ await patchChannelConfig(paths, slug, { postFetcher: wanted });
revalidatePath(`/channels/${slug}`);
return { ok: true };
}
diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts
@@ -17,8 +17,8 @@ import {
deleteChannel,
isValidChannelSlug,
listChannelConfigs,
+ patchChannelConfig,
readChannelConfig,
- writeChannelConfig,
} from "yt-dlp-transcript-common/controller/channels";
import { renameChannel } from "yt-dlp-transcript-common/controller/renameChannel";
import { inspectChannelMedia } from "yt-dlp-transcript-common/lib/channelMedia";
@@ -309,14 +309,15 @@ export async function updateChannelAction(
}
// The form parser only emits keys whose form value is meaningful, so a
// cleared input is absent from `parsed.config`. A plain spread would keep
- // the stale value from `existing`. Clear every form-managed key from the
- // baseline first, then layer the parsed config — non-form fields like
- // subLangs / lastSyncedAt / lastFullDownloadAt / excludeFromBuild are
- // preserved automatically.
- const merged: typeof existing = { ...existing };
- for (const key of CHANNEL_FORM_FIELDS) delete merged[key];
- Object.assign(merged, parsed.config);
- await writeChannelConfig(paths, slug, merged);
+ // the stale value. So the patch UNSETS every form-managed key first, then
+ // layers the parsed config — non-form fields like subLangs, the sync-state
+ // stamps (CHANNEL_SYNC_STATE_KEYS) and excludeFromBuild are preserved, and
+ // are re-read at write time, so a sync that stamped lastSyncedAt while the
+ // form was open is not reverted.
+ const written = await patchChannelConfig(paths, slug, parsed.config, {
+ unset: CHANNEL_FORM_FIELDS,
+ });
+ if (!written) return { error: `Channel "${slug}" not found` };
try {
await applySiteWrites(siteWrites, paths);
} catch (e) {
@@ -464,13 +465,9 @@ export async function toggleChannelBuildInclusionAction(
const paths = getPaths();
const existing = await readChannelConfig(paths, slug);
if (!existing) return { error: `Channel "${slug}" not found` };
- const next = { ...existing };
- if (existing.excludeFromBuild) {
- delete next.excludeFromBuild;
- } else {
- next.excludeFromBuild = true;
- }
- await writeChannelConfig(paths, slug, next);
+ await (existing.excludeFromBuild
+ ? patchChannelConfig(paths, slug, {}, { unset: ["excludeFromBuild"] })
+ : patchChannelConfig(paths, slug, { excludeFromBuild: true }));
revalidatePath("/channels");
return undefined;
}
@@ -485,13 +482,9 @@ export async function toggleChannelCleanupInclusionAction(
const paths = getPaths();
const existing = await readChannelConfig(paths, slug);
if (!existing) return { error: `Channel "${slug}" not found` };
- const next = { ...existing };
- if (existing.excludeFromCleanup) {
- delete next.excludeFromCleanup;
- } else {
- next.excludeFromCleanup = true;
- }
- await writeChannelConfig(paths, slug, next);
+ await (existing.excludeFromCleanup
+ ? patchChannelConfig(paths, slug, {}, { unset: ["excludeFromCleanup"] })
+ : patchChannelConfig(paths, slug, { excludeFromCleanup: true }));
revalidatePath("/cleanup");
revalidatePath("/channels");
return undefined;
diff --git a/editor/app/scheduler/actions.ts b/editor/app/scheduler/actions.ts
@@ -2,14 +2,12 @@
import { revalidatePath } from "next/cache";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import {
- readChannelConfig,
- writeChannelConfig,
-} from "yt-dlp-transcript-common/controller/channels";
+import { patchChannelConfig } from "yt-dlp-transcript-common/controller/channels";
import { requestChannelSnapshot } from "yt-dlp-transcript-common/jobs/snapshotScheduler";
import {
SYNC_INTERVAL_MAX_MINUTES,
SYNC_INTERVAL_MIN_MINUTES,
+ type ChannelConfig,
} from "yt-dlp-transcript-common/lib/channelConfig";
import {
getSettings,
@@ -83,18 +81,19 @@ export async function setChannelCadencesAction(
const paths = getPaths();
for (const slug of slugs) {
- const existing = await readChannelConfig(paths, slug);
- if (!existing) return { ok: false, error: `Channel "${slug}" not found` };
- const next = { ...existing };
+ // `minutes: undefined` = inherit the global default = unset the override.
+ const patch: Partial<ChannelConfig> = {};
+ const unset: Array<keyof ChannelConfig> = [];
if (sync !== "keep") {
- if (sync.minutes === undefined) delete next.syncIntervalMinutes;
- else next.syncIntervalMinutes = sync.minutes;
+ if (sync.minutes === undefined) unset.push("syncIntervalMinutes");
+ else patch.syncIntervalMinutes = sync.minutes;
}
if (sweep !== "keep") {
- if (sweep.minutes === undefined) delete next.fullSweepIntervalMinutes;
- else next.fullSweepIntervalMinutes = sweep.minutes;
+ if (sweep.minutes === undefined) unset.push("fullSweepIntervalMinutes");
+ else patch.fullSweepIntervalMinutes = sweep.minutes;
}
- await writeChannelConfig(paths, slug, next);
+ const written = await patchChannelConfig(paths, slug, patch, { unset });
+ if (!written) return { ok: false, error: `Channel "${slug}" not found` };
// Keep the channel report/badges in sync with the config edit.
requestChannelSnapshot(paths, slug);
revalidatePath(`/channels/${slug}`);