feat(integrity): ffprobe JSON parser + verdict assessor (12 tests)

Pillar 1 storage-layer piece. Spawn-free design — caller liefert das
ffprobe-Output als String, dieses Modul parsed + bewertet (no-video-stream,
duration-too-short, expected-duration mismatch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-11 23:52:48 +02:00
parent 5b1c68a122
commit 987fb73a0e
2 changed files with 247 additions and 0 deletions

View File

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

View File

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