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