import type { MediaItem, MediaStream, PlanResult } from "../types"; import { computeAppleCompat, transcodeTarget } from "./apple-compat"; import { normalizeLanguage } from "./jellyfin"; export interface AnalyzerConfig { subtitleLanguages: string[]; audioLanguages: string[]; // additional languages to keep (after OG) } /** * Given an item and its streams, compute what action to take for each stream * and whether the file needs audio remuxing. * * Subtitles are ALWAYS removed from the container (they get extracted to * sidecar files). is_noop considers audio removal/reorder, subtitle * extraction, and transcode — a "noop" is a file that needs no changes * at all. */ export function analyzeItem( item: Pick, streams: MediaStream[], config: AnalyzerConfig, ): PlanResult { const origLang = item.original_language ? normalizeLanguage(item.original_language) : null; const notes: string[] = []; const decisions: PlanResult["decisions"] = streams.map((s) => { const action = decideAction(s, origLang, config.audioLanguages); return { stream_id: s.id, action, target_index: null, transcode_codec: null }; }); const anyAudioRemoved = streams.some((s, i) => s.type === "Audio" && decisions[i].action === "remove"); assignTargetOrder(streams, decisions, origLang, config.audioLanguages); const audioOrderChanged = checkAudioOrderChanged(streams, decisions); for (const d of decisions) { if (d.action !== "keep") continue; const stream = streams.find((s) => s.id === d.stream_id); if (stream && stream.type === "Audio") { // Use Profile (DTS-HD MA, etc.) — NOT title — to pick the transcode target. // Passing title here used to cause lossless DTS-HD MA in MKV to fall back // to EAC3 instead of the better FLAC path when the title didn't happen to // contain "MA". d.transcode_codec = transcodeTarget(stream.codec ?? "", stream.profile, item.container); } } const keptAudioStreams = decisions .filter((d) => d.action === "keep") .map((d) => streams.find((s) => s.id === d.stream_id)) .filter((s): s is MediaStream => !!s && s.type === "Audio"); const keptAudioCodecs = keptAudioStreams.map((s) => s.codec ?? ""); const needsTranscode = decisions.some((d) => d.transcode_codec != null); const apple_compat = computeAppleCompat(keptAudioCodecs, item.container); const job_type = needsTranscode ? ("transcode" as const) : ("copy" as const); const hasSubs = streams.some((s) => s.type === "Subtitle"); // Pipeline also sets default disposition on the first kept audio and writes // canonical iso3 language tags. If either is already wrong in the file, // running ffmpeg would produce a different output → not a noop. const keptAudioSorted = [...keptAudioStreams].sort((a, b) => { const ai = decisions.find((d) => d.stream_id === a.id)?.target_index ?? 0; const bi = decisions.find((d) => d.stream_id === b.id)?.target_index ?? 0; return ai - bi; }); const firstKeptAudio = keptAudioSorted[0]; const defaultMismatch = !!firstKeptAudio && firstKeptAudio.is_default !== 1; const nonDefaultHasDefault = keptAudioSorted.slice(1).some((s) => s.is_default === 1); const languageMismatch = keptAudioStreams.some( (s) => s.language != null && s.language !== normalizeLanguage(s.language), ); const is_noop = !anyAudioRemoved && !audioOrderChanged && !hasSubs && !needsTranscode && !defaultMismatch && !nonDefaultHasDefault && !languageMismatch; if (!origLang && item.needs_review) { notes.push("Original language unknown — audio tracks not filtered; manual review required"); } return { is_noop, has_subs: hasSubs, confidence: "low", apple_compat, job_type, decisions, notes }; } function decideAction(stream: MediaStream, origLang: string | null, audioLanguages: string[]): "keep" | "remove" { switch (stream.type) { case "Video": case "Data": case "EmbeddedImage": return "keep"; case "Audio": { if (!origLang) return "keep"; if (!stream.language) return "keep"; const normalized = normalizeLanguage(stream.language); if (normalized === origLang) return "keep"; if (audioLanguages.includes(normalized)) return "keep"; return "remove"; } case "Subtitle": return "remove"; default: return "keep"; } } /** * Assign target_index to each kept stream. target_index is the 0-based * position within its type group in the output file, after sorting audio * streams by language rank (OG first, then additional languages in * configured order, then by original stream_index for stability). */ export function assignTargetOrder( allStreams: MediaStream[], decisions: PlanResult["decisions"], origLang: string | null, audioLanguages: string[], ): void { const keptByType = new Map(); for (const s of allStreams) { const dec = decisions.find((d) => d.stream_id === s.id); if (dec?.action !== "keep") continue; if (!keptByType.has(s.type)) keptByType.set(s.type, []); keptByType.get(s.type)!.push(s); } const audio = keptByType.get("Audio"); if (audio) { audio.sort((a, b) => { const aRank = langRank(a.language, origLang, audioLanguages); const bRank = langRank(b.language, origLang, audioLanguages); if (aRank !== bRank) return aRank - bRank; return a.stream_index - b.stream_index; }); } for (const [, streams] of keptByType) { streams.forEach((s, idx) => { const dec = decisions.find((d) => d.stream_id === s.id); if (dec) dec.target_index = idx; }); } } function langRank(lang: string | null, origLang: string | null, audioLanguages: string[]): number { const normalized = lang ? normalizeLanguage(lang) : null; if (origLang && normalized === origLang) return 0; if (normalized) { const idx = audioLanguages.indexOf(normalized); if (idx >= 0) return idx + 1; } return audioLanguages.length + 1; } /** * True when the output order of kept audio streams differs from their * original order in the input. Compares original stream_index order * against target_index order. */ function checkAudioOrderChanged(streams: MediaStream[], decisions: PlanResult["decisions"]): boolean { const keptAudio = streams .filter((s) => s.type === "Audio" && decisions.find((d) => d.stream_id === s.id)?.action === "keep") .sort((a, b) => a.stream_index - b.stream_index); for (let i = 0; i < keptAudio.length; i++) { const dec = decisions.find((d) => d.stream_id === keptAudio[i].id); if (dec?.target_index !== i) return true; } return false; }