- execute: actually call isInScheduleWindow/waitForWindow/sleepBetweenJobs in runSequential (they were dead code); emit queue_status SSE events (running/paused/sleeping/idle) so the pipeline's existing QueueStatus listener lights up - review: POST /:id/retry resets an errored plan to approved, wipes old done/error jobs, rebuilds command from current decisions, queues fresh job - scan: dev-mode DELETE now also wipes jobs + subtitle_files (previously orphaned after every dev reset) - biome: migrate config to 2.4 schema, autoformat 68 files (strings + indentation), relax opinionated a11y/hooks-deps/index-key rules that don't fit this codebase - routeTree.gen.ts regenerated after /nodes removal
154 lines
5.2 KiB
TypeScript
154 lines
5.2 KiB
TypeScript
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<MediaItem, "original_language" | "needs_review" | "container">,
|
|
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") {
|
|
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 is MediaStream => !!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");
|
|
const is_noop = !anyAudioRemoved && !audioOrderChanged && !hasSubs && !needsTranscode;
|
|
|
|
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<string, MediaStream[]>();
|
|
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;
|
|
}
|