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:
2026-08-01 00:05:31 -07:00
parent 7254db66ad
commit ff25e5cafa
13 changed files with 767 additions and 23 deletions
+27 -1
View File
@@ -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(
+24
View File
@@ -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'
+93
View File
@@ -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);
});
+80
View File
@@ -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 };
}
+165
View File
@@ -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');
},
);
});
+232
View File
@@ -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());
}),
});
});
});
}