feat(anime): append queued episodes to mpv playlist

- Resolve queued streams immediately and cache subtitles in the background
- Attach prepared tracks as mpv advances through the playlist
This commit is contained in:
2026-08-15 21:44:51 -07:00
parent 183560d2c3
commit 935e4c145f
21 changed files with 660 additions and 289 deletions
+37
View File
@@ -3,6 +3,8 @@ import assert from 'node:assert/strict';
import {
buildLoadfileOptions,
buildPlaybackCommands,
buildQueuedLoadfileOptions,
buildQueuedPlaybackCommands,
buildTrackCommands,
normalizeLangTag,
selectPreferredStream,
@@ -39,6 +41,41 @@ test('loadfile options keep one visible track and never scan the filesystem', ()
assert.ok(!options.includes('slang'));
});
test('queued playback carries file-local title and language preferences into mpv', () => {
const options = {
stream: {
url: 'https://video.example/episode.m3u8',
quality: '1080p',
headers: { Referer: 'https://source.example/watch?a=1,b=2' },
audios: [],
subtitles: [],
},
title: 'Series, Part 1 = Episode 2',
};
const languagePreference = 'ja,jpn,jp,japanese';
assert.ok(
buildQueuedLoadfileOptions(options).includes(
`alang=%${Buffer.byteLength(languagePreference)}%${languagePreference}`,
),
);
assert.ok(
buildQueuedLoadfileOptions(options).includes(
`force-media-title=%${Buffer.byteLength(options.title)}%${options.title}`,
),
);
assert.deepEqual(buildQueuedPlaybackCommands(options), [
['script-message', 'subminer-managed-subtitles-loading'],
[
'loadfile',
'https://video.example/episode.m3u8',
'append-play',
-1,
buildQueuedLoadfileOptions(options),
],
]);
});
test('headers are percent-escaped so their commas do not split the option list', () => {
const headers = { Referer: 'https://a.test/', 'User-Agent': 'X' };
const options = buildLoadfileOptions({ stream: stream({ headers }) });
+25
View File
@@ -73,6 +73,23 @@ export function buildLoadfileOptions(options: BuildPlaybackOptions): string {
return parts.join(',');
}
/**
* Build the file-local options needed when a resolved stream waits in mpv's
* playlist. Unlike the regular playback path, there is no opportunity to set
* global properties immediately before mpv advances to this file.
*/
export function buildQueuedLoadfileOptions(options: BuildPlaybackOptions): string {
const parts = [
buildLoadfileOptions(options),
`alang=${escapeOptionValue(JAPANESE_LANGUAGE_PREFERENCE)}`,
`slang=${escapeOptionValue(JAPANESE_LANGUAGE_PREFERENCE)}`,
];
if (options.title !== undefined && options.title.length > 0) {
parts.push(`force-media-title=${escapeOptionValue(options.title)}`);
}
return parts.join(',');
}
/**
* mpv splits `loadfile` options on commas and `=`-separates keys, so a value
* containing either must be quoted. Percent-encoding is mpv's own escape for
@@ -107,6 +124,14 @@ export function buildPlaybackCommands(options: BuildPlaybackOptions): MpvCommand
return commands;
}
/** Append a fully resolved stream without replacing the file playing now. */
export function buildQueuedPlaybackCommands(options: BuildPlaybackOptions): MpvCommand[] {
return [
['script-message', 'subminer-managed-subtitles-loading'],
['loadfile', options.stream.url, 'append-play', -1, buildQueuedLoadfileOptions(options)],
];
}
/**
* Commands that attach the extension's external audio and subtitle tracks.
*
+14 -2
View File
@@ -14,6 +14,18 @@ function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForExit(hasExited: Promise<void>, timeoutMs: number): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | null = null;
const timedOut = new Promise<void>((resolve) => {
timeout = setTimeout(resolve, timeoutMs);
});
try {
await Promise.race([hasExited, timedOut]);
} finally {
if (timeout !== null) clearTimeout(timeout);
}
}
/** Ask the OS for a free loopback port, then hand it to the JVM. */
export async function allocatePort(): Promise<number> {
return new Promise((resolve, reject) => {
@@ -122,10 +134,10 @@ export async function startSidecar(options: StartSidecarOptions): Promise<Sideca
child.kill();
// kill() only sends the signal. Wait for the process to actually go, so a
// restart cannot race the old one still holding the port.
await Promise.race([hasExited, delay(stopTimeoutMs)]);
await waitForExit(hasExited, stopTimeoutMs);
if (exited === null) {
child.kill('SIGKILL');
await Promise.race([hasExited, delay(stopTimeoutMs)]);
await waitForExit(hasExited, stopTimeoutMs);
}
// Never report success while the child may still hold the port: a caller
// that restarts on the same port would race the survivor.