From f11861658e7951bf9691b14f43d927f8c6299bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Mon, 13 Apr 2026 07:35:24 +0200 Subject: [PATCH] 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' --- package.json | 2 +- server/api/execute.ts | 39 ++++- server/lib/__tests__/validate.test.ts | 34 ++++ server/services/__tests__/analyzer.test.ts | 191 ++++++++++++++++++++ server/services/__tests__/ffmpeg.test.ts | 193 +++++++++++++++++++++ 5 files changed, 456 insertions(+), 3 deletions(-) create mode 100644 server/lib/__tests__/validate.test.ts create mode 100644 server/services/__tests__/analyzer.test.ts create mode 100644 server/services/__tests__/ffmpeg.test.ts diff --git a/package.json b/package.json index 0ebc495..f598819 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/api/execute.ts b/server/api/execute.ts index e9d7d1d..6310897 100644 --- a/server/api/execute.ts +++ b/server/api/execute.ts @@ -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 { 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 { 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, prefix = '') => { @@ -247,11 +277,16 @@ async function runJob(job: Job): Promise { 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); } diff --git a/server/lib/__tests__/validate.test.ts b/server/lib/__tests__/validate.test.ts new file mode 100644 index 0000000..47d4cba --- /dev/null +++ b/server/lib/__tests__/validate.test.ts @@ -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); + }); +}); diff --git a/server/services/__tests__/analyzer.test.ts b/server/services/__tests__/analyzer.test.ts new file mode 100644 index 0000000..43d157d --- /dev/null +++ b/server/services/__tests__/analyzer.test.ts @@ -0,0 +1,191 @@ +import { describe, test, expect } from 'bun:test'; +import { analyzeItem } from '../analyzer'; +import type { MediaStream } from '../../types'; + +type StreamOverride = Partial & Pick; + +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'); + }); +}); diff --git a/server/services/__tests__/ffmpeg.test.ts b/server/services/__tests__/ffmpeg.test.ts new file mode 100644 index 0000000..46cc6d7 --- /dev/null +++ b/server/services/__tests__/ffmpeg.test.ts @@ -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 & Pick): 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 & Pick): 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'); + }); +});