feat(anime): append queued episodes to mpv playlist

- Resolve queued streams immediately and cache subtitles in the background
- Attach prepared tracks as mpv advances through the playlist
This commit is contained in:
2026-08-14 23:45:45 -07:00
parent 49271ba491
commit 9bff3f6b56
21 changed files with 660 additions and 289 deletions
+1 -1
View File
@@ -28,4 +28,4 @@ area: anime
- Episodes already watched are dimmed and marked in the episode list, with a watched count in the header. The marks come from the stats history playback already writes (an episode is marked once a session passes the completion threshold), so they match the stats window and survive the stream URL changing between playbacks. They refresh when the browser window comes back to the front, and stay empty when immersion tracking is disabled.
- Right-clicking an episode opens a menu for marking it watched or unwatched by hand, plus "Mark this and N below watched/unwatched" for the episode and every episode listed under it. Sources list newest first, so a span covers the back catalogue, which is how a series watched elsewhere gets caught up. A filter never narrows what a span covers, and the status bar reports how many episodes were touched.
- Marking an episode that was never played creates its stats row, carrying the same series, season and episode fields playback would have recorded. Both stats library views join the lifetime tables, so a manual mark does not show up there as watch time nobody spent, and clearing a mark creates nothing.
- Episodes can be queued instead of replacing what is playing. Every episode row has **Play** and **Queue** buttons (clicking the row still plays now), the right-click menu offers the same two, and a queued episode shows its place in line ("next up", "#2 in queue") with a queue count and **Clear queue** in the episode header. The queue spans anime, so episodes from different series can be lined up together, and it starts the next episode when the current one ends — resolving its stream at that point, so a signed stream URL cannot expire while it waits. Queueing with nothing playing just plays. mpv's `keep-open` is held off while the queue waits, since it otherwise pauses at the end of a file forever, and the setting is restored once the queue empties.
- Episodes can be queued instead of replacing what is playing. Every episode row has **Play** and **Queue** buttons (clicking the row still plays now), the right-click menu offers the same two, and a queued episode shows its place in line ("next up", "#2 in queue") with a queue count and **Clear queue** in the episode header. The queue spans anime, resolves and appends each episode to mpv's real playlist as soon as it is queued while subtitle tracks cache in the background, so next/previous navigation works immediately and the next episode starts without a resolution pause when the current one ends. Queueing with nothing playing just plays.
+8
View File
@@ -254,6 +254,14 @@ success. If mpv gives up instead — a dead host, an undecodable stream — the
browser shows mpv's error rather than pretending playback started (a failed
load leaves no mpv window, because the player idles windowless).
Choosing **Queue** resolves the episode and appends the playable stream to mpv's
own playlist immediately, while its subtitle tracks cache in the background.
That makes mpv's next command available at once and lets the next episode begin
automatically when the current one ends, without waiting for another source
request. Resolved stream URLs can be short-lived, so a very long queue can still
outlive what its source issued; dequeue and queue that episode again to refresh
it.
### Japanese audio, and switching tracks
Sources often return a dub and the original audio as two separate entries — or
+3 -2
View File
@@ -33,8 +33,9 @@ Read when: you need to find the owner module for a behavior or test surface
browser window UI in `src/animeui/` (preload `src/preload-animeui.ts`); runtime wiring in
`src/main/runtime/anime-browser-runtime.ts`, `src/main/runtime/anime-browser-ipc-handlers.ts`,
`src/main/runtime/anime-bridge-installer.ts`, `src/main/runtime/stream-playback-metadata.ts`.
The play queue is app-level rather than an mpv playlist (`src/main/runtime/anime-browser-queue.ts`):
a queued episode is resolved when its turn comes, driven off mpv's `end-file`
The play queue resolves episodes on click and appends them to mpv's real playlist
(`src/main/runtime/anime-browser-queue.ts`), then observes media-path changes to
attach prepared external tracks and update the browser queue state.
- Window trackers: `src/window-trackers/`
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
in `src/core/services/stats-server/`
+37
View File
@@ -3,6 +3,8 @@ import assert from 'node:assert/strict';
import {
buildLoadfileOptions,
buildPlaybackCommands,
buildQueuedLoadfileOptions,
buildQueuedPlaybackCommands,
buildTrackCommands,
normalizeLangTag,
selectPreferredStream,
@@ -39,6 +41,41 @@ test('loadfile options keep one visible track and never scan the filesystem', ()
assert.ok(!options.includes('slang'));
});
test('queued playback carries file-local title and language preferences into mpv', () => {
const options = {
stream: {
url: 'https://video.example/episode.m3u8',
quality: '1080p',
headers: { Referer: 'https://source.example/watch?a=1,b=2' },
audios: [],
subtitles: [],
},
title: 'Series, Part 1 = Episode 2',
};
const languagePreference = 'ja,jpn,jp,japanese';
assert.ok(
buildQueuedLoadfileOptions(options).includes(
`alang=%${Buffer.byteLength(languagePreference)}%${languagePreference}`,
),
);
assert.ok(
buildQueuedLoadfileOptions(options).includes(
`force-media-title=%${Buffer.byteLength(options.title)}%${options.title}`,
),
);
assert.deepEqual(buildQueuedPlaybackCommands(options), [
['script-message', 'subminer-managed-subtitles-loading'],
[
'loadfile',
'https://video.example/episode.m3u8',
'append-play',
-1,
buildQueuedLoadfileOptions(options),
],
]);
});
test('headers are percent-escaped so their commas do not split the option list', () => {
const headers = { Referer: 'https://a.test/', 'User-Agent': 'X' };
const options = buildLoadfileOptions({ stream: stream({ headers }) });
+25
View File
@@ -73,6 +73,23 @@ export function buildLoadfileOptions(options: BuildPlaybackOptions): string {
return parts.join(',');
}
/**
* Build the file-local options needed when a resolved stream waits in mpv's
* playlist. Unlike the regular playback path, there is no opportunity to set
* global properties immediately before mpv advances to this file.
*/
export function buildQueuedLoadfileOptions(options: BuildPlaybackOptions): string {
const parts = [
buildLoadfileOptions(options),
`alang=${escapeOptionValue(JAPANESE_LANGUAGE_PREFERENCE)}`,
`slang=${escapeOptionValue(JAPANESE_LANGUAGE_PREFERENCE)}`,
];
if (options.title !== undefined && options.title.length > 0) {
parts.push(`force-media-title=${escapeOptionValue(options.title)}`);
}
return parts.join(',');
}
/**
* mpv splits `loadfile` options on commas and `=`-separates keys, so a value
* containing either must be quoted. Percent-encoding is mpv's own escape for
@@ -107,6 +124,14 @@ export function buildPlaybackCommands(options: BuildPlaybackOptions): MpvCommand
return commands;
}
/** Append a fully resolved stream without replacing the file playing now. */
export function buildQueuedPlaybackCommands(options: BuildPlaybackOptions): MpvCommand[] {
return [
['script-message', 'subminer-managed-subtitles-loading'],
['loadfile', options.stream.url, 'append-play', -1, buildQueuedLoadfileOptions(options)],
];
}
/**
* Commands that attach the extension's external audio and subtitle tracks.
*
+14 -2
View File
@@ -14,6 +14,18 @@ function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForExit(hasExited: Promise<void>, timeoutMs: number): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | null = null;
const timedOut = new Promise<void>((resolve) => {
timeout = setTimeout(resolve, timeoutMs);
});
try {
await Promise.race([hasExited, timedOut]);
} finally {
if (timeout !== null) clearTimeout(timeout);
}
}
/** Ask the OS for a free loopback port, then hand it to the JVM. */
export async function allocatePort(): Promise<number> {
return new Promise((resolve, reject) => {
@@ -122,10 +134,10 @@ export async function startSidecar(options: StartSidecarOptions): Promise<Sideca
child.kill();
// kill() only sends the signal. Wait for the process to actually go, so a
// restart cannot race the old one still holding the port.
await Promise.race([hasExited, delay(stopTimeoutMs)]);
await waitForExit(hasExited, stopTimeoutMs);
if (exited === null) {
child.kill('SIGKILL');
await Promise.race([hasExited, delay(stopTimeoutMs)]);
await waitForExit(hasExited, stopTimeoutMs);
}
// Never report success while the child may still hold the port: a caller
// that restarts on the same port would race the survivor.
+2 -2
View File
@@ -86,8 +86,8 @@ export function createEpisodeQueueControls(options: EpisodeQueueControlsOptions)
if (!anime) return;
const queued = isQueued(episode);
// Queueing behind nothing would wait for an end that never comes, so with
// an idle player the second option collapses into the first.
// With an idle player the second option still collapses into Play so the
// browser can report mpv's startup outcome before claiming success.
if (!queued) {
const playing = await capture(() => api.isPlaying());
if (playing.ok && !playing.value) {
+7 -1
View File
@@ -16,9 +16,12 @@ const SUBTITLE_BODY = '1\n00:00:01,000 --> 00:00:02,000\nhello\n';
*/
async function startStubHost(): Promise<{
url: (pathname: string) => string;
paths: string[];
close: () => Promise<void>;
}> {
const server = http.createServer((_req, res) => {
const paths: string[] = [];
const server = http.createServer((req, res) => {
paths.push(new URL(req.url ?? '/', 'http://localhost').pathname);
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(SUBTITLE_BODY);
});
@@ -28,6 +31,7 @@ async function startStubHost(): Promise<{
return {
url: (pathname) => `http://127.0.0.1:${port}${pathname}`,
paths,
close: () =>
new Promise<void>((resolve) => {
server.close(() => resolve());
@@ -114,6 +118,7 @@ test('runSubsyncManual syncs stream subtitle tracks served over http', async (t)
assert.equal(alassArgs.length, 3);
assert.equal(fs.readFileSync(path.join(tmpDir, 'reference.copy'), 'utf8'), SUBTITLE_BODY);
assert.equal(fs.readFileSync(path.join(tmpDir, 'target.copy'), 'utf8'), SUBTITLE_BODY);
assert.deepEqual(host.paths, ['/subs/en.srt', '/subs/ja.srt']);
const loadCommand = sentCommands.find((command) => command[0] === 'sub-add');
assert.equal(loadCommand?.[1], alassArgs[2]);
@@ -209,6 +214,7 @@ test('runSubsyncManual retimes a dropped file:// track against a stream referenc
assert.equal(fs.readFileSync(path.join(tmpDir, 'target.copy'), 'utf8'), targetBody);
assert.equal(alassArgs[2], targetPath);
assert.equal(fs.readFileSync(targetPath, 'utf8'), 'retimed');
assert.deepEqual(host.paths, ['/subs/en.srt']);
const loadCommand = sentCommands.find((command) => command[0] === 'sub-add');
assert.equal(loadCommand?.[1], targetPath);
+2 -3
View File
@@ -903,8 +903,9 @@ test('runSubsyncManual keeps a retimed secondary track in the secondary slot', a
);
});
test('runSubsyncManual converts VTT stream tracks to SRT before running alass', async () => {
test('runSubsyncManual converts VTT stream tracks to SRT before running alass', async (t) => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-vtt-'));
t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
const alassLogPath = path.join(tmpDir, 'alass-args.log');
const alassPath = path.join(tmpDir, 'alass.sh');
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
@@ -978,8 +979,6 @@ test('runSubsyncManual converts VTT stream tracks to SRT before running alass',
assert.equal(sentCommands[0]?.[0], 'sub-add');
assert.equal(typeof loadedPath, 'string');
assert.match(fs.readFileSync(fromShellPath(String(loadedPath)), 'utf8'), /しっかし/);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('runSubsyncManual keeps internal alass source file alive until sync finishes', async () => {
+21
View File
@@ -3330,6 +3330,13 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({
client.on('end-file', listener);
return () => client.off('end-file', listener);
},
onPlaybackPathChange: (listener) => {
const client = appState.mpvClient;
if (!client) return () => {};
const handler = ({ path: mediaPath }: { path: string }) => listener(mediaPath);
client.on('media-path-change', handler);
return () => client.off('media-path-change', handler);
},
readMpvProperty: (name) => {
const client = appState.mpvClient;
if (!client) return Promise.reject(new Error('mpv is not connected.'));
@@ -3370,6 +3377,20 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({
episodeNumber: metadata.episodeNumber,
});
},
onPreparedPlaybackMetadata: (metadata) => {
streamPlaybackMetadata.set(metadata);
// Register the URL alias before mpv can advance to it, but do not replace
// the title of the file that is still playing now.
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordStreamPlaybackMetadata({
mediaPath: metadata.mediaPath,
statsPath: metadata.statsPath,
displayTitle: metadata.displayTitle,
seriesTitle: metadata.seriesTitle,
seasonNumber: metadata.seasonNumber,
episodeNumber: metadata.episodeNumber,
});
},
onBridgeState: (state) => {
const window = appState.animeBrowserWindow;
if (window && !window.isDestroyed()) {
@@ -79,10 +79,7 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
);
}
/**
* Coerce a play (or queue) request. A queued request is held until its turn
* comes, so a bad field would surface long after the click that sent it.
*/
/** Coerce a play (or queue) request at the renderer trust boundary. */
function toPlayRequest(value: unknown): AnimeBrowserPlayRequest {
const request = (value ?? {}) as Partial<AnimeBrowserPlayRequest>;
return {
@@ -85,3 +85,91 @@ test('a superseded episode stops instead of driving mpv behind the newer one', a
assert.equal(overlays, 1);
await playback.dispose();
});
test('prepared queue playback appends without publishing it as the active title', async () => {
const client = {
getVideoList: async () => [{ videoUrl: 'http://stream/queued.m3u8', quality: '1080p' }],
} as unknown as AnimeBridgeClient;
const commands: Array<Array<string | number>> = [];
const activeMetadata: string[] = [];
const preparedMetadata: string[] = [];
const playback = createAnimeBrowserPlayback({
deps: {
sendMpvCommand: (command) => void commands.push(command),
ensureMpvConnected: async () => true,
onPlaybackMetadata: (metadata) => void activeMetadata.push(metadata.mediaPath),
onPreparedPlaybackMetadata: (metadata) => void preparedMetadata.push(metadata.mediaPath),
log: () => undefined,
},
bridge: async () => ({ client, baseUrl: 'http://127.0.0.1:1234' }),
sourceFor: async () => ({}) as BridgeSource,
stripProxy: () => null,
});
const result = await playback.prepareEpisode(request);
assert.equal(result.ok, true);
if (!result.ok) return;
playback.appendEpisode(result.playback);
assert.deepEqual(activeMetadata, []);
assert.deepEqual(preparedMetadata, ['http://stream/queued.m3u8']);
assert.equal(commands.at(-1)?.[0], 'loadfile');
assert.equal(commands.at(-1)?.[2], 'append-play');
await playback.dispose();
});
test('queued video can append while subtitle caching continues in the background', async () => {
let finishFetch!: (response: {
ok: boolean;
status: number;
arrayBuffer: () => Promise<ArrayBuffer>;
}) => void;
const fetchPending = new Promise<{
ok: boolean;
status: number;
arrayBuffer: () => Promise<ArrayBuffer>;
}>((resolve) => {
finishFetch = resolve;
});
const client = {
getVideoList: async () => [
{
videoUrl: 'http://stream/queued.m3u8',
quality: '1080p',
subtitleTracks: [{ url: 'http://stream/subtitle', lang: 'Japanese' }],
},
],
} as unknown as AnimeBridgeClient;
const commands: Array<Array<string | number>> = [];
const playback = createAnimeBrowserPlayback({
deps: {
sendMpvCommand: (command) => void commands.push(command),
ensureMpvConnected: async () => true,
subtitleCacheIo: {
fetch: async () => await fetchPending,
makeTempDir: async () => '/tmp/subminer-queued-test',
writeFile: async () => undefined,
removeDir: async () => undefined,
},
log: () => undefined,
},
bridge: async () => ({ client, baseUrl: 'http://127.0.0.1:1234' }),
sourceFor: async () => ({}) as BridgeSource,
stripProxy: () => null,
});
const result = await playback.prepareEpisode(request);
assert.equal(result.ok, true);
if (!result.ok) return;
playback.appendEpisode(result.playback);
assert.equal(commands.at(-1)?.[2], 'append-play');
finishFetch({
ok: true,
status: 200,
arrayBuffer: async () =>
new TextEncoder().encode('1\n00:00:00,000 --> 00:00:01,000\n字幕').buffer,
});
await playback.discardEpisode(result.playback);
await playback.dispose();
});
+192 -38
View File
@@ -4,12 +4,15 @@ import { resolveStream } from '../../anime-bridge/headers';
import { resolveBridgeMediaUrl, routeHlsThroughProxy } from '../../anime-bridge/media-url';
import {
buildPlaybackCommands,
buildQueuedPlaybackCommands,
buildTrackCommands,
selectPreferredStream,
} from '../../anime-bridge/mpv-playback';
import { watchPlaybackOutcome } from '../../anime-bridge/playback-outcome';
import { cacheSubtitleTracks, removeSubtitleCache } from '../../anime-bridge/subtitle-cache';
import type { StreamStripProxyHandle } from '../../anime-bridge/stream-strip-proxy';
import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata';
import type { ResolvedStream } from '../../anime-bridge/types';
import type { AnimeBrowserPlayRequest, AnimeBrowserPlayResult } from '../../types/anime-browser';
import type { AnimeBrowserPlaybackDeps } from './anime-browser-runtime-deps';
@@ -22,11 +25,29 @@ interface AnimeBrowserPlaybackOptions {
stripProxy: () => StreamStripProxyHandle | null;
}
export interface PreparedAnimeBrowserPlayback {
request: AnimeBrowserPlayRequest;
stream: ResolvedStream;
metadata: AnimeStreamMetadata;
trackPreparation: Promise<PreparedTrackSetup>;
}
interface PreparedTrackSetup {
stream: ResolvedStream;
subtitleCacheDir: string | null;
}
export type PrepareAnimeBrowserPlaybackResult =
| { ok: true; playback: PreparedAnimeBrowserPlayback; quality: string | null; error: null }
| { ok: false; playback: null; quality: null; error: string };
export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions) {
const { deps, bridge, sourceFor, stripProxy } = options;
const wait =
deps.wait ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
let subtitleCacheDir: string | null = null;
const queuedSubtitleCacheDirs = new Set<string>();
const queuedTrackPreparations = new Set<Promise<PreparedTrackSetup>>();
// Overlapping playEpisode calls share mpv and subtitleCacheDir, so each call
// carries a generation and only acts while it is still the newest one.
let playbackGeneration = 0;
@@ -68,39 +89,175 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
return cached.tracks.map((track) => ({ url: track.url, lang: track.lang }));
}
async function resolveEpisode(request: AnimeBrowserPlayRequest): Promise<{
stream: ResolvedStream;
metadata: AnimeStreamMetadata;
} | null> {
const { client, baseUrl } = await bridge();
const videos = await client.getVideoList(await sourceFor(request.sourceId), request.episodeUrl);
const streams = videos
.map((video) => resolveStream(video))
.filter((stream): stream is NonNullable<typeof stream> => stream !== null)
.map((stream) => ({
...stream,
url: resolveBridgeMediaUrl(baseUrl, stream.url),
audios: stream.audios.map((track) => ({
...track,
url: resolveBridgeMediaUrl(baseUrl, track.url),
})),
subtitles: stream.subtitles.map((track) => ({
...track,
url: resolveBridgeMediaUrl(baseUrl, track.url),
})),
}));
const selected = selectPreferredStream(streams, deps.preferredQuality?.());
if (!selected) return null;
const proxy = stripProxy();
const stream = proxy
? { ...selected, url: routeHlsThroughProxy(selected.url, baseUrl, proxy.origin) }
: selected;
const metadata = buildAnimeStreamMetadata({
sourceId: request.sourceId,
animeUrl: request.animeUrl,
animeTitle: request.animeTitle,
episodeUrl: request.episodeUrl,
episodeName: request.episodeName,
episodeNumber: request.episodeNumber ?? null,
mediaPath: stream.url,
});
return { stream, metadata };
}
/** Resolve a queued stream and start preparing its external tracks. */
async function prepareEpisode(
request: AnimeBrowserPlayRequest,
): Promise<PrepareAnimeBrowserPlaybackResult> {
try {
const resolved = await resolveEpisode(request);
if (!resolved) {
return {
ok: false,
playback: null,
error: 'That source returned no playable video.',
quality: null,
};
}
if (!(await deps.ensureMpvConnected())) {
return {
ok: false,
playback: null,
error: 'mpv is not running and could not be started.',
quality: null,
};
}
// Do not hold the mpv append behind subtitle downloads. The preparation
// starts now and activation waits for it only when this item begins.
const trackPreparation = prepareQueuedTracks(resolved.stream);
return {
ok: true,
playback: {
request,
stream: resolved.stream,
metadata: resolved.metadata,
trackPreparation,
},
error: null,
quality: resolved.stream.quality || null,
};
} catch (error) {
deps.log(`[anime-browser] queued playback preparation failed: ${String(error)}`);
return { ok: false, playback: null, error: describeError(error), quality: null };
}
}
function prepareQueuedTracks(stream: ResolvedStream): Promise<PreparedTrackSetup> {
const preparation = (async (): Promise<PreparedTrackSetup> => {
try {
const cached = await cacheSubtitleTracks({
tracks: stream.subtitles,
headers: stream.headers,
io: deps.subtitleCacheIo,
log: deps.log,
});
if (cached.dir) queuedSubtitleCacheDirs.add(cached.dir);
return {
stream: {
...stream,
subtitles: cached.tracks.map((track) => ({ url: track.url, lang: track.lang })),
},
subtitleCacheDir: cached.dir,
};
} catch (error) {
deps.log(`[anime-browser] queued subtitle preparation failed: ${String(error)}`);
return { stream, subtitleCacheDir: null };
}
})();
queuedTrackPreparations.add(preparation);
void preparation.then(() => queuedTrackPreparations.delete(preparation));
return preparation;
}
/** The prepared stream becomes a real mpv playlist entry immediately. */
function appendEpisode(playback: PreparedAnimeBrowserPlayback): void {
deps.onPreparedPlaybackMetadata?.(playback.metadata);
for (const command of buildQueuedPlaybackCommands({
stream: playback.stream,
title: playback.metadata.displayTitle,
})) {
deps.sendMpvCommand(command);
}
}
/** Attach the prepared external tracks when mpv reaches this playlist item. */
async function activateEpisode(playback: PreparedAnimeBrowserPlayback): Promise<void> {
const generation = ++playbackGeneration;
const preparedTracks = await playback.trackPreparation;
if (generation !== playbackGeneration) {
await releasePreparedTracks(preparedTracks);
return;
}
const previousDir = subtitleCacheDir;
subtitleCacheDir = preparedTracks.subtitleCacheDir;
if (preparedTracks.subtitleCacheDir) {
queuedSubtitleCacheDirs.delete(preparedTracks.subtitleCacheDir);
}
if (previousDir !== preparedTracks.subtitleCacheDir) {
await removeSubtitleCache(previousDir, deps.subtitleCacheIo);
}
if (generation !== playbackGeneration) return;
if (preparedTracks.stream.audios.length > 0 || preparedTracks.stream.subtitles.length > 0) {
await wait(TRACK_ATTACH_DELAY_MS);
if (generation !== playbackGeneration) return;
for (const command of buildTrackCommands(preparedTracks.stream)) {
deps.sendMpvCommand(command);
}
}
deps.showVisibleOverlay?.();
deps.showMpvOsd?.(playback.metadata.displayTitle);
}
async function discardEpisode(playback: PreparedAnimeBrowserPlayback): Promise<void> {
await releasePreparedTracks(await playback.trackPreparation);
}
async function releasePreparedTracks(preparedTracks: PreparedTrackSetup): Promise<void> {
const dir = preparedTracks.subtitleCacheDir;
if (!dir || !queuedSubtitleCacheDirs.delete(dir)) return;
await removeSubtitleCache(dir, deps.subtitleCacheIo);
}
async function playEpisode(request: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> {
const generation = ++playbackGeneration;
const isCurrent = (): boolean => generation === playbackGeneration;
try {
const { client, baseUrl } = await bridge();
const videos = await client.getVideoList(
await sourceFor(request.sourceId),
request.episodeUrl,
);
const streams = videos
.map((video) => resolveStream(video))
.filter((stream): stream is NonNullable<typeof stream> => stream !== null)
.map((stream) => ({
...stream,
url: resolveBridgeMediaUrl(baseUrl, stream.url),
audios: stream.audios.map((track) => ({
...track,
url: resolveBridgeMediaUrl(baseUrl, track.url),
})),
subtitles: stream.subtitles.map((track) => ({
...track,
url: resolveBridgeMediaUrl(baseUrl, track.url),
})),
}));
const selected = selectPreferredStream(streams, deps.preferredQuality?.());
if (!selected) {
const resolved = await resolveEpisode(request);
if (!resolved) {
return { ok: false, error: 'That source returned no playable video.', quality: null };
}
const proxy = stripProxy();
const stream = proxy
? { ...selected, url: routeHlsThroughProxy(selected.url, baseUrl, proxy.origin) }
: selected;
const { stream, metadata } = resolved;
if (!(await deps.ensureMpvConnected())) {
return { ok: false, error: 'mpv is not running and could not be started.', quality: null };
@@ -120,15 +277,6 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
: null;
try {
const metadata = buildAnimeStreamMetadata({
sourceId: request.sourceId,
animeUrl: request.animeUrl,
animeTitle: request.animeTitle,
episodeUrl: request.episodeUrl,
episodeName: request.episodeName,
episodeNumber: request.episodeNumber ?? null,
mediaPath: stream.url,
});
const title = metadata.displayTitle;
deps.onPlaybackMetadata?.(metadata);
for (const command of buildPlaybackCommands({ stream, title })) {
@@ -188,10 +336,16 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
playbackGeneration += 1;
const cacheDir = subtitleCacheDir;
subtitleCacheDir = null;
await removeSubtitleCache(cacheDir, deps.subtitleCacheIo);
await Promise.all(queuedTrackPreparations);
const queuedDirs = [...queuedSubtitleCacheDirs];
queuedSubtitleCacheDirs.clear();
await Promise.all([
removeSubtitleCache(cacheDir, deps.subtitleCacheIo),
...queuedDirs.map((dir) => removeSubtitleCache(dir, deps.subtitleCacheIo)),
]);
}
return { playEpisode, dispose };
return { playEpisode, prepareEpisode, appendEpisode, activateEpisode, discardEpisode, dispose };
}
function superseded(): AnimeBrowserPlayResult {
+208 -208
View File
@@ -1,10 +1,13 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { PlaybackEndFileEvent } from '../../anime-bridge/playback-outcome';
import type { AnimeBrowserPlayRequest, AnimeBrowserQueueState } from '../../types/anime-browser';
import type {
PreparedAnimeBrowserPlayback,
PrepareAnimeBrowserPlaybackResult,
} from './anime-browser-playback';
import { createAnimeBrowserQueue, type AnimeBrowserQueueDeps } from './anime-browser-queue';
function makeRequest(overrides: Partial<AnimeBrowserPlayRequest> = {}): AnimeBrowserPlayRequest {
function request(overrides: Partial<AnimeBrowserPlayRequest> = {}): AnimeBrowserPlayRequest {
return {
sourceId: 'source',
animeUrl: '/anime',
@@ -16,263 +19,260 @@ function makeRequest(overrides: Partial<AnimeBrowserPlayRequest> = {}): AnimeBro
};
}
function prepared(input: AnimeBrowserPlayRequest): PreparedAnimeBrowserPlayback {
const mediaPath = `https://stream.example${input.episodeUrl}.m3u8`;
return {
request: input,
stream: { url: mediaPath, quality: '1080p', headers: {}, audios: [], subtitles: [] },
metadata: {
mediaPath,
statsPath: `animebrowser://${encodeURIComponent(input.episodeUrl)}`,
seriesTitle: input.animeTitle,
seasonNumber: null,
episodeNumber: input.episodeNumber,
episodeTitle: null,
displayTitle: `${input.animeTitle} - ${input.episodeName}`,
},
trackPreparation: Promise.resolve({
stream: {
url: mediaPath,
quality: '1080p',
headers: {},
audios: [],
subtitles: [],
},
subtitleCacheDir: null,
}),
};
}
function successful(input: AnimeBrowserPlayRequest): PrepareAnimeBrowserPlaybackResult {
return { ok: true, playback: prepared(input), error: null, quality: '1080p' };
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
interface Harness {
deps: AnimeBrowserQueueDeps;
played: AnimeBrowserPlayRequest[];
prepared: AnimeBrowserPlayRequest[];
appended: PreparedAnimeBrowserPlayback[];
activated: PreparedAnimeBrowserPlayback[];
discarded: PreparedAnimeBrowserPlayback[];
commands: Array<Array<string | number>>;
states: AnimeBrowserQueueState[];
osd: string[];
endFile: (event: PlaybackEndFileEvent) => void;
/** How many listeners are subscribed right now. */
listenerCount: () => number;
pathChange: (path: string) => void;
playlist: Array<{ id: number; filename: string; current?: boolean }>;
}
function makeHarness(
options: {
play?: (request: AnimeBrowserPlayRequest) => Promise<{
ok: boolean;
error: string | null;
quality: string | null;
}>;
keepOpen?: unknown;
withEndFile?: boolean;
} = {},
function harness(
prepareEpisode: AnimeBrowserQueueDeps['prepareEpisode'] = async (input) => successful(input),
): Harness {
const played: AnimeBrowserPlayRequest[] = [];
const preparedRequests: AnimeBrowserPlayRequest[] = [];
const appended: PreparedAnimeBrowserPlayback[] = [];
const activated: PreparedAnimeBrowserPlayback[] = [];
const discarded: PreparedAnimeBrowserPlayback[] = [];
const commands: Array<Array<string | number>> = [];
const states: AnimeBrowserQueueState[] = [];
const osd: string[] = [];
const listeners = new Set<(event: PlaybackEndFileEvent) => void>();
const deps: AnimeBrowserQueueDeps = {
play: async (request) => {
played.push(request);
return options.play
? await options.play(request)
: { ok: true, error: null, quality: '1080p' };
},
sendMpvCommand: (command) => void commands.push(command),
onQueueState: (state) => void states.push(state),
showMpvOsd: (message) => void osd.push(message),
log: () => undefined,
};
if (options.withEndFile !== false) {
deps.onPlaybackEndFile = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
}
if (options.keepOpen !== undefined) {
deps.readMpvProperty = async (name) => {
if (name !== 'keep-open') throw new Error(`unexpected property ${name}`);
return options.keepOpen;
};
}
const listeners = new Set<(path: string) => void>();
const playlist: Array<{ id: number; filename: string; current?: boolean }> = [
{ id: 1, filename: '/current.mkv', current: true },
];
return {
deps,
played,
deps: {
prepareEpisode: async (input) => {
preparedRequests.push(input);
return await prepareEpisode(input);
},
appendEpisode: (playback) => {
appended.push(playback);
playlist.push({ id: playlist.length + 1, filename: playback.stream.url });
},
activateEpisode: async (playback) => void activated.push(playback),
discardEpisode: async (playback) => void discarded.push(playback),
armNextEpisode: () => undefined,
onPlaybackPathChange: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
readMpvProperty: async (name) => {
assert.equal(name, 'playlist');
return playlist;
},
sendMpvCommand: (command) => {
commands.push(command);
if (command[0] === 'playlist-remove' && typeof command[1] === 'number') {
playlist.splice(command[1], 1);
}
},
onQueueState: (state) => void states.push(state),
log: () => undefined,
},
prepared: preparedRequests,
appended,
activated,
discarded,
commands,
states,
osd,
endFile: (event) => {
for (const listener of [...listeners]) listener(event);
pathChange: (path) => {
for (const listener of [...listeners]) listener(path);
},
listenerCount: () => listeners.size,
playlist,
};
}
const EOF: PlaybackEndFileEvent = { reason: 'eof', fileError: null };
test('queue resolves immediately and appends the playable stream to mpv', async () => {
const h = harness();
const queue = createAnimeBrowserQueue(h.deps);
test('an episode that runs to its end hands the queue its turn', async () => {
const harness = makeHarness();
const queue = createAnimeBrowserQueue(harness.deps);
const state = await queue.enqueue(request());
queue.enqueue(makeRequest());
queue.enqueue(makeRequest({ episodeUrl: '/episode-2', episodeName: 'Episode 2' }));
harness.endFile(EOF);
assert.deepEqual(
h.prepared.map((entry) => entry.episodeUrl),
['/episode-1'],
);
assert.deepEqual(
h.appended.map((entry) => entry.stream.url),
['https://stream.example/episode-1.m3u8'],
);
assert.deepEqual(
state.entries.map((entry) => entry.episodeUrl),
['/episode-1'],
);
});
test('concurrent resolutions append in click order', async () => {
const first = deferred<PrepareAnimeBrowserPlaybackResult>();
const second = deferred<PrepareAnimeBrowserPlaybackResult>();
const h = harness((input) =>
input.episodeUrl === '/episode-1' ? first.promise : second.promise,
);
const queue = createAnimeBrowserQueue(h.deps);
const one = queue.enqueue(request());
const twoRequest = request({ episodeUrl: '/episode-2', episodeName: 'Episode 2' });
const two = queue.enqueue(twoRequest);
second.resolve(successful(twoRequest));
await new Promise(setImmediate);
assert.equal(h.appended.length, 0);
first.resolve(successful(request()));
await Promise.all([one, two]);
assert.deepEqual(
h.appended.map((entry) => entry.request.episodeUrl),
['/episode-1', '/episode-2'],
);
});
test('mpv path navigation advances queue state and activates prepared tracks', async () => {
const h = harness();
const queue = createAnimeBrowserQueue(h.deps);
await queue.enqueue(request());
await queue.enqueue(request({ episodeUrl: '/episode-2', episodeName: 'Episode 2' }));
h.pathChange('https://stream.example/episode-1.m3u8');
await new Promise(setImmediate);
assert.deepEqual(
harness.played.map((request) => request.episodeUrl),
h.activated.map((entry) => entry.request.episodeUrl),
['/episode-1'],
);
assert.deepEqual(
queue.getState().entries.map((entry) => entry.episodeUrl),
['/episode-2'],
);
assert.equal(queue.getState().advances, 1);
assert.equal(queue.getState().lastStarted?.episodeUrl, '/episode-1');
});
test('only a file that ended by itself advances the queue', async () => {
const harness = makeHarness();
const queue = createAnimeBrowserQueue(harness.deps);
test('dequeue removes the resolved entry from mpv by playlist id', async () => {
const h = harness();
const queue = createAnimeBrowserQueue(h.deps);
await queue.enqueue(request());
queue.enqueue(makeRequest());
harness.endFile({ reason: 'stop', fileError: null });
harness.endFile({ reason: 'quit', fileError: null });
harness.endFile({ reason: 'error', fileError: 'dead host' });
await new Promise(setImmediate);
const state = await queue.dequeue('source', '/episode-1');
assert.deepEqual(harness.played, []);
assert.equal(queue.getState().entries.length, 1);
assert.deepEqual(state.entries, []);
assert.deepEqual(h.commands, [['playlist-remove', 1]]);
assert.equal(h.discarded.length, 1);
});
test('queueing the same episode twice leaves one entry', () => {
const harness = makeHarness();
const queue = createAnimeBrowserQueue(harness.deps);
test('dequeue does not stop an item that mpv began before the request landed', async () => {
const h = harness();
const queue = createAnimeBrowserQueue(h.deps);
await queue.enqueue(request());
h.playlist[0]!.current = false;
h.playlist[1]!.current = true;
queue.enqueue(makeRequest());
const state = queue.enqueue(makeRequest());
await queue.dequeue('source', '/episode-1');
assert.equal(state.entries.length, 1);
assert.deepEqual(h.commands, []);
assert.equal(h.activated.length, 1);
assert.equal(h.discarded.length, 0);
});
test('an advance is counted and names the episode it started', async () => {
const harness = makeHarness();
const queue = createAnimeBrowserQueue(harness.deps);
test('clear removes owned playlist entries from the end toward the current file', async () => {
const h = harness();
const queue = createAnimeBrowserQueue(h.deps);
await queue.enqueue(request());
await queue.enqueue(request({ episodeUrl: '/episode-2', episodeName: 'Episode 2' }));
queue.enqueue(makeRequest());
assert.equal(queue.getState().advances, 0);
harness.endFile(EOF);
await new Promise(setImmediate);
await queue.clear();
const state = queue.getState();
assert.equal(state.advances, 1);
assert.equal(state.lastStarted?.episodeUrl, '/episode-1');
assert.deepEqual(h.commands, [
['playlist-remove', 2],
['playlist-remove', 1],
]);
assert.deepEqual(queue.getState().entries, []);
assert.equal(h.discarded.length, 2);
});
test('an episode dequeues by its own source and url', () => {
const harness = makeHarness();
const queue = createAnimeBrowserQueue(harness.deps);
queue.enqueue(makeRequest());
queue.enqueue(makeRequest({ sourceId: 'other' }));
const state = queue.dequeue('source', '/episode-1');
assert.deepEqual(
state.entries.map((entry) => entry.sourceId),
['other'],
test('a preparation failure leaves later episodes queued and reports the source error', async () => {
const h = harness(async (input) =>
input.episodeUrl === '/episode-1'
? { ok: false, playback: null, error: 'No playable video.', quality: null }
: successful(input),
);
});
const queue = createAnimeBrowserQueue(h.deps);
test('a queued episode that will not play stops the queue and reports why', async () => {
const harness = makeHarness({
play: async () => ({
ok: false,
error: 'That source returned no playable video.',
quality: null,
}),
});
const queue = createAnimeBrowserQueue(harness.deps);
const failed = await queue.enqueue(request());
await queue.enqueue(request({ episodeUrl: '/episode-2', episodeName: 'Episode 2' }));
queue.enqueue(makeRequest());
queue.enqueue(makeRequest({ episodeUrl: '/episode-2', episodeName: 'Episode 2' }));
harness.endFile(EOF);
await new Promise(setImmediate);
const state = queue.getState();
assert.equal(state.lastError, 'Episode 1: That source returned no playable video.');
// The failed episode is gone; the rest is still the user's queue.
assert.equal(failed.lastError, 'Episode 1: No playable video.');
assert.deepEqual(
state.entries.map((entry) => entry.episodeUrl),
queue.getState().entries.map((entry) => entry.episodeUrl),
['/episode-2'],
);
assert.equal(harness.osd.length, 1);
});
test('a second end-file while an advance is resolving does not double-load', async () => {
let release = (): void => undefined;
const started = new Promise<void>((resolve) => {
release = resolve;
});
const harness = makeHarness({
play: async () => {
await started;
return { ok: true, error: null, quality: null };
},
});
const queue = createAnimeBrowserQueue(harness.deps);
test('queueing the same episode twice resolves and appends it once', async () => {
const h = harness();
const queue = createAnimeBrowserQueue(h.deps);
queue.enqueue(makeRequest());
queue.enqueue(makeRequest({ episodeUrl: '/episode-2', episodeName: 'Episode 2' }));
harness.endFile(EOF);
harness.endFile(EOF);
release();
await queue.enqueue(request());
await queue.enqueue(request());
assert.equal(h.prepared.length, 1);
assert.equal(h.appended.length, 1);
});
test('dequeue while resolution is pending prevents a late append and releases its cache', async () => {
const pending = deferred<PrepareAnimeBrowserPlaybackResult>();
const h = harness(() => pending.promise);
const queue = createAnimeBrowserQueue(h.deps);
const enqueue = queue.enqueue(request());
await new Promise(setImmediate);
await queue.dequeue('source', '/episode-1');
pending.resolve(successful(request()));
await enqueue;
assert.deepEqual(
harness.played.map((request) => request.episodeUrl),
['/episode-1'],
);
});
test('mpv is told not to hold the last file open while the queue waits', async () => {
const harness = makeHarness({ keepOpen: 'yes' });
const queue = createAnimeBrowserQueue(harness.deps);
queue.enqueue(makeRequest());
await new Promise(setImmediate);
assert.deepEqual(harness.commands, [['set_property', 'keep-open', 'no']]);
queue.clear();
assert.deepEqual(harness.commands[1], ['set_property', 'keep-open', 'yes']);
});
test('mpv that already lets files end is left alone', async () => {
const harness = makeHarness({ keepOpen: 'no' });
const queue = createAnimeBrowserQueue(harness.deps);
queue.enqueue(makeRequest());
await new Promise(setImmediate);
queue.clear();
assert.deepEqual(harness.commands, []);
});
test('the last queued episode plays under the user own keep-open setting', async () => {
const harness = makeHarness({ keepOpen: 'always' });
const queue = createAnimeBrowserQueue(harness.deps);
queue.enqueue(makeRequest());
await new Promise(setImmediate);
harness.endFile(EOF);
await new Promise(setImmediate);
assert.deepEqual(harness.commands, [
['set_property', 'keep-open', 'no'],
['set_property', 'keep-open', 'always'],
]);
});
test('playback started outside the queue re-points the end-file listener', async () => {
const harness = makeHarness();
const queue = createAnimeBrowserQueue(harness.deps);
queue.enqueue(makeRequest());
queue.handlePlaybackStarted();
queue.handlePlaybackStarted();
// Each re-arm replaces the previous subscription rather than stacking on it,
// so one end-file cannot advance the queue several times over.
assert.equal(harness.listenerCount(), 1);
harness.endFile(EOF);
await new Promise(setImmediate);
assert.equal(harness.played.length, 1);
});
test('an emptied queue stops listening for the end of the file', () => {
const harness = makeHarness();
const queue = createAnimeBrowserQueue(harness.deps);
queue.enqueue(makeRequest());
assert.equal(harness.listenerCount(), 1);
queue.dequeue('source', '/episode-1');
assert.equal(harness.listenerCount(), 0);
});
test('the queue still accepts episodes when mpv reports no events', () => {
const harness = makeHarness({ withEndFile: false });
const queue = createAnimeBrowserQueue(harness.deps);
assert.equal(queue.enqueue(makeRequest()).entries.length, 1);
assert.deepEqual(h.appended, []);
assert.equal(h.discarded.length, 1);
});
Binary file not shown.
@@ -34,12 +34,16 @@ export interface AnimeBrowserRuntimeDeps {
ensureMpvConnected: () => Promise<boolean>;
/** Subscribe to mpv end-file events so playback startup can be confirmed. */
onPlaybackEndFile?: (listener: (event: PlaybackEndFileEvent) => void) => () => void;
/** Subscribe to the active mpv path for real-playlist anime queue advances. */
onPlaybackPathChange?: (listener: (path: string) => void) => () => void;
/** One-shot mpv property read; rejects while the property is unavailable. */
readMpvProperty?: (name: string) => Promise<unknown>;
showMpvOsd?: (message: string) => void;
showVisibleOverlay?: () => void;
/** Publishes stream identity before loadfile starts the stats session. */
onPlaybackMetadata?: (metadata: AnimeStreamMetadata) => void;
/** Registers a queued stream before mpv can navigate to its playlist entry. */
onPreparedPlaybackMetadata?: (metadata: AnimeStreamMetadata) => void;
/**
* Watch state for the given stats paths, from the same store playback writes
* to. Absent (or resolving empty) when stats tracking is disabled, which the
@@ -78,6 +82,7 @@ export type AnimeBrowserPlaybackDeps = Pick<
| 'showMpvOsd'
| 'showVisibleOverlay'
| 'onPlaybackMetadata'
| 'onPreparedPlaybackMetadata'
| 'wait'
| 'subtitleCacheIo'
| 'preferredQuality'
+14 -9
View File
@@ -355,8 +355,13 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
});
const queue = createAnimeBrowserQueue({
play: (request) => playback.playEpisode(request),
onPlaybackEndFile: deps.onPlaybackEndFile,
prepareEpisode: (request) => playback.prepareEpisode(request),
appendEpisode: (prepared) => playback.appendEpisode(prepared),
activateEpisode: (prepared) => playback.activateEpisode(prepared),
discardEpisode: (prepared) => playback.discardEpisode(prepared),
armNextEpisode: () =>
deps.sendMpvCommand(['script-message', 'subminer-managed-subtitles-loading']),
onPlaybackPathChange: deps.onPlaybackPathChange,
readMpvProperty: deps.readMpvProperty,
sendMpvCommand: deps.sendMpvCommand,
onQueueState: deps.onQueueState,
@@ -598,16 +603,16 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
return result;
},
queueEpisode(request: AnimeBrowserPlayRequest): AnimeBrowserQueueState {
return queue.enqueue(request);
async queueEpisode(request: AnimeBrowserPlayRequest): Promise<AnimeBrowserQueueState> {
return await queue.enqueue(request);
},
dequeueEpisode(sourceId: string, episodeUrl: string): AnimeBrowserQueueState {
return queue.dequeue(sourceId, episodeUrl);
async dequeueEpisode(sourceId: string, episodeUrl: string): Promise<AnimeBrowserQueueState> {
return await queue.dequeue(sourceId, episodeUrl);
},
clearQueue(): AnimeBrowserQueueState {
return queue.clear();
async clearQueue(): Promise<AnimeBrowserQueueState> {
return await queue.clear();
},
getQueue(): AnimeBrowserQueueState {
@@ -634,7 +639,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
stripProxy = null;
starting = null;
setState(IDLE_STATE);
queue.dispose();
await queue.dispose();
await playback.dispose();
await proxy?.close();
await handle?.stop();
@@ -43,6 +43,21 @@ test('the store stops answering once the player moves on', () => {
assert.equal(store.match(metadata().mediaPath), null);
});
test('the store keeps metadata for more than one resolved playlist entry', () => {
const store = createStreamPlaybackMetadataStore();
const first = metadata();
const second = metadata({
mediaPath: 'http://127.0.0.1:41234/video/def.m3u8',
statsPath: 'animebrowser://9001/%2Fanime%2Fmushoku/%2Fwatch%2Fep-5',
episodeNumber: 5,
});
store.set(first);
store.set(second);
assert.equal(store.match(first.mediaPath), first);
assert.equal(store.match(second.mediaPath), second);
});
test('an explicit target path does not inherit the current stream metadata', () => {
const store = createStreamPlaybackMetadataStore();
const current = metadata();
+10 -10
View File
@@ -3,7 +3,7 @@ import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-upda
import type { JimakuMediaInfo } from '../../types';
/**
* Holds what the anime browser resolved for the episode currently streaming.
* Holds what the anime browser resolved for active and queued streams.
*
* Consumers otherwise have to re-derive the series and episode from the mpv
* title, and some of them only ever see the stream URL, which carries no title
@@ -13,30 +13,30 @@ export interface StreamPlaybackMetadataStore {
set: (metadata: AnimeStreamMetadata) => void;
clear: () => void;
/**
* The metadata for `mediaPath`, or null when the player has moved on to
* something else. Matching on the path is what makes this self-expiring —
* there is no teardown hook to miss.
* The registered metadata for `mediaPath`, whether that stream is active or
* waiting in the playlist. A match identifies the stream but does not prove
* that mpv is currently playing it.
*/
match: (mediaPath: string | null) => AnimeStreamMetadata | null;
}
export function createStreamPlaybackMetadataStore(): StreamPlaybackMetadataStore {
let current: AnimeStreamMetadata | null = null;
const byPath = new Map<string, AnimeStreamMetadata>();
return {
set(metadata: AnimeStreamMetadata): void {
current = metadata;
byPath.set(metadata.mediaPath, metadata);
byPath.set(metadata.statsPath, metadata);
},
clear(): void {
current = null;
byPath.clear();
},
match(mediaPath: string | null): AnimeStreamMetadata | null {
if (!current) return null;
const trimmed = typeof mediaPath === 'string' ? mediaPath.trim() : '';
if (!trimmed) return null;
// The stats path is accepted too: stats rewrites the volatile stream URL
// The stats path is indexed too: stats rewrites the volatile stream URL
// to it, so callers reading from there still resolve.
return trimmed === current.mediaPath || trimmed === current.statsPath ? current : null;
return byPath.get(trimmed) ?? null;
},
};
}
+5 -1
View File
@@ -13,7 +13,11 @@ function withTempBin(run: (dir: string) => void): void {
process.env.PATH = dir;
run(dir);
} finally {
process.env.PATH = previousPath;
if (previousPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = previousPath;
}
fs.rmSync(dir, { recursive: true, force: true });
}
}
+2 -8
View File
@@ -222,17 +222,11 @@ export interface AnimeBrowserPlayResult {
quality: string | null;
}
/**
* One episode waiting for its turn.
*
* It is the play request itself rather than a resolved stream: extension stream
* URLs are signed and short-lived, so a queued episode is resolved when it
* reaches the front, not when it was queued half an hour earlier.
*/
/** One resolved episode waiting in mpv's playlist. */
export type AnimeBrowserQueueEntry = AnimeBrowserPlayRequest;
export interface AnimeBrowserQueueState {
/** In play order; the first entry starts when the current episode ends. */
/** In mpv play order; the first entry starts when the current episode ends. */
entries: AnimeBrowserQueueEntry[];
/**
* Why the last automatic advance failed, or null. Cleared by the next queue