diff --git a/CHANGELOG.md b/CHANGELOG.md index d77dc01d..3240d1a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Anime Browser Language Filter: The Extensions tab's available list now has a language chip row above it. Pick one or more languages to narrow a repository index that otherwise lists every language it knows, or "All" to clear the filter — selecting a language replaces "All" rather than sitting beside it. Extension rows name the language ("Japanese" instead of `ja`), and the Available heading shows how many of the offered extensions the filter leaves. +### Changed + +- Anime Browser Subtitles: A stream's subtitle tracks are now downloaded to a temp directory and loaded into mpv as files instead of streamed from the source URL, so they can serve as the alass reference in Subsync (the same way Jellyfin subtitles do) — a streamed track had no file on disk and was rejected by the source picker. The format is detected from the file's own content, a track that fails to download falls back to its URL so the episode still plays, and the directory is removed when the next episode starts or the app exits. + ### Fixed - Anime Browser Window Switching: Opening the anime browser now shows a tray icon on every platform and — on macOS — puts the app in the Cmd+Tab switcher (which requires the Dock icon; the two are inseparable on macOS), so you can switch between it and mpv. Previously the subtitle overlay's fullscreen support hid the whole app from the Dock and Cmd+Tab, leaving no way to reach the window. The Dock icon is released again when the window closes during playback. diff --git a/src/anime-bridge/subtitle-cache.test.ts b/src/anime-bridge/subtitle-cache.test.ts new file mode 100644 index 00000000..148917d8 --- /dev/null +++ b/src/anime-bridge/subtitle-cache.test.ts @@ -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; + removed: string[]; + requests: Array<{ url: string; headers: Record }>; +} + +function fakeIo(bodies: Record): FakeIo { + const written = new Map(); + const removed: string[] = []; + const requests: Array<{ url: string; headers: Record }> = []; + + 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); +}); diff --git a/src/anime-bridge/subtitle-cache.ts b/src/anime-bridge/subtitle-cache.ts new file mode 100644 index 00000000..76af1732 --- /dev/null +++ b/src/anime-bridge/subtitle-cache.ts @@ -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; +} + +export interface SubtitleCacheIo { + fetch: ( + url: string, + init: { headers: Record; signal: AbortSignal }, + ) => Promise; + makeTempDir: (prefix: string) => Promise; + writeFile: (filePath: string, bytes: Uint8Array) => Promise; + removeDir: (dir: string) => Promise; +} + +export interface CacheSubtitleTracksOptions { + tracks: SubtitleTrackRef[]; + /** Headers the stream was resolved with; some hosts gate subtitles too. */ + headers?: Record; + 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) && /]|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(); + 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, +): Promise { + 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 { + 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 => { + 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 { + if (!dir) return; + try { + await io.removeDir(dir); + } catch {} +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/runtime/anime-browser-runtime.ts b/src/main/runtime/anime-browser-runtime.ts index a8ff7cab..e239af57 100644 --- a/src/main/runtime/anime-browser-runtime.ts +++ b/src/main/runtime/anime-browser-runtime.ts @@ -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; + /** 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((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 { + 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; + subtitles: Array<{ url: string; lang: string }>; + }): Promise> { + 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 { 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(); },