fix(subsync): sync subtitles from stream URLs, auto-discover tool paths

- Download URL-loaded subtitle tracks (Aniyomi extension streams, Jellyfin) to a temp file first, reusing mpv's own request headers, instead of rejecting them with "Subtitle file not found"
- Pass mpv's headers through to ffmpeg for internal track extraction from streams too
- Auto-discover alass/ffsubsync/ffmpeg on PATH and common install prefixes when the config path is empty, instead of a hard-coded /usr/bin fallback that never exists on macOS
- Log subsync failures to the app log, not just a toast that vanishes in seconds
- Update docs-site and README credits for the new behavior
This commit is contained in:
2026-08-01 17:20:45 -07:00
parent feb8d5a55c
commit 4abbe8d567
15 changed files with 943 additions and 320 deletions
+175
View File
@@ -0,0 +1,175 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'fs';
import * as http from 'http';
import * as os from 'os';
import * as path from 'path';
import { cleanupTemporaryFile, extractSubtitleTrackToFile } from './subsync-extract';
import type { ResolvedMpvHttpHeaders } from './mpv-http-headers';
const SUBTITLE_BODY = '1\n00:00:01,000 --> 00:00:02,000\nhello\n';
interface StubServer {
url: (pathname: string) => string;
requests: Array<{ url: string; headers: http.IncomingHttpHeaders }>;
close: () => Promise<void>;
}
async function startSubtitleServer(): Promise<StubServer> {
const requests: StubServer['requests'] = [];
const server = http.createServer((req, res) => {
requests.push({ url: req.url ?? '', headers: req.headers });
if (req.url?.startsWith('/forbidden')) {
res.writeHead(403);
res.end('nope');
return;
}
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(SUBTITLE_BODY);
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
return {
url: (pathname) => `http://127.0.0.1:${port}${pathname}`,
requests,
close: () =>
new Promise<void>((resolve) => {
server.close(() => resolve());
}),
};
}
test('extractSubtitleTrackToFile downloads an external track served over http', async () => {
const server = await startSubtitleServer();
try {
const result = await extractSubtitleTrackToFile({
ffmpegPath: '/unused/ffmpeg',
videoPath: server.url('/stream.mp4'),
track: { id: 2, type: 'sub', external: true, 'external-filename': server.url('/subs/ja.srt') },
httpHeaders: null,
});
assert.equal(result.temporary, true);
assert.equal(path.extname(result.path), '.srt');
assert.equal(fs.readFileSync(result.path, 'utf8'), SUBTITLE_BODY);
cleanupTemporaryFile(result);
assert.equal(fs.existsSync(result.path), false);
} finally {
await server.close();
}
});
test('extractSubtitleTrackToFile forwards mpv request headers to the subtitle host', async () => {
const server = await startSubtitleServer();
const httpHeaders: ResolvedMpvHttpHeaders = {
headers: { Referer: 'https://example.test/watch' },
userAgent: 'SubMinerTest/1.0',
};
try {
const result = await extractSubtitleTrackToFile({
ffmpegPath: '/unused/ffmpeg',
videoPath: server.url('/stream.mp4'),
track: { id: 2, type: 'sub', external: true, 'external-filename': server.url('/subs/ja.srt') },
httpHeaders,
});
cleanupTemporaryFile(result);
const sent = server.requests.at(-1);
assert.equal(sent?.headers.referer, 'https://example.test/watch');
assert.equal(sent?.headers['user-agent'], 'SubMinerTest/1.0');
} finally {
await server.close();
}
});
test('extractSubtitleTrackToFile reports the HTTP status when the download fails', async () => {
const server = await startSubtitleServer();
try {
await assert.rejects(
extractSubtitleTrackToFile({
ffmpegPath: '/unused/ffmpeg',
videoPath: server.url('/stream.mp4'),
track: {
id: 2,
type: 'sub',
external: true,
'external-filename': server.url('/forbidden/ja.srt'),
},
httpHeaders: null,
}),
/Failed to download subtitle track.*403/s,
);
} finally {
await server.close();
}
});
test('extractSubtitleTrackToFile still uses a local external track in place', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-extract-'));
const localPath = path.join(dir, 'local.srt');
fs.writeFileSync(localPath, SUBTITLE_BODY);
try {
const result = await extractSubtitleTrackToFile({
ffmpegPath: '/unused/ffmpeg',
videoPath: path.join(dir, 'video.mkv'),
track: { id: 2, type: 'sub', external: true, 'external-filename': localPath },
httpHeaders: null,
});
assert.deepEqual(result, { path: localPath, temporary: false });
// A borrowed file must survive cleanup.
cleanupTemporaryFile(result);
assert.equal(fs.existsSync(localPath), true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('extractSubtitleTrackToFile rejects a missing local external track', async () => {
await assert.rejects(
extractSubtitleTrackToFile({
ffmpegPath: '/unused/ffmpeg',
videoPath: '/tmp/video.mkv',
track: { id: 2, type: 'sub', external: true, 'external-filename': '/tmp/does-not-exist.srt' },
httpHeaders: null,
}),
/Subtitle file not found/,
);
});
test('cleanupTemporaryFile preserves the retimed output sharing the temp directory', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-extract-'));
const sourcePath = path.join(dir, 'remote_track.srt');
const retimedPath = path.join(dir, 'remote_track_retimed.srt');
fs.writeFileSync(sourcePath, SUBTITLE_BODY);
fs.writeFileSync(retimedPath, SUBTITLE_BODY);
try {
cleanupTemporaryFile({ path: sourcePath, temporary: true }, retimedPath);
assert.equal(fs.existsSync(sourcePath), false);
assert.equal(fs.existsSync(retimedPath), true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('cleanupTemporaryFile keeps the file when it is itself the preserved output', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-extract-'));
const inPlacePath = path.join(dir, 'remote_track.srt');
fs.writeFileSync(inPlacePath, SUBTITLE_BODY);
try {
cleanupTemporaryFile({ path: inPlacePath, temporary: true }, inPlacePath);
assert.equal(fs.existsSync(inPlacePath), true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});