mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-02 18:22:42 -08:00
149 lines
4.5 KiB
TypeScript
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);
|
|
});
|