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 183560d2c3
commit 935e4c145f
21 changed files with 660 additions and 289 deletions
+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