import type { MediaItem, MediaStream, PlanResult } from '../types'; import { normalizeLanguage } from './jellyfin'; export interface AnalyzerConfig { subtitleLanguages: string[]; // kept for potential future use } /** * 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 only considers audio changes. */ export function analyzeItem( item: Pick, streams: MediaStream[], config: AnalyzerConfig ): PlanResult { const origLang = item.original_language ? normalizeLanguage(item.original_language) : null; const notes: string[] = []; // Compute action for each stream const decisions: PlanResult['decisions'] = streams.map((s) => { const action = decideAction(s, origLang); return { stream_id: s.id, action, target_index: null }; }); // Audio-only noop: only consider audio removals/reordering // (subtitles are always removed from container — that's implicit, not a "change" to review) const anyAudioRemoved = streams.some((s, i) => s.type === 'Audio' && decisions[i].action === 'remove'); // Compute target ordering for kept streams within type groups const keptStreams = streams.filter((_, i) => decisions[i].action === 'keep'); assignTargetOrder(keptStreams, decisions, streams, origLang); // Check if audio ordering changes const audioOrderChanged = checkAudioOrderChanged(streams, decisions); const isNoop = !anyAudioRemoved && !audioOrderChanged; const hasSubs = streams.some((s) => s.type === 'Subtitle'); // Generate notes for edge cases if (!origLang && item.needs_review) { notes.push('Original language unknown — audio tracks not filtered; manual review required'); } return { is_noop: isNoop, has_subs: hasSubs, decisions, notes: notes.length > 0 ? notes.join('\n') : null, }; } function decideAction( stream: MediaStream, origLang: string | null, ): 'keep' | 'remove' { switch (stream.type) { case 'Video': case 'Data': case 'EmbeddedImage': return 'keep'; case 'Audio': { if (!origLang) return 'keep'; // unknown lang → keep all if (!stream.language) return 'keep'; // undetermined → keep return normalizeLanguage(stream.language) === origLang ? 'keep' : 'remove'; } case 'Subtitle': // All subtitles are removed from the container and extracted to sidecar files return 'remove'; default: return 'keep'; } } function assignTargetOrder( keptStreams: MediaStream[], decisions: PlanResult['decisions'], allStreams: MediaStream[], origLang: string | null ): void { // Group kept streams by type const byType: Record = {}; for (const s of keptStreams) { const t = s.type; byType[t] = byType[t] ?? []; byType[t].push(s); } // Sort audio: original lang first, then by stream_index if (byType['Audio']) { byType['Audio'].sort((a, b) => { const aIsOrig = origLang && a.language && normalizeLanguage(a.language) === origLang ? 0 : 1; const bIsOrig = origLang && b.language && normalizeLanguage(b.language) === origLang ? 0 : 1; if (aIsOrig !== bIsOrig) return aIsOrig - bIsOrig; return a.stream_index - b.stream_index; }); } // Assign target_index per type group for (const [, typeStreams] of Object.entries(byType)) { typeStreams.forEach((s, idx) => { const dec = decisions.find((d) => d.stream_id === s.id); if (dec) dec.target_index = idx; }); } } /** Check if audio stream ordering changes (ignores subtitles which are always removed). */ function checkAudioOrderChanged( streams: MediaStream[], decisions: PlanResult['decisions'] ): boolean { const keptAudio = streams.filter((s) => { if (s.type !== 'Audio') return false; const dec = decisions.find((d) => d.stream_id === s.id); return dec?.action === 'keep'; }); const sorted = [...keptAudio].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) continue; const currentPos = sorted.findIndex((s) => s.id === keptAudio[i].id); if (dec.target_index !== null && dec.target_index !== currentPos) return true; } return false; }