diff --git a/src/main/domain/integrity-check.test.ts b/src/main/domain/integrity-check.test.ts new file mode 100644 index 0000000..a66720a --- /dev/null +++ b/src/main/domain/integrity-check.test.ts @@ -0,0 +1,115 @@ +import { test, expect, describe } from 'vitest'; +import { parseFfprobeJson, assessIntegrity, verifyIntegrityFromJson } from './integrity-check'; + +const FIXTURE_GOOD = JSON.stringify({ + streams: [ + { index: 0, codec_type: 'video', codec_name: 'h264', width: 1920, height: 1080, duration: '600.5' }, + { index: 1, codec_type: 'audio', codec_name: 'aac', duration: '600.5' }, + ], + format: { duration: '600.5', size: '50000000' }, +}); + +const FIXTURE_NO_VIDEO = JSON.stringify({ + streams: [ + { index: 0, codec_type: 'audio', codec_name: 'aac', duration: '10' }, + ], + format: { duration: '10', size: '500000' }, +}); + +const FIXTURE_EMPTY = JSON.stringify({ + streams: [], + format: { duration: '0.04', size: '1234' }, +}); + +describe('parseFfprobeJson', () => { + test('parses streams + format', () => { + const r = parseFfprobeJson(FIXTURE_GOOD); + expect(r.streams).toHaveLength(2); + expect(r.streams[0].codecType).toBe('video'); + expect(r.streams[0].codecName).toBe('h264'); + expect(r.streams[0].width).toBe(1920); + expect(r.durationSeconds).toBe(600.5); + expect(r.sizeBytes).toBe(50000000); + }); + + test('handles missing format gracefully', () => { + const r = parseFfprobeJson(JSON.stringify({ streams: [] })); + expect(r.durationSeconds).toBe(0); + expect(r.sizeBytes).toBe(0); + }); + + test('throws on malformed JSON', () => { + expect(() => parseFfprobeJson('{not-valid')).toThrow(/parse failed/); + }); + + test('coerces numeric strings to numbers', () => { + const r = parseFfprobeJson(JSON.stringify({ + streams: [{ codec_type: 'video', duration: '12.34' }], + format: { duration: '12.34', size: '987654' }, + })); + expect(r.durationSeconds).toBe(12.34); + expect(r.streams[0].durationSeconds).toBe(12.34); + expect(r.sizeBytes).toBe(987654); + }); +}); + +describe('assessIntegrity', () => { + test('valid file: ok=true, no reasons', () => { + const probe = parseFfprobeJson(FIXTURE_GOOD); + const v = assessIntegrity(probe); + expect(v.ok).toBe(true); + expect(v.reasons).toEqual([]); + expect(v.hasVideo).toBe(true); + expect(v.hasAudio).toBe(true); + expect(v.durationSeconds).toBe(600.5); + }); + + test('no-video stream rejected', () => { + const v = assessIntegrity(parseFfprobeJson(FIXTURE_NO_VIDEO)); + expect(v.ok).toBe(false); + expect(v.reasons).toContain('no-video-stream'); + expect(v.hasVideo).toBe(false); + }); + + test('zero-duration rejected as too-short', () => { + const v = assessIntegrity(parseFfprobeJson(FIXTURE_EMPTY)); + expect(v.ok).toBe(false); + expect(v.reasons.some(r => r.startsWith('duration-too-short'))).toBe(true); + }); + + test('expected-duration mismatch outside tolerance flagged', () => { + const v = assessIntegrity(parseFfprobeJson(FIXTURE_GOOD), { + expectedDurationSeconds: 700, + durationToleranceSeconds: 5, + }); + expect(v.ok).toBe(false); + expect(v.reasons.some(r => r.startsWith('duration-mismatch'))).toBe(true); + }); + + test('expected-duration within tolerance accepted', () => { + const v = assessIntegrity(parseFfprobeJson(FIXTURE_GOOD), { + expectedDurationSeconds: 598, + durationToleranceSeconds: 5, + }); + expect(v.ok).toBe(true); + }); + + test('custom minDurationSeconds threshold', () => { + const v = assessIntegrity(parseFfprobeJson(FIXTURE_GOOD), { + minDurationSeconds: 700, + }); + expect(v.ok).toBe(false); + expect(v.reasons.some(r => r.startsWith('duration-too-short'))).toBe(true); + }); +}); + +describe('verifyIntegrityFromJson', () => { + test('one-shot parse + assess', () => { + const v = verifyIntegrityFromJson(FIXTURE_GOOD); + expect(v.ok).toBe(true); + }); + + test('propagates parse errors', () => { + expect(() => verifyIntegrityFromJson('{broken')).toThrow(); + }); +}); diff --git a/src/main/domain/integrity-check.ts b/src/main/domain/integrity-check.ts new file mode 100644 index 0000000..0a12fae --- /dev/null +++ b/src/main/domain/integrity-check.ts @@ -0,0 +1,132 @@ +// Wrappt ffprobe -show_streams -show_format -of json + entscheidet, ob eine +// fertige Recording-/Download-Datei strukturell valide ist. +// Pure-Parser-Layer ist getrennt testbar; das eigentliche Spawn ist im Caller. + +export interface ProbeStream { + index: number; + codecType: string; // 'video' | 'audio' | 'subtitle' | ... + codecName?: string; + width?: number; + height?: number; + durationSeconds?: number; +} + +export interface ProbeResult { + streams: ProbeStream[]; + durationSeconds: number; + sizeBytes: number; +} + +export interface IntegrityVerdict { + ok: boolean; + reasons: string[]; + durationSeconds: number; + hasVideo: boolean; + hasAudio: boolean; +} + +export interface IntegrityCheckOptions { + expectedDurationSeconds?: number; + durationToleranceSeconds?: number; // default 5 + minDurationSeconds?: number; // default 1 +} + +interface FfprobeJsonStream { + index?: number; + codec_type?: string; + codec_name?: string; + width?: number; + height?: number; + duration?: string | number; +} + +interface FfprobeJson { + streams?: FfprobeJsonStream[]; + format?: { + duration?: string | number; + size?: string | number; + }; +} + +function toNumber(v: unknown, fallback = 0): number { + if (typeof v === 'number' && Number.isFinite(v)) return v; + if (typeof v === 'string') { + const n = Number(v); + if (Number.isFinite(n)) return n; + } + return fallback; +} + +export function parseFfprobeJson(rawJson: string): ProbeResult { + let parsed: FfprobeJson; + try { + parsed = JSON.parse(rawJson) as FfprobeJson; + } catch (e) { + throw new Error(`integrity-check: ffprobe JSON parse failed: ${e instanceof Error ? e.message : String(e)}`); + } + + const streams: ProbeStream[] = (parsed.streams ?? []).map((s, idx) => ({ + index: typeof s.index === 'number' ? s.index : idx, + codecType: typeof s.codec_type === 'string' ? s.codec_type : 'unknown', + codecName: typeof s.codec_name === 'string' ? s.codec_name : undefined, + width: typeof s.width === 'number' ? s.width : undefined, + height: typeof s.height === 'number' ? s.height : undefined, + durationSeconds: s.duration !== undefined ? toNumber(s.duration) : undefined, + })); + + const formatDuration = toNumber(parsed.format?.duration, 0); + const formatSize = toNumber(parsed.format?.size, 0); + + return { + streams, + durationSeconds: formatDuration, + sizeBytes: formatSize, + }; +} + +export function assessIntegrity(probe: ProbeResult, opts: IntegrityCheckOptions = {}): IntegrityVerdict { + const minDuration = opts.minDurationSeconds ?? 1; + const tolerance = opts.durationToleranceSeconds ?? 5; + + const hasVideo = probe.streams.some(s => s.codecType === 'video'); + const hasAudio = probe.streams.some(s => s.codecType === 'audio'); + + const reasons: string[] = []; + + if (!hasVideo) { + reasons.push('no-video-stream'); + } + + if (probe.durationSeconds < minDuration) { + reasons.push(`duration-too-short:${probe.durationSeconds.toFixed(2)}s<${minDuration}s`); + } + + if (typeof opts.expectedDurationSeconds === 'number' && opts.expectedDurationSeconds > 0) { + const diff = Math.abs(probe.durationSeconds - opts.expectedDurationSeconds); + if (diff > tolerance) { + reasons.push( + `duration-mismatch:actual=${probe.durationSeconds.toFixed(2)}s,` + + `expected=${opts.expectedDurationSeconds.toFixed(2)}s,` + + `tolerance=${tolerance}s` + ); + } + } + + return { + ok: reasons.length === 0, + reasons, + durationSeconds: probe.durationSeconds, + hasVideo, + hasAudio, + }; +} + +/** + * Convenience: vollstaendige integrity-check Pipeline. Caller liefert die + * ffprobe-JSON-Ausgabe als String (so bleibt das Modul Spawn-frei + leicht + * testbar; die main.ts hat schon ffprobe-Spawn-Helpers). + */ +export function verifyIntegrityFromJson(rawJson: string, opts?: IntegrityCheckOptions): IntegrityVerdict { + const probe = parseFfprobeJson(rawJson); + return assessIntegrity(probe, opts); +}