Files
SubMiner/src/anime-bridge/playback-outcome.test.ts
T
sudacode 835426d1e9 fix(anime): strip disguised HLS segments and report real playback errors
Some hosts prepend a fake image header (a real 1x1 PNG) to every HLS
segment; ffmpeg probes the segment as a picture and mpv drops back to
idle with no window while the browser claims the episode is playing.

- Route bridge-served m3u8 streams through a local strip proxy that
  scans each segment for the first genuine MPEG-TS packet run and drops
  the junk in front of it; playlists get absolute origins rewritten so
  segment requests come back through the proxy. Non-TS bodies pass
  through untouched.
- Only report ok from playEpisode once mpv configures a video output;
  an end-file with reason error surfaces mpv's own message (e.g. "no
  audio or video data played") in the browser status bar instead of
  "Playing". New end-file event plumbed through the mpv IPC client.
2026-08-03 02:11:21 -07:00

94 lines
3.2 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { watchPlaybackOutcome, type PlaybackEndFileEvent } from './playback-outcome';
type Harness = {
emitEndFile: (event: PlaybackEndFileEvent) => void;
listenerCount: () => number;
setProperty: (name: string, value: unknown) => void;
failProperty: (name: string) => void;
};
function createHarness(overrides?: { timeoutMs?: number; probeIntervalMs?: number }) {
const listeners = new Set<(event: PlaybackEndFileEvent) => void>();
const properties = new Map<string, unknown>();
const failing = new Set<string>();
const watch = watchPlaybackOutcome({
onEndFile: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
readProperty: async (name) => {
if (failing.has(name)) throw new Error(`Failed to read MPV property '${name}'`);
return properties.get(name);
},
wait: async () => {},
timeoutMs: overrides?.timeoutMs ?? 1000,
probeIntervalMs: overrides?.probeIntervalMs ?? 100,
});
const harness: Harness = {
emitEndFile: (event) => {
for (const listener of listeners) listener(event);
},
listenerCount: () => listeners.size,
setProperty: (name, value) => properties.set(name, value),
failProperty: (name) => failing.add(name),
};
return { watch, harness };
}
test('resolves ok once mpv configures a video output', async () => {
const { watch, harness } = createHarness();
harness.setProperty('vo-configured', true);
const outcome = await watch.wait();
assert.deepEqual(outcome, { ok: true });
watch.dispose();
});
test('resolves failure with the mpv error when the file ends in error', async () => {
const { watch, harness } = createHarness();
const pending = watch.wait();
harness.emitEndFile({ reason: 'error', fileError: 'no audio or video data played' });
const outcome = await pending;
assert.equal(outcome.ok, false);
assert.ok(!outcome.ok && outcome.error.includes('no audio or video data played'));
watch.dispose();
});
test('ignores the end-file fired for the file being replaced', async () => {
const { watch, harness } = createHarness();
const pending = watch.wait();
harness.emitEndFile({ reason: 'stop', fileError: null });
harness.setProperty('vo-configured', true);
const outcome = await pending;
assert.deepEqual(outcome, { ok: true });
watch.dispose();
});
test('times out with a failure when nothing ever starts', async () => {
const { watch } = createHarness({ timeoutMs: 300, probeIntervalMs: 100 });
const outcome = await watch.wait();
assert.equal(outcome.ok, false);
assert.ok(!outcome.ok && outcome.error.length > 0);
watch.dispose();
});
test('keeps polling through property read failures', async () => {
const { watch, harness } = createHarness();
harness.failProperty('vo-configured');
const pending = watch.wait();
harness.emitEndFile({ reason: 'error', fileError: null });
const outcome = await pending;
assert.equal(outcome.ok, false);
watch.dispose();
});
test('dispose removes the end-file subscription', () => {
const { watch, harness } = createHarness();
assert.equal(harness.listenerCount(), 1);
watch.dispose();
assert.equal(harness.listenerCount(), 0);
});