// Media file naming — the shapes this app writes into a video dir, and the // predicates that recognize them. // // SPLIT OUT OF lib/videoStatus.ts because these are pure string rules with no // filesystem in them, and videoStatus imports node:fs. The editor's VideoPanel // is a client component and needs the same extension lists it used to keep a // private copy of; importing videoStatus there would drag node:fs into the // browser bundle. lib/videoStatus.ts re-exports everything here, so every // existing import site is unchanged. // VideoPanel's "can I transcribe this here?" question is audio-only: audio.mp4 // is a wrong-format cleanup target, not something to offer a transcribe button // for (editor/e2e/video-page.spec.ts pins that). export const AUDIO_EXTS: ReadonlyArray = [ "mp3", "m4a", "aac", "ogg", "opus", "wav", "flac", ]; // Video container extensions the app may keep as a persisted source video and // transcribe directly from (parakeet's stitcher ffmpeg-slices any container). // Consolidated here so VideoPanel and the transcribe fallback share one list. export const VIDEO_CONTAINER_EXTS: ReadonlyArray = [ "mp4", "webm", "mkv", "mov", "m4v", "ogv", "avi", ]; // Everything ffmpeg may hand us as a media file under one of the two managed // base names. The union is deliberate and not audio alone: a failed // extract-to-mp3 leaves audio.mp4 / audio.webm behind (12 audio.mp4 in this // corpus), and those are exactly what the wrong-format sweep exists to remove. export const MEDIA_EXTS: ReadonlyArray = [ ...AUDIO_EXTS, ...VIDEO_CONTAINER_EXTS, ]; // A persisted source video container is written under data//source-media. // (a deliberately distinct base name from audio. so it's never mistaken for // an extractable/cleanable audio file by isRealAudioFile). The keep-latest // persistence rule downloads the full video here and the app extracts audio from // it; the container is then kept (Phase 3 moves it to the saved-video store). export const SOURCE_MEDIA_BASENAME = "source-media"; const MEDIA_EXT_ALT = MEDIA_EXTS.join("|"); // THE SHAPE IS THE RULE, NOT A DENYLIST. The app writes exactly one finalized // media file per role per video dir, and it is always a SINGLE segment after the // base name: audio. and source-media.. So these predicates anchor on // that shape instead of allow-listing a prefix and subtracting known-bad // suffixes. // // The denylist this replaces was demonstrably losing, and not cosmetically: // audio.en-orig.vtt (a SUBTITLE — no rule excluded .vtt) and // audio.live_chat.json.part-Frag114 (the .part rule used endsWith) both passed // it, and audioFilesToRemove shares this predicate, so the cleanup sweep could // delete a subtitle believing it was audio. On the source-media side the // denylist also missed .temp., so yt-dlp's postprocessor scratch // source-media.temp.mp4 read as a finalized container — and that one causes a // WRITE (ytdlp/downloadOneManaged.ts finalizeAppExtraction transcodes from // whatever findSourceMedia returns and then moves it into the saved-video // store), so it was an archive-corruption path. // // An extension allowlist ALONE would not be enough: this repo's own transcode // scratch is audio.tmp-. (controller/transcode.ts) — a // perfectly valid audio extension — and admitting it would race the running // transcoder. Anchoring to one segment rejects audio.tmp-2760235.mp3, // audio.m4a.part.good, audio.live_chat.json.part-Frag114 and audio.en-orig.vtt // with a single rule. const REAL_AUDIO_RE = new RegExp(`^audio\\.(?:${MEDIA_EXT_ALT})$`, "i"); const SOURCE_MEDIA_RE = new RegExp( `^${SOURCE_MEDIA_BASENAME}\\.(?:${MEDIA_EXT_ALT})$`, "i", ); const PART_AUDIO_RE = new RegExp(`^audio\\.(?:${MEDIA_EXT_ALT})\\.part$`, "i"); // A finalized media output under data//audio.. export function isRealAudioFile(name: string): boolean { return REAL_AUDIO_RE.test(name); } // The finalized audio files to delete from a video dir. Operates on the raw // readdir() entries: keeps only real audio (excludes .part partials, snapshots, // sidecars, temp files via isRealAudioFile). With wrongFormatOnly the channel's // target audio file is preserved and only off-target formats are returned — // this is how "remove wrong-format audio" strips the cornbreadman-style // audio.m4a/audio.mp4 leftovers that failed yt-dlp's extract-to-mp3 step. // // THIS IS THE ONE DELETE SET, and it is why narrowing isRealAudioFile is safe // rather than merely hoped to be: the body is a filter optionally intersected // with a second filter, and both are monotone in the predicate, so a strictly // narrower predicate yields a pointwise SUBSET for every input. The delete set // can only shrink. (No call site in common/, editor/ or export/ negates these // predicates, so there is no branch that could invert that direction.) export function audioFilesToRemove( entries: string[], opts: { targetAudioFile?: string; wrongFormatOnly?: boolean } = {}, ): string[] { let files = entries.filter(isRealAudioFile); if (opts.wrongFormatOnly) { files = files.filter((name) => name !== opts.targetAudioFile); } return files; } export function isVideoContainer(name: string): boolean { const dot = name.lastIndexOf("."); if (dot < 0) return false; return VIDEO_CONTAINER_EXTS.includes(name.slice(dot + 1).toLowerCase()); } // A finalized persisted source container under data//source-media.. export function isSourceMediaFile(name: string): boolean { return SOURCE_MEDIA_RE.test(name); } // The persisted source container in a video dir, if any (the keep-latest source // media). Null when no finalized source-media. exists. // // SORTED, not readdir order: finalizeAppExtraction transcodes from whatever this // returns and may then MOVE it into the saved-video store as the permanent copy, // so "whichever the filesystem happened to list first" is not an acceptable way // to choose between two containers. Sorting makes the choice reproducible; the // anchored predicate above is what keeps scratch files out of the running. export function findSourceMedia(entries: string[]): string | null { const found = entries.filter(isSourceMediaFile).sort(); return found[0] ?? null; } // A genuine resumable partial: an interrupted audio download, NOT a live-chat // sidecar that merely ends in .part, and not an audio-check snapshot // (.part.good/.part.testing). export function isPartAudioFile(name: string): boolean { return PART_AUDIO_RE.test(name); } // --------------------------------------------------------------------------- // Which audio file to use when a video dir holds more than one: mp3 first, // because that is what this corpus overwhelmingly holds (900 audio.mp3 against // 12 audio.mp4 and 1 audio.aac) and what the transcribe and diarize paths were // tuned against. A second ordering — the format most likely to be the ORIGINAL // download, for picking a transcode SOURCE — left with the transcode operation // on 2026-08-30; pickPreferredAudio keeps its `preference` parameter and every // caller (transcribeOne, diarizeOne, operations.ts) takes the default. export const AUDIO_READ_PREFERENCE: ReadonlyArray = [ "audio.mp3", "audio.m4a", "audio.opus", ]; // Pick one audio file from a set of candidates by preference, falling back to a // stable sort so the answer never depends on readdir order. export function pickPreferredAudio( candidates: string[], preference: ReadonlyArray = AUDIO_READ_PREFERENCE, ): string | null { if (candidates.length === 0) return null; for (const preferred of preference) { if (candidates.includes(preferred)) return preferred; } return [...candidates].sort()[0] ?? null; }