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