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 streams. * - Marks the first kept audio stream as default, clears all others. * - Sets harmonized language-name titles on all kept audio streams. */ function buildStreamFlags( kept: { stream: MediaStream; dec: StreamDecision }[] ): string[] { const audioKept = kept.filter((k) => k.stream.type === 'Audio'); 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)}`); }); 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 maps = buildMaps(streams, kept); const streamFlags = buildStreamFlags(kept); const parts: string[] = [ 'ffmpeg', '-y', '-i', shellQuote(inputPath), ...maps, ...streamFlags, '-c copy', shellQuote(tmpPath), '&&', '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 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); return [ 'ffmpeg', '-y', '-i', shellQuote(inputPath), ...maps, ...streamFlags, '-c copy', '-f matroska', shellQuote(tmpPath), '&&', 'mv', shellQuote(tmpPath), shellQuote(outputPath), ].join(' '); } /** * Build a command that extracts subtitles to sidecar files AND * remuxes the container without subtitle streams (single ffmpeg pass). * * ffmpeg supports multiple outputs: first we extract each subtitle * track to its own sidecar file, then the final output copies all * video + audio streams into a temp file without subtitles. */ 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; const inputPath = item.file_path; const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv'; const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`); // Only map audio if the file actually has audio streams const hasAudio = streams.some((s) => s.type === 'Audio'); const remuxMaps = hasAudio ? ['-map 0:v', '-map 0:a'] : ['-map 0:v']; // Single ffmpeg pass: extract sidecar files + remux without subtitles const parts: string[] = [ 'ffmpeg', '-y', '-i', shellQuote(inputPath), // Subtitle extraction outputs (each to its own file) ...extractionOutputs, // Final output: copy all video + audio, no subtitles ...remuxMaps, '-c copy', shellQuote(tmpPath), '&&', 'mv', shellQuote(tmpPath), shellQuote(inputPath), ]; return parts.join(' '); } /** * Build a single FFmpeg command that: * 1. Extracts subtitles to sidecar files * 2. Remuxes with reordered/filtered audio * 3. Transcodes incompatible audio codecs */ export function buildPipelineCommand( item: MediaItem, streams: MediaStream[], decisions: (StreamDecision & { stream?: MediaStream })[] ): { command: string; extractedFiles: Array<{ path: string; language: string | null; codec: string | null; is_forced: number; is_hearing_impaired: number }> } { const inputPath = item.file_path; const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv'; const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`); const basePath = inputPath.replace(/\.[^.]+$/, ''); // --- Subtitle extraction outputs --- const extractionEntries = computeExtractionEntries(streams, basePath); const subOutputArgs: string[] = []; for (const e of extractionEntries) { subOutputArgs.push(`-map 0:s:${e.typeIdx}`, `-c:s ${e.codecArg}`, shellQuote(e.outPath)); } // --- Kept streams for remuxed output --- // Enrich decisions with stream data const enriched = decisions.map(d => { const stream = d.stream ?? streams.find(s => s.id === d.stream_id); return { ...d, stream: stream! }; }).filter(d => d.action === 'keep' && d.stream); // Sort by type priority then target_index const typeOrder: Record = { Video: 0, Audio: 1, Data: 2, EmbeddedImage: 3 }; enriched.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.target_index ?? 0) - (b.target_index ?? 0); }); // Build -map flags const maps = buildMaps(streams, enriched.map(d => ({ stream: d.stream, dec: d }))); // Build per-stream codec flags const codecFlags: string[] = ['-c:v copy']; let audioIdx = 0; for (const d of enriched) { if (d.stream.type === 'Audio') { if (d.transcode_codec) { codecFlags.push(`-c:a:${audioIdx} ${d.transcode_codec}`); // For EAC3, set a reasonable bitrate based on channel count if (d.transcode_codec === 'eac3') { const bitrate = (d.stream.channels ?? 2) >= 6 ? '640k' : '256k'; codecFlags.push(`-b:a:${audioIdx} ${bitrate}`); } } else { codecFlags.push(`-c:a:${audioIdx} copy`); } audioIdx++; } } // If no audio transcoding, simplify to -c copy (covers video + audio) const hasTranscode = enriched.some(d => d.transcode_codec); const finalCodecFlags = hasTranscode ? codecFlags : ['-c copy']; // Disposition + metadata flags for audio const streamFlags = buildStreamFlags(enriched.map(d => ({ stream: d.stream, dec: d }))); // Assemble command const parts: string[] = [ 'ffmpeg', '-y', '-i', shellQuote(inputPath), ]; // Subtitle extraction outputs first parts.push(...subOutputArgs); // Map flags for remuxed output parts.push(...maps); // Codec flags parts.push(...finalCodecFlags); // Stream flags (disposition, metadata) parts.push(...streamFlags); // Output file parts.push(shellQuote(tmpPath)); const command = parts.join(' ') + ` && mv ${shellQuote(tmpPath)} ${shellQuote(inputPath)}`; return { command, extractedFiles: extractionEntries.map(e => ({ path: e.outPath, language: e.stream.language, codec: e.stream.codec, is_forced: e.stream.is_forced ? 1 : 0, is_hearing_impaired: e.stream.is_hearing_impaired ? 1 : 0, })), }; } /** 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(' · '); }