mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-01 19:21:34 -07:00
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:
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user