Archilyzer · Source

archilyzer

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

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:
Mcommon/controller/buildIndex.ts | 15++-------------
Mcommon/controller/buildStats.ts | 16+++-------------
Mcommon/controller/channels.test.ts | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/controller/channels.ts | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mcommon/controller/fetchPosts.ts | 12+++++++++---
Mcommon/controller/relocateChannelMedia.ts | 19+++++++++++++------
Mcommon/controller/renameChannel.ts | 11+++++++----
Mcommon/controller/storageLocations.ts | 12+++++-------
Mcommon/lib/jsonFile-server.ts | 25++++++++++++++++++++++---
Mcommon/ytdlp/runYtdlp.ts | 38+++++++++++++-------------------------
Meditor/app/channels/[slug]/socialActions.ts | 4++--
Meditor/app/channels/actions.ts | 39++++++++++++++++-----------------------
Meditor/app/scheduler/actions.ts | 23+++++++++++------------
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}`);