From 10ad19f934d012c0dc9cb6d85ecaaad54ac71a35 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 2 Aug 2026 02:04:24 -0700 Subject: [PATCH] fix(anime): guard playback races and harden install/extract tests - Tag subtitle cache writes with a generation counter so overlapping playEpisode calls can't clobber the shared cache dir, and don't fail playback when track setup errors - Ignore stale episode clicks in the detail panel via a LatestRequest guard on playback - Extract a resetGrid helper in animeui to dedupe grid-clearing logic - Assert reader cancellation and extracted-file writes actually happen in installer/subsync tests --- src/anime-bridge/extension-installer.test.ts | 3 ++ src/animeui/animeui.ts | 20 +++---- src/animeui/detail-panel.ts | 7 +++ src/core/services/subsync-extract.test.ts | 1 + src/main/runtime/anime-browser-playback.ts | 55 +++++++++++++------- 5 files changed, 57 insertions(+), 29 deletions(-) diff --git a/src/anime-bridge/extension-installer.test.ts b/src/anime-bridge/extension-installer.test.ts index afc56274..8581c223 100644 --- a/src/anime-bridge/extension-installer.test.ts +++ b/src/anime-bridge/extension-installer.test.ts @@ -166,11 +166,13 @@ test('the byte limit stops the read instead of buffering the whole body', async test('a failed reader cancellation does not hide the size-limit error', async () => { const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-')); + let cancellationAttempted = false; const body = new ReadableStream({ pull(controller) { controller.enqueue(new Uint8Array(1025)); }, async cancel() { + cancellationAttempted = true; throw new Error('cancel failed'); }, }); @@ -186,6 +188,7 @@ test('a failed reader cancellation does not hide the size-limit error', async () }), /larger than the 1024 byte limit/, ); + assert.ok(cancellationAttempted, 'the reader was never cancelled'); }); test('a failed staged write preserves the installed apk and removes the partial file', async () => { diff --git a/src/animeui/animeui.ts b/src/animeui/animeui.ts index 868c5c1f..6f7cdf44 100644 --- a/src/animeui/animeui.ts +++ b/src/animeui/animeui.ts @@ -202,10 +202,16 @@ function createCard(entry: AnimeBrowserEntry, showSource: boolean): HTMLButtonEl return card; } -function renderEntries(entries: AnimeBrowserEntry[], emptyMessage: string): void { - // Which source a cover came from only matters when they are mixed together. +/** Drops every card and the empty-state message so a fresh page can build up. */ +function resetGrid(): void { seenEntries.clear(); grid.replaceChildren(); + gridEmpty.classList.add('hidden'); +} + +function renderEntries(entries: AnimeBrowserEntry[], emptyMessage: string): void { + // Which source a cover came from only matters when they are mixed together. + resetGrid(); appendEntries(entries); const empty = grid.childElementCount === 0; @@ -255,11 +261,7 @@ api.onSearchUpdate((update) => { const request = soleBrowseRequest(inFlightBrowses); activeStreamRequestId = request?.id ?? 0; const append = request?.append === true; - if (!append) { - seenEntries.clear(); - grid.replaceChildren(); - gridEmpty.classList.add('hidden'); - } + if (!append) resetGrid(); return; } if (activeStreamRequestId !== browseState.requestId) return; @@ -317,9 +319,7 @@ async function runSearch(query: string): Promise { // A new search means new results; leave the detail page for them. if (detailPanel.isOpen()) detailPanel.close(); setStatus(query ? `Searching for “${query}”…` : 'Loading popular…'); - seenEntries.clear(); - grid.replaceChildren(); - gridEmpty.classList.add('hidden'); + resetGrid(); await runBrowse(started.request); } diff --git a/src/animeui/detail-panel.ts b/src/animeui/detail-panel.ts index f8894ed2..dd799cbf 100644 --- a/src/animeui/detail-panel.ts +++ b/src/animeui/detail-panel.ts @@ -25,6 +25,7 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) { let selectedAnime: { url: string; title: string; sourceId: string } | null = null; let resultsScrollTop = 0; const requests = new LatestRequest(); + const playbacks = new LatestRequest(); function formatEpisodeIndex(episode: AnimeBrowserEpisode, fallbackIndex: number): string { const value = episode.number ?? fallbackIndex; @@ -38,6 +39,9 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) { const anime = selectedAnime; if (!anime) return; + // Only the newest click owns the button states and the status line; an + // earlier episode resolving late must not overwrite them. + const playback = playbacks.begin(); for (const other of episodes.querySelectorAll('.cue')) { other.removeAttribute('data-state'); } @@ -55,6 +59,8 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) { }), ); + if (!playbacks.isCurrent(playback)) return; + if (!attempt.ok) { button.removeAttribute('data-state'); setStatus(describe(attempt.error), 'error'); @@ -163,6 +169,7 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) { function close(): void { requests.cancel(); + playbacks.cancel(); detail.classList.add('hidden'); results.classList.remove('hidden'); results.scrollTop = resultsScrollTop; diff --git a/src/core/services/subsync-extract.test.ts b/src/core/services/subsync-extract.test.ts index 50648652..547d2376 100644 --- a/src/core/services/subsync-extract.test.ts +++ b/src/core/services/subsync-extract.test.ts @@ -185,6 +185,7 @@ test('internal WebVTT extraction uses ffmpeg webvtt muxer with a vtt output file assert.equal(args[formatIndex + 1], 'webvtt'); assert.equal(path.extname(result.path), '.vtt'); + assert.ok(fs.existsSync(result.path), 'the extracted file was not written'); cleanupTemporaryFile(result); } finally { fs.rmSync(dir, { recursive: true, force: true }); diff --git a/src/main/runtime/anime-browser-playback.ts b/src/main/runtime/anime-browser-playback.ts index f71891e2..bc624301 100644 --- a/src/main/runtime/anime-browser-playback.ts +++ b/src/main/runtime/anime-browser-playback.ts @@ -27,24 +27,34 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions) const wait = deps.wait ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); let subtitleCacheDir: string | null = null; + // Overlapping playEpisode calls share subtitleCacheDir, so each call carries a + // generation and only writes the shared slot while it is still the newest one. + let cacheGeneration = 0; - async function clearSubtitleCache(): Promise { + async function clearSubtitleCache(generation: number): Promise { const previousDir = subtitleCacheDir; - subtitleCacheDir = null; + if (generation === cacheGeneration) subtitleCacheDir = null; await removeSubtitleCache(previousDir, deps.subtitleCacheIo); } - async function cacheStreamSubtitles(stream: { - headers: Record; - subtitles: Array<{ url: string; lang: string }>; - }): Promise> { + async function cacheStreamSubtitles( + stream: { + headers: Record; + subtitles: Array<{ url: string; lang: string }>; + }, + generation: number, + ): Promise> { const cached = await cacheSubtitleTracks({ tracks: stream.subtitles, headers: stream.headers, io: deps.subtitleCacheIo, log: deps.log, }); - subtitleCacheDir = cached.dir; + if (generation === cacheGeneration) { + subtitleCacheDir = cached.dir; + } else { + await removeSubtitleCache(cached.dir, deps.subtitleCacheIo); + } const localCount = cached.tracks.filter((track) => track.local).length; if (cached.tracks.length > 0) { @@ -57,6 +67,7 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions) } async function playEpisode(request: AnimeBrowserPlayRequest): Promise { + const generation = ++cacheGeneration; try { const { client, baseUrl } = await bridge(); const videos = await client.getVideoList( @@ -116,20 +127,26 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions) for (const command of buildPlaybackCommands({ stream, title })) { deps.sendMpvCommand(command); } - await clearSubtitleCache(); + // The file is already loading; a subtitle cache or track attach failure + // costs extra tracks, not the episode, so it must not fail playback. + try { + await clearSubtitleCache(generation); - 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)`, - ); - const [subtitles] = await Promise.all([ - cacheStreamSubtitles(stream), - wait(TRACK_ATTACH_DELAY_MS), - ]); - for (const command of buildTrackCommands({ ...stream, subtitles })) { - deps.sendMpvCommand(command); + 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)`, + ); + const [subtitles] = await Promise.all([ + cacheStreamSubtitles(stream, generation), + wait(TRACK_ATTACH_DELAY_MS), + ]); + for (const command of buildTrackCommands({ ...stream, subtitles })) { + deps.sendMpvCommand(command); + } } + } catch (error) { + deps.log(`[anime-browser] external track setup failed: ${String(error)}`); } if (watch) {