Files
SubMiner/src/core/services/immersion-tracker/metadata.test.ts

149 lines
4.5 KiB
TypeScript

import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { EventEmitter } from 'node:events';
import test from 'node:test';
import type { spawn as spawnFn } from 'node:child_process';
import { SOURCE_TYPE_LOCAL } from './types';
import { getLocalVideoMetadata, runFfprobe } from './metadata';
type Spawn = typeof spawnFn;
function createSpawnStub(options: {
stdout?: string;
stderr?: string;
emitError?: boolean;
}): Spawn {
return (() => {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
};
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
queueMicrotask(() => {
if (options.emitError) {
child.emit('error', new Error('ffprobe failed'));
return;
}
if (options.stderr) {
child.stderr.emit('data', Buffer.from(options.stderr));
}
if (options.stdout !== undefined) {
child.stdout.emit('data', Buffer.from(options.stdout));
}
child.emit('close', 0);
});
return child as unknown as ReturnType<Spawn>;
}) as Spawn;
}
test('runFfprobe parses valid JSON from stream and format sections', async () => {
const metadata = await runFfprobe('/tmp/video.mp4', {
spawn: createSpawnStub({
stdout: JSON.stringify({
format: { duration: '12.34', bit_rate: '3456000' },
streams: [
{
codec_type: 'video',
codec_tag_string: 'avc1',
width: 1920,
height: 1080,
avg_frame_rate: '24000/1001',
},
{
codec_type: 'audio',
codec_tag_string: 'mp4a',
},
],
}),
}),
});
assert.equal(metadata.durationMs, 12340);
assert.equal(metadata.bitrateKbps, 3456);
assert.equal(metadata.widthPx, 1920);
assert.equal(metadata.heightPx, 1080);
assert.equal(metadata.fpsX100, 2398);
assert.equal(metadata.containerId, 0);
assert.ok(Number(metadata.codecId) > 0);
assert.ok(Number(metadata.audioCodecId) > 0);
});
test('runFfprobe returns empty metadata for invalid JSON and process errors', async () => {
const invalidJsonMetadata = await runFfprobe('/tmp/broken.mp4', {
spawn: createSpawnStub({ stdout: '{invalid' }),
});
assert.deepEqual(invalidJsonMetadata, {
durationMs: null,
codecId: null,
containerId: null,
widthPx: null,
heightPx: null,
fpsX100: null,
bitrateKbps: null,
audioCodecId: null,
});
const errorMetadata = await runFfprobe('/tmp/error.mp4', {
spawn: createSpawnStub({ emitError: true }),
});
assert.deepEqual(errorMetadata, {
durationMs: null,
codecId: null,
containerId: null,
widthPx: null,
heightPx: null,
fpsX100: null,
bitrateKbps: null,
audioCodecId: null,
});
});
test('getLocalVideoMetadata derives title and falls back to null hash on read errors', async () => {
const successMetadata = await getLocalVideoMetadata('/tmp/Episode 01.mkv', {
spawn: createSpawnStub({ stdout: JSON.stringify({ format: { duration: '0' }, streams: [] }) }),
fs: {
createReadStream: () => {
const stream = new EventEmitter();
queueMicrotask(() => {
stream.emit('data', Buffer.from('hello world'));
stream.emit('end');
});
return stream as unknown as ReturnType<typeof import('node:fs').createReadStream>;
},
promises: {
stat: (async () => ({ size: 1234 }) as unknown) as typeof import('node:fs').promises.stat,
},
} as never,
});
assert.equal(successMetadata.sourceType, SOURCE_TYPE_LOCAL);
assert.equal(successMetadata.canonicalTitle, 'Episode 01');
assert.equal(successMetadata.fileSizeBytes, 1234);
assert.equal(
successMetadata.hashSha256,
createHash('sha256').update('hello world').digest('hex'),
);
const hashFallbackMetadata = await getLocalVideoMetadata('/tmp/Episode 02.mkv', {
spawn: createSpawnStub({ stdout: JSON.stringify({ format: {}, streams: [] }) }),
fs: {
createReadStream: () => {
const stream = new EventEmitter();
queueMicrotask(() => {
stream.emit('error', new Error('read failed'));
});
return stream as unknown as ReturnType<typeof import('node:fs').createReadStream>;
},
promises: {
stat: (async () => ({ size: 5678 }) as unknown) as typeof import('node:fs').promises.stat,
},
} as never,
});
assert.equal(hashFallbackMetadata.canonicalTitle, 'Episode 02');
assert.equal(hashFallbackMetadata.hashSha256, null);
});