import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig"; import { AUDIO_CHECK_COPY_TIMEOUT_MAX_SECONDS, AUDIO_CHECK_COPY_TIMEOUT_MIN_SECONDS, AUDIO_CHECK_INTERVAL_MAX_SECONDS, AUDIO_CHECK_INTERVAL_MIN_SECONDS, AUDIO_CHECK_MAX_ROLLBACKS_MAX, AUDIO_CHECK_MAX_ROLLBACKS_MIN, KEEP_LATEST_MAX, SYNC_INTERVAL_MAX_MINUTES, SYNC_INTERVAL_MIN_MINUTES, type AudioCheckConfig, type ExtractionMode, } from "yt-dlp-transcript-common/lib/channelConfig"; import { PLATFORM_VALUES, detectPlatform, isSocialPlatform, type Platform, } from "yt-dlp-transcript-common/lib/platform"; import { handleFromAccountUrl } from "yt-dlp-transcript-common/social/fetchers"; import { isCookieMode } from "yt-dlp-transcript-common/lib/cookiePolicy"; import { isDownloadFormatPreset, isSourceVideoQuality, } from "yt-dlp-transcript-common/ytdlp/downloadFormat"; import { downloadFilterPatternProblem } from "yt-dlp-transcript-common/lib/downloadFilters"; import { recordedDatePatternProblem } from "yt-dlp-transcript-common/lib/recordedDate"; import { isRejectedLivestreamMode } from "yt-dlp-transcript-common/lib/channelConfig"; export type ParsedChannelForm = { name: string; slug: string; config: ChannelConfig; }; // ChannelConfig keys the form authoritatively controls. updateChannelAction // clears these from the existing config before layering parsed.config on top, // so a cleared input actually clears the stored value instead of inheriting // the previous one. New form fields just get added here. export const CHANNEL_FORM_FIELDS = [ "sourceKind", "postFetcher", "socialHandle", "platform", "url", "audioFormat", "downloadFormat", "sourceVideoQuality", "keepSourceVideo", "keepLatest", "extractionMode", "savedVideosDir", "ytdlpExtraArgs", "syncIntervalMinutes", "fullSweepIntervalMinutes", "sleepBetweenDownloadsSeconds", "cookiesFromBrowser", "cookieMode", "audioCheck", "downloadFilter", "recordedDate", ] as const satisfies ReadonlyArray; // Maps FormData -> ChannelConfig. Throws on invalid input so the server // action can render an error message. export function parseChannelForm(formData: FormData): ParsedChannelForm { const name = String(formData.get("name") ?? "").trim(); if (!name) throw new Error("Name is required"); const handlingRaw = String(formData.get("handling") ?? ""); if (handlingRaw !== "youtube" && handlingRaw !== "transcribe") { throw new Error("Handling must be 'youtube' or 'transcribe'"); } const slug = String(formData.get("slug") ?? "").trim(); const url = stringOrUndef(formData, "url"); const platformRaw = String(formData.get("platform") ?? ""); const platform: Platform | undefined = PLATFORM_VALUES.includes( platformRaw as Platform, ) ? (platformRaw as Platform) : (detectPlatform(url) ?? undefined); // Source kind. A social channel is ingested into the posts corpus instead of // the video pipeline; the form hides every video-only control for it. An // explicit "social" wins, otherwise infer from the platform so a social URL // pasted into the form does the right thing without a hidden field. const sourceKindRaw = String(formData.get("sourceKind") ?? "").trim(); const sourceKind: ChannelConfig["sourceKind"] | undefined = sourceKindRaw === "social" || (!sourceKindRaw && isSocialPlatform(platform)) ? "social" : sourceKindRaw === "video" ? "video" : undefined; const postFetcher = sourceKind === "social" ? stringOrUndef(formData, "postFetcher") : undefined; const socialHandleRaw = sourceKind === "social" ? stringOrUndef(formData, "socialHandle") : undefined; const socialHandle = socialHandleRaw?.replace(/^@/, "") || (sourceKind === "social" && url ? (handleFromAccountUrl(url) ?? undefined) : undefined); if (sourceKind === "social" && !socialHandle) { throw new Error( "A social source needs an account handle (could not derive one from the URL)", ); } const audioFormatRaw = String(formData.get("audioFormat") ?? ""); const audioFormat: ChannelConfig["audioFormat"] | undefined = audioFormatRaw === "m4a" || audioFormatRaw === "mp3" || audioFormatRaw === "opus" ? audioFormatRaw : undefined; const downloadFormatRaw = String(formData.get("downloadFormat") ?? ""); const downloadFormat: ChannelConfig["downloadFormat"] | undefined = isDownloadFormatPreset(downloadFormatRaw) ? downloadFormatRaw : undefined; // Blank = inherit the global sourceVideoQuality (omit the key). const sourceVideoQualityRaw = String(formData.get("sourceVideoQuality") ?? ""); const sourceVideoQuality: ChannelConfig["sourceVideoQuality"] | undefined = isSourceVideoQuality(sourceVideoQualityRaw) ? sourceVideoQualityRaw : undefined; const keepSourceVideo = formData.get("keepSourceVideo") != null; // Keep-latest retention/persistence window. Blank = inherit-off (omit), so a // cleared input clears the stored value. "0" is the explicit "disabled" // sentinel; any positive value is the window size. const keepLatestRaw = String(formData.get("keepLatest") ?? "").trim(); let keepLatest: number | undefined; if (keepLatestRaw) { const n = Number.parseInt(keepLatestRaw, 10); if (!Number.isFinite(n) || n < 0 || n > KEEP_LATEST_MAX) { throw new Error(`Keep latest must be 0 (off) or 1–${KEEP_LATEST_MAX}`); } keepLatest = n; } // Audio-extraction owner. "" = inherit the default ("ytdlp"); otherwise an // explicit mode. Persisting a source video forces "app" at download time // regardless of this setting. const extractionModeRaw = String(formData.get("extractionMode") ?? "").trim(); const extractionMode: ExtractionMode | undefined = extractionModeRaw === "ytdlp" || extractionModeRaw === "app" ? extractionModeRaw : undefined; const savedVideosDir = stringOrUndef(formData, "savedVideosDir"); const argsBlob = String(formData.get("ytdlpExtraArgs") ?? "").trim(); const ytdlpExtraArgs = argsBlob ? argsBlob .split("\n") .map((s) => s.trim()) .filter(Boolean) : undefined; const sleepRaw = String( formData.get("sleepBetweenDownloadsSeconds") ?? "", ).trim(); let sleepBetweenDownloadsSeconds: number | undefined; if (sleepRaw) { const n = Number.parseInt(sleepRaw, 10); if (!Number.isFinite(n) || n < 0 || n > 600) { throw new Error( "Sleep between managed downloads must be 0–600 seconds", ); } sleepBetweenDownloadsSeconds = n; } // Auto-sync cadence. The select emits "" for "inherit global default" // (absent from config), "0" for "off", or a positive minute count. const syncIntervalRaw = String( formData.get("syncIntervalMinutes") ?? "", ).trim(); let syncIntervalMinutes: number | undefined; if (syncIntervalRaw) { const n = Number.parseInt(syncIntervalRaw, 10); if ( !Number.isFinite(n) || n < 0 || (n !== 0 && (n < SYNC_INTERVAL_MIN_MINUTES || n > SYNC_INTERVAL_MAX_MINUTES)) ) { throw new Error( `Auto-sync interval must be 0 (off) or ${SYNC_INTERVAL_MIN_MINUTES}–${SYNC_INTERVAL_MAX_MINUTES} minutes`, ); } syncIntervalMinutes = n; } // Full-sweep cadence — same three-state shape as the auto-sync interval // above: "" inherits the global default, "0" disables the sweep for this // channel, anything else is a concrete minute count. const fullSweepRaw = String( formData.get("fullSweepIntervalMinutes") ?? "", ).trim(); let fullSweepIntervalMinutes: number | undefined; if (fullSweepRaw) { const n = Number.parseInt(fullSweepRaw, 10); if ( !Number.isFinite(n) || n < 0 || (n !== 0 && (n < SYNC_INTERVAL_MIN_MINUTES || n > SYNC_INTERVAL_MAX_MINUTES)) ) { throw new Error( `Full sweep interval must be 0 (off) or ${SYNC_INTERVAL_MIN_MINUTES}–${SYNC_INTERVAL_MAX_MINUTES} minutes`, ); } fullSweepIntervalMinutes = n; } // Cookie overrides. Blank value / "Inherit global" mode = omit, so a cleared // input actually clears the stored override (see CHANNEL_FORM_FIELDS). const cookiesFromBrowser = stringOrUndef(formData, "cookiesFromBrowser"); const cookieModeRaw = String(formData.get("cookieMode") ?? "").trim(); const cookieMode = isCookieMode(cookieModeRaw) ? cookieModeRaw : undefined; // Download filter. Both patterns are regex SOURCES compiled with "i" at // download time, so the form is the place that refuses an unparseable one: // a bad pattern saved here would make the filter silently inert on every // download, and the operator would see a channel that downloads everything // with no explanation. Blank = omit, so clearing a field clears the stored // pattern (see CHANNEL_FORM_FIELDS). const downloadFilterInclude = stringOrUndef(formData, "downloadFilterInclude"); const downloadFilterExclude = stringOrUndef(formData, "downloadFilterExclude"); for (const [label, pattern] of [ ["include", downloadFilterInclude], ["exclude", downloadFilterExclude], ] as const) { if (!pattern) continue; try { new RegExp(pattern, "i"); } catch (e) { throw new Error( `Download filter ${label} is not a valid regular expression: ${ (e as Error).message }`, ); } // Valid is not the same as safe. This pattern runs over every stored // title+description on every snapshot, every sync page and every render of // this form, on the server's single thread — a catastrophically // backtracking one does not fail a request, it hangs the editor. const problem = downloadFilterPatternProblem(pattern); if (problem) { throw new Error(`Download filter ${label} ${problem}`); } } const includeLivestreams = formData.get("downloadFilterIncludeLivestreams") != null; // WHAT TO DO WITH A REJECTED LIVESTREAM. Only ever stored alongside a real // filter and only when it is not the default — with nothing rejecting, the // mode names a decision that can never be taken, and a stored "skip" would be // a key on disk that changes nothing. const rejectedLivestreamsRaw = String( formData.get("downloadFilterRejectedLivestreams") ?? "", ).trim(); if ( rejectedLivestreamsRaw && !isRejectedLivestreamMode(rejectedLivestreamsRaw) ) { throw new Error( `Filtered-out livestreams must be "skip" or "chat-only" (got ${JSON.stringify( rejectedLivestreamsRaw, )})`, ); } const rejectedLivestreams = isRejectedLivestreamMode(rejectedLivestreamsRaw) ? rejectedLivestreamsRaw : "skip"; const downloadFilter: ChannelConfig["downloadFilter"] | undefined = downloadFilterInclude || downloadFilterExclude || includeLivestreams ? { ...(downloadFilterInclude ? { include: downloadFilterInclude } : {}), ...(downloadFilterExclude ? { exclude: downloadFilterExclude } : {}), ...(includeLivestreams ? { includeLivestreams: true } : {}), ...(rejectedLivestreams !== "skip" ? { rejectedLivestreams } : {}), } : undefined; // A VOD mirror's recorded date (release 20 D1, lib/recordedDate.ts): a regex // with named groups year/month/day, refused here — as the download filter's // are — rather than saved and silently dropped by the config reader. const recordedDateTitlePattern = stringOrUndef(formData, "recordedDateTitlePattern"); if (recordedDateTitlePattern) { const problem = recordedDatePatternProblem(recordedDateTitlePattern); if (problem) throw new Error(`Recorded-date pattern ${problem}`); } const audioCheckEnabled = formData.get("audioCheckEnabled") != null; let audioCheck: AudioCheckConfig | undefined; if (audioCheckEnabled) { audioCheck = { enabled: true }; const intervalRaw = String( formData.get("audioCheckIntervalSeconds") ?? "", ).trim(); if (intervalRaw) { const n = Number.parseInt(intervalRaw, 10); if ( !Number.isFinite(n) || n < AUDIO_CHECK_INTERVAL_MIN_SECONDS || n > AUDIO_CHECK_INTERVAL_MAX_SECONDS ) { throw new Error( `Audio-check interval must be ${AUDIO_CHECK_INTERVAL_MIN_SECONDS}–${AUDIO_CHECK_INTERVAL_MAX_SECONDS} seconds`, ); } audioCheck.intervalSeconds = n; } const rollbacksRaw = String( formData.get("audioCheckMaxRollbacks") ?? "", ).trim(); if (rollbacksRaw) { const n = Number.parseInt(rollbacksRaw, 10); if ( !Number.isFinite(n) || n < AUDIO_CHECK_MAX_ROLLBACKS_MIN || n > AUDIO_CHECK_MAX_ROLLBACKS_MAX ) { throw new Error( `Audio-check max rollbacks must be ${AUDIO_CHECK_MAX_ROLLBACKS_MIN}–${AUDIO_CHECK_MAX_ROLLBACKS_MAX}`, ); } audioCheck.maxRollbacks = n; } const copyTimeoutRaw = String( formData.get("audioCheckCopyTimeoutSeconds") ?? "", ).trim(); if (copyTimeoutRaw) { const n = Number.parseInt(copyTimeoutRaw, 10); if ( !Number.isFinite(n) || n < AUDIO_CHECK_COPY_TIMEOUT_MIN_SECONDS || n > AUDIO_CHECK_COPY_TIMEOUT_MAX_SECONDS ) { throw new Error( `Audio-check copy timeout must be ${AUDIO_CHECK_COPY_TIMEOUT_MIN_SECONDS}–${AUDIO_CHECK_COPY_TIMEOUT_MAX_SECONDS} seconds`, ); } audioCheck.copyTimeoutSeconds = n; } // Opt-out: checked = legacy resume-immediately. Absent (unchecked) leaves // the field off, which defaults to the safer pause-during-probe behavior. if (formData.get("audioCheckResumeDuringProbe") != null) { audioCheck.resumeDuringProbe = true; } } const config: ChannelConfig = { handling: handlingRaw, name, }; if (sourceKind) config.sourceKind = sourceKind; if (postFetcher) config.postFetcher = postFetcher; if (socialHandle) config.socialHandle = socialHandle; if (platform) config.platform = platform; if (url) config.url = url; if (audioFormat) config.audioFormat = audioFormat; if (downloadFormat) config.downloadFormat = downloadFormat; if (sourceVideoQuality) config.sourceVideoQuality = sourceVideoQuality; if (keepSourceVideo) config.keepSourceVideo = true; if (keepLatest != null) config.keepLatest = keepLatest; if (extractionMode) config.extractionMode = extractionMode; if (savedVideosDir) config.savedVideosDir = savedVideosDir; if (ytdlpExtraArgs?.length) config.ytdlpExtraArgs = ytdlpExtraArgs; if (syncIntervalMinutes != null) { config.syncIntervalMinutes = syncIntervalMinutes; } if (fullSweepIntervalMinutes != null) { config.fullSweepIntervalMinutes = fullSweepIntervalMinutes; } if (sleepBetweenDownloadsSeconds != null) { config.sleepBetweenDownloadsSeconds = sleepBetweenDownloadsSeconds; } if (cookiesFromBrowser) config.cookiesFromBrowser = cookiesFromBrowser; if (cookieMode) config.cookieMode = cookieMode; if (audioCheck) config.audioCheck = audioCheck; if (downloadFilter) config.downloadFilter = downloadFilter; if (recordedDateTitlePattern) { config.recordedDate = { titlePattern: recordedDateTitlePattern }; } return { name, slug, config }; } function stringOrUndef(fd: FormData, key: string): string | undefined { const v = String(fd.get(key) ?? "").trim(); return v ? v : undefined; }