import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig"; // THE INVERSE OF parseChannelForm, and it exists for exactly one caller: // /api/ops/channel-config. // // WHY A ROUND TRIP THROUGH FormData rather than a direct config write. The form // parser is where a download-filter regex is refused, where a sync cadence is // bounded and where a cleared field is turned into an absent key — and // `updateChannelAction` DELETES every CHANNEL_FORM_FIELDS key from the stored // config before layering the parse result on top, so that a cleared input // actually clears. Feed it a FormData carrying only a patch and it would clear // everything the patch did not name. So the patch is applied ON TOP of the // channel's current form representation, which is what this builds, and the // action then behaves EXACTLY as it does for the browser form. One validator, // one writer, one set of error sentences. // // Every key below is a field name `parseChannelForm` reads. Checkbox fields // follow the browser: present means checked, absent means unchecked — which is // why they are emitted only when true. // The checkbox-shaped fields (presence, not value). export const CHANNEL_FORM_FLAGS = [ "keepSourceVideo", "downloadFilterIncludeLivestreams", "audioCheckEnabled", "audioCheckResumeDuringProbe", ] as const; // The value-shaped fields. A patch may set any of these to "" to clear it. export const CHANNEL_FORM_VALUES = [ "name", "handling", "url", "platform", "sourceKind", "postFetcher", "socialHandle", "audioFormat", "downloadFormat", "sourceVideoQuality", "keepLatest", "extractionMode", "savedVideosDir", "ytdlpExtraArgs", "sleepBetweenDownloadsSeconds", "syncIntervalMinutes", "fullSweepIntervalMinutes", "cookiesFromBrowser", "cookieMode", "downloadFilterInclude", "downloadFilterExclude", "downloadFilterRejectedLivestreams", "recordedDateTitlePattern", "audioCheckIntervalSeconds", "audioCheckMaxRollbacks", "audioCheckCopyTimeoutSeconds", ] as const; export type ChannelFormFlag = (typeof CHANNEL_FORM_FLAGS)[number]; export type ChannelFormValue = (typeof CHANNEL_FORM_VALUES)[number]; export type ChannelFormField = ChannelFormFlag | ChannelFormValue; export const CHANNEL_FORM_FIELD_NAMES: readonly ChannelFormField[] = [ ...CHANNEL_FORM_VALUES, ...CHANNEL_FORM_FLAGS, ]; function put(fd: FormData, key: string, value: string | undefined | null): void { if (value === undefined || value === null || value === "") return; fd.set(key, value); } function putNum(fd: FormData, key: string, value: number | undefined): void { if (value === undefined || value === null) return; fd.set(key, String(value)); } // The channel's stored config as the Configure form would post it. export function channelConfigToFormData(config: ChannelConfig): FormData { const fd = new FormData(); // Both are REQUIRED by parseChannelForm and are not CHANNEL_FORM_FIELDS, so // they always travel. fd.set("name", config.name ?? ""); fd.set("handling", config.handling ?? "transcribe"); put(fd, "url", config.url); put(fd, "platform", config.platform); put(fd, "sourceKind", config.sourceKind); put(fd, "postFetcher", config.postFetcher); put(fd, "socialHandle", config.socialHandle); put(fd, "audioFormat", config.audioFormat); put(fd, "downloadFormat", config.downloadFormat); put(fd, "sourceVideoQuality", config.sourceVideoQuality); if (config.keepSourceVideo) fd.set("keepSourceVideo", "on"); putNum(fd, "keepLatest", config.keepLatest); put(fd, "extractionMode", config.extractionMode); put(fd, "savedVideosDir", config.savedVideosDir); if (config.ytdlpExtraArgs?.length) { fd.set("ytdlpExtraArgs", config.ytdlpExtraArgs.join("\n")); } putNum(fd, "sleepBetweenDownloadsSeconds", config.sleepBetweenDownloadsSeconds); putNum(fd, "syncIntervalMinutes", config.syncIntervalMinutes); putNum(fd, "fullSweepIntervalMinutes", config.fullSweepIntervalMinutes); put(fd, "cookiesFromBrowser", config.cookiesFromBrowser); put(fd, "cookieMode", config.cookieMode); put(fd, "downloadFilterInclude", config.downloadFilter?.include); put(fd, "downloadFilterExclude", config.downloadFilter?.exclude); if (config.downloadFilter?.includeLivestreams) { fd.set("downloadFilterIncludeLivestreams", "on"); } // Omitted when it is "skip" — the parser reads an absent field as "skip", so // emitting it would be a value with no effect, and a patch clearing it ("") // has to land on the same default the form's own select does. put( fd, "downloadFilterRejectedLivestreams", config.downloadFilter?.rejectedLivestreams, ); put(fd, "recordedDateTitlePattern", config.recordedDate?.titlePattern); if (config.audioCheck?.enabled) { fd.set("audioCheckEnabled", "on"); putNum(fd, "audioCheckIntervalSeconds", config.audioCheck.intervalSeconds); putNum(fd, "audioCheckMaxRollbacks", config.audioCheck.maxRollbacks); putNum( fd, "audioCheckCopyTimeoutSeconds", config.audioCheck.copyTimeoutSeconds, ); if (config.audioCheck.resumeDuringProbe) { fd.set("audioCheckResumeDuringProbe", "on"); } } return fd; } export type ChannelConfigPatch = Record; // Lay a JSON patch over a FormData built above. Values: a string (or number, // stringified) sets the field, `""` clears it; a flag takes a boolean. // `ytdlpExtraArgs` additionally accepts an array of lines, because that is the // shape it has in config.json and a caller should not have to know the textarea // is newline-separated. // // Throws on a key this form has no field for — the allow-list IS the documented // body shape, and a misspelled key that silently did nothing would look like a // successful save. export function applyChannelFormPatch( fd: FormData, patch: ChannelConfigPatch, ): void { validateChannelFormPatch(patch); for (const [key, value] of Object.entries(patch)) { if ((CHANNEL_FORM_FLAGS as readonly string[]).includes(key)) { if (value) fd.set(key, "on"); else fd.delete(key); continue; } if (key === "ytdlpExtraArgs" && Array.isArray(value)) { const joined = (value as string[]).join("\n"); if (joined.trim()) fd.set(key, joined); else fd.delete(key); continue; } if (value === null || value === "") { fd.delete(key); continue; } fd.set(key, typeof value === "number" ? String(value) : (value as string)); } } // SHAPE ONLY, AND SEPARATE ON PURPOSE: a misspelled field name is a fact about // the REQUEST, so the route checks it before it looks the channel up. Otherwise // `{ slug: "typo", patch: { downloadFilterExcluded: … } }` reports the channel // and never mentions the key that was actually wrong. export function validateChannelFormPatch(patch: ChannelConfigPatch): void { for (const [key, value] of Object.entries(patch)) { if ((CHANNEL_FORM_FLAGS as readonly string[]).includes(key)) { if (typeof value !== "boolean") { throw new Error(`"${key}" must be a boolean`); } continue; } if (!(CHANNEL_FORM_VALUES as readonly string[]).includes(key)) { throw new Error( `"${key}" is not a channel config field — accepted: ${CHANNEL_FORM_FIELD_NAMES.join(", ")}`, ); } if (key === "ytdlpExtraArgs" && Array.isArray(value)) { if (value.some((v) => typeof v !== "string")) { throw new Error(`"${key}" must be an array of strings`); } continue; } if (value === null || typeof value === "number" || typeof value === "string") { continue; } throw new Error(`"${key}" must be a string, a number, or "" to clear it`); } }