// THE CHANNEL config.json SCHEMA, as zod — the server's reader and writer. // // one-core phase 3 slice 4b, on slice 4a's pattern: every field is // `settingsField(coerce)`, and every coerce is the entry for that key in // CHANNEL_CONFIG_COERCIONS (lib/channelConfig.ts) — the ONE set of coercions. // The client-safe `parseChannelConfig` in that file composes the same functions // without zod; this module is the same list as a zod object, `.describe()`d // from CHANNEL_CONFIG_FIELD_DOCS, for the server paths // (controller/channels.ts: readChannelConfigFile, writeChannelConfig). // // WHY TWO COMPOSITIONS AND NOT ONE. channelConfig.ts is imported as VALUES by // six `"use client"` modules; a zod import there would ship zod to the browser. // So the coercions live there, zod-free, and this server-only module wraps // them (slice 4b record, deviation 2). channelConfigSchema.test.ts pins that // the two agree. // // THE OMIT RULE. A config's optional keys are OMITTED when invalid, never // defaulted: `"keepLatest" in config` means the file set it. zod emits a key // whose transform returned `undefined` when that key was PRESENT in the input, // so `stripUndefined` runs after the object parse. The nested objects // (`audioCheck`, `downloadFilter`) are built by their coercions with no // undefined members, so the strip is one level deep. // // NOT A CHANNEL → null. The object parse runs only for a value // `isChannelShape` accepts (an object with a valid `handling`). import { z } from "zod"; import { CHANNEL_CONFIG_COERCIONS, CHANNEL_CONFIG_FIELD_DOCS, isChannelShape, type ChannelConfig, } from "./channelConfig"; import { settingsField } from "./settingsFieldSchemas"; function field(key: K) { // The cast is the correlated-union case TS cannot follow through a `-?` // mapped type; the map's own declaration is what checks each entry. const coerce = CHANNEL_CONFIG_COERCIONS[key] as ( v: unknown, ) => ChannelConfig[K] | undefined; return settingsField(coerce).describe(CHANNEL_CONFIG_FIELD_DOCS[key]); } export const channelConfigObjectSchema = z.object({ handling: field("handling"), sourceKind: field("sourceKind"), postFetcher: field("postFetcher"), socialHandle: field("socialHandle"), postPagePauseSeconds: field("postPagePauseSeconds"), platform: field("platform"), name: field("name"), url: field("url"), audioFormat: field("audioFormat"), downloadFormat: field("downloadFormat"), sourceVideoQuality: field("sourceVideoQuality"), keepSourceVideo: field("keepSourceVideo"), keepLatest: field("keepLatest"), extractionMode: field("extractionMode"), savedVideosDir: field("savedVideosDir"), dataDir: field("dataDir"), mediaDir: field("mediaDir"), ytdlpExtraArgs: field("ytdlpExtraArgs"), subLangs: field("subLangs"), lastSyncedAt: field("lastSyncedAt"), lastFullDownloadAt: field("lastFullDownloadAt"), lastFullSweepAt: field("lastFullSweepAt"), excludeFromBuild: field("excludeFromBuild"), excludeFromCleanup: field("excludeFromCleanup"), syncIntervalMinutes: field("syncIntervalMinutes"), fullSweepIntervalMinutes: field("fullSweepIntervalMinutes"), skipLiveDownloads: field("skipLiveDownloads"), downloadFilter: field("downloadFilter"), cookiesFromBrowser: field("cookiesFromBrowser"), cookieMode: field("cookieMode"), sleepBetweenDownloadsSeconds: field("sleepBetweenDownloadsSeconds"), audioCheck: field("audioCheck"), recordedDate: field("recordedDate"), }); // Drop every own key whose value is `undefined` (the OMIT rule, above). export function stripUndefined(obj: T): T { const out: Record = {}; for (const [k, v] of Object.entries(obj)) if (v !== undefined) out[k] = v; return out as T; } export const channelConfigSchema: z.ZodType = z.unknown().transform((raw): ChannelConfig | null => isChannelShape(raw) ? (stripUndefined(channelConfigObjectSchema.parse(raw)) as ChannelConfig) : null, );