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 f51ecb69d1
commit 23d790d8f2
4 changed files with 457 additions and 5 deletions
+64 -5
View File
@@ -26,6 +26,11 @@ import {
type ExtensionSource,
type InstalledExtension,
} from '../../anime-bridge/extension-store';
import {
cacheSubtitleTracks,
removeSubtitleCache,
type SubtitleCacheIo,
} from '../../anime-bridge/subtitle-cache';
import { interleave, mapSourcesConcurrently } from '../../anime-bridge/multi-source-search';
import { startSidecar, type SidecarHandle } from '../../anime-bridge/sidecar-process';
import {
@@ -84,6 +89,8 @@ export interface AnimeBrowserRuntimeDeps {
showVisibleOverlay?: () => void;
/** Lets tests drive the pause between `loadfile` and the track commands. */
wait?: (ms: number) => Promise<void>;
/** Overrides the filesystem/network the subtitle cache uses. Tests only. */
subtitleCacheIo?: SubtitleCacheIo;
onBridgeState: (state: AnimeBrowserBridgeState) => void;
/**
* Streams per-source progress while a search invoke is pending. Optional so
@@ -110,10 +117,54 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
let loadFailures: ExtensionLoadFailure[] = [];
// Monotonic; identifies the newest browse so stale ones stop emitting.
let searchToken = 0;
// Temp directory holding the playing episode's downloaded subtitles. Kept
// until the next episode replaces it, because alass reads it mid-playback.
let subtitleCacheDir: string | null = null;
const preferenceStore = new PreferenceStore(deps.preferencesFile);
const wait =
deps.wait ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
/**
* Drop the previous episode's downloaded subtitles.
*
* Deliberately not done at end-file: alass reads those files for as long as
* the episode is up, so they only go once another one replaces them.
*/
async function clearSubtitleCache(): Promise<void> {
const previousDir = subtitleCacheDir;
subtitleCacheDir = null;
await removeSubtitleCache(previousDir, deps.subtitleCacheIo);
}
/**
* Download the stream's subtitle tracks so mpv loads files instead of URLs.
*
* alass needs the reference track on disk, and the subsync picker rejects an
* external track whose `external-filename` is not a real file, so a streamed
* track cannot be used to retime anything.
*/
async function cacheStreamSubtitles(stream: {
headers: Record<string, string>;
subtitles: Array<{ url: string; lang: string }>;
}): Promise<Array<{ url: string; lang: string }>> {
const cached = await cacheSubtitleTracks({
tracks: stream.subtitles,
headers: stream.headers,
io: deps.subtitleCacheIo,
log: deps.log,
});
subtitleCacheDir = cached.dir;
const localCount = cached.tracks.filter((track) => track.local).length;
if (cached.tracks.length > 0) {
deps.log(
`[anime-browser] cached ${localCount}/${cached.tracks.length} subtitle track(s) to disk` +
(cached.dir ? ` in ${cached.dir}` : ''),
);
}
return cached.tracks.map((track) => ({ url: track.url, lang: track.lang }));
}
function setState(state: AnimeBrowserBridgeState): void {
bridgeState = state;
deps.onBridgeState(state);
@@ -564,18 +615,23 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
for (const command of buildPlaybackCommands({ stream, title })) {
deps.sendMpvCommand(command);
}
// The old episode's subtitles are dead the moment this one loads,
// whether or not the new one brings any of its own.
await clearSubtitleCache();
const trackCommands = buildTrackCommands(stream);
if (trackCommands.length > 0) {
if (stream.audios.length > 0 || stream.subtitles.length > 0) {
deps.log(
`[anime-browser] ${stream.audios.length} external audio, ` +
`${stream.subtitles.length} external subtitle track(s)`,
);
// mpv attaches added tracks to the file that is loading, so give the
// loadfile a moment to take effect first. Same pause the Jellyfin
// subtitle preload uses.
await wait(TRACK_ATTACH_DELAY_MS);
for (const command of trackCommands) {
// subtitle preload uses. The download runs inside that pause.
const [subtitles] = await Promise.all([
cacheStreamSubtitles(stream),
wait(TRACK_ATTACH_DELAY_MS),
]);
for (const command of buildTrackCommands({ ...stream, subtitles })) {
deps.sendMpvCommand(command);
}
}
@@ -603,10 +659,13 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
async dispose(): Promise<void> {
const handle = sidecar;
const proxy = stripProxy;
const cacheDir = subtitleCacheDir;
sidecar = null;
stripProxy = null;
starting = null;
subtitleCacheDir = null;
setState(IDLE_STATE);
await removeSubtitleCache(cacheDir, deps.subtitleCacheIo);
await proxy?.close();
await handle?.stop();
},