mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-15 13:55:51 -07:00
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:
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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
|
browser shows mpv's error rather than pretending playback started (a failed
|
||||||
load leaves no mpv window, because the player idles windowless).
|
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
|
### Japanese audio, and switching tracks
|
||||||
|
|
||||||
Sources often return a dub and the original audio as two separate entries — or
|
Sources often return a dub and the original audio as two separate entries — or
|
||||||
|
|||||||
@@ -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
|
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-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`.
|
`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`):
|
The play queue resolves episodes on click and appends them to mpv's real playlist
|
||||||
a queued episode is resolved when its turn comes, driven off mpv's `end-file`
|
(`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/`
|
- Window trackers: `src/window-trackers/`
|
||||||
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
|
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
|
||||||
in `src/core/services/stats-server/`
|
in `src/core/services/stats-server/`
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import assert from 'node:assert/strict';
|
|||||||
import {
|
import {
|
||||||
buildLoadfileOptions,
|
buildLoadfileOptions,
|
||||||
buildPlaybackCommands,
|
buildPlaybackCommands,
|
||||||
|
buildQueuedLoadfileOptions,
|
||||||
|
buildQueuedPlaybackCommands,
|
||||||
buildTrackCommands,
|
buildTrackCommands,
|
||||||
normalizeLangTag,
|
normalizeLangTag,
|
||||||
selectPreferredStream,
|
selectPreferredStream,
|
||||||
@@ -39,6 +41,41 @@ test('loadfile options keep one visible track and never scan the filesystem', ()
|
|||||||
assert.ok(!options.includes('slang'));
|
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', () => {
|
test('headers are percent-escaped so their commas do not split the option list', () => {
|
||||||
const headers = { Referer: 'https://a.test/', 'User-Agent': 'X' };
|
const headers = { Referer: 'https://a.test/', 'User-Agent': 'X' };
|
||||||
const options = buildLoadfileOptions({ stream: stream({ headers }) });
|
const options = buildLoadfileOptions({ stream: stream({ headers }) });
|
||||||
|
|||||||
@@ -73,6 +73,23 @@ export function buildLoadfileOptions(options: BuildPlaybackOptions): string {
|
|||||||
return parts.join(',');
|
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
|
* 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
|
* 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;
|
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.
|
* Commands that attach the extension's external audio and subtitle tracks.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ function delay(ms: number): Promise<void> {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
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. */
|
/** Ask the OS for a free loopback port, then hand it to the JVM. */
|
||||||
export async function allocatePort(): Promise<number> {
|
export async function allocatePort(): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -122,10 +134,10 @@ export async function startSidecar(options: StartSidecarOptions): Promise<Sideca
|
|||||||
child.kill();
|
child.kill();
|
||||||
// kill() only sends the signal. Wait for the process to actually go, so a
|
// kill() only sends the signal. Wait for the process to actually go, so a
|
||||||
// restart cannot race the old one still holding the port.
|
// restart cannot race the old one still holding the port.
|
||||||
await Promise.race([hasExited, delay(stopTimeoutMs)]);
|
await waitForExit(hasExited, stopTimeoutMs);
|
||||||
if (exited === null) {
|
if (exited === null) {
|
||||||
child.kill('SIGKILL');
|
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
|
// Never report success while the child may still hold the port: a caller
|
||||||
// that restarts on the same port would race the survivor.
|
// that restarts on the same port would race the survivor.
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ export function createEpisodeQueueControls(options: EpisodeQueueControlsOptions)
|
|||||||
if (!anime) return;
|
if (!anime) return;
|
||||||
|
|
||||||
const queued = isQueued(episode);
|
const queued = isQueued(episode);
|
||||||
// Queueing behind nothing would wait for an end that never comes, so with
|
// With an idle player the second option still collapses into Play so the
|
||||||
// an idle player the second option collapses into the first.
|
// browser can report mpv's startup outcome before claiming success.
|
||||||
if (!queued) {
|
if (!queued) {
|
||||||
const playing = await capture(() => api.isPlaying());
|
const playing = await capture(() => api.isPlaying());
|
||||||
if (playing.ok && !playing.value) {
|
if (playing.ok && !playing.value) {
|
||||||
|
|||||||
@@ -16,9 +16,12 @@ const SUBTITLE_BODY = '1\n00:00:01,000 --> 00:00:02,000\nhello\n';
|
|||||||
*/
|
*/
|
||||||
async function startStubHost(): Promise<{
|
async function startStubHost(): Promise<{
|
||||||
url: (pathname: string) => string;
|
url: (pathname: string) => string;
|
||||||
|
paths: string[];
|
||||||
close: () => Promise<void>;
|
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.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
res.end(SUBTITLE_BODY);
|
res.end(SUBTITLE_BODY);
|
||||||
});
|
});
|
||||||
@@ -28,6 +31,7 @@ async function startStubHost(): Promise<{
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
url: (pathname) => `http://127.0.0.1:${port}${pathname}`,
|
url: (pathname) => `http://127.0.0.1:${port}${pathname}`,
|
||||||
|
paths,
|
||||||
close: () =>
|
close: () =>
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
server.close(() => 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(alassArgs.length, 3);
|
||||||
assert.equal(fs.readFileSync(path.join(tmpDir, 'reference.copy'), 'utf8'), SUBTITLE_BODY);
|
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.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');
|
const loadCommand = sentCommands.find((command) => command[0] === 'sub-add');
|
||||||
assert.equal(loadCommand?.[1], alassArgs[2]);
|
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(fs.readFileSync(path.join(tmpDir, 'target.copy'), 'utf8'), targetBody);
|
||||||
assert.equal(alassArgs[2], targetPath);
|
assert.equal(alassArgs[2], targetPath);
|
||||||
assert.equal(fs.readFileSync(targetPath, 'utf8'), 'retimed');
|
assert.equal(fs.readFileSync(targetPath, 'utf8'), 'retimed');
|
||||||
|
assert.deepEqual(host.paths, ['/subs/en.srt']);
|
||||||
|
|
||||||
const loadCommand = sentCommands.find((command) => command[0] === 'sub-add');
|
const loadCommand = sentCommands.find((command) => command[0] === 'sub-add');
|
||||||
assert.equal(loadCommand?.[1], targetPath);
|
assert.equal(loadCommand?.[1], targetPath);
|
||||||
|
|||||||
@@ -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-'));
|
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 alassLogPath = path.join(tmpDir, 'alass-args.log');
|
||||||
const alassPath = path.join(tmpDir, 'alass.sh');
|
const alassPath = path.join(tmpDir, 'alass.sh');
|
||||||
const ffmpegPath = path.join(tmpDir, 'ffmpeg.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(sentCommands[0]?.[0], 'sub-add');
|
||||||
assert.equal(typeof loadedPath, 'string');
|
assert.equal(typeof loadedPath, 'string');
|
||||||
assert.match(fs.readFileSync(fromShellPath(String(loadedPath)), 'utf8'), /しっかし/);
|
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 () => {
|
test('runSubsyncManual keeps internal alass source file alive until sync finishes', async () => {
|
||||||
|
|||||||
+21
@@ -3330,6 +3330,13 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({
|
|||||||
client.on('end-file', listener);
|
client.on('end-file', listener);
|
||||||
return () => client.off('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) => {
|
readMpvProperty: (name) => {
|
||||||
const client = appState.mpvClient;
|
const client = appState.mpvClient;
|
||||||
if (!client) return Promise.reject(new Error('mpv is not connected.'));
|
if (!client) return Promise.reject(new Error('mpv is not connected.'));
|
||||||
@@ -3370,6 +3377,20 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({
|
|||||||
episodeNumber: metadata.episodeNumber,
|
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) => {
|
onBridgeState: (state) => {
|
||||||
const window = appState.animeBrowserWindow;
|
const window = appState.animeBrowserWindow;
|
||||||
if (window && !window.isDestroyed()) {
|
if (window && !window.isDestroyed()) {
|
||||||
|
|||||||
@@ -79,10 +79,7 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Coerce a play (or queue) request at the renderer trust boundary. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
function toPlayRequest(value: unknown): AnimeBrowserPlayRequest {
|
function toPlayRequest(value: unknown): AnimeBrowserPlayRequest {
|
||||||
const request = (value ?? {}) as Partial<AnimeBrowserPlayRequest>;
|
const request = (value ?? {}) as Partial<AnimeBrowserPlayRequest>;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -85,3 +85,91 @@ test('a superseded episode stops instead of driving mpv behind the newer one', a
|
|||||||
assert.equal(overlays, 1);
|
assert.equal(overlays, 1);
|
||||||
await playback.dispose();
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import { resolveStream } from '../../anime-bridge/headers';
|
|||||||
import { resolveBridgeMediaUrl, routeHlsThroughProxy } from '../../anime-bridge/media-url';
|
import { resolveBridgeMediaUrl, routeHlsThroughProxy } from '../../anime-bridge/media-url';
|
||||||
import {
|
import {
|
||||||
buildPlaybackCommands,
|
buildPlaybackCommands,
|
||||||
|
buildQueuedPlaybackCommands,
|
||||||
buildTrackCommands,
|
buildTrackCommands,
|
||||||
selectPreferredStream,
|
selectPreferredStream,
|
||||||
} from '../../anime-bridge/mpv-playback';
|
} from '../../anime-bridge/mpv-playback';
|
||||||
import { watchPlaybackOutcome } from '../../anime-bridge/playback-outcome';
|
import { watchPlaybackOutcome } from '../../anime-bridge/playback-outcome';
|
||||||
import { cacheSubtitleTracks, removeSubtitleCache } from '../../anime-bridge/subtitle-cache';
|
import { cacheSubtitleTracks, removeSubtitleCache } from '../../anime-bridge/subtitle-cache';
|
||||||
import type { StreamStripProxyHandle } from '../../anime-bridge/stream-strip-proxy';
|
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 { AnimeBrowserPlayRequest, AnimeBrowserPlayResult } from '../../types/anime-browser';
|
||||||
import type { AnimeBrowserPlaybackDeps } from './anime-browser-runtime-deps';
|
import type { AnimeBrowserPlaybackDeps } from './anime-browser-runtime-deps';
|
||||||
|
|
||||||
@@ -22,11 +25,29 @@ interface AnimeBrowserPlaybackOptions {
|
|||||||
stripProxy: () => StreamStripProxyHandle | null;
|
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) {
|
export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions) {
|
||||||
const { deps, bridge, sourceFor, stripProxy } = options;
|
const { deps, bridge, sourceFor, stripProxy } = options;
|
||||||
const wait =
|
const wait =
|
||||||
deps.wait ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
deps.wait ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
||||||
let subtitleCacheDir: string | null = null;
|
let subtitleCacheDir: string | null = null;
|
||||||
|
const queuedSubtitleCacheDirs = new Set<string>();
|
||||||
|
const queuedTrackPreparations = new Set<Promise<PreparedTrackSetup>>();
|
||||||
// Overlapping playEpisode calls share mpv and subtitleCacheDir, so each call
|
// Overlapping playEpisode calls share mpv and subtitleCacheDir, so each call
|
||||||
// carries a generation and only acts while it is still the newest one.
|
// carries a generation and only acts while it is still the newest one.
|
||||||
let playbackGeneration = 0;
|
let playbackGeneration = 0;
|
||||||
@@ -68,39 +89,175 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
|
|||||||
return cached.tracks.map((track) => ({ url: track.url, lang: track.lang }));
|
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> {
|
async function playEpisode(request: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> {
|
||||||
const generation = ++playbackGeneration;
|
const generation = ++playbackGeneration;
|
||||||
const isCurrent = (): boolean => generation === playbackGeneration;
|
const isCurrent = (): boolean => generation === playbackGeneration;
|
||||||
try {
|
try {
|
||||||
const { client, baseUrl } = await bridge();
|
const resolved = await resolveEpisode(request);
|
||||||
const videos = await client.getVideoList(
|
if (!resolved) {
|
||||||
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 { ok: false, error: 'That source returned no playable video.', quality: null };
|
return { ok: false, error: 'That source returned no playable video.', quality: null };
|
||||||
}
|
}
|
||||||
const proxy = stripProxy();
|
const { stream, metadata } = resolved;
|
||||||
const stream = proxy
|
|
||||||
? { ...selected, url: routeHlsThroughProxy(selected.url, baseUrl, proxy.origin) }
|
|
||||||
: selected;
|
|
||||||
|
|
||||||
if (!(await deps.ensureMpvConnected())) {
|
if (!(await deps.ensureMpvConnected())) {
|
||||||
return { ok: false, error: 'mpv is not running and could not be started.', quality: null };
|
return { ok: false, error: 'mpv is not running and could not be started.', quality: null };
|
||||||
@@ -120,15 +277,6 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
try {
|
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;
|
const title = metadata.displayTitle;
|
||||||
deps.onPlaybackMetadata?.(metadata);
|
deps.onPlaybackMetadata?.(metadata);
|
||||||
for (const command of buildPlaybackCommands({ stream, title })) {
|
for (const command of buildPlaybackCommands({ stream, title })) {
|
||||||
@@ -188,10 +336,16 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
|
|||||||
playbackGeneration += 1;
|
playbackGeneration += 1;
|
||||||
const cacheDir = subtitleCacheDir;
|
const cacheDir = subtitleCacheDir;
|
||||||
subtitleCacheDir = null;
|
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 {
|
function superseded(): AnimeBrowserPlayResult {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import type { PlaybackEndFileEvent } from '../../anime-bridge/playback-outcome';
|
|
||||||
import type { AnimeBrowserPlayRequest, AnimeBrowserQueueState } from '../../types/anime-browser';
|
import type { AnimeBrowserPlayRequest, AnimeBrowserQueueState } from '../../types/anime-browser';
|
||||||
|
import type {
|
||||||
|
PreparedAnimeBrowserPlayback,
|
||||||
|
PrepareAnimeBrowserPlaybackResult,
|
||||||
|
} from './anime-browser-playback';
|
||||||
import { createAnimeBrowserQueue, type AnimeBrowserQueueDeps } from './anime-browser-queue';
|
import { createAnimeBrowserQueue, type AnimeBrowserQueueDeps } from './anime-browser-queue';
|
||||||
|
|
||||||
function makeRequest(overrides: Partial<AnimeBrowserPlayRequest> = {}): AnimeBrowserPlayRequest {
|
function request(overrides: Partial<AnimeBrowserPlayRequest> = {}): AnimeBrowserPlayRequest {
|
||||||
return {
|
return {
|
||||||
sourceId: 'source',
|
sourceId: 'source',
|
||||||
animeUrl: '/anime',
|
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 {
|
interface Harness {
|
||||||
deps: AnimeBrowserQueueDeps;
|
deps: AnimeBrowserQueueDeps;
|
||||||
played: AnimeBrowserPlayRequest[];
|
prepared: AnimeBrowserPlayRequest[];
|
||||||
|
appended: PreparedAnimeBrowserPlayback[];
|
||||||
|
activated: PreparedAnimeBrowserPlayback[];
|
||||||
|
discarded: PreparedAnimeBrowserPlayback[];
|
||||||
commands: Array<Array<string | number>>;
|
commands: Array<Array<string | number>>;
|
||||||
states: AnimeBrowserQueueState[];
|
states: AnimeBrowserQueueState[];
|
||||||
osd: string[];
|
pathChange: (path: string) => void;
|
||||||
endFile: (event: PlaybackEndFileEvent) => void;
|
playlist: Array<{ id: number; filename: string; current?: boolean }>;
|
||||||
/** How many listeners are subscribed right now. */
|
|
||||||
listenerCount: () => number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeHarness(
|
function harness(
|
||||||
options: {
|
prepareEpisode: AnimeBrowserQueueDeps['prepareEpisode'] = async (input) => successful(input),
|
||||||
play?: (request: AnimeBrowserPlayRequest) => Promise<{
|
|
||||||
ok: boolean;
|
|
||||||
error: string | null;
|
|
||||||
quality: string | null;
|
|
||||||
}>;
|
|
||||||
keepOpen?: unknown;
|
|
||||||
withEndFile?: boolean;
|
|
||||||
} = {},
|
|
||||||
): Harness {
|
): Harness {
|
||||||
const played: AnimeBrowserPlayRequest[] = [];
|
const preparedRequests: AnimeBrowserPlayRequest[] = [];
|
||||||
|
const appended: PreparedAnimeBrowserPlayback[] = [];
|
||||||
|
const activated: PreparedAnimeBrowserPlayback[] = [];
|
||||||
|
const discarded: PreparedAnimeBrowserPlayback[] = [];
|
||||||
const commands: Array<Array<string | number>> = [];
|
const commands: Array<Array<string | number>> = [];
|
||||||
const states: AnimeBrowserQueueState[] = [];
|
const states: AnimeBrowserQueueState[] = [];
|
||||||
const osd: string[] = [];
|
const listeners = new Set<(path: string) => void>();
|
||||||
const listeners = new Set<(event: PlaybackEndFileEvent) => void>();
|
const playlist: Array<{ id: number; filename: string; current?: boolean }> = [
|
||||||
|
{ id: 1, filename: '/current.mkv', current: true },
|
||||||
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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deps,
|
deps: {
|
||||||
played,
|
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,
|
commands,
|
||||||
states,
|
states,
|
||||||
osd,
|
pathChange: (path) => {
|
||||||
endFile: (event) => {
|
for (const listener of [...listeners]) listener(path);
|
||||||
for (const listener of [...listeners]) listener(event);
|
|
||||||
},
|
},
|
||||||
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 state = await queue.enqueue(request());
|
||||||
const harness = makeHarness();
|
|
||||||
const queue = createAnimeBrowserQueue(harness.deps);
|
|
||||||
|
|
||||||
queue.enqueue(makeRequest());
|
assert.deepEqual(
|
||||||
queue.enqueue(makeRequest({ episodeUrl: '/episode-2', episodeName: 'Episode 2' }));
|
h.prepared.map((entry) => entry.episodeUrl),
|
||||||
harness.endFile(EOF);
|
['/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);
|
await new Promise(setImmediate);
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
harness.played.map((request) => request.episodeUrl),
|
h.activated.map((entry) => entry.request.episodeUrl),
|
||||||
['/episode-1'],
|
['/episode-1'],
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
queue.getState().entries.map((entry) => entry.episodeUrl),
|
queue.getState().entries.map((entry) => entry.episodeUrl),
|
||||||
['/episode-2'],
|
['/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 () => {
|
test('dequeue removes the resolved entry from mpv by playlist id', async () => {
|
||||||
const harness = makeHarness();
|
const h = harness();
|
||||||
const queue = createAnimeBrowserQueue(harness.deps);
|
const queue = createAnimeBrowserQueue(h.deps);
|
||||||
|
await queue.enqueue(request());
|
||||||
|
|
||||||
queue.enqueue(makeRequest());
|
const state = await queue.dequeue('source', '/episode-1');
|
||||||
harness.endFile({ reason: 'stop', fileError: null });
|
|
||||||
harness.endFile({ reason: 'quit', fileError: null });
|
|
||||||
harness.endFile({ reason: 'error', fileError: 'dead host' });
|
|
||||||
await new Promise(setImmediate);
|
|
||||||
|
|
||||||
assert.deepEqual(harness.played, []);
|
assert.deepEqual(state.entries, []);
|
||||||
assert.equal(queue.getState().entries.length, 1);
|
assert.deepEqual(h.commands, [['playlist-remove', 1]]);
|
||||||
|
assert.equal(h.discarded.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('queueing the same episode twice leaves one entry', () => {
|
test('dequeue does not stop an item that mpv began before the request landed', async () => {
|
||||||
const harness = makeHarness();
|
const h = harness();
|
||||||
const queue = createAnimeBrowserQueue(harness.deps);
|
const queue = createAnimeBrowserQueue(h.deps);
|
||||||
|
await queue.enqueue(request());
|
||||||
|
h.playlist[0]!.current = false;
|
||||||
|
h.playlist[1]!.current = true;
|
||||||
|
|
||||||
queue.enqueue(makeRequest());
|
await queue.dequeue('source', '/episode-1');
|
||||||
const state = queue.enqueue(makeRequest());
|
|
||||||
|
|
||||||
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 () => {
|
test('clear removes owned playlist entries from the end toward the current file', async () => {
|
||||||
const harness = makeHarness();
|
const h = harness();
|
||||||
const queue = createAnimeBrowserQueue(harness.deps);
|
const queue = createAnimeBrowserQueue(h.deps);
|
||||||
|
await queue.enqueue(request());
|
||||||
|
await queue.enqueue(request({ episodeUrl: '/episode-2', episodeName: 'Episode 2' }));
|
||||||
|
|
||||||
queue.enqueue(makeRequest());
|
await queue.clear();
|
||||||
assert.equal(queue.getState().advances, 0);
|
|
||||||
harness.endFile(EOF);
|
|
||||||
await new Promise(setImmediate);
|
|
||||||
|
|
||||||
const state = queue.getState();
|
assert.deepEqual(h.commands, [
|
||||||
assert.equal(state.advances, 1);
|
['playlist-remove', 2],
|
||||||
assert.equal(state.lastStarted?.episodeUrl, '/episode-1');
|
['playlist-remove', 1],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(queue.getState().entries, []);
|
||||||
|
assert.equal(h.discarded.length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an episode dequeues by its own source and url', () => {
|
test('a preparation failure leaves later episodes queued and reports the source error', async () => {
|
||||||
const harness = makeHarness();
|
const h = harness(async (input) =>
|
||||||
const queue = createAnimeBrowserQueue(harness.deps);
|
input.episodeUrl === '/episode-1'
|
||||||
|
? { ok: false, playback: null, error: 'No playable video.', quality: null }
|
||||||
queue.enqueue(makeRequest());
|
: successful(input),
|
||||||
queue.enqueue(makeRequest({ sourceId: 'other' }));
|
|
||||||
const state = queue.dequeue('source', '/episode-1');
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
state.entries.map((entry) => entry.sourceId),
|
|
||||||
['other'],
|
|
||||||
);
|
);
|
||||||
});
|
const queue = createAnimeBrowserQueue(h.deps);
|
||||||
|
|
||||||
test('a queued episode that will not play stops the queue and reports why', async () => {
|
const failed = await queue.enqueue(request());
|
||||||
const harness = makeHarness({
|
await queue.enqueue(request({ episodeUrl: '/episode-2', episodeName: 'Episode 2' }));
|
||||||
play: async () => ({
|
|
||||||
ok: false,
|
|
||||||
error: 'That source returned no playable video.',
|
|
||||||
quality: null,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const queue = createAnimeBrowserQueue(harness.deps);
|
|
||||||
|
|
||||||
queue.enqueue(makeRequest());
|
assert.equal(failed.lastError, 'Episode 1: No playable video.');
|
||||||
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.deepEqual(
|
assert.deepEqual(
|
||||||
state.entries.map((entry) => entry.episodeUrl),
|
queue.getState().entries.map((entry) => entry.episodeUrl),
|
||||||
['/episode-2'],
|
['/episode-2'],
|
||||||
);
|
);
|
||||||
assert.equal(harness.osd.length, 1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a second end-file while an advance is resolving does not double-load', async () => {
|
test('queueing the same episode twice resolves and appends it once', async () => {
|
||||||
let release = (): void => undefined;
|
const h = harness();
|
||||||
const started = new Promise<void>((resolve) => {
|
const queue = createAnimeBrowserQueue(h.deps);
|
||||||
release = resolve;
|
|
||||||
});
|
|
||||||
const harness = makeHarness({
|
|
||||||
play: async () => {
|
|
||||||
await started;
|
|
||||||
return { ok: true, error: null, quality: null };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const queue = createAnimeBrowserQueue(harness.deps);
|
|
||||||
|
|
||||||
queue.enqueue(makeRequest());
|
await queue.enqueue(request());
|
||||||
queue.enqueue(makeRequest({ episodeUrl: '/episode-2', episodeName: 'Episode 2' }));
|
await queue.enqueue(request());
|
||||||
harness.endFile(EOF);
|
|
||||||
harness.endFile(EOF);
|
assert.equal(h.prepared.length, 1);
|
||||||
release();
|
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 new Promise(setImmediate);
|
||||||
|
await queue.dequeue('source', '/episode-1');
|
||||||
|
pending.resolve(successful(request()));
|
||||||
|
await enqueue;
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(h.appended, []);
|
||||||
harness.played.map((request) => request.episodeUrl),
|
assert.equal(h.discarded.length, 1);
|
||||||
['/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);
|
|
||||||
});
|
});
|
||||||
|
|||||||
Binary file not shown.
@@ -34,12 +34,16 @@ export interface AnimeBrowserRuntimeDeps {
|
|||||||
ensureMpvConnected: () => Promise<boolean>;
|
ensureMpvConnected: () => Promise<boolean>;
|
||||||
/** Subscribe to mpv end-file events so playback startup can be confirmed. */
|
/** Subscribe to mpv end-file events so playback startup can be confirmed. */
|
||||||
onPlaybackEndFile?: (listener: (event: PlaybackEndFileEvent) => void) => () => void;
|
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. */
|
/** One-shot mpv property read; rejects while the property is unavailable. */
|
||||||
readMpvProperty?: (name: string) => Promise<unknown>;
|
readMpvProperty?: (name: string) => Promise<unknown>;
|
||||||
showMpvOsd?: (message: string) => void;
|
showMpvOsd?: (message: string) => void;
|
||||||
showVisibleOverlay?: () => void;
|
showVisibleOverlay?: () => void;
|
||||||
/** Publishes stream identity before loadfile starts the stats session. */
|
/** Publishes stream identity before loadfile starts the stats session. */
|
||||||
onPlaybackMetadata?: (metadata: AnimeStreamMetadata) => void;
|
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
|
* 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
|
* to. Absent (or resolving empty) when stats tracking is disabled, which the
|
||||||
@@ -78,6 +82,7 @@ export type AnimeBrowserPlaybackDeps = Pick<
|
|||||||
| 'showMpvOsd'
|
| 'showMpvOsd'
|
||||||
| 'showVisibleOverlay'
|
| 'showVisibleOverlay'
|
||||||
| 'onPlaybackMetadata'
|
| 'onPlaybackMetadata'
|
||||||
|
| 'onPreparedPlaybackMetadata'
|
||||||
| 'wait'
|
| 'wait'
|
||||||
| 'subtitleCacheIo'
|
| 'subtitleCacheIo'
|
||||||
| 'preferredQuality'
|
| 'preferredQuality'
|
||||||
|
|||||||
@@ -355,8 +355,13 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const queue = createAnimeBrowserQueue({
|
const queue = createAnimeBrowserQueue({
|
||||||
play: (request) => playback.playEpisode(request),
|
prepareEpisode: (request) => playback.prepareEpisode(request),
|
||||||
onPlaybackEndFile: deps.onPlaybackEndFile,
|
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,
|
readMpvProperty: deps.readMpvProperty,
|
||||||
sendMpvCommand: deps.sendMpvCommand,
|
sendMpvCommand: deps.sendMpvCommand,
|
||||||
onQueueState: deps.onQueueState,
|
onQueueState: deps.onQueueState,
|
||||||
@@ -598,16 +603,16 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
queueEpisode(request: AnimeBrowserPlayRequest): AnimeBrowserQueueState {
|
async queueEpisode(request: AnimeBrowserPlayRequest): Promise<AnimeBrowserQueueState> {
|
||||||
return queue.enqueue(request);
|
return await queue.enqueue(request);
|
||||||
},
|
},
|
||||||
|
|
||||||
dequeueEpisode(sourceId: string, episodeUrl: string): AnimeBrowserQueueState {
|
async dequeueEpisode(sourceId: string, episodeUrl: string): Promise<AnimeBrowserQueueState> {
|
||||||
return queue.dequeue(sourceId, episodeUrl);
|
return await queue.dequeue(sourceId, episodeUrl);
|
||||||
},
|
},
|
||||||
|
|
||||||
clearQueue(): AnimeBrowserQueueState {
|
async clearQueue(): Promise<AnimeBrowserQueueState> {
|
||||||
return queue.clear();
|
return await queue.clear();
|
||||||
},
|
},
|
||||||
|
|
||||||
getQueue(): AnimeBrowserQueueState {
|
getQueue(): AnimeBrowserQueueState {
|
||||||
@@ -634,7 +639,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
stripProxy = null;
|
stripProxy = null;
|
||||||
starting = null;
|
starting = null;
|
||||||
setState(IDLE_STATE);
|
setState(IDLE_STATE);
|
||||||
queue.dispose();
|
await queue.dispose();
|
||||||
await playback.dispose();
|
await playback.dispose();
|
||||||
await proxy?.close();
|
await proxy?.close();
|
||||||
await handle?.stop();
|
await handle?.stop();
|
||||||
|
|||||||
@@ -43,6 +43,21 @@ test('the store stops answering once the player moves on', () => {
|
|||||||
assert.equal(store.match(metadata().mediaPath), null);
|
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', () => {
|
test('an explicit target path does not inherit the current stream metadata', () => {
|
||||||
const store = createStreamPlaybackMetadataStore();
|
const store = createStreamPlaybackMetadataStore();
|
||||||
const current = metadata();
|
const current = metadata();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-upda
|
|||||||
import type { JimakuMediaInfo } from '../../types';
|
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
|
* 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
|
* 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;
|
set: (metadata: AnimeStreamMetadata) => void;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
/**
|
/**
|
||||||
* The metadata for `mediaPath`, or null when the player has moved on to
|
* The registered metadata for `mediaPath`, whether that stream is active or
|
||||||
* something else. Matching on the path is what makes this self-expiring —
|
* waiting in the playlist. A match identifies the stream but does not prove
|
||||||
* there is no teardown hook to miss.
|
* that mpv is currently playing it.
|
||||||
*/
|
*/
|
||||||
match: (mediaPath: string | null) => AnimeStreamMetadata | null;
|
match: (mediaPath: string | null) => AnimeStreamMetadata | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createStreamPlaybackMetadataStore(): StreamPlaybackMetadataStore {
|
export function createStreamPlaybackMetadataStore(): StreamPlaybackMetadataStore {
|
||||||
let current: AnimeStreamMetadata | null = null;
|
const byPath = new Map<string, AnimeStreamMetadata>();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
set(metadata: AnimeStreamMetadata): void {
|
set(metadata: AnimeStreamMetadata): void {
|
||||||
current = metadata;
|
byPath.set(metadata.mediaPath, metadata);
|
||||||
|
byPath.set(metadata.statsPath, metadata);
|
||||||
},
|
},
|
||||||
clear(): void {
|
clear(): void {
|
||||||
current = null;
|
byPath.clear();
|
||||||
},
|
},
|
||||||
match(mediaPath: string | null): AnimeStreamMetadata | null {
|
match(mediaPath: string | null): AnimeStreamMetadata | null {
|
||||||
if (!current) return null;
|
|
||||||
const trimmed = typeof mediaPath === 'string' ? mediaPath.trim() : '';
|
const trimmed = typeof mediaPath === 'string' ? mediaPath.trim() : '';
|
||||||
if (!trimmed) return null;
|
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.
|
// to it, so callers reading from there still resolve.
|
||||||
return trimmed === current.mediaPath || trimmed === current.statsPath ? current : null;
|
return byPath.get(trimmed) ?? null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ function withTempBin(run: (dir: string) => void): void {
|
|||||||
process.env.PATH = dir;
|
process.env.PATH = dir;
|
||||||
run(dir);
|
run(dir);
|
||||||
} finally {
|
} finally {
|
||||||
process.env.PATH = previousPath;
|
if (previousPath === undefined) {
|
||||||
|
delete process.env.PATH;
|
||||||
|
} else {
|
||||||
|
process.env.PATH = previousPath;
|
||||||
|
}
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,17 +222,11 @@ export interface AnimeBrowserPlayResult {
|
|||||||
quality: string | null;
|
quality: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** One resolved episode waiting in mpv's playlist. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export type AnimeBrowserQueueEntry = AnimeBrowserPlayRequest;
|
export type AnimeBrowserQueueEntry = AnimeBrowserPlayRequest;
|
||||||
|
|
||||||
export interface AnimeBrowserQueueState {
|
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[];
|
entries: AnimeBrowserQueueEntry[];
|
||||||
/**
|
/**
|
||||||
* Why the last automatic advance failed, or null. Cleared by the next queue
|
* Why the last automatic advance failed, or null. Cleared by the next queue
|
||||||
|
|||||||
Reference in New Issue
Block a user