Files
netfelix-audio-fix/server/services/ffmpeg.ts
T

499 lines
17 KiB
TypeScript

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<string, string> = {
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<string, string> = {
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<number, number>();
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<string>();
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<string, string> = {
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<string, string> = { 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<number, number>();
const counts: Record<string, number> = {};
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<string, number> = { 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<string, number> = { 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<string, number> = { 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(' · ');
}