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
@@ -5,6 +5,7 @@ import {
codecToExtension,
fileExists,
MpvTrack,
resolveLocalMediaPath,
runCommand,
summarizeCommandFailure,
} from '../../subsync/utils';
@@ -148,10 +149,12 @@ export async function extractSubtitleTrackToFile(
input: SubtitleExtractionInput,
): Promise<FileExtractionResult> {
if (input.track.external) {
const externalPath = input.track['external-filename'];
if (typeof externalPath !== 'string' || externalPath.length === 0) {
const externalFilename = input.track['external-filename'];
if (typeof externalFilename !== 'string' || externalFilename.length === 0) {
throw new Error('External subtitle track has no file path');
}
// A dropped subtitle arrives as a `file://` URL, which is local, not remote.
const externalPath = resolveLocalMediaPath(externalFilename);
if (isRemoteMediaPath(externalPath)) {
return downloadRemoteSubtitleTrack(externalPath, input.httpHeaders);
}
+95
View File
@@ -4,6 +4,7 @@ import * as fs from 'fs';
import * as http from 'http';
import * as os from 'os';
import * as path from 'path';
import { pathToFileURL } from 'url';
import { runSubsyncManual } from './subsync';
import type { TriggerSubsyncFromConfigDeps } from './subsync';
@@ -122,3 +123,97 @@ test('runSubsyncManual syncs stream subtitle tracks served over http', async (t)
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
/**
* Dropping a subtitle onto mpv hands it a `file://` URL, and mpv reports that
* URL back as the track's `external-filename`. Reading it as a stream made the
* run die with `Protocol "file:" not supported. Expected "http:"`.
*/
test('runSubsyncManual retimes a dropped file:// track against a stream reference', async (t) => {
if (process.platform === 'win32') {
t.skip('stub shell scripts are not executable on Windows');
return;
}
const host = await startStubHost();
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-file-url-'));
const alassLogPath = path.join(tmpDir, 'alass-args.log');
const alassPath = path.join(tmpDir, 'alass.sh');
fs.writeFileSync(
alassPath,
`#!/bin/sh\n: > "${alassLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${alassLogPath}"; done\ncp "$2" "${tmpDir}/target.copy"\nprintf '%s' "retimed" > "$3"\nexit 0\n`,
{ mode: 0o755 },
);
// Percent-encoded on purpose: the space and the CJK are what a naive
// "strip file://" would leave mangled.
const targetPath = path.join(tmpDir, 'ワンピース sdh.srt');
const targetBody = '1\n00:00:03,000 --> 00:00:04,000\nドロップ\n';
fs.writeFileSync(targetPath, targetBody);
const referenceUrl = host.url('/subs/en.srt');
const sentCommands: Array<Array<string | number>> = [];
const deps: Pick<TriggerSubsyncFromConfigDeps, 'getMpvClient' | 'getResolvedConfig'> = {
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: (payload) => {
sentCommands.push(payload.command);
},
requestProperty: async (name: string) => {
if (name === 'path') return host.url('/stream/video.m3u8');
if (name === 'sid') return 2;
if (name === 'secondary-sid') return null;
if (name === 'track-list') {
return [
{
id: 1,
type: 'sub',
selected: true,
external: true,
lang: 'en',
'external-filename': referenceUrl,
},
{
id: 2,
type: 'sub',
selected: true,
external: true,
lang: 'ja',
'external-filename': pathToFileURL(targetPath).href,
},
];
}
return null;
},
}),
getResolvedConfig: () => ({
alassPath,
ffsubsyncPath: '',
ffmpegPath: path.join(tmpDir, 'no-such-ffmpeg'),
replace: true,
}),
};
try {
const result = await runSubsyncManual(
{ engine: 'alass', referenceTrackId: 1, targetTrackId: 2 },
deps,
);
assert.equal(result.ok, true, result.message);
const alassArgs = fs.readFileSync(alassLogPath, 'utf8').trim().split('\n');
// The dropped file went in as-is, decoded, and was retimed in place.
assert.equal(alassArgs[1], targetPath);
assert.equal(fs.readFileSync(path.join(tmpDir, 'target.copy'), 'utf8'), targetBody);
assert.equal(alassArgs[2], targetPath);
assert.equal(fs.readFileSync(targetPath, 'utf8'), 'retimed');
const loadCommand = sentCommands.find((command) => command[0] === 'sub-add');
assert.equal(loadCommand?.[1], targetPath);
} finally {
await host.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
+4 -1
View File
@@ -6,6 +6,7 @@ import {
formatTrackLabel,
getTrackById,
MpvTrack,
resolveLocalMediaPath,
runCommand,
summarizeCommandFailure,
SubsyncContext,
@@ -136,7 +137,9 @@ async function gatherSubsyncContext(client: MpvClientLike): Promise<SubsyncConte
client.requestProperty('track-list'),
]);
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
// A dropped file is reported as a `file://` URL; alass, ffsubsync and ffmpeg
// all need the plain path, and the stream checks must not read it as remote.
const videoPath = typeof videoPathRaw === 'string' ? resolveLocalMediaPath(videoPathRaw) : '';
if (!videoPath) {
throw new Error('No video is currently loaded');
}