import type { MediaItem, MediaStream, StreamDecision } from '../types'; import { normalizeLanguage } from './jellyfin'; // ─── Subtitle extraction helpers ────────────────────────────────────────────── /** ISO 639-2/B → ISO 639-1 two-letter codes for subtitle filenames. */ const ISO639_1: Record = { eng: 'en', deu: 'de', spa: 'es', fra: 'fr', ita: 'it', por: 'pt', jpn: 'ja', kor: 'ko', zho: 'zh', ara: 'ar', rus: 'ru', nld: 'nl', swe: 'sv', nor: 'no', dan: 'da', fin: 'fi', pol: 'pl', tur: 'tr', tha: 'th', hin: 'hi', hun: 'hu', ces: 'cs', ron: 'ro', ell: 'el', heb: 'he', fas: 'fa', ukr: 'uk', ind: 'id', cat: 'ca', nob: 'nb', nno: 'nn', isl: 'is', hrv: 'hr', slk: 'sk', bul: 'bg', srp: 'sr', slv: 'sl', lav: 'lv', lit: 'lt', est: 'et', }; /** Subtitle codec → external file extension. */ const SUBTITLE_EXT: Record = { subrip: 'srt', srt: 'srt', ass: 'ass', ssa: 'ssa', webvtt: 'vtt', vtt: 'vtt', hdmv_pgs_subtitle: 'sup', pgssub: 'sup', dvd_subtitle: 'sub', dvbsub: 'sub', mov_text: 'srt', text: 'srt', }; function subtitleLang2(lang: string | null): string { if (!lang) return 'und'; const n = normalizeLanguage(lang); return ISO639_1[n] ?? n; } /** Returns the ffmpeg codec name to use when extracting this subtitle stream. */ function subtitleCodecArg(codec: string | null): string { if (!codec) return 'copy'; return codec.toLowerCase() === 'mov_text' ? 'subrip' : 'copy'; } function subtitleExtForCodec(codec: string | null): string { if (!codec) return 'srt'; return SUBTITLE_EXT[codec.toLowerCase()] ?? 'srt'; } /** * Build ffmpeg output args for extracting ALL subtitle streams * to external sidecar files next to the video. * * Returns a flat array of args to append after the main output in the * command. Each subtitle becomes a separate ffmpeg output: * -map 0:s:N -c:s copy 'basename.en.srt' * * @param allStreams All streams for the item (needed to compute type-relative indices) * @param basePath Video file path without extension (host or /work path) */ interface ExtractionEntry { stream: MediaStream; typeIdx: number; outPath: string; codecArg: string; } /** Compute extraction metadata for all subtitle streams. Shared by buildExtractionOutputs and predictExtractedFiles. */ function computeExtractionEntries( allStreams: MediaStream[], basePath: string ): ExtractionEntry[] { const subTypeIdx = new Map(); let subCount = 0; for (const s of [...allStreams].sort((a, b) => a.stream_index - b.stream_index)) { if (s.type === 'Subtitle') subTypeIdx.set(s.id, subCount++); } const allSubs = allStreams .filter((s) => s.type === 'Subtitle') .sort((a, b) => a.stream_index - b.stream_index); if (allSubs.length === 0) return []; const usedNames = new Set(); const entries: ExtractionEntry[] = []; for (const s of allSubs) { const typeIdx = subTypeIdx.get(s.id) ?? 0; const langCode = subtitleLang2(s.language); const ext = subtitleExtForCodec(s.codec); const codecArg = subtitleCodecArg(s.codec); const nameParts = [langCode]; if (s.is_forced) nameParts.push('forced'); if (s.is_hearing_impaired) nameParts.push('hi'); let outPath = `${basePath}.${nameParts.join('.')}.${ext}`; let counter = 2; while (usedNames.has(outPath)) { outPath = `${basePath}.${nameParts.join('.')}.${counter}.${ext}`; counter++; } usedNames.add(outPath); entries.push({ stream: s, typeIdx, outPath, codecArg }); } return entries; } function buildExtractionOutputs( allStreams: MediaStream[], basePath: string ): string[] { const entries = computeExtractionEntries(allStreams, basePath); const args: string[] = []; for (const e of entries) { args.push(`-map 0:s:${e.typeIdx}`, `-c:s ${e.codecArg}`, shellQuote(e.outPath)); } return args; } /** * Predict the sidecar files that subtitle extraction will create. * Used to populate the subtitle_files table after a successful job. */ export function predictExtractedFiles( item: MediaItem, streams: MediaStream[] ): Array<{ file_path: string; language: string | null; codec: string | null; is_forced: boolean; is_hearing_impaired: boolean }> { const basePath = item.file_path.replace(/\.[^.]+$/, ''); const entries = computeExtractionEntries(streams, basePath); return entries.map((e) => ({ file_path: e.outPath, language: e.stream.language, codec: e.stream.codec, is_forced: !!e.stream.is_forced, is_hearing_impaired: !!e.stream.is_hearing_impaired, })); } // ───────────────────────────────────────────────────────────────────────────── const LANG_NAMES: Record = { eng: 'English', deu: 'German', spa: 'Spanish', fra: 'French', ita: 'Italian', por: 'Portuguese', jpn: 'Japanese', kor: 'Korean', zho: 'Chinese', ara: 'Arabic', rus: 'Russian', nld: 'Dutch', swe: 'Swedish', nor: 'Norwegian', dan: 'Danish', fin: 'Finnish', pol: 'Polish', tur: 'Turkish', tha: 'Thai', hin: 'Hindi', hun: 'Hungarian', ces: 'Czech', ron: 'Romanian', ell: 'Greek', heb: 'Hebrew', fas: 'Persian', ukr: 'Ukrainian', ind: 'Indonesian', cat: 'Catalan', nob: 'Norwegian Bokmål', nno: 'Norwegian Nynorsk', isl: 'Icelandic', slk: 'Slovak', hrv: 'Croatian', bul: 'Bulgarian', srp: 'Serbian', slv: 'Slovenian', lav: 'Latvian', lit: 'Lithuanian', est: 'Estonian', }; function trackTitle(stream: MediaStream): string | null { if (stream.type === 'Subtitle') { // Subtitles always get a clean language-based title so Jellyfin displays // "German", "English (Forced)", etc. regardless of the original file title. // The review UI shows a ⚠ badge when the original title looks like a // different language, so users can spot and remove mislabeled tracks. if (!stream.language) return null; const lang = normalizeLanguage(stream.language); const base = LANG_NAMES[lang] ?? lang.toUpperCase(); if (stream.is_forced) return `${base} (Forced)`; if (stream.is_hearing_impaired) return `${base} (CC)`; return base; } // For audio and other stream types: preserve any existing title // (e.g. "Director's Commentary") and fall back to language name. if (stream.title) return stream.title; if (!stream.language) return null; const lang = normalizeLanguage(stream.language); return LANG_NAMES[lang] ?? lang.toUpperCase(); } const TYPE_SPEC: Record = { Video: 'v', Audio: 'a', Subtitle: 's' }; /** * Build -map flags using type-relative specifiers (0:v:N, 0:a:N, 0:s:N). * * Jellyfin's stream_index is an absolute index that can include EmbeddedImage * and Data streams which ffmpeg may count differently (e.g. cover art stored * as attachments). Using the stream's position within its own type group * matches ffmpeg's 0:a:N convention exactly and avoids silent mismatches. */ function buildMaps( allStreams: MediaStream[], kept: { stream: MediaStream; dec: StreamDecision }[] ): string[] { // Map each stream id → its 0-based position among streams of the same type, // sorted by stream_index (the order ffmpeg sees them in the input). const typePos = new Map(); const counts: Record = {}; for (const s of [...allStreams].sort((a, b) => a.stream_index - b.stream_index)) { if (!TYPE_SPEC[s.type]) continue; const n = counts[s.type] ?? 0; typePos.set(s.id, n); counts[s.type] = n + 1; } return kept .filter((k) => !!TYPE_SPEC[k.stream.type]) .map((k) => `-map 0:${TYPE_SPEC[k.stream.type]}:${typePos.get(k.stream.id) ?? 0}`); } /** * Build disposition and metadata flags for kept audio + subtitle streams. * - Marks the first kept audio stream as default, clears all others. * - Sets harmonized language-name titles on all kept audio/subtitle streams. */ function buildStreamFlags( kept: { stream: MediaStream; dec: StreamDecision }[] ): string[] { const audioKept = kept.filter((k) => k.stream.type === 'Audio'); const subKept = kept.filter((k) => k.stream.type === 'Subtitle'); const args: string[] = []; // Disposition: first audio = default, rest = clear audioKept.forEach((_, i) => { args.push(`-disposition:a:${i}`, i === 0 ? 'default' : '0'); }); // Titles for audio streams (custom_title overrides generated title) audioKept.forEach((k, i) => { const title = k.dec.custom_title ?? trackTitle(k.stream); if (title) args.push(`-metadata:s:a:${i}`, `title=${shellQuote(title)}`); }); // Titles for subtitle streams (custom_title overrides generated title) subKept.forEach((k, i) => { const title = k.dec.custom_title ?? trackTitle(k.stream); if (title) args.push(`-metadata:s:s:${i}`, `title=${shellQuote(title)}`); }); return args; } /** * Build the full shell command to remux a media file, keeping only the * streams specified by the decisions and in the target order. * * Returns null if all streams are kept and ordering is unchanged (noop). */ export function buildCommand( item: MediaItem, streams: MediaStream[], decisions: StreamDecision[] ): string { // Sort kept streams by type priority then target_index const kept = streams .map((s) => { const dec = decisions.find((d) => d.stream_id === s.id); return dec?.action === 'keep' ? { stream: s, dec } : null; }) .filter(Boolean) as { stream: MediaStream; dec: StreamDecision }[]; // Sort: Video first, Audio second, Subtitle third, Data last const typeOrder: Record = { Video: 0, Audio: 1, Subtitle: 2, Data: 3, EmbeddedImage: 4 }; kept.sort((a, b) => { const ta = typeOrder[a.stream.type] ?? 9; const tb = typeOrder[b.stream.type] ?? 9; if (ta !== tb) return ta - tb; return (a.dec.target_index ?? 0) - (b.dec.target_index ?? 0); }); const inputPath = item.file_path; const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv'; const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`); const basePath = inputPath.replace(/\.[^.]+$/, ''); const maps = buildMaps(streams, kept); const streamFlags = buildStreamFlags(kept); const extractionOutputs = buildExtractionOutputs(streams, basePath); const parts: string[] = [ 'ffmpeg', '-y', '-i', shellQuote(inputPath), ...maps, ...streamFlags, '-c copy', shellQuote(tmpPath), ...extractionOutputs, '&&', 'mv', shellQuote(tmpPath), shellQuote(inputPath), ]; return parts.join(' '); } /** * Build a command that also changes the container to MKV. * Used when MP4 container can't hold certain subtitle codecs. */ export function buildMkvConvertCommand( item: MediaItem, streams: MediaStream[], decisions: StreamDecision[] ): string { const inputPath = item.file_path; const outputPath = inputPath.replace(/\.[^.]+$/, '.mkv'); const tmpPath = inputPath.replace(/\.[^.]+$/, '.tmp.mkv'); const basePath = outputPath.replace(/\.[^.]+$/, ''); const kept = streams .map((s) => { const dec = decisions.find((d) => d.stream_id === s.id); return dec?.action === 'keep' ? { stream: s, dec } : null; }) .filter(Boolean) as { stream: MediaStream; dec: StreamDecision }[]; const typeOrder: Record = { Video: 0, Audio: 1, Subtitle: 2, Data: 3 }; kept.sort((a, b) => { const ta = typeOrder[a.stream.type] ?? 9; const tb = typeOrder[b.stream.type] ?? 9; if (ta !== tb) return ta - tb; return (a.dec.target_index ?? 0) - (b.dec.target_index ?? 0); }); const maps = buildMaps(streams, kept); const streamFlags = buildStreamFlags(kept); const extractionOutputs = buildExtractionOutputs(streams, basePath); return [ 'ffmpeg', '-y', '-i', shellQuote(inputPath), ...maps, ...streamFlags, '-c copy', '-f matroska', shellQuote(tmpPath), ...extractionOutputs, '&&', 'mv', shellQuote(tmpPath), shellQuote(outputPath), ].join(' '); } /** * Build a Docker-wrapped version of the FFmpeg command. * Mounts the file's directory to /work inside the container and rewrites * all paths accordingly. Requires only Docker as a system dependency. * * Image: jrottenberg/ffmpeg — entrypoint is ffmpeg, so we use --entrypoint sh * to run ffmpeg + mv in a single shell invocation. */ export function buildDockerCommand( item: MediaItem, streams: MediaStream[], decisions: StreamDecision[], opts: { moviesPath?: string; seriesPath?: string } = {} ): { command: string; mountDir: string } { const inputPath = item.file_path; const isEpisode = item.type === 'Episode'; let mountDir: string; let relPath: string; const hostRoot = (isEpisode ? opts.seriesPath : opts.moviesPath)?.replace(/\/$/, '') ?? ''; // Jellyfin always mounts libraries at /movies and /series by convention const jellyfinPrefix = isEpisode ? '/series' : '/movies'; if (hostRoot) { mountDir = hostRoot; if (inputPath.startsWith(jellyfinPrefix + '/')) { relPath = inputPath.slice(jellyfinPrefix.length); // keeps leading / } else { // Path doesn't match the expected prefix — strip 1 component as best effort const components = inputPath.split('/').filter(Boolean); relPath = '/' + components.slice(1).join('/'); } } else { // No host path configured — fall back to mounting the file's immediate parent directory const lastSlash = inputPath.lastIndexOf('/'); mountDir = lastSlash >= 0 ? inputPath.slice(0, lastSlash) : '.'; relPath = '/' + (lastSlash >= 0 ? inputPath.slice(lastSlash + 1) : inputPath); } const ext = relPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv'; const tmpRelPath = relPath.replace(/\.[^.]+$/, `.tmp.${ext}`); const workInput = `/work${relPath}`; const workTmp = `/work${tmpRelPath}`; const workBasePath = workInput.replace(/\.[^.]+$/, ''); const kept = streams .map((s) => { const dec = decisions.find((d) => d.stream_id === s.id); return dec?.action === 'keep' ? { stream: s, dec } : null; }) .filter(Boolean) as { stream: MediaStream; dec: StreamDecision }[]; const typeOrder: Record = { Video: 0, Audio: 1, Subtitle: 2, Data: 3, EmbeddedImage: 4 }; kept.sort((a, b) => { const ta = typeOrder[a.stream.type] ?? 9; const tb = typeOrder[b.stream.type] ?? 9; if (ta !== tb) return ta - tb; return (a.dec.target_index ?? 0) - (b.dec.target_index ?? 0); }); const maps = buildMaps(streams, kept); const streamFlags = buildStreamFlags(kept); // Subtitle extraction uses /work paths so files land in the mounted directory const extractionOutputs = buildExtractionOutputs(streams, workBasePath); // The jrottenberg/ffmpeg entrypoint IS ffmpeg — run it directly so no inner // shell is needed and no nested quoting is required. The mv step runs on the // host (outside Docker) so it uses the real host paths. const hostInput = mountDir + relPath; const hostTmp = mountDir + tmpRelPath; const parts = [ 'docker run --rm', `-v ${shellQuote(mountDir + ':/work')}`, 'jrottenberg/ffmpeg:latest', '-y', '-i', shellQuote(workInput), ...maps, ...streamFlags, '-c copy', shellQuote(workTmp), ...extractionOutputs, '&&', 'mv', shellQuote(hostTmp), shellQuote(hostInput), ]; return { command: parts.join(' '), mountDir }; } /** * Build a command that ONLY extracts subtitles to sidecar files * without modifying the container. Useful when the item is otherwise * a noop but the user wants sidecar subtitle files. */ export function buildExtractOnlyCommand( item: MediaItem, streams: MediaStream[] ): string | null { const basePath = item.file_path.replace(/\.[^.]+$/, ''); const extractionOutputs = buildExtractionOutputs(streams, basePath); if (extractionOutputs.length === 0) return null; return ['ffmpeg', '-y', '-i', shellQuote(item.file_path), ...extractionOutputs].join(' '); } /** * Build a Docker command that ONLY extracts subtitles to sidecar files. */ export function buildDockerExtractOnlyCommand( item: MediaItem, streams: MediaStream[], opts: { moviesPath?: string; seriesPath?: string } = {} ): { command: string; mountDir: string } | null { const inputPath = item.file_path; const isEpisode = item.type === 'Episode'; let mountDir: string; let relPath: string; const hostRoot = (isEpisode ? opts.seriesPath : opts.moviesPath)?.replace(/\/$/, '') ?? ''; const jellyfinPrefix = isEpisode ? '/series' : '/movies'; if (hostRoot) { mountDir = hostRoot; if (inputPath.startsWith(jellyfinPrefix + '/')) { relPath = inputPath.slice(jellyfinPrefix.length); } else { const components = inputPath.split('/').filter(Boolean); relPath = '/' + components.slice(1).join('/'); } } else { const lastSlash = inputPath.lastIndexOf('/'); mountDir = lastSlash >= 0 ? inputPath.slice(0, lastSlash) : '.'; relPath = '/' + (lastSlash >= 0 ? inputPath.slice(lastSlash + 1) : inputPath); } const workInput = `/work${relPath}`; const workBasePath = workInput.replace(/\.[^.]+$/, ''); const extractionOutputs = buildExtractionOutputs(streams, workBasePath); if (extractionOutputs.length === 0) return null; const parts = [ 'docker run --rm', `-v ${shellQuote(mountDir + ':/work')}`, 'jrottenberg/ffmpeg:latest', '-y', '-i', shellQuote(workInput), ...extractionOutputs, ]; return { command: parts.join(' '), mountDir }; } /** Safely quote a path for shell usage. */ export function shellQuote(s: string): string { return `'${s.replace(/'/g, "'\\''")}'`; } /** Returns a human-readable summary of what will change. */ export function summarizeChanges( streams: MediaStream[], decisions: StreamDecision[] ): { removed: MediaStream[]; kept: MediaStream[] } { const removed: MediaStream[] = []; const kept: MediaStream[] = []; for (const s of streams) { const dec = decisions.find((d) => d.stream_id === s.id); if (!dec || dec.action === 'remove') removed.push(s); else kept.push(s); } return { removed, kept }; } /** Format a stream for display. */ export function streamLabel(s: MediaStream): string { const parts: string[] = [s.type]; if (s.codec) parts.push(s.codec); if (s.language_display || s.language) parts.push(s.language_display ?? s.language!); if (s.title) parts.push(`"${s.title}"`); if (s.type === 'Audio' && s.channels) parts.push(`${s.channels}ch`); if (s.is_forced) parts.push('forced'); if (s.is_hearing_impaired) parts.push('CC'); return parts.join(' · '); }