fix(anime): stop superseded playEpisode calls from touching mpv

- Extend the generation guard from subtitleCacheDir to the whole
  playback: a stale playEpisode call now bails out before sending mpv
  commands, showing the overlay, or reading a playback outcome that
  belongs to a newer episode
- Add a regression test where a slow-resolving first call must not
  drive mpv after a second call for a newer episode has already
  started playing
This commit is contained in:
2026-08-02 03:39:17 -07:00
parent 10ad19f934
commit 96ecb4a136
2 changed files with 70 additions and 6 deletions
@@ -38,3 +38,50 @@ test('playback returns the established error when an extension has no playable s
assert.equal(mpvConnections, 0); assert.equal(mpvConnections, 0);
await playback.dispose(); await playback.dispose();
}); });
test('a superseded episode stops instead of driving mpv behind the newer one', async () => {
let releaseFirst = (): void => undefined;
const firstResolved = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
let calls = 0;
const client = {
async getVideoList() {
calls += 1;
if (calls === 1) await firstResolved;
return [{ videoUrl: `http://stream/${calls}.m3u8`, quality: '1080p' }];
},
} as unknown as AnimeBridgeClient;
const commands: Array<Array<string | number>> = [];
let overlays = 0;
const playback = createAnimeBrowserPlayback({
deps: {
sendMpvCommand: (command) => void commands.push(command),
ensureMpvConnected: async () => true,
log: () => undefined,
showVisibleOverlay: () => {
overlays += 1;
},
},
bridge: async () => ({ client, baseUrl: 'http://127.0.0.1:1234' }),
sourceFor: async () => ({}) as BridgeSource,
stripProxy: () => null,
});
const stale = playback.playEpisode(request);
const fresh = await playback.playEpisode({ ...request, episodeUrl: '/episode-2' });
const commandsAfterFresh = commands.length;
releaseFirst();
assert.equal(fresh.ok, true);
assert.equal(overlays, 1);
assert.deepEqual(await stale, {
ok: false,
error: 'A newer episode replaced this playback.',
quality: null,
});
assert.equal(commands.length, commandsAfterFresh);
assert.equal(overlays, 1);
await playback.dispose();
});
+23 -6
View File
@@ -27,13 +27,15 @@ 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 // Overlapping playEpisode calls share mpv and subtitleCacheDir, so each call
// generation and only writes the shared slot while it is still the newest one. // carries a generation and only acts while it is still the newest one.
let cacheGeneration = 0; let playbackGeneration = 0;
async function clearSubtitleCache(generation: number): Promise<void> { async function clearSubtitleCache(generation: number): Promise<void> {
// A stale call must not delete the directory a newer playback now owns.
if (generation !== playbackGeneration) return;
const previousDir = subtitleCacheDir; const previousDir = subtitleCacheDir;
if (generation === cacheGeneration) subtitleCacheDir = null; subtitleCacheDir = null;
await removeSubtitleCache(previousDir, deps.subtitleCacheIo); await removeSubtitleCache(previousDir, deps.subtitleCacheIo);
} }
@@ -50,7 +52,7 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
io: deps.subtitleCacheIo, io: deps.subtitleCacheIo,
log: deps.log, log: deps.log,
}); });
if (generation === cacheGeneration) { if (generation === playbackGeneration) {
subtitleCacheDir = cached.dir; subtitleCacheDir = cached.dir;
} else { } else {
await removeSubtitleCache(cached.dir, deps.subtitleCacheIo); await removeSubtitleCache(cached.dir, deps.subtitleCacheIo);
@@ -67,7 +69,8 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
} }
async function playEpisode(request: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> { async function playEpisode(request: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> {
const generation = ++cacheGeneration; const generation = ++playbackGeneration;
const isCurrent = (): boolean => generation === playbackGeneration;
try { try {
const { client, baseUrl } = await bridge(); const { client, baseUrl } = await bridge();
const videos = await client.getVideoList( const videos = await client.getVideoList(
@@ -103,6 +106,10 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
return { ok: false, error: 'mpv is not running and could not be started.', quality: null }; return { ok: false, error: 'mpv is not running and could not be started.', quality: null };
} }
// Resolving the stream can outlast a newer click; from here on every step
// touches mpv or the overlay, so a superseded call stops instead.
if (!isCurrent()) return superseded();
const watch = const watch =
deps.onPlaybackEndFile && deps.readMpvProperty deps.onPlaybackEndFile && deps.readMpvProperty
? watchPlaybackOutcome({ ? watchPlaybackOutcome({
@@ -141,6 +148,7 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
cacheStreamSubtitles(stream, generation), cacheStreamSubtitles(stream, generation),
wait(TRACK_ATTACH_DELAY_MS), wait(TRACK_ATTACH_DELAY_MS),
]); ]);
if (!isCurrent()) return superseded();
for (const command of buildTrackCommands({ ...stream, subtitles })) { for (const command of buildTrackCommands({ ...stream, subtitles })) {
deps.sendMpvCommand(command); deps.sendMpvCommand(command);
} }
@@ -149,8 +157,13 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
deps.log(`[anime-browser] external track setup failed: ${String(error)}`); deps.log(`[anime-browser] external track setup failed: ${String(error)}`);
} }
if (!isCurrent()) return superseded();
if (watch) { if (watch) {
const outcome = await watch.wait(); const outcome = await watch.wait();
// The outcome belongs to whichever file mpv is playing now, so a
// superseded call must not read it as its own.
if (!isCurrent()) return superseded();
if (!outcome.ok) { if (!outcome.ok) {
deps.log(`[anime-browser] playback failed to start: ${outcome.error}`); deps.log(`[anime-browser] playback failed to start: ${outcome.error}`);
return { ok: false, error: outcome.error, quality: null }; return { ok: false, error: outcome.error, quality: null };
@@ -178,6 +191,10 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
return { playEpisode, dispose }; return { playEpisode, dispose };
} }
function superseded(): AnimeBrowserPlayResult {
return { ok: false, error: 'A newer episode replaced this playback.', quality: null };
}
function describeError(error: unknown): string { function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }