fix(subsync): decode file:// tracks mpv reports for dropped subtitles

- Sync a subtitle dropped onto mpv without hitting "Protocol file: not supported"; decode file:// external-filename/path back to a real path for target, reference, and video
- Stream strip proxy: destroy the connection instead of retrying/502 once a response is handed off, and cap buffered playlist bodies
- Bridge installer: cap downloaded bundle size to guard against a lying/missing content-length
- Anime browser playback: bump generation on dispose so a stale in-flight playEpisode cleans up its own subtitle cache
- Fix a flaky sidecar-process test by binding to an OS-assigned port instead of pre-allocating one
This commit is contained in:
2026-08-06 22:41:16 -07:00
parent 47d31e9628
commit 5c778a9442
10 changed files with 200 additions and 8 deletions
+5 -2
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { EventEmitter } from 'node:events';
import type { AddressInfo } from 'node:net';
import type { spawn as spawnType, ChildProcess } from 'node:child_process';
import { allocatePort, startSidecar } from './sidecar-process';
import type { BundleBinaries } from './sidecar-bundle';
@@ -92,7 +93,6 @@ test('an early exit is reported with its code rather than waiting out the deadli
});
test('onExit reports a death after readiness, including to late subscribers', async () => {
const port = await allocatePort();
// Fake the bridge's capabilities endpoint so startSidecar reports ready.
const server = http.createServer((_req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
@@ -100,7 +100,10 @@ test('onExit reports a death after readiness, including to late subscribers', as
JSON.stringify({ mangatanMihonBridge: 1, sourceFactory: true, preferenceCallbacks: true }),
);
});
await new Promise<void>((resolve) => server.listen(port, '127.0.0.1', resolve));
// Bind first and take the port the OS assigned: allocating one up front and
// binding it after leaves a window for another listener to claim it.
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
const child = fakeChild();
const spawnImpl = (() => child) as unknown as typeof spawnType;
+23 -1
View File
@@ -149,6 +149,10 @@ export function startStreamStripProxy(
attempt: number,
): void {
const mayRetry = req.method === 'GET' && attempt === 0;
// Once the response is handed off, its headers (and often part of its body)
// are already on the wire: a later upstream error can only be reported by
// killing the connection, never by retrying or writing a 502.
let handedOff = false;
const retry = (): void => {
setTimeout(
() => requestUpstream(req, res, upstreamUrl, requestHeaders, attempt + 1),
@@ -172,6 +176,7 @@ export function startStreamStripProxy(
}
log(`[stream-proxy] upstream ${status} for ${upstreamUrl.pathname}`);
}
handedOff = true;
handleUpstreamResponse(req, res, upstream);
},
);
@@ -180,6 +185,11 @@ export function startStreamStripProxy(
upstreamRequest.destroy(new Error(`upstream silent for ${UPSTREAM_TIMEOUT_MS}ms`));
});
upstreamRequest.on('error', (error) => {
if (handedOff) {
log(`[stream-proxy] upstream failed mid-response: ${String(error)}`);
res.destroy();
return;
}
if (mayRetry) {
log(`[stream-proxy] upstream request failed: ${String(error)}; retrying once`);
retry();
@@ -214,7 +224,19 @@ export function startStreamStripProxy(
if (isPlaylist) {
const chunks: Buffer[] = [];
upstream.on('data', (chunk: Buffer) => chunks.push(chunk));
let buffered = 0;
upstream.on('data', (chunk: Buffer) => {
buffered += chunk.length;
// A playlist is text and small; anything this large is not one, and it
// has to be held whole in memory to be rewritten.
if (buffered > DECISION_BYTES) {
log(`[stream-proxy] playlist body over ${DECISION_BYTES} bytes; dropping`);
upstream.destroy();
res.destroy();
return;
}
chunks.push(chunk);
});
upstream.on('end', () => {
const body = rewritePlaylistOrigins(
Buffer.concat(chunks).toString('utf8'),