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:
parent
5b1c68a122
commit
987fb73a0e
115
src/main/domain/integrity-check.test.ts
Normal file
115
src/main/domain/integrity-check.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
132
src/main/domain/integrity-check.ts
Normal file
132
src/main/domain/integrity-check.ts
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user