mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-01 07:21:33 -07:00
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.
This commit is contained in:
@@ -13,5 +13,7 @@ area: anime
|
||||
- Added `anime.repos`, `anime.extensionsDir`, and `anime.preferredQuality` config keys. SubMiner ships no extension repositories and performs no discovery.
|
||||
- Anime playback targets Japanese audio: dub-labelled entries are skipped when the source offers an alternative, `alang` prefers Japanese, and the source's own audio and subtitle tracks are loaded into mpv (Japanese selected) instead of being discarded, so all of them can be switched from mpv's track menu.
|
||||
- The primary subtitle slot stays reserved for Japanese: a source that only has, say, English subtitles gets them added with a normalized language tag (`English` → `en`) but not selected, so the regular `secondarySub` auto-load can route them to the secondary slot instead.
|
||||
- HLS streams pass through a local strip proxy that removes fake image headers some hosts glue onto their video segments, so streams ffmpeg would otherwise probe as "a PNG" and abandon now play in mpv.
|
||||
- "Playing" is only reported once mpv actually configures a video output; when a stream fails to decode, the browser shows mpv's error instead of claiming playback started while no window ever appeared.
|
||||
- The Linux x64 bridge bundle is verified and pinned, so the anime browser starts on Linux instead of refusing with "No pinned checksum for linux-x64-bundle.zip".
|
||||
- The bridge bundle is fetched from the pinned release tag rather than whatever release is newest, so an upstream publish no longer breaks every install with a checksum mismatch.
|
||||
|
||||
@@ -155,6 +155,21 @@ Selecting an episode resolves the best available stream, applies the source's
|
||||
required headers as mpv `http-header-fields`, and loads it. The headers are
|
||||
readable back off mpv, so Anki card audio and screenshots fetch correctly too.
|
||||
|
||||
HLS streams are routed through a small local proxy before mpv sees them. Some
|
||||
hosts disguise their video segments by prepending a fake image header (a real
|
||||
1x1 PNG) so scrapers back off; Aniyomi's own player strips this, but ffmpeg
|
||||
probes the segment as a picture and playback dies with "no audio or video data
|
||||
played". The proxy scans each segment for the first genuine MPEG-TS packet run
|
||||
and drops whatever junk sits in front of it. Segments that are not TS (fMP4,
|
||||
subtitles, encryption keys) pass through untouched, and direct-file streams
|
||||
skip the proxy entirely.
|
||||
|
||||
"Playing" in the status bar means playing: after handing mpv the stream,
|
||||
SubMiner waits until mpv actually configures a video output before reporting
|
||||
success. If mpv gives up instead — a dead host, an undecodable stream — the
|
||||
browser shows mpv's error rather than pretending playback started (a failed
|
||||
load leaves no mpv window, because the player idles windowless).
|
||||
|
||||
### Japanese audio, and switching tracks
|
||||
|
||||
Sources often return a dub and the original audio as two separate entries — or
|
||||
|
||||
@@ -1,8 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseAnimeStatus, resolveBridgeMediaUrl } from './media-url';
|
||||
import { parseAnimeStatus, resolveBridgeMediaUrl, routeHlsThroughProxy } from './media-url';
|
||||
|
||||
const BRIDGE = 'http://127.0.0.1:56037';
|
||||
const PROXY = 'http://127.0.0.1:60001';
|
||||
|
||||
test('a bridge m3u8 stream is routed through the strip proxy', () => {
|
||||
assert.equal(
|
||||
routeHlsThroughProxy(`${BRIDGE}/video/master.m3u8?q=1080`, BRIDGE, PROXY),
|
||||
`${PROXY}/video/master.m3u8?q=1080`,
|
||||
);
|
||||
});
|
||||
|
||||
test('non-HLS bridge streams stay on the bridge', () => {
|
||||
const direct = `${BRIDGE}/video/movie-token`;
|
||||
assert.equal(routeHlsThroughProxy(direct, BRIDGE, PROXY), direct);
|
||||
});
|
||||
|
||||
test('external m3u8 streams are not routed through the proxy', () => {
|
||||
const remote = 'https://cdn.example.com/hls/master.m3u8';
|
||||
assert.equal(routeHlsThroughProxy(remote, BRIDGE, PROXY), remote);
|
||||
});
|
||||
|
||||
test('unparseable stream urls pass through routeHlsThroughProxy unchanged', () => {
|
||||
assert.equal(routeHlsThroughProxy('not a url', BRIDGE, PROXY), 'not a url');
|
||||
assert.equal(
|
||||
routeHlsThroughProxy(`${BRIDGE}/video/a.m3u8`, 'garbage', PROXY),
|
||||
`${BRIDGE}/video/a.m3u8`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a loopback proxy url is rebased onto the live bridge port', () => {
|
||||
assert.equal(
|
||||
|
||||
@@ -47,6 +47,30 @@ export function resolveBridgeMediaUrl(bridgeBaseUrl: string, mediaUrl: string):
|
||||
return rebased.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a bridge-served HLS stream through the local strip proxy instead. Only
|
||||
* `.m3u8` URLs on the bridge origin qualify: direct files need no fixing, and
|
||||
* an external URL would not resolve through a proxy that forwards to the
|
||||
* bridge. Anything unparseable comes back unchanged.
|
||||
*/
|
||||
export function routeHlsThroughProxy(
|
||||
streamUrl: string,
|
||||
bridgeBaseUrl: string,
|
||||
proxyOrigin: string,
|
||||
): string {
|
||||
let stream: URL;
|
||||
let bridge: URL;
|
||||
try {
|
||||
stream = new URL(streamUrl);
|
||||
bridge = new URL(bridgeBaseUrl);
|
||||
} catch {
|
||||
return streamUrl;
|
||||
}
|
||||
if (stream.origin !== bridge.origin) return streamUrl;
|
||||
if (!stream.pathname.endsWith('.m3u8')) return streamUrl;
|
||||
return `${proxyOrigin}${stream.pathname}${stream.search}`;
|
||||
}
|
||||
|
||||
/** Aniyomi's SAnime status constants. */
|
||||
export type AnimeStatus =
|
||||
| 'unknown'
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Confirms that a `loadfile` handed to mpv actually turned into playback.
|
||||
*
|
||||
* Sending the command proves nothing: mpv accepts the file, fails to decode it
|
||||
* (a dead host, a disguised stream the proxy could not fix), fires `end-file`
|
||||
* with reason "error", and drops back to `--idle` — with no window, because
|
||||
* idle mpv shows none. The UI would happily say "Playing" over a blank desktop.
|
||||
*
|
||||
* Success is mpv configuring a video output (`vo-configured`), which is
|
||||
* literally "a window with frames in it". `file-loaded` is not enough — the
|
||||
* broken stream in the wild reached it before dying. Failure is an `end-file`
|
||||
* with reason "error"; the end-file of the file being *replaced* arrives with
|
||||
* "stop"/"redirect" and is ignored.
|
||||
*/
|
||||
|
||||
export interface PlaybackEndFileEvent {
|
||||
reason: string;
|
||||
fileError: string | null;
|
||||
}
|
||||
|
||||
export type PlaybackOutcome = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export interface WatchPlaybackOutcomeDeps {
|
||||
/** Subscribe to mpv end-file events; returns the unsubscribe. */
|
||||
onEndFile: (listener: (event: PlaybackEndFileEvent) => void) => () => void;
|
||||
/** One-shot mpv property read; may reject while the file is still loading. */
|
||||
readProperty: (name: string) => Promise<unknown>;
|
||||
wait: (ms: number) => Promise<void>;
|
||||
timeoutMs?: number;
|
||||
probeIntervalMs?: number;
|
||||
}
|
||||
|
||||
export interface PlaybackOutcomeWatch {
|
||||
wait: () => Promise<PlaybackOutcome>;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export const DEFAULT_PLAYBACK_OUTCOME_TIMEOUT_MS = 20_000;
|
||||
const DEFAULT_PROBE_INTERVAL_MS = 500;
|
||||
|
||||
/**
|
||||
* Call *before* sending `loadfile` so the error subscription cannot lose a
|
||||
* race against a fast failure; await `wait()` after the commands went out.
|
||||
*/
|
||||
export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOutcomeWatch {
|
||||
const timeoutMs = deps.timeoutMs ?? DEFAULT_PLAYBACK_OUTCOME_TIMEOUT_MS;
|
||||
const probeIntervalMs = deps.probeIntervalMs ?? DEFAULT_PROBE_INTERVAL_MS;
|
||||
|
||||
let failure: PlaybackOutcome | null = null;
|
||||
const unsubscribe = deps.onEndFile((event) => {
|
||||
if (event.reason !== 'error') return;
|
||||
failure = {
|
||||
ok: false,
|
||||
error: event.fileError
|
||||
? `mpv could not play this stream: ${event.fileError}`
|
||||
: 'mpv could not play this stream.',
|
||||
};
|
||||
});
|
||||
|
||||
async function wait(): Promise<PlaybackOutcome> {
|
||||
for (let elapsed = 0; elapsed < timeoutMs; elapsed += probeIntervalMs) {
|
||||
if (failure) return failure;
|
||||
try {
|
||||
if ((await deps.readProperty('vo-configured')) === true) return { ok: true };
|
||||
} catch {
|
||||
// The property is unreadable while mpv is between files; keep polling.
|
||||
}
|
||||
if (failure) return failure;
|
||||
await deps.wait(probeIntervalMs);
|
||||
}
|
||||
return (
|
||||
failure ?? {
|
||||
ok: false,
|
||||
error: 'Playback did not start. mpv gave no error; try another server or quality.',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return { wait, dispose: unsubscribe };
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import {
|
||||
findTsSyncOffset,
|
||||
rewritePlaylistOrigins,
|
||||
startStreamStripProxy,
|
||||
TS_PACKET_LENGTH,
|
||||
} from './stream-strip-proxy';
|
||||
|
||||
/** A valid MPEG-TS payload: 0x47 sync byte every 188 bytes. */
|
||||
function makeTsPackets(count: number): Buffer {
|
||||
const data = Buffer.alloc(count * TS_PACKET_LENGTH, 0x11);
|
||||
for (let i = 0; i < count; i++) data[i * TS_PACKET_LENGTH] = 0x47;
|
||||
return data;
|
||||
}
|
||||
|
||||
/** The disguise seen in the wild: a real PNG header glued before the TS data. */
|
||||
const PNG_HEADER = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
]);
|
||||
|
||||
test('findTsSyncOffset returns 0 for clean TS data', () => {
|
||||
assert.equal(findTsSyncOffset(makeTsPackets(6)), 0);
|
||||
});
|
||||
|
||||
test('findTsSyncOffset finds TS data behind a PNG prefix', () => {
|
||||
const disguised = Buffer.concat([PNG_HEADER, makeTsPackets(6)]);
|
||||
assert.equal(findTsSyncOffset(disguised), PNG_HEADER.length);
|
||||
});
|
||||
|
||||
test('findTsSyncOffset ignores a lone sync byte in the junk prefix', () => {
|
||||
const junk = Buffer.alloc(64, 0x00);
|
||||
junk[10] = 0x47; // decoy: no packet run follows it
|
||||
const disguised = Buffer.concat([junk, makeTsPackets(6)]);
|
||||
assert.equal(findTsSyncOffset(disguised), junk.length);
|
||||
});
|
||||
|
||||
test('findTsSyncOffset returns null when no TS run exists', () => {
|
||||
assert.equal(findTsSyncOffset(Buffer.alloc(4096, 0x42)), null);
|
||||
});
|
||||
|
||||
test('findTsSyncOffset returns null when the run starts past the scan limit', () => {
|
||||
const disguised = Buffer.concat([Buffer.alloc(600, 0x00), makeTsPackets(6)]);
|
||||
assert.equal(findTsSyncOffset(disguised, 500), null);
|
||||
});
|
||||
|
||||
test('rewritePlaylistOrigins swaps absolute upstream URLs and keeps relative lines', () => {
|
||||
const body = [
|
||||
'#EXTM3U',
|
||||
'#EXTINF:6.006,',
|
||||
'/video/aaa.ts',
|
||||
'#EXTINF:4.463,',
|
||||
'http://127.0.0.1:41569/video/bbb.ts',
|
||||
].join('\n');
|
||||
const rewritten = rewritePlaylistOrigins(body, 'http://127.0.0.1:41569', 'http://127.0.0.1:9999');
|
||||
assert.ok(rewritten.includes('http://127.0.0.1:9999/video/bbb.ts'));
|
||||
assert.ok(rewritten.includes('\n/video/aaa.ts\n'));
|
||||
assert.ok(!rewritten.includes('41569'));
|
||||
});
|
||||
|
||||
/* ---------- proxy end-to-end ---------- */
|
||||
|
||||
type Route = { status: number; contentType: string; body: Buffer };
|
||||
|
||||
async function withProxy(
|
||||
routes: Record<string, Route>,
|
||||
run: (proxyOrigin: string, upstreamOrigin: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const upstream = http.createServer((req, res) => {
|
||||
const route = routes[req.url ?? ''];
|
||||
if (!route) {
|
||||
res.writeHead(404).end('missing');
|
||||
return;
|
||||
}
|
||||
res.writeHead(route.status, { 'content-type': route.contentType });
|
||||
res.end(route.body);
|
||||
});
|
||||
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve));
|
||||
const upstreamOrigin = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`;
|
||||
|
||||
const proxy = await startStreamStripProxy({ upstreamOrigin: () => upstreamOrigin });
|
||||
try {
|
||||
await run(proxy.origin, upstreamOrigin);
|
||||
} finally {
|
||||
await proxy.close();
|
||||
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBytes(url: string): Promise<{ status: number; body: Buffer }> {
|
||||
const response = await fetch(url);
|
||||
return { status: response.status, body: Buffer.from(await response.arrayBuffer()) };
|
||||
}
|
||||
|
||||
test('proxy strips the PNG disguise off a segment', async () => {
|
||||
const ts = makeTsPackets(8);
|
||||
const disguised = Buffer.concat([PNG_HEADER, ts]);
|
||||
await withProxy(
|
||||
{ '/video/seg.ts': { status: 200, contentType: 'video/mp2t', body: disguised } },
|
||||
async (origin) => {
|
||||
const { status, body } = await fetchBytes(`${origin}/video/seg.ts`);
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(body, ts);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('proxy passes a clean segment through unchanged', async () => {
|
||||
const ts = makeTsPackets(8);
|
||||
await withProxy(
|
||||
{ '/video/seg.ts': { status: 200, contentType: 'video/mp2t', body: ts } },
|
||||
async (origin) => {
|
||||
const { body } = await fetchBytes(`${origin}/video/seg.ts`);
|
||||
assert.deepEqual(body, ts);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('proxy leaves non-TS bodies alone', async () => {
|
||||
const vtt = Buffer.from('WEBVTT\n\n00:00.000 --> 00:01.000\nhello\n');
|
||||
await withProxy(
|
||||
{ '/video/sub.vtt': { status: 200, contentType: 'text/vtt', body: vtt } },
|
||||
async (origin) => {
|
||||
const { body } = await fetchBytes(`${origin}/video/sub.vtt`);
|
||||
assert.deepEqual(body, vtt);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('proxy rewrites absolute upstream playlist entries to its own origin', async () => {
|
||||
const upstream = http.createServer((req, res) => {
|
||||
const origin = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`;
|
||||
if (req.url === '/video/list.m3u8') {
|
||||
res.writeHead(200, { 'content-type': 'application/vnd.apple.mpegurl' });
|
||||
res.end(`#EXTM3U\n#EXTINF:6,\n${origin}/video/abs.ts\n`);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve));
|
||||
const upstreamOrigin = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`;
|
||||
const proxy = await startStreamStripProxy({ upstreamOrigin: () => upstreamOrigin });
|
||||
try {
|
||||
const { body } = await fetchBytes(`${proxy.origin}/video/list.m3u8`);
|
||||
const text = body.toString('utf8');
|
||||
assert.ok(text.includes(`${proxy.origin}/video/abs.ts`));
|
||||
assert.ok(!text.includes(upstreamOrigin));
|
||||
} finally {
|
||||
await proxy.close();
|
||||
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test('proxy forwards error statuses without touching the body', async () => {
|
||||
await withProxy(
|
||||
{ '/video/gone.ts': { status: 404, contentType: 'text/plain', body: Buffer.from('nope') } },
|
||||
async (origin) => {
|
||||
const { status, body } = await fetchBytes(`${origin}/video/gone.ts`);
|
||||
assert.equal(status, 404);
|
||||
assert.equal(body.toString('utf8'), 'nope');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import http from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
|
||||
/**
|
||||
* Loopback proxy between mpv and the anime bridge that undoes segment
|
||||
* disguises. Some hosts prepend a real image header (a 1x1 PNG in the wild) to
|
||||
* every HLS segment so scrapers see "an image"; ffmpeg then probes the segment
|
||||
* as a picture and playback dies with "no audio or video data played". Aniyomi
|
||||
* strips this in its player; mpv needs the bytes fixed before it sees them.
|
||||
*
|
||||
* Only bridge-origin `.m3u8` streams are routed through here (see
|
||||
* anime-browser-runtime). Playlist bodies get their absolute upstream origins
|
||||
* rewritten so segment requests come back through the proxy; segment bodies are
|
||||
* scanned for the first genuine MPEG-TS packet run and any junk before it is
|
||||
* dropped. Anything that is not TS (fMP4, VTT, keys) passes through untouched.
|
||||
*/
|
||||
|
||||
export const TS_PACKET_LENGTH = 188;
|
||||
const TS_SYNC_BYTE = 0x47;
|
||||
/**
|
||||
* Sync bytes that must repeat at exact packet spacing before an offset counts
|
||||
* as TS data. One or two matches happen by chance in binary data; five in a
|
||||
* row at 188-byte strides do not.
|
||||
*/
|
||||
const SYNC_RUN = 5;
|
||||
/** A disguise prefix is small; give up scanning after this much. */
|
||||
export const DEFAULT_SCAN_LIMIT_BYTES = 1024 * 1024;
|
||||
/** Bytes needed to either find a run within the limit or rule one out. */
|
||||
const DECISION_BYTES = DEFAULT_SCAN_LIMIT_BYTES + (SYNC_RUN - 1) * TS_PACKET_LENGTH + 1;
|
||||
|
||||
/**
|
||||
* First offset at which a confirmed MPEG-TS packet run starts, or null when
|
||||
* the data does not look like TS at all (within the scan limit).
|
||||
*/
|
||||
export function findTsSyncOffset(
|
||||
data: Buffer,
|
||||
scanLimit = DEFAULT_SCAN_LIMIT_BYTES,
|
||||
): number | null {
|
||||
const lastConfirmable = data.length - (SYNC_RUN - 1) * TS_PACKET_LENGTH - 1;
|
||||
const end = Math.min(lastConfirmable, scanLimit);
|
||||
for (let offset = 0; offset <= end; offset++) {
|
||||
if (data[offset] !== TS_SYNC_BYTE) continue;
|
||||
let confirmed = true;
|
||||
for (let packet = 1; packet < SYNC_RUN; packet++) {
|
||||
if (data[offset + packet * TS_PACKET_LENGTH] !== TS_SYNC_BYTE) {
|
||||
confirmed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (confirmed) return offset;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Point absolute playlist entries at the proxy. Relative entries already
|
||||
* resolve against whatever origin served the playlist, so they need no help.
|
||||
*/
|
||||
export function rewritePlaylistOrigins(
|
||||
body: string,
|
||||
upstreamOrigin: string,
|
||||
proxyOrigin: string,
|
||||
): string {
|
||||
return body.split(upstreamOrigin).join(proxyOrigin);
|
||||
}
|
||||
|
||||
export interface StreamStripProxyOptions {
|
||||
/** Read per request so a bridge restart on a new port keeps working. */
|
||||
upstreamOrigin: () => string;
|
||||
log?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface StreamStripProxyHandle {
|
||||
origin: string;
|
||||
port: number;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Response headers that must not be forwarded verbatim. */
|
||||
const DROPPED_HEADERS = new Set([
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'transfer-encoding',
|
||||
'content-length',
|
||||
]);
|
||||
|
||||
function forwardableHeaders(headers: http.IncomingHttpHeaders): http.OutgoingHttpHeaders {
|
||||
const result: http.OutgoingHttpHeaders = {};
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if (value === undefined || DROPPED_HEADERS.has(name.toLowerCase())) continue;
|
||||
result[name] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function startStreamStripProxy(
|
||||
options: StreamStripProxyOptions,
|
||||
): Promise<StreamStripProxyHandle> {
|
||||
const log = options.log ?? (() => {});
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.writeHead(405).end();
|
||||
return;
|
||||
}
|
||||
|
||||
let upstreamUrl: URL;
|
||||
try {
|
||||
upstreamUrl = new URL(req.url ?? '/', options.upstreamOrigin());
|
||||
} catch {
|
||||
res.writeHead(502).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const requestHeaders = forwardableHeaders(req.headers);
|
||||
delete requestHeaders.host;
|
||||
|
||||
const upstreamRequest = http.request(
|
||||
upstreamUrl,
|
||||
{ method: req.method, headers: requestHeaders },
|
||||
(upstream) => handleUpstreamResponse(req, res, upstream),
|
||||
);
|
||||
upstreamRequest.on('error', (error) => {
|
||||
log(`[stream-proxy] upstream request failed: ${String(error)}`);
|
||||
if (!res.headersSent) res.writeHead(502);
|
||||
res.end();
|
||||
});
|
||||
upstreamRequest.end();
|
||||
});
|
||||
|
||||
function handleUpstreamResponse(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
upstream: http.IncomingMessage,
|
||||
): void {
|
||||
const status = upstream.statusCode ?? 502;
|
||||
const pathname = (req.url ?? '').split('?', 1)[0] ?? '';
|
||||
const contentType = String(upstream.headers['content-type'] ?? '');
|
||||
const isPlaylist = pathname.endsWith('.m3u8') || contentType.includes('mpegurl');
|
||||
|
||||
upstream.on('error', () => res.destroy());
|
||||
|
||||
// Only a full 200 body is safe to modify; everything else (errors, range
|
||||
// responses, HEAD) forwards untouched.
|
||||
if (status !== 200 || req.method === 'HEAD') {
|
||||
res.writeHead(status, forwardableHeaders(upstream.headers));
|
||||
upstream.pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlaylist) {
|
||||
const chunks: Buffer[] = [];
|
||||
upstream.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
upstream.on('end', () => {
|
||||
const body = rewritePlaylistOrigins(
|
||||
Buffer.concat(chunks).toString('utf8'),
|
||||
options.upstreamOrigin(),
|
||||
origin,
|
||||
);
|
||||
res.writeHead(status, {
|
||||
...forwardableHeaders(upstream.headers),
|
||||
'content-length': Buffer.byteLength(body, 'utf8'),
|
||||
});
|
||||
res.end(body);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
stripSegment(res, upstream);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffer just enough of the body to find (or rule out) a TS packet run,
|
||||
* drop everything before it, then stream the rest through untouched.
|
||||
*/
|
||||
function stripSegment(res: http.ServerResponse, upstream: http.IncomingMessage): void {
|
||||
const chunks: Buffer[] = [];
|
||||
let buffered = 0;
|
||||
|
||||
const respond = (data: Buffer, remainderFollows: boolean): void => {
|
||||
const offset = findTsSyncOffset(data) ?? 0;
|
||||
if (offset > 0) log(`[stream-proxy] stripped ${offset} disguise bytes off a segment`);
|
||||
const body = offset > 0 ? data.subarray(offset) : data;
|
||||
|
||||
const headers = forwardableHeaders(upstream.headers);
|
||||
const upstreamLength = Number(upstream.headers['content-length']);
|
||||
if (remainderFollows) {
|
||||
if (Number.isFinite(upstreamLength)) headers['content-length'] = upstreamLength - offset;
|
||||
} else {
|
||||
headers['content-length'] = body.length;
|
||||
}
|
||||
|
||||
res.writeHead(upstream.statusCode ?? 200, headers);
|
||||
res.write(body);
|
||||
};
|
||||
|
||||
const onData = (chunk: Buffer): void => {
|
||||
chunks.push(chunk);
|
||||
buffered += chunk.length;
|
||||
if (buffered < DECISION_BYTES) return;
|
||||
upstream.off('data', onData);
|
||||
upstream.off('end', onEnd);
|
||||
respond(Buffer.concat(chunks), true);
|
||||
upstream.pipe(res);
|
||||
};
|
||||
const onEnd = (): void => {
|
||||
respond(Buffer.concat(chunks), false);
|
||||
res.end();
|
||||
};
|
||||
upstream.on('data', onData);
|
||||
upstream.on('end', onEnd);
|
||||
}
|
||||
|
||||
let origin = '';
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
origin = `http://127.0.0.1:${port}`;
|
||||
resolve({
|
||||
origin,
|
||||
port,
|
||||
close: () =>
|
||||
new Promise<void>((resolveClose) => {
|
||||
server.closeAllConnections?.();
|
||||
server.close(() => resolveClose());
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -351,6 +351,28 @@ test('visibility and boolean parsers handle text values', () => {
|
||||
assert.equal(asBoolean('0', true), false);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage emits end-file with reason and file error', async () => {
|
||||
const received: Array<{ reason: string; fileError: string | null }> = [];
|
||||
const { deps } = createDeps({
|
||||
emitEndFile: (payload) => {
|
||||
received.push(payload);
|
||||
},
|
||||
});
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'end-file', reason: 'error', file_error: 'no audio or video data played' },
|
||||
deps,
|
||||
);
|
||||
await dispatchMpvProtocolMessage({ event: 'end-file', reason: 'eof' }, deps);
|
||||
await dispatchMpvProtocolMessage({ event: 'end-file' }, deps);
|
||||
|
||||
assert.deepEqual(received, [
|
||||
{ reason: 'error', fileError: 'no audio or video data played' },
|
||||
{ reason: 'eof', fileError: null },
|
||||
{ reason: 'unknown', fileError: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage emits client-message string args', async () => {
|
||||
const received: Array<{ args: string[] }> = [];
|
||||
const { deps } = createDeps({
|
||||
|
||||
@@ -7,6 +7,8 @@ export type MpvMessage = {
|
||||
args?: unknown;
|
||||
request_id?: number;
|
||||
error?: string;
|
||||
reason?: unknown;
|
||||
file_error?: unknown;
|
||||
};
|
||||
|
||||
export const MPV_REQUEST_ID_SUBTEXT = 101;
|
||||
@@ -96,6 +98,7 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
shouldQuitOnMpvShutdown: () => boolean;
|
||||
requestAppQuit: () => void;
|
||||
emitClientMessage?: (payload: { args: string[] }) => void;
|
||||
emitEndFile?: (payload: { reason: string; fileError: string | null }) => void;
|
||||
}
|
||||
|
||||
type SubtitleTrackCandidate = {
|
||||
@@ -385,6 +388,12 @@ export async function dispatchMpvProtocolMessage(
|
||||
if (args.length > 0) {
|
||||
deps.emitClientMessage?.({ args });
|
||||
}
|
||||
} else if (msg.event === 'end-file') {
|
||||
deps.emitEndFile?.({
|
||||
reason: typeof msg.reason === 'string' ? msg.reason : 'unknown',
|
||||
fileError:
|
||||
typeof msg.file_error === 'string' && msg.file_error.length > 0 ? msg.file_error : null,
|
||||
});
|
||||
} else if (msg.event === 'shutdown') {
|
||||
deps.restorePreviousSecondarySubVisibility();
|
||||
if (deps.shouldQuitOnMpvShutdown()) {
|
||||
|
||||
@@ -131,6 +131,7 @@ export interface MpvIpcClientEventMap {
|
||||
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
|
||||
'secondary-subtitle-visibility': { visible: boolean };
|
||||
'client-message': { args: string[] };
|
||||
'end-file': { reason: string; fileError: string | null };
|
||||
}
|
||||
|
||||
type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
|
||||
@@ -496,6 +497,9 @@ export class MpvIpcClient implements MpvClient {
|
||||
emitClientMessage: (payload) => {
|
||||
this.emit('client-message', payload);
|
||||
},
|
||||
emitEndFile: (payload) => {
|
||||
this.emit('end-file', payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+11
@@ -3232,6 +3232,17 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({
|
||||
}),
|
||||
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
|
||||
ensureMpvConnected: () => ensureMpvConnectedForPlayback(),
|
||||
onPlaybackEndFile: (listener) => {
|
||||
const client = appState.mpvClient;
|
||||
if (!client) return () => {};
|
||||
client.on('end-file', listener);
|
||||
return () => client.off('end-file', listener);
|
||||
},
|
||||
readMpvProperty: (name) => {
|
||||
const client = appState.mpvClient;
|
||||
if (!client) return Promise.reject(new Error('mpv is not connected.'));
|
||||
return client.requestProperty(name);
|
||||
},
|
||||
showVisibleOverlay: () => {
|
||||
// Launching a video turns a browse-only instance into a regular SubMiner
|
||||
// playback session: tray icon plus the overlay runtime that
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { AnimeBridgeClient } from '../../anime-bridge/bridge-client';
|
||||
import { resolveStream } from '../../anime-bridge/headers';
|
||||
import { parseAnimeStatus, resolveBridgeMediaUrl } from '../../anime-bridge/media-url';
|
||||
import {
|
||||
parseAnimeStatus,
|
||||
resolveBridgeMediaUrl,
|
||||
routeHlsThroughProxy,
|
||||
} from '../../anime-bridge/media-url';
|
||||
import {
|
||||
watchPlaybackOutcome,
|
||||
type PlaybackEndFileEvent,
|
||||
} from '../../anime-bridge/playback-outcome';
|
||||
import {
|
||||
startStreamStripProxy,
|
||||
type StreamStripProxyHandle,
|
||||
} from '../../anime-bridge/stream-strip-proxy';
|
||||
import {
|
||||
buildPlaybackCommands,
|
||||
buildTrackCommands,
|
||||
@@ -60,6 +72,14 @@ export interface AnimeBrowserRuntimeDeps {
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
/** Brings mpv up if it is not already connected. Resolves false on failure. */
|
||||
ensureMpvConnected: () => Promise<boolean>;
|
||||
/**
|
||||
* Subscribe to mpv end-file events; returns the unsubscribe. Together with
|
||||
* readMpvProperty this lets playEpisode confirm playback really started
|
||||
* instead of reporting success the moment the commands were written.
|
||||
*/
|
||||
onPlaybackEndFile?: (listener: (event: PlaybackEndFileEvent) => void) => () => void;
|
||||
/** One-shot mpv property read; rejects while the property is unavailable. */
|
||||
readMpvProperty?: (name: string) => Promise<unknown>;
|
||||
showMpvOsd?: (message: string) => void;
|
||||
showVisibleOverlay?: () => void;
|
||||
/** Lets tests drive the pause between `loadfile` and the track commands. */
|
||||
@@ -82,6 +102,7 @@ const TRACK_ATTACH_DELAY_MS = 300;
|
||||
export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
let bridgeState: AnimeBrowserBridgeState = IDLE_STATE;
|
||||
let sidecar: SidecarHandle | null = null;
|
||||
let stripProxy: StreamStripProxyHandle | null = null;
|
||||
let starting: Promise<SidecarHandle> | null = null;
|
||||
let extensions: InstalledExtension[] = [];
|
||||
let sources: ExtensionSource[] = [];
|
||||
@@ -125,6 +146,16 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
});
|
||||
sidecar = handle;
|
||||
|
||||
try {
|
||||
stripProxy = await startStreamStripProxy({
|
||||
upstreamOrigin: () => sidecar?.baseUrl ?? handle.baseUrl,
|
||||
log: deps.log,
|
||||
});
|
||||
} catch (error) {
|
||||
// Playback still works for undisguised streams; log and carry on.
|
||||
deps.log(`[anime-browser] stream proxy failed to start: ${describeError(error)}`);
|
||||
}
|
||||
|
||||
await scanExtensions(handle);
|
||||
return handle;
|
||||
}
|
||||
@@ -470,10 +501,15 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
})),
|
||||
}));
|
||||
|
||||
const stream = selectPreferredStream(streams, deps.preferredQuality?.());
|
||||
if (!stream) {
|
||||
const selected = selectPreferredStream(streams, deps.preferredQuality?.());
|
||||
if (!selected) {
|
||||
return { ok: false, error: 'That source returned no playable video.', quality: null };
|
||||
}
|
||||
// HLS goes through the local strip proxy, which undoes fake-image
|
||||
// segment disguises mpv cannot decode around.
|
||||
const stream = stripProxy
|
||||
? { ...selected, url: routeHlsThroughProxy(selected.url, baseUrl, stripProxy.origin) }
|
||||
: selected;
|
||||
|
||||
if (!(await deps.ensureMpvConnected())) {
|
||||
return {
|
||||
@@ -483,29 +519,51 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
};
|
||||
}
|
||||
|
||||
const title = `${request.animeTitle} — ${request.episodeName}`;
|
||||
for (const command of buildPlaybackCommands({ stream, title })) {
|
||||
deps.sendMpvCommand(command);
|
||||
}
|
||||
// Subscribed before loadfile so a fast failure cannot slip past it.
|
||||
const watch =
|
||||
deps.onPlaybackEndFile && deps.readMpvProperty
|
||||
? watchPlaybackOutcome({
|
||||
onEndFile: deps.onPlaybackEndFile,
|
||||
readProperty: deps.readMpvProperty,
|
||||
wait,
|
||||
})
|
||||
: null;
|
||||
|
||||
const trackCommands = buildTrackCommands(stream);
|
||||
if (trackCommands.length > 0) {
|
||||
deps.log(
|
||||
`[anime-browser] ${stream.audios.length} external audio, ` +
|
||||
`${stream.subtitles.length} external subtitle track(s)`,
|
||||
);
|
||||
// mpv attaches added tracks to the file that is loading, so give the
|
||||
// loadfile a moment to take effect first. Same pause the Jellyfin
|
||||
// subtitle preload uses.
|
||||
await wait(TRACK_ATTACH_DELAY_MS);
|
||||
for (const command of trackCommands) {
|
||||
try {
|
||||
const title = `${request.animeTitle} — ${request.episodeName}`;
|
||||
for (const command of buildPlaybackCommands({ stream, title })) {
|
||||
deps.sendMpvCommand(command);
|
||||
}
|
||||
}
|
||||
|
||||
deps.showVisibleOverlay?.();
|
||||
deps.showMpvOsd?.(title);
|
||||
return { ok: true, error: null, quality: stream.quality || null };
|
||||
const trackCommands = buildTrackCommands(stream);
|
||||
if (trackCommands.length > 0) {
|
||||
deps.log(
|
||||
`[anime-browser] ${stream.audios.length} external audio, ` +
|
||||
`${stream.subtitles.length} external subtitle track(s)`,
|
||||
);
|
||||
// mpv attaches added tracks to the file that is loading, so give the
|
||||
// loadfile a moment to take effect first. Same pause the Jellyfin
|
||||
// subtitle preload uses.
|
||||
await wait(TRACK_ATTACH_DELAY_MS);
|
||||
for (const command of trackCommands) {
|
||||
deps.sendMpvCommand(command);
|
||||
}
|
||||
}
|
||||
|
||||
if (watch) {
|
||||
const outcome = await watch.wait();
|
||||
if (!outcome.ok) {
|
||||
deps.log(`[anime-browser] playback failed to start: ${outcome.error}`);
|
||||
return { ok: false, error: outcome.error, quality: null };
|
||||
}
|
||||
}
|
||||
|
||||
deps.showVisibleOverlay?.();
|
||||
deps.showMpvOsd?.(title);
|
||||
return { ok: true, error: null, quality: stream.quality || null };
|
||||
} finally {
|
||||
watch?.dispose();
|
||||
}
|
||||
} catch (error) {
|
||||
deps.log(`[anime-browser] playback failed: ${String(error)}`);
|
||||
return { ok: false, error: describeError(error), quality: null };
|
||||
@@ -514,9 +572,12 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
const handle = sidecar;
|
||||
const proxy = stripProxy;
|
||||
sidecar = null;
|
||||
stripProxy = null;
|
||||
starting = null;
|
||||
setState(IDLE_STATE);
|
||||
await proxy?.close();
|
||||
await handle?.stop();
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user