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
This commit is contained in:
2026-08-02 02:04:24 -07:00
parent fbfdea7c64
commit 10ad19f934
5 changed files with 57 additions and 29 deletions
@@ -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 () => { test('a failed reader cancellation does not hide the size-limit error', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-')); const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
let cancellationAttempted = false;
const body = new ReadableStream<Uint8Array>({ const body = new ReadableStream<Uint8Array>({
pull(controller) { pull(controller) {
controller.enqueue(new Uint8Array(1025)); controller.enqueue(new Uint8Array(1025));
}, },
async cancel() { async cancel() {
cancellationAttempted = true;
throw new Error('cancel failed'); 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/, /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 () => { test('a failed staged write preserves the installed apk and removes the partial file', async () => {
+10 -10
View File
@@ -202,10 +202,16 @@ function createCard(entry: AnimeBrowserEntry, showSource: boolean): HTMLButtonEl
return card; return card;
} }
function renderEntries(entries: AnimeBrowserEntry[], emptyMessage: string): void { /** Drops every card and the empty-state message so a fresh page can build up. */
// Which source a cover came from only matters when they are mixed together. function resetGrid(): void {
seenEntries.clear(); seenEntries.clear();
grid.replaceChildren(); 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); appendEntries(entries);
const empty = grid.childElementCount === 0; const empty = grid.childElementCount === 0;
@@ -255,11 +261,7 @@ api.onSearchUpdate((update) => {
const request = soleBrowseRequest(inFlightBrowses); const request = soleBrowseRequest(inFlightBrowses);
activeStreamRequestId = request?.id ?? 0; activeStreamRequestId = request?.id ?? 0;
const append = request?.append === true; const append = request?.append === true;
if (!append) { if (!append) resetGrid();
seenEntries.clear();
grid.replaceChildren();
gridEmpty.classList.add('hidden');
}
return; return;
} }
if (activeStreamRequestId !== browseState.requestId) return; if (activeStreamRequestId !== browseState.requestId) return;
@@ -317,9 +319,7 @@ async function runSearch(query: string): Promise<void> {
// A new search means new results; leave the detail page for them. // A new search means new results; leave the detail page for them.
if (detailPanel.isOpen()) detailPanel.close(); if (detailPanel.isOpen()) detailPanel.close();
setStatus(query ? `Searching for “${query}”…` : 'Loading popular…'); setStatus(query ? `Searching for “${query}”…` : 'Loading popular…');
seenEntries.clear(); resetGrid();
grid.replaceChildren();
gridEmpty.classList.add('hidden');
await runBrowse(started.request); await runBrowse(started.request);
} }
+7
View File
@@ -25,6 +25,7 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
let selectedAnime: { url: string; title: string; sourceId: string } | null = null; let selectedAnime: { url: string; title: string; sourceId: string } | null = null;
let resultsScrollTop = 0; let resultsScrollTop = 0;
const requests = new LatestRequest(); const requests = new LatestRequest();
const playbacks = new LatestRequest();
function formatEpisodeIndex(episode: AnimeBrowserEpisode, fallbackIndex: number): string { function formatEpisodeIndex(episode: AnimeBrowserEpisode, fallbackIndex: number): string {
const value = episode.number ?? fallbackIndex; const value = episode.number ?? fallbackIndex;
@@ -38,6 +39,9 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
const anime = selectedAnime; const anime = selectedAnime;
if (!anime) return; 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<HTMLButtonElement>('.cue')) { for (const other of episodes.querySelectorAll<HTMLButtonElement>('.cue')) {
other.removeAttribute('data-state'); other.removeAttribute('data-state');
} }
@@ -55,6 +59,8 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
}), }),
); );
if (!playbacks.isCurrent(playback)) return;
if (!attempt.ok) { if (!attempt.ok) {
button.removeAttribute('data-state'); button.removeAttribute('data-state');
setStatus(describe(attempt.error), 'error'); setStatus(describe(attempt.error), 'error');
@@ -163,6 +169,7 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
function close(): void { function close(): void {
requests.cancel(); requests.cancel();
playbacks.cancel();
detail.classList.add('hidden'); detail.classList.add('hidden');
results.classList.remove('hidden'); results.classList.remove('hidden');
results.scrollTop = resultsScrollTop; results.scrollTop = resultsScrollTop;
@@ -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(args[formatIndex + 1], 'webvtt');
assert.equal(path.extname(result.path), '.vtt'); assert.equal(path.extname(result.path), '.vtt');
assert.ok(fs.existsSync(result.path), 'the extracted file was not written');
cleanupTemporaryFile(result); cleanupTemporaryFile(result);
} finally { } finally {
fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(dir, { recursive: true, force: true });
+36 -19
View File
@@ -27,24 +27,34 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
const wait = const wait =
deps.wait ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))); deps.wait ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
let subtitleCacheDir: string | null = null; 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<void> { async function clearSubtitleCache(generation: number): Promise<void> {
const previousDir = subtitleCacheDir; const previousDir = subtitleCacheDir;
subtitleCacheDir = null; if (generation === cacheGeneration) subtitleCacheDir = null;
await removeSubtitleCache(previousDir, deps.subtitleCacheIo); await removeSubtitleCache(previousDir, deps.subtitleCacheIo);
} }
async function cacheStreamSubtitles(stream: { async function cacheStreamSubtitles(
headers: Record<string, string>; stream: {
subtitles: Array<{ url: string; lang: string }>; headers: Record<string, string>;
}): Promise<Array<{ url: string; lang: string }>> { subtitles: Array<{ url: string; lang: string }>;
},
generation: number,
): Promise<Array<{ url: string; lang: string }>> {
const cached = await cacheSubtitleTracks({ const cached = await cacheSubtitleTracks({
tracks: stream.subtitles, tracks: stream.subtitles,
headers: stream.headers, headers: stream.headers,
io: deps.subtitleCacheIo, io: deps.subtitleCacheIo,
log: deps.log, 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; const localCount = cached.tracks.filter((track) => track.local).length;
if (cached.tracks.length > 0) { if (cached.tracks.length > 0) {
@@ -57,6 +67,7 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
} }
async function playEpisode(request: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> { async function playEpisode(request: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> {
const generation = ++cacheGeneration;
try { try {
const { client, baseUrl } = await bridge(); const { client, baseUrl } = await bridge();
const videos = await client.getVideoList( const videos = await client.getVideoList(
@@ -116,20 +127,26 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
for (const command of buildPlaybackCommands({ stream, title })) { for (const command of buildPlaybackCommands({ stream, title })) {
deps.sendMpvCommand(command); 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) { if (stream.audios.length > 0 || stream.subtitles.length > 0) {
deps.log( deps.log(
`[anime-browser] ${stream.audios.length} external audio, ` + `[anime-browser] ${stream.audios.length} external audio, ` +
`${stream.subtitles.length} external subtitle track(s)`, `${stream.subtitles.length} external subtitle track(s)`,
); );
const [subtitles] = await Promise.all([ const [subtitles] = await Promise.all([
cacheStreamSubtitles(stream), cacheStreamSubtitles(stream, generation),
wait(TRACK_ATTACH_DELAY_MS), wait(TRACK_ATTACH_DELAY_MS),
]); ]);
for (const command of buildTrackCommands({ ...stream, subtitles })) { for (const command of buildTrackCommands({ ...stream, subtitles })) {
deps.sendMpvCommand(command); deps.sendMpvCommand(command);
}
} }
} catch (error) {
deps.log(`[anime-browser] external track setup failed: ${String(error)}`);
} }
if (watch) { if (watch) {