mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-19 05:16:27 -07:00
292 lines
9.7 KiB
TypeScript
292 lines
9.7 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { EventEmitter } from 'node:events';
|
|
import net from 'node:net';
|
|
import { describe, test } from 'node:test';
|
|
import { buildMediaTimingPreviewArgs, MediaTimingPreviewSession } from './media-timing-preview';
|
|
|
|
describe('buildMediaTimingPreviewArgs', () => {
|
|
test('creates a hidden audio-only reusable mpv session', () => {
|
|
const args = buildMediaTimingPreviewArgs('/tmp/review.sock', {
|
|
mediaPath: '/video/show.mkv',
|
|
audioTrackId: 3,
|
|
volume: 55,
|
|
});
|
|
|
|
assert.ok(args.includes('--no-video'));
|
|
assert.ok(args.includes('--force-window=no'));
|
|
assert.ok(args.includes('--idle=yes'));
|
|
assert.ok(args.includes('--pause=yes'));
|
|
assert.ok(args.includes('--input-ipc-server=/tmp/review.sock'));
|
|
assert.ok(args.includes('--aid=3'));
|
|
assert.ok(args.includes('--volume=55'));
|
|
assert.equal(args.at(-2), '--');
|
|
assert.equal(args.at(-1), '/video/show.mkv');
|
|
});
|
|
|
|
test('keeps source timestamps for cached remote windows', () => {
|
|
const args = buildMediaTimingPreviewArgs('/tmp/review.sock', {
|
|
mediaPath: '/tmp/window.mkv',
|
|
absoluteTimestamps: true,
|
|
});
|
|
|
|
assert.ok(args.includes('--rebase-start-time=no'));
|
|
assert.equal(
|
|
buildMediaTimingPreviewArgs('/tmp/review.sock', { mediaPath: '/video/show.mkv' }).includes(
|
|
'--rebase-start-time=no',
|
|
),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('separates an option-like media path without adding optional audio arguments', () => {
|
|
const args = buildMediaTimingPreviewArgs('/tmp/review.sock', {
|
|
mediaPath: '--fullscreen',
|
|
});
|
|
|
|
assert.equal(args.at(-2), '--');
|
|
assert.equal(args.at(-1), '--fullscreen');
|
|
assert.equal(
|
|
args.some((arg) => arg.startsWith('--aid=')),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
args.some((arg) => arg.startsWith('--volume=')),
|
|
false,
|
|
);
|
|
});
|
|
});
|
|
|
|
test('preview session handles socket errors after connecting', async () => {
|
|
const socket = new net.Socket();
|
|
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
|
child.kill = () => true;
|
|
const session = new MediaTimingPreviewSession({
|
|
platform: 'linux',
|
|
spawnProcess: () => child as never,
|
|
connectSocket: () => {
|
|
queueMicrotask(() => socket.emit('connect'));
|
|
return socket;
|
|
},
|
|
removeSocketFile: () => undefined,
|
|
createSocketPath: () => '/tmp/review.sock',
|
|
});
|
|
|
|
await session.start({ mediaPath: '/video/show.mkv' });
|
|
assert.doesNotThrow(() => socket.emit('error', new Error('pipe closed')));
|
|
await assert.rejects(session.play(1, 2), /not ready/);
|
|
session.dispose();
|
|
});
|
|
|
|
test('preview session keeps failed connection errors handled through destruction', async () => {
|
|
const socket = new EventEmitter() as EventEmitter & {
|
|
destroy: () => void;
|
|
};
|
|
socket.destroy = () => {
|
|
socket.emit('error', new Error('socket failed again while closing'));
|
|
};
|
|
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
|
child.kill = () => true;
|
|
const times = [0, 0, 0, 6_000];
|
|
const session = new MediaTimingPreviewSession({
|
|
platform: 'linux',
|
|
spawnProcess: () => child as never,
|
|
connectSocket: () => {
|
|
queueMicrotask(() => socket.emit('error', new Error('connection failed')));
|
|
return socket as never;
|
|
},
|
|
now: () => times.shift() ?? 6_000,
|
|
removeSocketFile: () => undefined,
|
|
createSocketPath: () => '/tmp/review.sock',
|
|
});
|
|
|
|
await assert.rejects(session.start({ mediaPath: '/video/show.mkv' }), /Timed out starting/);
|
|
});
|
|
|
|
test('preview session rejects a connection that finishes after disposal', async () => {
|
|
const socket = new net.Socket();
|
|
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
|
child.kill = () => true;
|
|
const session = new MediaTimingPreviewSession({
|
|
platform: 'linux',
|
|
spawnProcess: () => child as never,
|
|
connectSocket: () => socket,
|
|
removeSocketFile: () => undefined,
|
|
createSocketPath: () => '/tmp/review.sock',
|
|
});
|
|
|
|
const pendingStart = session.start({ mediaPath: '-playlist' });
|
|
session.dispose();
|
|
socket.emit('connect');
|
|
|
|
await assert.rejects(pendingStart, /closed/);
|
|
assert.equal(socket.destroyed, true);
|
|
});
|
|
|
|
test('preview session shares one startup across concurrent start calls', async () => {
|
|
const socket = new net.Socket();
|
|
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
|
child.kill = () => true;
|
|
let spawnCount = 0;
|
|
const session = new MediaTimingPreviewSession({
|
|
platform: 'linux',
|
|
spawnProcess: () => {
|
|
spawnCount += 1;
|
|
return child as never;
|
|
},
|
|
connectSocket: () => socket,
|
|
removeSocketFile: () => undefined,
|
|
createSocketPath: () => '/tmp/review.sock',
|
|
});
|
|
|
|
const firstStart = session.start({ mediaPath: '/video/show.mkv' });
|
|
const secondStart = session.start({ mediaPath: '/video/show.mkv' });
|
|
socket.emit('connect');
|
|
|
|
await Promise.all([firstStart, secondStart]);
|
|
assert.equal(spawnCount, 1);
|
|
session.dispose();
|
|
});
|
|
|
|
test('preview session can start again after a startup failure', async () => {
|
|
const socket = new net.Socket();
|
|
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
|
child.kill = () => true;
|
|
let spawnCount = 0;
|
|
const session = new MediaTimingPreviewSession({
|
|
platform: 'linux',
|
|
spawnProcess: () => {
|
|
spawnCount += 1;
|
|
if (spawnCount === 1) throw new Error('spawn failed');
|
|
return child as never;
|
|
},
|
|
connectSocket: () => {
|
|
queueMicrotask(() => socket.emit('connect'));
|
|
return socket;
|
|
},
|
|
removeSocketFile: () => undefined,
|
|
createSocketPath: () => '/tmp/review.sock',
|
|
});
|
|
|
|
await assert.rejects(session.start({ mediaPath: '/video/show.mkv' }), /spawn failed/);
|
|
await session.start({ mediaPath: '/video/show.mkv' });
|
|
assert.equal(spawnCount, 2);
|
|
session.dispose();
|
|
});
|
|
|
|
test('preview session bounds a connection attempt that never settles', async () => {
|
|
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
|
child.kill = () => true;
|
|
let nowMs = 0;
|
|
let connectAttempts = 0;
|
|
const session = new MediaTimingPreviewSession({
|
|
platform: 'linux',
|
|
spawnProcess: () => child as never,
|
|
connectSocket: () => {
|
|
connectAttempts += 1;
|
|
const socket = new net.Socket();
|
|
socket.destroy = (() => {
|
|
socket.emit('error', new Error('socket failed while timing out'));
|
|
return socket;
|
|
}) as typeof socket.destroy;
|
|
return socket;
|
|
},
|
|
now: () => {
|
|
const current = nowMs;
|
|
nowMs += 1_000;
|
|
return current;
|
|
},
|
|
schedule: (callback) => setTimeout(callback, 0),
|
|
cancelSchedule: (timeout) => clearTimeout(timeout),
|
|
removeSocketFile: () => undefined,
|
|
createSocketPath: () => '/tmp/review.sock',
|
|
});
|
|
|
|
await assert.rejects(session.start({ mediaPath: '/video/show.mkv' }), /Timed out starting/);
|
|
assert.equal(connectAttempts, 1);
|
|
});
|
|
|
|
function createFakeSocket() {
|
|
const socket = new EventEmitter() as EventEmitter & {
|
|
destroyed: boolean;
|
|
write: (data: string) => boolean;
|
|
end: () => void;
|
|
destroy: () => void;
|
|
off: EventEmitter['off'];
|
|
};
|
|
const writes: string[] = [];
|
|
socket.destroyed = false;
|
|
socket.write = (data) => {
|
|
writes.push(data);
|
|
return true;
|
|
};
|
|
socket.end = () => undefined;
|
|
socket.destroy = () => {
|
|
socket.destroyed = true;
|
|
};
|
|
return { socket, writes };
|
|
}
|
|
|
|
test('preview session plays once to the clip end and reports when mpv has drained it', async () => {
|
|
const { socket, writes } = createFakeSocket();
|
|
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
|
child.kill = () => true;
|
|
const session = new MediaTimingPreviewSession({
|
|
platform: 'linux',
|
|
spawnProcess: () => child as never,
|
|
connectSocket: () => {
|
|
queueMicrotask(() => socket.emit('connect'));
|
|
return socket as never;
|
|
},
|
|
removeSocketFile: () => undefined,
|
|
createSocketPath: () => '/tmp/review.sock',
|
|
});
|
|
let endedCount = 0;
|
|
session.onPlaybackEnded(() => {
|
|
endedCount += 1;
|
|
});
|
|
const property = (name: string, data: boolean): string =>
|
|
`${JSON.stringify({ event: 'property-change', name, data })}\n`;
|
|
|
|
await session.start({ mediaPath: '/video/show.mkv' });
|
|
assert.deepEqual(
|
|
writes.map((line) => JSON.parse(line).command),
|
|
[
|
|
['observe_property', 1, 'eof-reached'],
|
|
['observe_property', 2, 'pause'],
|
|
],
|
|
);
|
|
// The observers' initial replies describe the idle paused player, not a finished preview.
|
|
socket.emit('data', property('eof-reached', false) + property('pause', true));
|
|
assert.equal(endedCount, 0);
|
|
|
|
writes.length = 0;
|
|
await session.play(12.25, 14.5);
|
|
assert.deepEqual(
|
|
writes.map((line) => JSON.parse(line).command),
|
|
[
|
|
['set_property', 'pause', true],
|
|
['seek', 12.25, 'absolute+exact'],
|
|
['set_property', 'end', '14.500'],
|
|
['set_property', 'pause', false],
|
|
],
|
|
);
|
|
|
|
// Events may arrive split across chunks. The decoder passing `end` flips eof-reached while
|
|
// audio still drains; only the keep-open pause that follows marks the preview as finished.
|
|
socket.emit('data', property('eof-reached', false) + property('pause', false).slice(0, 20));
|
|
socket.emit('data', property('pause', false).slice(20) + property('eof-reached', true));
|
|
assert.equal(endedCount, 0);
|
|
socket.emit('data', property('pause', true));
|
|
assert.equal(endedCount, 1);
|
|
socket.emit('data', property('pause', true));
|
|
assert.equal(endedCount, 1);
|
|
|
|
// Stopping early pauses without an end signal, and a later real EOF is not a preview end.
|
|
await session.play(1, 2);
|
|
socket.emit('data', property('eof-reached', false) + property('pause', false));
|
|
await session.stop();
|
|
socket.emit('data', property('pause', true) + property('eof-reached', true));
|
|
assert.equal(endedCount, 1);
|
|
session.dispose();
|
|
});
|