diff --git a/changes/anime-browser.md b/changes/anime-browser.md index d3588ace..014b01cf 100644 --- a/changes/anime-browser.md +++ b/changes/anime-browser.md @@ -13,8 +13,8 @@ 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 and gives disguised segment URLs (`.image`, `.jpg`, `.css`, and other rotating fake extensions) 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, disconnecting clients release their active upstream fetch instead of consuming the socket and bandwidth in the background, and only origin-form requests can reach the configured local bridge. +- HLS streams pass through a local strip proxy that removes fake image headers some hosts glue onto their video segments and gives disguised segment URLs (`.image`, `.jpg`, `.css`, and other rotating fake extensions) a media-safe local alias, so those streams play in mpv and support Anki audio and image extraction with current ffmpeg releases. Upstream responses use identity encoding so playlists remain readable for URL rewriting. +- 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, disconnecting clients release their active upstream fetch instead of consuming the socket and bandwidth in the background, stalled response bodies retain the upstream inactivity timeout, and only origin-form requests can reach the configured local bridge. - 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. - "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. diff --git a/src/anime-bridge/stream-strip-proxy.test.ts b/src/anime-bridge/stream-strip-proxy.test.ts index 5ddbaf24..1eb0afcf 100644 --- a/src/anime-bridge/stream-strip-proxy.test.ts +++ b/src/anime-bridge/stream-strip-proxy.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import http from 'node:http'; import net from 'node:net'; import type { AddressInfo } from 'node:net'; +import { gzipSync } from 'node:zlib'; import { findTsSyncOffset, rewritePlaylistOrigins, @@ -204,6 +205,31 @@ test('proxy leaves non-TS bodies alone', async () => { ); }); +test('proxy forces identity encoding so playlists remain readable for rewriting', async () => { + let upstreamAcceptEncoding: string | undefined; + await withProxy( + (req, res) => { + upstreamAcceptEncoding = req.headers['accept-encoding']; + const body = `#EXTM3U\n#EXTINF:6,\nhttp://${req.headers.host}/video/seg.ts\n`; + const compressed = upstreamAcceptEncoding?.includes('gzip') === true; + res.writeHead(200, { + 'content-type': 'application/vnd.apple.mpegurl', + ...(compressed ? { 'content-encoding': 'gzip' } : {}), + }); + res.end(compressed ? gzipSync(body) : body); + }, + async (proxyOrigin, upstreamOrigin) => { + const response = await fetch(`${proxyOrigin}/video/list.m3u8`, { + headers: { 'accept-encoding': 'gzip' }, + }); + const body = await response.text(); + assert.equal(upstreamAcceptEncoding, 'identity'); + assert.ok(body.includes(`${proxyOrigin}/video/seg.ts`)); + assert.ok(!body.includes(upstreamOrigin)); + }, + ); +}); + test('proxy accepts only origin-form targets for its configured HTTP upstream', async () => { let upstreamHits = 0; await withProxy( diff --git a/src/anime-bridge/stream-strip-transport.ts b/src/anime-bridge/stream-strip-transport.ts index ca690cd8..b2b42c54 100644 --- a/src/anime-bridge/stream-strip-transport.ts +++ b/src/anime-bridge/stream-strip-transport.ts @@ -7,22 +7,23 @@ interface ClientRequestLifecycle { closed: boolean; } +const DROPPED_REQUEST_HEADERS = new Set([ + 'accept-encoding', + 'connection', + 'keep-alive', + 'transfer-encoding', + 'content-length', +]); + export function forwardableRequestHeaders( headers: http.IncomingHttpHeaders, ): http.OutgoingHttpHeaders { const result: http.OutgoingHttpHeaders = {}; for (const [name, value] of Object.entries(headers)) { - if ( - value === undefined || - name.toLowerCase() === 'connection' || - name.toLowerCase() === 'keep-alive' || - name.toLowerCase() === 'transfer-encoding' || - name.toLowerCase() === 'content-length' - ) { - continue; - } + if (value === undefined || DROPPED_REQUEST_HEADERS.has(name.toLowerCase())) continue; result[name] = value; } + result['accept-encoding'] = 'identity'; return result; } @@ -75,7 +76,6 @@ function requestAttempt( }; upstream.once('end', clearActiveRequest); upstream.once('close', clearActiveRequest); - upstreamRequest.setTimeout(0); const status = upstream.statusCode ?? 502; if (status === 404 || status >= 500) { if (mayRetry) {