diff --git a/changes/anime-browser.md b/changes/anime-browser.md index cbe3bb22..80f9cbfa 100644 --- a/changes/anime-browser.md +++ b/changes/anime-browser.md @@ -13,7 +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. +- HLS streams pass through a local strip proxy that removes fake image headers some hosts glue onto their video segments and gives `.image` segment URLs a media-safe local alias, so those streams play in mpv and support Anki audio and image extraction with current ffmpeg releases. - The strip proxy retries a failed segment fetch once after a short pause and logs upstream error statuses; a host that errors on the very first fetches right after an episode resolves no longer kills the whole playback. - The strip proxy no longer forwards `Range` headers to the bridge: ffmpeg opens every HLS segment with `Range: bytes=0-`, the bridge answers some of those with 206, and a partial response bypassed the disguise strip, so whether an episode played depended on the bridge's cache state. - A bridge that dies out from under the app (killed, crashed, or stopped mid-operation) no longer leaves the browser failing every request until an app restart: the exit is detected, surfaced in the status bar, and the bridge restarts on the next request. diff --git a/docs-site/anime-browser.md b/docs-site/anime-browser.md index 46788f86..e297c8d8 100644 --- a/docs-site/anime-browser.md +++ b/docs-site/anime-browser.md @@ -246,7 +246,9 @@ 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. +skip the proxy entirely. Disguised `.image` segment URLs are exposed locally +with a `.ts` suffix so current ffmpeg releases accept them when Anki extracts +audio, screenshots, or animated images from the playing stream. "Playing" in the status bar means playing: after handing mpv the stream, SubMiner waits until mpv actually configures a video output before reporting diff --git a/src/anime-bridge/stream-strip-proxy.test.ts b/src/anime-bridge/stream-strip-proxy.test.ts index 452aebc8..dfc8942f 100644 --- a/src/anime-bridge/stream-strip-proxy.test.ts +++ b/src/anime-bridge/stream-strip-proxy.test.ts @@ -6,6 +6,7 @@ import { findTsSyncOffset, rewritePlaylistOrigins, startStreamStripProxy, + TS_SEGMENT_ALIAS_SUFFIX, TS_PACKET_LENGTH, } from './stream-strip-proxy'; @@ -60,6 +61,25 @@ test('rewritePlaylistOrigins swaps absolute upstream URLs and keeps relative lin assert.ok(!rewritten.includes('41569')); }); +test('rewritePlaylistOrigins gives proxied image segments an ffmpeg-safe TS suffix', () => { + const body = [ + '#EXTM3U', + '#EXTINF:6.006,', + '/video/relative.image?token=one', + '#EXTINF:4.463,', + 'http://127.0.0.1:41569/video/absolute.image', + '#EXTINF:3,', + 'https://cdn.example/video/external.image', + ].join('\n'); + const rewritten = rewritePlaylistOrigins(body, 'http://127.0.0.1:41569', 'http://127.0.0.1:9999'); + + assert.ok(rewritten.includes(`/video/relative.image${TS_SEGMENT_ALIAS_SUFFIX}?token=one`)); + assert.ok( + rewritten.includes(`http://127.0.0.1:9999/video/absolute.image${TS_SEGMENT_ALIAS_SUFFIX}`), + ); + assert.ok(rewritten.includes('https://cdn.example/video/external.image')); +}); + /* ---------- proxy end-to-end ---------- */ type Route = { status: number; contentType: string; body: Buffer }; @@ -158,6 +178,32 @@ test('proxy rewrites absolute upstream playlist entries to its own origin', asyn ); }); +test('proxy aliases image segment URLs and removes the alias before fetching upstream', async () => { + const ts = makeTsPackets(8); + const disguised = Buffer.concat([PNG_HEADER, ts]); + await withProxy( + { + '/video/list.m3u8': { + status: 200, + contentType: 'application/vnd.apple.mpegurl', + body: Buffer.from('#EXTM3U\n#EXTINF:6,\n/video/seg.image\n'), + }, + '/video/seg.image': { status: 200, contentType: 'image/png', body: disguised }, + }, + async (proxyOrigin) => { + const playlist = await fetch(`${proxyOrigin}/video/list.m3u8`).then((response) => + response.text(), + ); + const segmentPath = playlist.split('\n').find((line) => line.startsWith('/video/')); + assert.equal(segmentPath, `/video/seg.image${TS_SEGMENT_ALIAS_SUFFIX}`); + + const { status, body } = await fetchBytes(`${proxyOrigin}${segmentPath}`); + assert.equal(status, 200); + assert.deepEqual(body, ts); + }, + ); +}); + test('proxy strips even when the client asks for a byte range', async () => { // ffmpeg opens every HLS segment with `Range: bytes=0-`. The proxy drops the // header, so the upstream answers 200 with the whole body and the strip diff --git a/src/anime-bridge/stream-strip-proxy.ts b/src/anime-bridge/stream-strip-proxy.ts index 950978ee..80da2d15 100644 --- a/src/anime-bridge/stream-strip-proxy.ts +++ b/src/anime-bridge/stream-strip-proxy.ts @@ -23,6 +23,12 @@ const TS_SYNC_BYTE = 0x47; * row at 188-byte strides do not. */ const SYNC_RUN = 5; +/** + * FFmpeg 8.1 rejects HLS media whose URL suffix is not in its segment allowlist. + * The disguised MPEG-TS segments seen in the wild use `.image`, so the local + * playlist gives them this safe alias and removes it again before forwarding. + */ +export const TS_SEGMENT_ALIAS_SUFFIX = '.subminer.ts'; /** 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. */ @@ -61,7 +67,36 @@ export function rewritePlaylistOrigins( upstreamOrigin: string, proxyOrigin: string, ): string { - return body.split(upstreamOrigin).join(proxyOrigin); + const rebased = body.split(upstreamOrigin).join(proxyOrigin); + return rebased + .split(/(\r?\n)/) + .map((line) => { + const uri = line.trim(); + if (!uri || uri.startsWith('#')) return line; + + let resolved: URL; + try { + resolved = new URL(uri, proxyOrigin); + } catch { + return line; + } + if (resolved.origin !== proxyOrigin || !resolved.pathname.toLowerCase().endsWith('.image')) { + return line; + } + + const queryIndex = uri.search(/[?#]/); + const aliasIndex = queryIndex === -1 ? uri.length : queryIndex; + const leadingWhitespace = line.slice(0, line.indexOf(uri)); + const trailingWhitespace = line.slice(leadingWhitespace.length + uri.length); + return `${leadingWhitespace}${uri.slice(0, aliasIndex)}${TS_SEGMENT_ALIAS_SUFFIX}${uri.slice(aliasIndex)}${trailingWhitespace}`; + }) + .join(''); +} + +function removeTsSegmentAlias(url: URL): void { + if (url.pathname.endsWith(TS_SEGMENT_ALIAS_SUFFIX)) { + url.pathname = url.pathname.slice(0, -TS_SEGMENT_ALIAS_SUFFIX.length); + } } export interface StreamStripProxyOptions { @@ -118,6 +153,7 @@ export function startStreamStripProxy( let upstreamUrl: URL; try { upstreamUrl = new URL(req.url ?? '/', options.upstreamOrigin()); + removeTsSegmentAlias(upstreamUrl); } catch { res.writeHead(502).end(); return;