feat(anime): cache stream subtitles to disk for the alass reference

- Download a stream's subtitle tracks to a temp dir and load them into mpv as files instead of streaming from the source URL, so Subsync can use them as the alass reference (mirrors the Jellyfin subtitle preload)
- Detect format from content first, falling back to the URL extension
- A track that fails to download falls back to its URL so the episode still plays
- Remove the temp dir when the next episode loads or the runtime disposes
This commit is contained in:
2026-08-01 02:32:25 -07:00
parent 4eaaf5769f
commit dec7851a17
4 changed files with 457 additions and 5 deletions
+166
View File
@@ -0,0 +1,166 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as path from 'path';
import {
cacheSubtitleTracks,
removeSubtitleCache,
resolveSubtitleExtension,
sniffSubtitleExtension,
subtitleExtensionFromUrl,
type SubtitleCacheIo,
} from './subtitle-cache';
interface FakeIo extends SubtitleCacheIo {
written: Map<string, string>;
removed: string[];
requests: Array<{ url: string; headers: Record<string, string> }>;
}
function fakeIo(bodies: Record<string, string | { status: number }>): FakeIo {
const written = new Map<string, string>();
const removed: string[] = [];
const requests: Array<{ url: string; headers: Record<string, string> }> = [];
return {
written,
removed,
requests,
async fetch(url, init) {
requests.push({ url, headers: init.headers });
const body = bodies[url];
if (body === undefined) throw new Error(`unexpected fetch: ${url}`);
if (typeof body !== 'string') {
return { ok: false, status: body.status, arrayBuffer: async () => new ArrayBuffer(0) };
}
const bytes = Buffer.from(body, 'utf8');
return {
ok: true,
status: 200,
arrayBuffer: async () =>
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer,
};
},
async makeTempDir(prefix) {
return `${prefix}test`;
},
async writeFile(filePath, bytes) {
written.set(filePath, Buffer.from(bytes).toString('utf8'));
},
async removeDir(dir) {
removed.push(dir);
},
};
}
const SRT = '1\n00:00:01,000 --> 00:00:02,000\nこんにちは\n';
const ASS = '[Script Info]\nScriptType: v4.00+\n\n[Events]\n';
test('content decides the extension before the url does', () => {
assert.equal(sniffSubtitleExtension(ASS), 'ass');
assert.equal(sniffSubtitleExtension(SRT), 'srt');
assert.equal(sniffSubtitleExtension('WEBVTT\n\n00:01.000 --> 00:02.000\n'), 'vtt');
assert.equal(sniffSubtitleExtension('nothing recognisable'), null);
// An ASS body served from a .srt URL keeps the extension its parser needs.
assert.equal(resolveSubtitleExtension('http://host/sub.srt', ASS), 'ass');
});
test('a bom or leading whitespace does not hide the format marker', () => {
assert.equal(sniffSubtitleExtension(`${ASS}`), 'ass');
assert.equal(sniffSubtitleExtension(`\n\n${SRT}`), 'srt');
});
test('the url extension is the fallback, and only for formats we know', () => {
assert.equal(subtitleExtensionFromUrl('http://host/a/b.ASS?x=1'), 'ass');
assert.equal(subtitleExtensionFromUrl('http://host/video/token'), null);
assert.equal(subtitleExtensionFromUrl('http://host/a.mp4'), null);
// Nothing to go on: mpv can still probe past a wrong name.
assert.equal(resolveSubtitleExtension('http://host/video/token', 'unknown'), 'srt');
});
test('tracks are downloaded to a temp dir and handed back as file paths', async () => {
const io = fakeIo({ 'http://bridge/sub/ja': SRT, 'http://bridge/sub/en': ASS });
const result = await cacheSubtitleTracks({
tracks: [
{ url: 'http://bridge/sub/ja', lang: 'Japanese' },
{ url: 'http://bridge/sub/en', lang: 'English' },
],
headers: { Referer: 'https://host/' },
io,
});
assert.ok(result.dir);
assert.deepEqual(
result.tracks.map((track) => path.basename(track.url)),
['track-0.srt', 'track-1.ass'],
);
assert.ok(result.tracks.every((track) => track.local));
assert.equal(io.written.get(result.tracks[0]!.url), SRT);
// The stream's headers ride along; some hosts gate the subtitle URL too.
assert.deepEqual(io.requests[0]!.headers, { Referer: 'https://host/' });
});
test('a failed download keeps its url so the episode still plays', async () => {
const io = fakeIo({ 'http://bridge/sub/ja': SRT, 'http://bridge/sub/en': { status: 404 } });
const logged: string[] = [];
const result = await cacheSubtitleTracks({
tracks: [
{ url: 'http://bridge/sub/ja', lang: 'Japanese' },
{ url: 'http://bridge/sub/en', lang: 'English' },
],
io,
log: (message) => logged.push(message),
});
assert.equal(result.tracks[0]!.local, true);
assert.equal(result.tracks[1]!.local, false);
assert.equal(result.tracks[1]!.url, 'http://bridge/sub/en');
assert.ok(logged.some((message) => message.includes('404')));
// One track survived, so the directory stays.
assert.ok(result.dir);
assert.deepEqual(io.removed, []);
});
test('a directory with nothing in it is removed and not reported', async () => {
const io = fakeIo({ 'http://bridge/sub/ja': { status: 500 } });
const result = await cacheSubtitleTracks({
tracks: [{ url: 'http://bridge/sub/ja', lang: 'Japanese' }],
io,
});
assert.equal(result.dir, null);
assert.equal(result.tracks[0]!.local, false);
assert.equal(io.removed.length, 1);
});
test('duplicate and empty urls are dropped before anything is fetched', async () => {
const io = fakeIo({ 'http://bridge/sub/ja': SRT });
const result = await cacheSubtitleTracks({
tracks: [
{ url: 'http://bridge/sub/ja', lang: 'Japanese' },
{ url: 'http://bridge/sub/ja', lang: 'Japanese' },
{ url: '', lang: 'English' },
],
io,
});
assert.equal(result.tracks.length, 1);
assert.equal(io.requests.length, 1);
});
test('no tracks means no temp directory at all', async () => {
const io = fakeIo({});
const result = await cacheSubtitleTracks({ tracks: [], io });
assert.deepEqual(result, { dir: null, tracks: [] });
assert.equal(io.written.size, 0);
});
test('cleanup is best effort and never throws', async () => {
const io = fakeIo({});
io.removeDir = async () => {
throw new Error('EBUSY');
};
await removeSubtitleCache('/tmp/subminer-anime-subtitles-x', io);
// A null directory is the common case after a source with no subtitles.
await removeSubtitleCache(null, io);
});
+223
View File
@@ -0,0 +1,223 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
/**
* Extension subtitle tracks arrive as URLs on the bridge's loopback proxy, and
* mpv is perfectly happy to stream them. alass is not: it needs a file on disk
* to use as the timing reference, and the subsync path rejects an external
* track whose `external-filename` is not an existing file. So every track is
* downloaded to a temp directory first and mpv is given the local path, the
* same way the Jellyfin preload caches its delivery URLs.
*
* The directory outlives the `sub-add` — alass reads it mid-playback — and is
* removed when the next episode starts or the runtime shuts down.
*/
/** Extensions mpv and alass both recognise off a filename. */
const KNOWN_SUBTITLE_EXTENSIONS = new Set([
'srt',
'ass',
'ssa',
'vtt',
'sub',
'ttml',
'smi',
'sbv',
]);
/** What an unrecognisable track is named; mpv still probes the content. */
const DEFAULT_SUBTITLE_EXTENSION = 'srt';
const DOWNLOAD_TIMEOUT_MS = 15_000;
/** Anything this large is not a subtitle file, and is not worth buffering. */
const MAX_SUBTITLE_BYTES = 32 * 1024 * 1024;
/** How much of the body is decoded to guess the format. */
const SNIFF_BYTES = 1024;
export interface SubtitleTrackRef {
url: string;
lang: string;
}
export interface CachedSubtitleTrack extends SubtitleTrackRef {
/** Where the track came from, kept for logs. */
sourceUrl: string;
/** False when the download failed and `url` is still the remote URL. */
local: boolean;
}
export interface SubtitleCacheResult {
/** The temp directory to remove later, or null when nothing was cached. */
dir: string | null;
tracks: CachedSubtitleTrack[];
}
interface FetchResponseLike {
ok: boolean;
status: number;
arrayBuffer: () => Promise<ArrayBuffer>;
}
export interface SubtitleCacheIo {
fetch: (
url: string,
init: { headers: Record<string, string>; signal: AbortSignal },
) => Promise<FetchResponseLike>;
makeTempDir: (prefix: string) => Promise<string>;
writeFile: (filePath: string, bytes: Uint8Array) => Promise<void>;
removeDir: (dir: string) => Promise<void>;
}
export interface CacheSubtitleTracksOptions {
tracks: SubtitleTrackRef[];
/** Headers the stream was resolved with; some hosts gate subtitles too. */
headers?: Record<string, string>;
io?: SubtitleCacheIo;
log?: (message: string) => void;
}
export function createSubtitleCacheIo(): SubtitleCacheIo {
return {
fetch: (url, init) => fetch(url, init),
makeTempDir: (prefix) => fs.promises.mkdtemp(prefix),
writeFile: (filePath, bytes) => fs.promises.writeFile(filePath, bytes),
removeDir: (dir) => fs.promises.rm(dir, { recursive: true, force: true }),
};
}
/**
* Guess a subtitle format from the start of the file.
*
* Bridge subtitle URLs are opaque tokens far more often than they are
* filenames, so the content is the only reliable signal. mpv and alass both
* pick their parser off the extension, and a `.srt` holding ASS is a parse
* error rather than a mistimed subtitle.
*/
export function sniffSubtitleExtension(head: string): string | null {
const text = head.replace(/^\uFEFF/, '').trimStart();
if (/^\[(script info|v4\+? styles|events)\]/i.test(text)) return 'ass';
if (/^WEBVTT(\s|$)/.test(text)) return 'vtt';
if (/^<\?xml/i.test(text) && /<tt[\s>]|ttml/i.test(text)) return 'ttml';
// Cue-numbered and bare-timestamp SRT; the `.` separator is a common variant.
if (/^(\d+\s*\r?\n)?\d{1,3}:\d{2}:\d{2}[,.]\d{1,3}\s*-->/.test(text)) return 'srt';
return null;
}
/** The URL's own extension, when it names a format we know. */
export function subtitleExtensionFromUrl(url: string): string | null {
const urlPath = (() => {
try {
return new URL(url).pathname;
} catch {
return url;
}
})();
const extension = path.extname(urlPath).slice(1).toLowerCase();
return KNOWN_SUBTITLE_EXTENSIONS.has(extension) ? extension : null;
}
/** Content first, then the URL, then a guess mpv can still probe past. */
export function resolveSubtitleExtension(url: string, head: string): string {
return (
sniffSubtitleExtension(head) ?? subtitleExtensionFromUrl(url) ?? DEFAULT_SUBTITLE_EXTENSION
);
}
function dedupeByUrl(tracks: SubtitleTrackRef[]): SubtitleTrackRef[] {
const seen = new Set<string>();
return tracks.filter((track) => {
if (track.url.length === 0 || seen.has(track.url)) return false;
seen.add(track.url);
return true;
});
}
async function downloadTrack(
io: SubtitleCacheIo,
dir: string,
index: number,
track: SubtitleTrackRef,
headers: Record<string, string>,
): Promise<string> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
let bytes: Uint8Array;
try {
const response = await io.fetch(track.url, { headers, signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
bytes = new Uint8Array(await response.arrayBuffer());
} finally {
clearTimeout(timeoutId);
}
if (bytes.byteLength === 0) {
throw new Error('empty response');
}
if (bytes.byteLength > MAX_SUBTITLE_BYTES) {
throw new Error(`response too large (${bytes.byteLength} bytes)`);
}
const head = Buffer.from(bytes.subarray(0, SNIFF_BYTES)).toString('utf8');
const extension = resolveSubtitleExtension(track.url, head);
const filePath = path.join(dir, `track-${index}.${extension}`);
// Written byte for byte: re-encoding would corrupt a non-UTF-8 track that
// mpv's own charset detection would otherwise handle.
await io.writeFile(filePath, bytes);
return filePath;
}
/**
* Download every subtitle track to a fresh temp directory.
*
* A track that fails to download keeps its remote URL, so a dead subtitle
* server costs the alass reference rather than the episode.
*/
export async function cacheSubtitleTracks(
options: CacheSubtitleTracksOptions,
): Promise<SubtitleCacheResult> {
const io = options.io ?? createSubtitleCacheIo();
const tracks = dedupeByUrl(options.tracks);
if (tracks.length === 0) return { dir: null, tracks: [] };
const dir = await io.makeTempDir(path.join(os.tmpdir(), 'subminer-anime-subtitles-'));
const cached = await Promise.all(
tracks.map(async (track, index): Promise<CachedSubtitleTrack> => {
try {
const filePath = await downloadTrack(io, dir, index, track, options.headers ?? {});
return { url: filePath, lang: track.lang, sourceUrl: track.url, local: true };
} catch (error) {
options.log?.(
`[anime-browser] subtitle download failed (${track.lang || 'unknown'}): ` +
describeError(error),
);
return { url: track.url, lang: track.lang, sourceUrl: track.url, local: false };
}
}),
);
if (!cached.some((track) => track.local)) {
await removeSubtitleCache(dir, io);
return { dir: null, tracks: cached };
}
return { dir, tracks: cached };
}
/** Remove a cache directory. Never throws: cleanup is best effort. */
export async function removeSubtitleCache(
dir: string | null,
io: SubtitleCacheIo = createSubtitleCacheIo(),
): Promise<void> {
if (!dir) return;
try {
await io.removeDir(dir);
} catch {}
}
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}