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",
|
||||
"lint": "biome check .",
|
||||
"format": "biome format . --write",
|
||||
"test": "echo 'No tests yet'"
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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);
|
||||
}
|
||||
|
||||
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) {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`
|
||||
@@ -218,6 +231,8 @@ async function runJob(job: Job): Promise<void> {
|
||||
const outputLines: string[] = [];
|
||||
let pendingFlush = false;
|
||||
let lastFlushAt = 0;
|
||||
let totalSeconds = 0;
|
||||
let lastProgressEmit = 0;
|
||||
const updateOutput = db.prepare('UPDATE jobs SET output = ? WHERE id = ?');
|
||||
|
||||
const flush = (final = false) => {
|
||||
@@ -233,6 +248,21 @@ async function runJob(job: Job): Promise<void> {
|
||||
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 {
|
||||
const proc = Bun.spawn(['sh', '-c', job.command], { stdout: 'pipe', stderr: 'pipe' });
|
||||
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/);
|
||||
buffer = parts.pop() ?? '';
|
||||
for (const line of parts) {
|
||||
if (line.trim()) outputLines.push(prefix + line);
|
||||
if (!line.trim()) continue;
|
||||
outputLines.push(prefix + line);
|
||||
consumeProgress(line);
|
||||
}
|
||||
flush();
|
||||
}
|
||||
if (buffer.trim()) outputLines.push(prefix + buffer);
|
||||
if (buffer.trim()) {
|
||||
outputLines.push(prefix + buffer);
|
||||
consumeProgress(buffer);
|
||||
}
|
||||
} catch (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