Files
netfelix-audio-fix/server/services/ffmpeg.ts
Felix Förtsch d5f4afd26b
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m54s
split audio/subtitle concerns, remove docker-in-docker, add per-node path mapping
- install ffmpeg in dockerfile (fixes exit code 127)
- buildCommand() now audio-only remux, no subtitle extraction
- add unapprove endpoint + ui button for approved items
- add batch extract-all subtitles endpoint + ui button
- audio detail page shows only video+audio streams
- remove global movies_path/series_path config, add per-node path mapping
- remove docker-in-docker command building (buildDockerCommand, buildDockerExtractOnlyCommand)
- ssh execution translates /movies/ and /series/ to node-specific paths
- remove media paths section from setup page
- add unraid-template.xml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 16:48:00 +01:00

369 lines
13 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 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(' ');
}
/** 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(' · ');
}