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

181 lines
5.9 KiB
TypeScript

import type { MediaItem, MediaStream, PlanResult } from '../types';
import { normalizeLanguage } from './jellyfin';
import { transcodeTarget, computeAppleCompat } from './apple-compat';
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 only considers audio changes.
*/
export function analyzeItem(
item: Pick<MediaItem, 'original_language' | 'needs_review' | 'container'>,
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, config.audioLanguages);
return { stream_id: s.id, action, target_index: null, transcode_codec: 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, config.audioLanguages);
// Check if audio ordering changes
const audioOrderChanged = checkAudioOrderChanged(streams, decisions);
// Step 3: Apple compatibility — compute transcode targets for kept audio
for (const d of decisions) {
if (d.action === 'keep') {
const stream = streams.find(s => s.id === d.stream_id);
if (stream && stream.type === 'Audio') {
d.transcode_codec = transcodeTarget(
stream.codec ?? '',
stream.title,
item.container,
);
}
}
}
const keptAudioCodecs = decisions
.filter(d => d.action === 'keep')
.map(d => streams.find(s => s.id === d.stream_id))
.filter(s => s && s.type === 'Audio')
.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');
// Extended is_noop: no audio changes AND no subs to extract AND no transcode needed
const is_noop = !anyAudioRemoved && !audioOrderChanged && !hasSubs && !needsTranscode;
// 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,
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'; // unknown lang → keep all
if (!stream.language) return 'keep'; // undetermined → keep
const normalized = normalizeLanguage(stream.language);
if (normalized === origLang) return 'keep';
if (audioLanguages.includes(normalized)) return 'keep';
return '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,
audioLanguages: string[],
): void {
// Group kept streams by type
const byType: Record<string, MediaStream[]> = {};
for (const s of keptStreams) {
const t = s.type;
byType[t] = byType[t] ?? [];
byType[t].push(s);
}
// Sort audio: OG first, then additional languages in configured order, then by stream_index within each group
if (byType['Audio']) {
byType['Audio'].sort((a, b) => {
const aLang = a.language ? normalizeLanguage(a.language) : null;
const bLang = b.language ? normalizeLanguage(b.language) : null;
const aRank = langRank(aLang, origLang, audioLanguages);
const bRank = langRank(bLang, origLang, audioLanguages);
if (aRank !== bRank) return aRank - bRank;
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;
});
}
}
/** Compute sort rank: OG = 0, additional languages = 1..N by config order, unknown/null = N+1. */
function langRank(lang: string | null, origLang: string | null, audioLanguages: string[]): number {
if (origLang && lang === origLang) return 0;
if (lang) {
const idx = audioLanguages.indexOf(lang);
if (idx >= 0) return idx + 1;
}
return audioLanguages.length + 1;
}
/** 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;
}