add bun:test coverage for analyzer + ffmpeg + validate, emit ffmpeg progress sse
- analyzer.test.ts: audio keep rules (OG + configured langs, unknown OG, undetermined lang, iso alias), ordering (OG first, reorder noop), subtitle forced-remove, transcode targets - ffmpeg.test.ts: shellQuote, sortKeptStreams canonical order, buildCommand tmp+mv, type-relative maps (0:a:N), disposition, buildPipelineCommand sub extraction + transcode bitrate, predictExtractedFiles dedup - validate.test.ts: parseId bounds + isOneOf narrowing - execute: parse ffmpeg Duration + time, emit job_progress SSE events throttled at 500ms so ProcessingColumn progress bar fills in (it already listened) - package: switch test script from placeholder echo to 'bun test'
This commit is contained in:
@@ -9,7 +9,7 @@
|
|||||||
"start": "bun server/index.tsx",
|
"start": "bun server/index.tsx",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"format": "biome format . --write",
|
"format": "biome format . --write",
|
||||||
"test": "echo 'No tests yet'"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-form": "^1.28.3",
|
"@tanstack/react-form": "^1.28.3",
|
||||||
|
|||||||
@@ -44,6 +44,19 @@ function emitJobUpdate(jobId: number, status: string, output?: string): void {
|
|||||||
for (const l of jobListeners) l(line);
|
for (const l of jobListeners) l(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emitJobProgress(jobId: number, seconds: number, total: number): void {
|
||||||
|
const line = `event: job_progress\ndata: ${JSON.stringify({ id: jobId, seconds, total })}\n\n`;
|
||||||
|
for (const l of jobListeners) l(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse "Duration: HH:MM:SS.MS" from ffmpeg startup output. */
|
||||||
|
function parseFFmpegDuration(line: string): number | null {
|
||||||
|
const match = line.match(/Duration:\s*(\d+):(\d+):(\d+)\.(\d+)/);
|
||||||
|
if (!match) return null;
|
||||||
|
const [, h, m, s] = match.map(Number);
|
||||||
|
return h * 3600 + m * 60 + s;
|
||||||
|
}
|
||||||
|
|
||||||
function loadJobRow(jobId: number) {
|
function loadJobRow(jobId: number) {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const row = db.prepare(`
|
const row = db.prepare(`
|
||||||
@@ -218,6 +231,8 @@ async function runJob(job: Job): Promise<void> {
|
|||||||
const outputLines: string[] = [];
|
const outputLines: string[] = [];
|
||||||
let pendingFlush = false;
|
let pendingFlush = false;
|
||||||
let lastFlushAt = 0;
|
let lastFlushAt = 0;
|
||||||
|
let totalSeconds = 0;
|
||||||
|
let lastProgressEmit = 0;
|
||||||
const updateOutput = db.prepare('UPDATE jobs SET output = ? WHERE id = ?');
|
const updateOutput = db.prepare('UPDATE jobs SET output = ? WHERE id = ?');
|
||||||
|
|
||||||
const flush = (final = false) => {
|
const flush = (final = false) => {
|
||||||
@@ -233,6 +248,21 @@ async function runJob(job: Job): Promise<void> {
|
|||||||
emitJobUpdate(job.id, 'running', text);
|
emitJobUpdate(job.id, 'running', text);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const consumeProgress = (line: string) => {
|
||||||
|
if (totalSeconds === 0) {
|
||||||
|
const d = parseFFmpegDuration(line);
|
||||||
|
if (d != null) totalSeconds = d;
|
||||||
|
}
|
||||||
|
const progressed = parseFFmpegProgress(line);
|
||||||
|
if (progressed != null && totalSeconds > 0) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastProgressEmit > 500) {
|
||||||
|
emitJobProgress(job.id, progressed, totalSeconds);
|
||||||
|
lastProgressEmit = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(['sh', '-c', job.command], { stdout: 'pipe', stderr: 'pipe' });
|
const proc = Bun.spawn(['sh', '-c', job.command], { stdout: 'pipe', stderr: 'pipe' });
|
||||||
const readStream = async (readable: ReadableStream<Uint8Array>, prefix = '') => {
|
const readStream = async (readable: ReadableStream<Uint8Array>, prefix = '') => {
|
||||||
@@ -247,11 +277,16 @@ async function runJob(job: Job): Promise<void> {
|
|||||||
const parts = buffer.split(/\r\n|\n|\r/);
|
const parts = buffer.split(/\r\n|\n|\r/);
|
||||||
buffer = parts.pop() ?? '';
|
buffer = parts.pop() ?? '';
|
||||||
for (const line of parts) {
|
for (const line of parts) {
|
||||||
if (line.trim()) outputLines.push(prefix + line);
|
if (!line.trim()) continue;
|
||||||
|
outputLines.push(prefix + line);
|
||||||
|
consumeProgress(line);
|
||||||
}
|
}
|
||||||
flush();
|
flush();
|
||||||
}
|
}
|
||||||
if (buffer.trim()) outputLines.push(prefix + buffer);
|
if (buffer.trim()) {
|
||||||
|
outputLines.push(prefix + buffer);
|
||||||
|
consumeProgress(buffer);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(`stream read error (${prefix.trim() || 'stdout'}):`, err);
|
logError(`stream read error (${prefix.trim() || 'stdout'}):`, err);
|
||||||
}
|
}
|
||||||
|
|||||||
34
server/lib/__tests__/validate.test.ts
Normal file
34
server/lib/__tests__/validate.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import { parseId, isOneOf } from '../validate';
|
||||||
|
|
||||||
|
describe('parseId', () => {
|
||||||
|
test('returns the integer for valid numeric strings', () => {
|
||||||
|
expect(parseId('42')).toBe(42);
|
||||||
|
expect(parseId('1')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for invalid, negative, zero, or missing ids', () => {
|
||||||
|
expect(parseId('0')).toBe(null);
|
||||||
|
expect(parseId('-1')).toBe(null);
|
||||||
|
expect(parseId('abc')).toBe(null);
|
||||||
|
expect(parseId('')).toBe(null);
|
||||||
|
expect(parseId(undefined)).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses leading integer from mixed strings (parseInt semantics)', () => {
|
||||||
|
expect(parseId('42abc')).toBe(42);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isOneOf', () => {
|
||||||
|
test('narrows to allowed string literals', () => {
|
||||||
|
expect(isOneOf('keep', ['keep', 'remove'] as const)).toBe(true);
|
||||||
|
expect(isOneOf('remove', ['keep', 'remove'] as const)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects disallowed values and non-strings', () => {
|
||||||
|
expect(isOneOf('delete', ['keep', 'remove'] as const)).toBe(false);
|
||||||
|
expect(isOneOf(null, ['keep', 'remove'] as const)).toBe(false);
|
||||||
|
expect(isOneOf(42, ['keep', 'remove'] as const)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
191
server/services/__tests__/analyzer.test.ts
Normal file
191
server/services/__tests__/analyzer.test.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import { analyzeItem } from '../analyzer';
|
||||||
|
import type { MediaStream } from '../../types';
|
||||||
|
|
||||||
|
type StreamOverride = Partial<MediaStream> & Pick<MediaStream, 'id' | 'type' | 'stream_index'>;
|
||||||
|
|
||||||
|
function stream(o: StreamOverride): MediaStream {
|
||||||
|
return {
|
||||||
|
item_id: 1,
|
||||||
|
codec: null,
|
||||||
|
language: null,
|
||||||
|
language_display: null,
|
||||||
|
title: null,
|
||||||
|
is_default: 0,
|
||||||
|
is_forced: 0,
|
||||||
|
is_hearing_impaired: 0,
|
||||||
|
channels: null,
|
||||||
|
channel_layout: null,
|
||||||
|
bit_rate: null,
|
||||||
|
sample_rate: null,
|
||||||
|
...o,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ITEM_DEFAULTS = { needs_review: 0 as number, container: 'mkv' as string | null };
|
||||||
|
|
||||||
|
describe('analyzeItem — audio keep rules', () => {
|
||||||
|
test('keeps only OG + configured languages, drops others', () => {
|
||||||
|
const streams = [
|
||||||
|
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }),
|
||||||
|
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }),
|
||||||
|
stream({ id: 3, type: 'Audio', stream_index: 2, codec: 'aac', language: 'deu' }),
|
||||||
|
stream({ id: 4, type: 'Audio', stream_index: 3, codec: 'aac', language: 'fra' }),
|
||||||
|
];
|
||||||
|
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
|
||||||
|
subtitleLanguages: [],
|
||||||
|
audioLanguages: ['deu'],
|
||||||
|
});
|
||||||
|
const actions = Object.fromEntries(result.decisions.map(d => [d.stream_id, d.action]));
|
||||||
|
expect(actions).toEqual({ 1: 'keep', 2: 'keep', 3: 'keep', 4: 'remove' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps all audio when OG language unknown', () => {
|
||||||
|
const streams = [
|
||||||
|
stream({ id: 1, type: 'Audio', stream_index: 0, language: 'eng' }),
|
||||||
|
stream({ id: 2, type: 'Audio', stream_index: 1, language: 'deu' }),
|
||||||
|
stream({ id: 3, type: 'Audio', stream_index: 2, language: 'fra' }),
|
||||||
|
];
|
||||||
|
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: null, needs_review: 1 }, streams, {
|
||||||
|
subtitleLanguages: [],
|
||||||
|
audioLanguages: ['deu'],
|
||||||
|
});
|
||||||
|
expect(result.decisions.every(d => d.action === 'keep')).toBe(true);
|
||||||
|
expect(result.notes.some(n => n.includes('manual review'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps audio tracks with undetermined language', () => {
|
||||||
|
const streams = [
|
||||||
|
stream({ id: 1, type: 'Audio', stream_index: 0, language: 'eng' }),
|
||||||
|
stream({ id: 2, type: 'Audio', stream_index: 1, language: null }),
|
||||||
|
];
|
||||||
|
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
|
||||||
|
subtitleLanguages: [],
|
||||||
|
audioLanguages: [],
|
||||||
|
});
|
||||||
|
const byId = Object.fromEntries(result.decisions.map(d => [d.stream_id, d.action]));
|
||||||
|
expect(byId[2]).toBe('keep');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes language codes (ger → deu)', () => {
|
||||||
|
const streams = [
|
||||||
|
stream({ id: 1, type: 'Audio', stream_index: 0, language: 'ger' }),
|
||||||
|
];
|
||||||
|
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'deu' }, streams, {
|
||||||
|
subtitleLanguages: [],
|
||||||
|
audioLanguages: [],
|
||||||
|
});
|
||||||
|
expect(result.decisions[0].action).toBe('keep');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('analyzeItem — audio ordering', () => {
|
||||||
|
test('OG first, then additional languages in configured order', () => {
|
||||||
|
const streams = [
|
||||||
|
stream({ id: 10, type: 'Audio', stream_index: 0, codec: 'aac', language: 'deu' }),
|
||||||
|
stream({ id: 11, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }),
|
||||||
|
stream({ id: 12, type: 'Audio', stream_index: 2, codec: 'aac', language: 'spa' }),
|
||||||
|
];
|
||||||
|
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
|
||||||
|
subtitleLanguages: [],
|
||||||
|
audioLanguages: ['deu', 'spa'],
|
||||||
|
});
|
||||||
|
const byId = Object.fromEntries(result.decisions.map(d => [d.stream_id, d.target_index]));
|
||||||
|
expect(byId[11]).toBe(0); // eng (OG) first
|
||||||
|
expect(byId[10]).toBe(1); // deu second
|
||||||
|
expect(byId[12]).toBe(2); // spa third
|
||||||
|
});
|
||||||
|
|
||||||
|
test('audioOrderChanged is_noop=false when OG audio is not first in input', () => {
|
||||||
|
const streams = [
|
||||||
|
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }),
|
||||||
|
stream({ id: 2, type: 'Audio', stream_index: 1, language: 'deu' }),
|
||||||
|
stream({ id: 3, type: 'Audio', stream_index: 2, language: 'eng' }),
|
||||||
|
];
|
||||||
|
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
|
||||||
|
subtitleLanguages: [],
|
||||||
|
audioLanguages: ['deu'],
|
||||||
|
});
|
||||||
|
expect(result.is_noop).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('audioOrderChanged is_noop=true when OG audio is already first', () => {
|
||||||
|
const streams = [
|
||||||
|
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }),
|
||||||
|
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }),
|
||||||
|
stream({ id: 3, type: 'Audio', stream_index: 2, codec: 'aac', language: 'deu' }),
|
||||||
|
];
|
||||||
|
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
|
||||||
|
subtitleLanguages: [],
|
||||||
|
audioLanguages: ['deu'],
|
||||||
|
});
|
||||||
|
expect(result.is_noop).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing an audio track triggers non-noop even if OG first', () => {
|
||||||
|
const streams = [
|
||||||
|
stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'aac', language: 'eng' }),
|
||||||
|
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'fra' }),
|
||||||
|
];
|
||||||
|
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
|
||||||
|
subtitleLanguages: [],
|
||||||
|
audioLanguages: [],
|
||||||
|
});
|
||||||
|
expect(result.is_noop).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('analyzeItem — subtitles & is_noop', () => {
|
||||||
|
test('subtitles are always marked remove (extracted to sidecar)', () => {
|
||||||
|
const streams = [
|
||||||
|
stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'aac', language: 'eng' }),
|
||||||
|
stream({ id: 2, type: 'Subtitle', stream_index: 1, codec: 'subrip', language: 'eng' }),
|
||||||
|
];
|
||||||
|
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
|
||||||
|
subtitleLanguages: ['eng'],
|
||||||
|
audioLanguages: [],
|
||||||
|
});
|
||||||
|
const subDec = result.decisions.find(d => d.stream_id === 2);
|
||||||
|
expect(subDec?.action).toBe('remove');
|
||||||
|
expect(result.is_noop).toBe(false); // subs present → not noop
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no audio change, no subs → is_noop true', () => {
|
||||||
|
const streams = [
|
||||||
|
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }),
|
||||||
|
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }),
|
||||||
|
];
|
||||||
|
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
|
||||||
|
subtitleLanguages: [],
|
||||||
|
audioLanguages: [],
|
||||||
|
});
|
||||||
|
expect(result.is_noop).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('analyzeItem — transcode targets', () => {
|
||||||
|
test('DTS on mp4 → transcode to eac3', () => {
|
||||||
|
const streams = [
|
||||||
|
stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'dts', language: 'eng' }),
|
||||||
|
];
|
||||||
|
const result = analyzeItem({ original_language: 'eng', needs_review: 0, container: 'mp4' }, streams, {
|
||||||
|
subtitleLanguages: [],
|
||||||
|
audioLanguages: [],
|
||||||
|
});
|
||||||
|
expect(result.decisions[0].transcode_codec).toBe('eac3');
|
||||||
|
expect(result.job_type).toBe('transcode');
|
||||||
|
expect(result.is_noop).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AAC passes through without transcode', () => {
|
||||||
|
const streams = [
|
||||||
|
stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'aac', language: 'eng' }),
|
||||||
|
];
|
||||||
|
const result = analyzeItem({ original_language: 'eng', needs_review: 0, container: 'mp4' }, streams, {
|
||||||
|
subtitleLanguages: [],
|
||||||
|
audioLanguages: [],
|
||||||
|
});
|
||||||
|
expect(result.decisions[0].transcode_codec).toBe(null);
|
||||||
|
expect(result.job_type).toBe('copy');
|
||||||
|
});
|
||||||
|
});
|
||||||
193
server/services/__tests__/ffmpeg.test.ts
Normal file
193
server/services/__tests__/ffmpeg.test.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import { buildCommand, buildPipelineCommand, shellQuote, sortKeptStreams, predictExtractedFiles } from '../ffmpeg';
|
||||||
|
import type { MediaItem, MediaStream, StreamDecision } from '../../types';
|
||||||
|
|
||||||
|
function stream(o: Partial<MediaStream> & Pick<MediaStream, 'id' | 'type' | 'stream_index'>): MediaStream {
|
||||||
|
return {
|
||||||
|
item_id: 1,
|
||||||
|
codec: null,
|
||||||
|
language: null,
|
||||||
|
language_display: null,
|
||||||
|
title: null,
|
||||||
|
is_default: 0,
|
||||||
|
is_forced: 0,
|
||||||
|
is_hearing_impaired: 0,
|
||||||
|
channels: null,
|
||||||
|
channel_layout: null,
|
||||||
|
bit_rate: null,
|
||||||
|
sample_rate: null,
|
||||||
|
...o,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decision(o: Partial<StreamDecision> & Pick<StreamDecision, 'stream_id' | 'action'>): StreamDecision {
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
plan_id: 1,
|
||||||
|
target_index: null,
|
||||||
|
custom_title: null,
|
||||||
|
transcode_codec: null,
|
||||||
|
...o,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ITEM: MediaItem = {
|
||||||
|
id: 1, jellyfin_id: 'x', type: 'Movie', name: 'Test', series_name: null,
|
||||||
|
series_jellyfin_id: null, season_number: null, episode_number: null, year: null,
|
||||||
|
file_path: '/movies/Test.mkv', file_size: null, container: 'mkv',
|
||||||
|
original_language: 'eng', orig_lang_source: 'jellyfin', needs_review: 0,
|
||||||
|
imdb_id: null, tmdb_id: null, tvdb_id: null, scan_status: 'scanned',
|
||||||
|
scan_error: null, last_scanned_at: null, created_at: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('shellQuote', () => {
|
||||||
|
test('wraps plain strings in single quotes', () => {
|
||||||
|
expect(shellQuote('hello')).toBe("'hello'");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('escapes single quotes safely', () => {
|
||||||
|
expect(shellQuote("it's")).toBe("'it'\\''s'");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles paths with spaces', () => {
|
||||||
|
expect(shellQuote('/movies/My Movie.mkv')).toBe("'/movies/My Movie.mkv'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sortKeptStreams', () => {
|
||||||
|
test('orders by type priority (Video, Audio, Subtitle, Data), then target_index', () => {
|
||||||
|
const streams = [
|
||||||
|
stream({ id: 1, type: 'Audio', stream_index: 1 }),
|
||||||
|
stream({ id: 2, type: 'Video', stream_index: 0 }),
|
||||||
|
stream({ id: 3, type: 'Audio', stream_index: 2 }),
|
||||||
|
];
|
||||||
|
const decisions = [
|
||||||
|
decision({ stream_id: 1, action: 'keep', target_index: 1 }),
|
||||||
|
decision({ stream_id: 2, action: 'keep', target_index: 0 }),
|
||||||
|
decision({ stream_id: 3, action: 'keep', target_index: 0 }),
|
||||||
|
];
|
||||||
|
const sorted = sortKeptStreams(streams, decisions);
|
||||||
|
expect(sorted.map(k => k.stream.id)).toEqual([2, 3, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops streams with action remove', () => {
|
||||||
|
const streams = [stream({ id: 1, type: 'Audio', stream_index: 0 })];
|
||||||
|
const decisions = [decision({ stream_id: 1, action: 'remove' })];
|
||||||
|
expect(sortKeptStreams(streams, decisions)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildCommand', () => {
|
||||||
|
test('produces ffmpeg remux with tmp-rename pattern', () => {
|
||||||
|
const streams = [
|
||||||
|
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }),
|
||||||
|
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }),
|
||||||
|
];
|
||||||
|
const decisions = [
|
||||||
|
decision({ stream_id: 1, action: 'keep', target_index: 0 }),
|
||||||
|
decision({ stream_id: 2, action: 'keep', target_index: 0 }),
|
||||||
|
];
|
||||||
|
const cmd = buildCommand(ITEM, streams, decisions);
|
||||||
|
expect(cmd).toContain('ffmpeg');
|
||||||
|
expect(cmd).toContain('-map 0:v:0');
|
||||||
|
expect(cmd).toContain('-map 0:a:0');
|
||||||
|
expect(cmd).toContain('-c copy');
|
||||||
|
expect(cmd).toContain("'/movies/Test.tmp.mkv'");
|
||||||
|
expect(cmd).toContain("mv '/movies/Test.tmp.mkv' '/movies/Test.mkv'");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses type-relative specifiers (0:a:N) not absolute stream_index', () => {
|
||||||
|
const streams = [
|
||||||
|
stream({ id: 1, type: 'Video', stream_index: 0 }),
|
||||||
|
stream({ id: 2, type: 'Audio', stream_index: 1 }),
|
||||||
|
stream({ id: 3, type: 'Audio', stream_index: 2 }),
|
||||||
|
];
|
||||||
|
// Keep only the second audio; still mapped as 0:a:1
|
||||||
|
const decisions = [
|
||||||
|
decision({ stream_id: 1, action: 'keep', target_index: 0 }),
|
||||||
|
decision({ stream_id: 2, action: 'remove' }),
|
||||||
|
decision({ stream_id: 3, action: 'keep', target_index: 0 }),
|
||||||
|
];
|
||||||
|
const cmd = buildCommand(ITEM, streams, decisions);
|
||||||
|
expect(cmd).toContain('-map 0:a:1');
|
||||||
|
expect(cmd).not.toContain('-map 0:a:2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets first kept audio as default, clears others', () => {
|
||||||
|
const streams = [
|
||||||
|
stream({ id: 1, type: 'Video', stream_index: 0 }),
|
||||||
|
stream({ id: 2, type: 'Audio', stream_index: 1, language: 'eng' }),
|
||||||
|
stream({ id: 3, type: 'Audio', stream_index: 2, language: 'deu' }),
|
||||||
|
];
|
||||||
|
const decisions = [
|
||||||
|
decision({ stream_id: 1, action: 'keep', target_index: 0 }),
|
||||||
|
decision({ stream_id: 2, action: 'keep', target_index: 0 }),
|
||||||
|
decision({ stream_id: 3, action: 'keep', target_index: 1 }),
|
||||||
|
];
|
||||||
|
const cmd = buildCommand(ITEM, streams, decisions);
|
||||||
|
expect(cmd).toContain('-disposition:a:0 default');
|
||||||
|
expect(cmd).toContain('-disposition:a:1 0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildPipelineCommand', () => {
|
||||||
|
test('emits subtitle extraction outputs and final remux in one pass', () => {
|
||||||
|
const streams = [
|
||||||
|
stream({ id: 1, type: 'Video', stream_index: 0 }),
|
||||||
|
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }),
|
||||||
|
stream({ id: 3, type: 'Subtitle', stream_index: 2, codec: 'subrip', language: 'eng' }),
|
||||||
|
];
|
||||||
|
const decisions = [
|
||||||
|
decision({ stream_id: 1, action: 'keep', target_index: 0 }),
|
||||||
|
decision({ stream_id: 2, action: 'keep', target_index: 0 }),
|
||||||
|
decision({ stream_id: 3, action: 'remove' }),
|
||||||
|
];
|
||||||
|
const { command, extractedFiles } = buildPipelineCommand(ITEM, streams, decisions);
|
||||||
|
expect(command).toContain('-map 0:s:0');
|
||||||
|
expect(command).toContain('-c:s copy');
|
||||||
|
expect(command).toContain("'/movies/Test.en.srt'");
|
||||||
|
expect(command).toContain('-map 0:v:0');
|
||||||
|
expect(command).toContain('-map 0:a:0');
|
||||||
|
expect(extractedFiles).toHaveLength(1);
|
||||||
|
expect(extractedFiles[0].path).toBe('/movies/Test.en.srt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transcodes incompatible audio with per-track codec flag', () => {
|
||||||
|
const dtsItem = { ...ITEM, container: 'mp4', file_path: '/movies/x.mp4' };
|
||||||
|
const streams = [
|
||||||
|
stream({ id: 1, type: 'Video', stream_index: 0 }),
|
||||||
|
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'dts', language: 'eng', channels: 6 }),
|
||||||
|
];
|
||||||
|
const decisions = [
|
||||||
|
decision({ stream_id: 1, action: 'keep', target_index: 0 }),
|
||||||
|
decision({ stream_id: 2, action: 'keep', target_index: 0, transcode_codec: 'eac3' }),
|
||||||
|
];
|
||||||
|
const { command } = buildPipelineCommand(dtsItem, streams, decisions);
|
||||||
|
expect(command).toContain('-c:a:0 eac3');
|
||||||
|
expect(command).toContain('-b:a:0 640k'); // 6 channels → 640k
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('predictExtractedFiles', () => {
|
||||||
|
test('predicts sidecar paths matching extraction output', () => {
|
||||||
|
const streams = [
|
||||||
|
stream({ id: 1, type: 'Subtitle', stream_index: 0, codec: 'subrip', language: 'eng' }),
|
||||||
|
stream({ id: 2, type: 'Subtitle', stream_index: 1, codec: 'subrip', language: 'deu', is_forced: 1 }),
|
||||||
|
];
|
||||||
|
const files = predictExtractedFiles(ITEM, streams);
|
||||||
|
expect(files).toHaveLength(2);
|
||||||
|
expect(files[0].file_path).toBe('/movies/Test.en.srt');
|
||||||
|
expect(files[1].file_path).toBe('/movies/Test.de.forced.srt');
|
||||||
|
expect(files[1].is_forced).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deduplicates paths with a numeric suffix', () => {
|
||||||
|
const streams = [
|
||||||
|
stream({ id: 1, type: 'Subtitle', stream_index: 0, codec: 'subrip', language: 'eng' }),
|
||||||
|
stream({ id: 2, type: 'Subtitle', stream_index: 1, codec: 'subrip', language: 'eng' }),
|
||||||
|
];
|
||||||
|
const files = predictExtractedFiles(ITEM, streams);
|
||||||
|
expect(files[0].file_path).toBe('/movies/Test.en.srt');
|
||||||
|
expect(files[1].file_path).toBe('/movies/Test.en.2.srt');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user