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