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:
2026-04-13 07:35:24 +02:00
parent 93ed0ac33c
commit f11861658e
5 changed files with 456 additions and 3 deletions

View File

@@ -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",

View File

@@ -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);
}

View 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);
});
});

View 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');
});
});

View 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');
});
});