mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 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:
@@ -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 }) });
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user