mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-01 07:21:33 -07:00
fix(anime): retry cold upstream fetches in the stream strip proxy
Right after an episode resolves, the bridge (or the host behind it) can error on the very first segment fetches and be fine a moment later; mpv sweeps the playlist of failing segments in ~2s and gives up with "no audio or video data played" while a manual reload of the same URL plays. Retry a failed GET once after 400ms and log upstream error statuses so the next failure names the real cause in the app log.
This commit is contained in:
@@ -14,6 +14,7 @@ area: anime
|
||||
- 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.
|
||||
- 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.
|
||||
- "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.
|
||||
|
||||
@@ -80,7 +80,10 @@ async function withProxy(
|
||||
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 });
|
||||
const proxy = await startStreamStripProxy({
|
||||
upstreamOrigin: () => upstreamOrigin,
|
||||
retryDelayMs: 5,
|
||||
});
|
||||
try {
|
||||
await run(proxy.origin, upstreamOrigin);
|
||||
} finally {
|
||||
@@ -163,3 +166,66 @@ test('proxy forwards error statuses without touching the body', async () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Upstream that fails the first `failures` hits per path, then serves the
|
||||
* route. Mirrors the bridge right after an episode resolve: mpv's immediate
|
||||
* segment fetch errors, the same fetch a moment later works.
|
||||
*/
|
||||
async function withFlakyUpstream(
|
||||
failures: number,
|
||||
failStatus: number,
|
||||
route: Route,
|
||||
run: (proxyOrigin: string, hits: () => number) => Promise<void>,
|
||||
): Promise<void> {
|
||||
let hits = 0;
|
||||
const upstream = http.createServer((_req, res) => {
|
||||
hits += 1;
|
||||
if (hits <= failures) {
|
||||
res.writeHead(failStatus, { 'content-type': 'text/plain' }).end('not ready');
|
||||
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,
|
||||
retryDelayMs: 5,
|
||||
});
|
||||
try {
|
||||
await run(proxy.origin, () => hits);
|
||||
} finally {
|
||||
await proxy.close();
|
||||
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
test('proxy retries a failed segment fetch once and serves the retry', async () => {
|
||||
const ts = makeTsPackets(8);
|
||||
await withFlakyUpstream(
|
||||
1,
|
||||
404,
|
||||
{ status: 200, contentType: 'video/mp2t', body: ts },
|
||||
async (origin, hits) => {
|
||||
const { status, body } = await fetchBytes(`${origin}/video/seg.ts`);
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(body, ts);
|
||||
assert.equal(hits(), 2);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('proxy gives up after one retry and forwards the error', async () => {
|
||||
await withFlakyUpstream(
|
||||
Infinity,
|
||||
503,
|
||||
{ status: 200, contentType: 'video/mp2t', body: makeTsPackets(8) },
|
||||
async (origin, hits) => {
|
||||
const { status } = await fetchBytes(`${origin}/video/seg.ts`);
|
||||
assert.equal(status, 503);
|
||||
assert.equal(hits(), 2);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -68,8 +68,12 @@ export interface StreamStripProxyOptions {
|
||||
/** Read per request so a bridge restart on a new port keeps working. */
|
||||
upstreamOrigin: () => string;
|
||||
log?: (message: string) => void;
|
||||
/** Pause before the single retry of a failed upstream GET. */
|
||||
retryDelayMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_RETRY_DELAY_MS = 400;
|
||||
|
||||
export interface StreamStripProxyHandle {
|
||||
origin: string;
|
||||
port: number;
|
||||
@@ -97,6 +101,7 @@ export function startStreamStripProxy(
|
||||
options: StreamStripProxyOptions,
|
||||
): Promise<StreamStripProxyHandle> {
|
||||
const log = options.log ?? (() => {});
|
||||
const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
@@ -114,19 +119,61 @@ export function startStreamStripProxy(
|
||||
|
||||
const requestHeaders = forwardableHeaders(req.headers);
|
||||
delete requestHeaders.host;
|
||||
res.on('error', () => {});
|
||||
|
||||
requestUpstream(req, res, upstreamUrl, requestHeaders, 0);
|
||||
});
|
||||
|
||||
/**
|
||||
* One delayed retry on a failed GET: right after an episode resolve, the
|
||||
* bridge (or the host behind it) can error on the very first segment
|
||||
* fetches and be fine a moment later — mpv treats a playlist full of failed
|
||||
* segments as a dead file and gives up for good.
|
||||
*/
|
||||
function requestUpstream(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
upstreamUrl: URL,
|
||||
requestHeaders: http.OutgoingHttpHeaders,
|
||||
attempt: number,
|
||||
): void {
|
||||
const mayRetry = req.method === 'GET' && attempt === 0;
|
||||
const retry = (): void => {
|
||||
setTimeout(
|
||||
() => requestUpstream(req, res, upstreamUrl, requestHeaders, attempt + 1),
|
||||
retryDelayMs,
|
||||
);
|
||||
};
|
||||
|
||||
const upstreamRequest = http.request(
|
||||
upstreamUrl,
|
||||
{ method: req.method, headers: requestHeaders },
|
||||
(upstream) => handleUpstreamResponse(req, res, upstream),
|
||||
(upstream) => {
|
||||
const status = upstream.statusCode ?? 502;
|
||||
if (status === 404 || status >= 500) {
|
||||
if (mayRetry) {
|
||||
log(`[stream-proxy] upstream ${status} for ${upstreamUrl.pathname}; retrying once`);
|
||||
upstream.resume();
|
||||
retry();
|
||||
return;
|
||||
}
|
||||
log(`[stream-proxy] upstream ${status} for ${upstreamUrl.pathname}`);
|
||||
}
|
||||
handleUpstreamResponse(req, res, upstream);
|
||||
},
|
||||
);
|
||||
upstreamRequest.on('error', (error) => {
|
||||
if (mayRetry) {
|
||||
log(`[stream-proxy] upstream request failed: ${String(error)}; retrying once`);
|
||||
retry();
|
||||
return;
|
||||
}
|
||||
log(`[stream-proxy] upstream request failed: ${String(error)}`);
|
||||
if (!res.headersSent) res.writeHead(502);
|
||||
res.end();
|
||||
});
|
||||
upstreamRequest.end();
|
||||
});
|
||||
}
|
||||
|
||||
function handleUpstreamResponse(
|
||||
req: http.IncomingMessage,
|
||||
|
||||
Reference in New Issue
Block a user