rewrite from monolithic hono jsx to react 19 spa with tanstack router + hono json api backend. add scan, review, execute, nodes, and setup pages. multi-stage dockerfile (node for vite build, bun for runtime). previously, server/ and src/shared/lib/ were silently excluded by global gitignore patterns (/server/ from emacs, lib/ from python). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
521 lines
18 KiB
TypeScript
521 lines
18 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 + 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<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 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<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);
|
|
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<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 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(' · ');
|
|
}
|