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>
134 lines
4.2 KiB
TypeScript
134 lines
4.2 KiB
TypeScript
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<MediaItem, 'original_language' | 'needs_review'>,
|
|
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<string, MediaStream[]> = {};
|
|
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;
|
|
}
|