mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-08 07:21:31 -07:00
fix(anime): scope preferences, paginate sources, harden installs
- Preference store keys entries by extension package + bridge source id; legacy unscoped entries are discarded once instead of being handed to whichever extension asks first - Source picker's "Load more" appends the next page without duplicating streamed results - Repository index fetches and subtitle/APK downloads now time out and are size-bounded instead of hanging or growing unbounded - APK installs are staged to a temp file and renamed into place - Stream metadata lookup matches the requested path, not only the currently playing one - Reworked animeui into browse-state/detail-panel/panels.css modules - Reverted premature CHANGELOG unreleased entries; refreshed anime-browser docs
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { AnimeBridgeClient, BridgeSource } from '../../anime-bridge/bridge-client';
|
||||
import type { AnimeBrowserPlayRequest } from '../../types/anime-browser';
|
||||
import { createAnimeBrowserPlayback } from './anime-browser-playback';
|
||||
|
||||
const request: AnimeBrowserPlayRequest = {
|
||||
sourceId: 'source',
|
||||
animeUrl: '/anime',
|
||||
animeTitle: 'Anime',
|
||||
episodeUrl: '/episode-1',
|
||||
episodeName: 'Episode 1',
|
||||
episodeNumber: 1,
|
||||
};
|
||||
|
||||
test('playback returns the established error when an extension has no playable stream', async () => {
|
||||
let mpvConnections = 0;
|
||||
const client = { getVideoList: async () => [] } as unknown as AnimeBridgeClient;
|
||||
const playback = createAnimeBrowserPlayback({
|
||||
deps: {
|
||||
sendMpvCommand: () => undefined,
|
||||
ensureMpvConnected: async () => {
|
||||
mpvConnections += 1;
|
||||
return true;
|
||||
},
|
||||
log: () => undefined,
|
||||
},
|
||||
bridge: async () => ({ client, baseUrl: 'http://127.0.0.1:1234' }),
|
||||
sourceFor: async () => ({}) as BridgeSource,
|
||||
stripProxy: () => null,
|
||||
});
|
||||
|
||||
assert.deepEqual(await playback.playEpisode(request), {
|
||||
ok: false,
|
||||
error: 'That source returned no playable video.',
|
||||
quality: null,
|
||||
});
|
||||
assert.equal(mpvConnections, 0);
|
||||
await playback.dispose();
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { AnimeBridgeClient, BridgeSource } from '../../anime-bridge/bridge-client';
|
||||
import { buildAnimeStreamMetadata } from '../../anime-bridge/episode-metadata';
|
||||
import { resolveStream } from '../../anime-bridge/headers';
|
||||
import { resolveBridgeMediaUrl, routeHlsThroughProxy } from '../../anime-bridge/media-url';
|
||||
import {
|
||||
buildPlaybackCommands,
|
||||
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 { AnimeBrowserPlayRequest, AnimeBrowserPlayResult } from '../../types/anime-browser';
|
||||
import type { AnimeBrowserPlaybackDeps } from './anime-browser-runtime-deps';
|
||||
|
||||
const TRACK_ATTACH_DELAY_MS = 300;
|
||||
|
||||
interface AnimeBrowserPlaybackOptions {
|
||||
deps: AnimeBrowserPlaybackDeps;
|
||||
bridge: () => Promise<{ client: AnimeBridgeClient; baseUrl: string }>;
|
||||
sourceFor: (sourceId: string) => Promise<BridgeSource>;
|
||||
stripProxy: () => StreamStripProxyHandle | null;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
async function clearSubtitleCache(): Promise<void> {
|
||||
const previousDir = subtitleCacheDir;
|
||||
subtitleCacheDir = null;
|
||||
await removeSubtitleCache(previousDir, deps.subtitleCacheIo);
|
||||
}
|
||||
|
||||
async function cacheStreamSubtitles(stream: {
|
||||
headers: Record<string, string>;
|
||||
subtitles: Array<{ url: string; lang: string }>;
|
||||
}): Promise<Array<{ url: string; lang: string }>> {
|
||||
const cached = await cacheSubtitleTracks({
|
||||
tracks: stream.subtitles,
|
||||
headers: stream.headers,
|
||||
io: deps.subtitleCacheIo,
|
||||
log: deps.log,
|
||||
});
|
||||
subtitleCacheDir = cached.dir;
|
||||
|
||||
const localCount = cached.tracks.filter((track) => track.local).length;
|
||||
if (cached.tracks.length > 0) {
|
||||
deps.log(
|
||||
`[anime-browser] cached ${localCount}/${cached.tracks.length} subtitle track(s) to disk` +
|
||||
(cached.dir ? ` in ${cached.dir}` : ''),
|
||||
);
|
||||
}
|
||||
return cached.tracks.map((track) => ({ url: track.url, lang: track.lang }));
|
||||
}
|
||||
|
||||
async function playEpisode(request: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> {
|
||||
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) {
|
||||
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;
|
||||
|
||||
if (!(await deps.ensureMpvConnected())) {
|
||||
return { ok: false, error: 'mpv is not running and could not be started.', quality: null };
|
||||
}
|
||||
|
||||
const watch =
|
||||
deps.onPlaybackEndFile && deps.readMpvProperty
|
||||
? watchPlaybackOutcome({
|
||||
onEndFile: deps.onPlaybackEndFile,
|
||||
readProperty: deps.readMpvProperty,
|
||||
wait,
|
||||
})
|
||||
: 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 })) {
|
||||
deps.sendMpvCommand(command);
|
||||
}
|
||||
await clearSubtitleCache();
|
||||
|
||||
if (stream.audios.length > 0 || stream.subtitles.length > 0) {
|
||||
deps.log(
|
||||
`[anime-browser] ${stream.audios.length} external audio, ` +
|
||||
`${stream.subtitles.length} external subtitle track(s)`,
|
||||
);
|
||||
const [subtitles] = await Promise.all([
|
||||
cacheStreamSubtitles(stream),
|
||||
wait(TRACK_ATTACH_DELAY_MS),
|
||||
]);
|
||||
for (const command of buildTrackCommands({ ...stream, subtitles })) {
|
||||
deps.sendMpvCommand(command);
|
||||
}
|
||||
}
|
||||
|
||||
if (watch) {
|
||||
const outcome = await watch.wait();
|
||||
if (!outcome.ok) {
|
||||
deps.log(`[anime-browser] playback failed to start: ${outcome.error}`);
|
||||
return { ok: false, error: outcome.error, quality: null };
|
||||
}
|
||||
}
|
||||
|
||||
deps.showVisibleOverlay?.();
|
||||
deps.showMpvOsd?.(title);
|
||||
return { ok: true, error: null, quality: stream.quality || null };
|
||||
} finally {
|
||||
watch?.dispose();
|
||||
}
|
||||
} catch (error) {
|
||||
deps.log(`[anime-browser] playback failed: ${String(error)}`);
|
||||
return { ok: false, error: describeError(error), quality: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function dispose(): Promise<void> {
|
||||
const cacheDir = subtitleCacheDir;
|
||||
subtitleCacheDir = null;
|
||||
await removeSubtitleCache(cacheDir, deps.subtitleCacheIo);
|
||||
}
|
||||
|
||||
return { playEpisode, dispose };
|
||||
}
|
||||
|
||||
function describeError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata';
|
||||
import type { PlaybackEndFileEvent } from '../../anime-bridge/playback-outcome';
|
||||
import type { SubtitleCacheIo } from '../../anime-bridge/subtitle-cache';
|
||||
import type { BundleBinaries } from '../../anime-bridge/sidecar-bundle';
|
||||
import { startSidecar } from '../../anime-bridge/sidecar-process';
|
||||
import { startStreamStripProxy } from '../../anime-bridge/stream-strip-proxy';
|
||||
import type { AnimeBrowserBridgeState, AnimeBrowserSearchUpdate } from '../../types/anime-browser';
|
||||
import type { InstallProgress } from './anime-bridge-installer';
|
||||
|
||||
export interface AnimeBrowserRuntimeDeps {
|
||||
/** Where user-supplied Aniyomi extension APKs live. Read lazily so config edits apply. */
|
||||
extensionsDir: () => string;
|
||||
/** Configured repository index URLs. Empty unless the user added one. */
|
||||
repos: () => string[];
|
||||
/** Persists the repository list. Config stays the source of truth. */
|
||||
setRepos: (repos: string[]) => void;
|
||||
/** JSON file holding each source's saved preference values. */
|
||||
preferencesFile: string;
|
||||
ensureBinaries: (onProgress: (progress: InstallProgress) => void) => Promise<BundleBinaries>;
|
||||
/** Sends mpv an IPC command; same transport the Jellyfin path uses. */
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
/** Brings mpv up if it is not already connected. Resolves false on failure. */
|
||||
ensureMpvConnected: () => Promise<boolean>;
|
||||
/** Subscribe to mpv end-file events so playback startup can be confirmed. */
|
||||
onPlaybackEndFile?: (listener: (event: PlaybackEndFileEvent) => 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;
|
||||
/** Lets tests drive the pause between loadfile and track attachment. */
|
||||
wait?: (ms: number) => Promise<void>;
|
||||
/** Overrides the filesystem/network the subtitle cache uses. Tests only. */
|
||||
subtitleCacheIo?: SubtitleCacheIo;
|
||||
onBridgeState: (state: AnimeBrowserBridgeState) => void;
|
||||
/** Streams per-source progress while a search invoke is pending. */
|
||||
onSearchUpdate?: (update: AnimeBrowserSearchUpdate) => void;
|
||||
preferredQuality?: () => string | undefined;
|
||||
log: (message: string) => void;
|
||||
/** Overrides process startup in focused runtime tests. */
|
||||
startSidecar?: typeof startSidecar;
|
||||
/** Overrides proxy startup in focused runtime tests. */
|
||||
startStreamStripProxy?: typeof startStreamStripProxy;
|
||||
}
|
||||
|
||||
export type AnimeBrowserPlaybackDeps = Pick<
|
||||
AnimeBrowserRuntimeDeps,
|
||||
| 'sendMpvCommand'
|
||||
| 'ensureMpvConnected'
|
||||
| 'onPlaybackEndFile'
|
||||
| 'readMpvProperty'
|
||||
| 'showMpvOsd'
|
||||
| 'showVisibleOverlay'
|
||||
| 'onPlaybackMetadata'
|
||||
| 'wait'
|
||||
| 'subtitleCacheIo'
|
||||
| 'preferredQuality'
|
||||
| 'log'
|
||||
>;
|
||||
@@ -0,0 +1,138 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { BridgePreference } from '../../anime-bridge/types';
|
||||
import { createAnimeBrowserRuntime } from './anime-browser-runtime';
|
||||
import type { AnimeBridgeClient } from '../../anime-bridge/bridge-client';
|
||||
|
||||
function textPreference(key: string, value = ''): BridgePreference {
|
||||
return { key, editTextPreference: { title: key, value, text: value } };
|
||||
}
|
||||
|
||||
async function setupRuntime(
|
||||
client: Record<string, unknown>,
|
||||
packages: Record<string, string>,
|
||||
storedPreferences?: Record<string, BridgePreference[]>,
|
||||
) {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-anime-runtime-'));
|
||||
for (const [pkg, contents] of Object.entries(packages)) {
|
||||
await writeFile(path.join(dir, `${pkg}.apk`), contents);
|
||||
}
|
||||
const preferencesFile = path.join(dir, 'preferences.json');
|
||||
if (storedPreferences) await writeFile(preferencesFile, JSON.stringify(storedPreferences));
|
||||
const runtime = createAnimeBrowserRuntime({
|
||||
extensionsDir: () => dir,
|
||||
repos: () => [],
|
||||
setRepos: () => undefined,
|
||||
preferencesFile,
|
||||
ensureBinaries: async () => ({}) as never,
|
||||
sendMpvCommand: () => undefined,
|
||||
ensureMpvConnected: async () => true,
|
||||
onBridgeState: () => undefined,
|
||||
log: () => undefined,
|
||||
startSidecar: async () => ({
|
||||
client: client as unknown as AnimeBridgeClient,
|
||||
baseUrl: 'http://127.0.0.1:12345',
|
||||
port: 12345,
|
||||
stop: async () => undefined,
|
||||
onExit: () => undefined,
|
||||
}),
|
||||
startStreamStripProxy: async () => ({
|
||||
origin: 'http://127.0.0.1:12346',
|
||||
port: 12346,
|
||||
close: async () => undefined,
|
||||
}),
|
||||
});
|
||||
await runtime.ensureBridge();
|
||||
return { runtime, preferencesFile };
|
||||
}
|
||||
|
||||
test('setPreference overlays saved values onto the extension current schema', async () => {
|
||||
const saved = textPreference('address', 'saved-address');
|
||||
const current = [textPreference('address'), textPreference('new-setting')];
|
||||
let received: BridgePreference[] = [];
|
||||
let schemaCalls = 0;
|
||||
const pkgBytes = 'one';
|
||||
const client = {
|
||||
listAnimeSources: async () => [{ id: 'shared', name: 'One', lang: 'en' }],
|
||||
getSourcePreferences: async () => {
|
||||
schemaCalls += 1;
|
||||
return current;
|
||||
},
|
||||
setSourcePreference: async (source: { preferences?: BridgePreference[] }) => {
|
||||
received = source.preferences ?? [];
|
||||
return received;
|
||||
},
|
||||
};
|
||||
const { runtime } = await setupRuntime(
|
||||
client,
|
||||
{ 'pkg.one': pkgBytes },
|
||||
{ 'pkg.one:shared': [saved] },
|
||||
);
|
||||
assert.equal(runtime.getSnapshot().sources[0]?.id, 'pkg.one:shared');
|
||||
|
||||
await runtime.setPreference('pkg.one:shared', 'new-setting', 'chosen');
|
||||
|
||||
assert.equal(schemaCalls, 1);
|
||||
const address = received.find((entry) => entry.key === 'address')?.editTextPreference as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const added = received.find((entry) => entry.key === 'new-setting')?.editTextPreference as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
assert.equal(address?.value, 'saved-address');
|
||||
assert.equal(added?.value, 'chosen');
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
test('colliding bridge ids keep package preferences isolated and uninstall clears only its package', async () => {
|
||||
const seen: Array<{ sourceId?: string; preferences?: BridgePreference[]; fingerprint: string }> =
|
||||
[];
|
||||
const client = {
|
||||
listAnimeSources: async () => [{ id: 'shared', name: 'Source', lang: 'en' }],
|
||||
getSourcePreferences: async (source: {
|
||||
sourceId?: string;
|
||||
preferences?: BridgePreference[];
|
||||
fingerprint: string;
|
||||
}) => {
|
||||
seen.push(source);
|
||||
return [textPreference('password')];
|
||||
},
|
||||
};
|
||||
const one = textPreference('password', 'one-secret');
|
||||
const two = textPreference('password', 'two-secret');
|
||||
const { runtime, preferencesFile } = await setupRuntime(
|
||||
client,
|
||||
{ 'pkg.one': 'one', 'pkg.two': 'two' },
|
||||
{ 'pkg.one:shared': [one], 'pkg.two:shared': [two] },
|
||||
);
|
||||
|
||||
const sourceIds = runtime.getSnapshot().sources.map((source) => source.id);
|
||||
assert.deepEqual(sourceIds, ['pkg.one:shared', 'pkg.two:shared']);
|
||||
const oneView = await runtime.getPreferences('pkg.one:shared');
|
||||
const twoView = await runtime.getPreferences('pkg.two:shared');
|
||||
|
||||
assert.equal(oneView[0]?.value, 'one-secret');
|
||||
assert.equal(twoView[0]?.value, 'two-secret');
|
||||
assert.deepEqual(
|
||||
seen.map((source) => source.sourceId),
|
||||
['shared', 'shared'],
|
||||
);
|
||||
assert.notEqual(seen[0]?.fingerprint, seen[1]?.fingerprint);
|
||||
assert.equal(
|
||||
(seen[0]?.preferences?.[0]?.editTextPreference as Record<string, unknown>)?.value,
|
||||
'one-secret',
|
||||
);
|
||||
assert.equal(
|
||||
(seen[1]?.preferences?.[0]?.editTextPreference as Record<string, unknown>)?.value,
|
||||
'two-secret',
|
||||
);
|
||||
|
||||
await runtime.removeExtension('pkg.one');
|
||||
const persisted = JSON.parse(await readFile(preferencesFile, 'utf8')) as Record<string, unknown>;
|
||||
assert.equal(persisted['pkg.one:shared'], undefined);
|
||||
assert.deepEqual(persisted['pkg.two:shared'], [two]);
|
||||
await runtime.dispose();
|
||||
});
|
||||
@@ -1,27 +1,9 @@
|
||||
import { AnimeBridgeClient } from '../../anime-bridge/bridge-client';
|
||||
import { resolveStream } from '../../anime-bridge/headers';
|
||||
import {
|
||||
parseAnimeStatus,
|
||||
resolveBridgeMediaUrl,
|
||||
routeHlsThroughProxy,
|
||||
} from '../../anime-bridge/media-url';
|
||||
import {
|
||||
watchPlaybackOutcome,
|
||||
type PlaybackEndFileEvent,
|
||||
} from '../../anime-bridge/playback-outcome';
|
||||
import { parseAnimeStatus, resolveBridgeMediaUrl } from '../../anime-bridge/media-url';
|
||||
import {
|
||||
startStreamStripProxy,
|
||||
type StreamStripProxyHandle,
|
||||
} from '../../anime-bridge/stream-strip-proxy';
|
||||
import {
|
||||
buildPlaybackCommands,
|
||||
buildTrackCommands,
|
||||
selectPreferredStream,
|
||||
} from '../../anime-bridge/mpv-playback';
|
||||
import {
|
||||
buildAnimeStreamMetadata,
|
||||
type AnimeStreamMetadata,
|
||||
} from '../../anime-bridge/episode-metadata';
|
||||
import {
|
||||
listExtensionSources,
|
||||
readInstalledExtensions,
|
||||
@@ -30,11 +12,6 @@ import {
|
||||
type ExtensionSource,
|
||||
type InstalledExtension,
|
||||
} from '../../anime-bridge/extension-store';
|
||||
import {
|
||||
cacheSubtitleTracks,
|
||||
removeSubtitleCache,
|
||||
type SubtitleCacheIo,
|
||||
} from '../../anime-bridge/subtitle-cache';
|
||||
import { interleave, mapSourcesConcurrently } from '../../anime-bridge/multi-source-search';
|
||||
import { startSidecar, type SidecarHandle } from '../../anime-bridge/sidecar-process';
|
||||
import {
|
||||
@@ -49,74 +26,25 @@ import {
|
||||
import { PreferenceStore } from '../../anime-bridge/preference-store';
|
||||
import { applyPreferenceValue, parsePreferences } from '../../anime-bridge/preferences';
|
||||
import type { SourcePreferenceView } from '../../anime-bridge/preferences';
|
||||
import type { BundleBinaries } from '../../anime-bridge/sidecar-bundle';
|
||||
import type { InstallProgress } from './anime-bridge-installer';
|
||||
import { ALL_SOURCES_ID } from '../../types/anime-browser';
|
||||
import type {
|
||||
AnimeBrowserBridgeState,
|
||||
AnimeBrowserDetails,
|
||||
AnimeBrowserEntry,
|
||||
AnimeBrowserEpisode,
|
||||
AnimeBrowserPlayRequest,
|
||||
AnimeBrowserPlayResult,
|
||||
AnimeBrowserSearchResult,
|
||||
AnimeBrowserSearchUpdate,
|
||||
AnimeBrowserSnapshot,
|
||||
AvailableExtensionsResult,
|
||||
ExtensionLoadFailure,
|
||||
} from '../../types/anime-browser';
|
||||
import type { BridgeAnimePage } from '../../anime-bridge/types';
|
||||
|
||||
export interface AnimeBrowserRuntimeDeps {
|
||||
/** Where user-supplied Aniyomi extension APKs live. Read lazily so config edits apply. */
|
||||
extensionsDir: () => string;
|
||||
/** Configured repository index URLs. Empty unless the user added one. */
|
||||
repos: () => string[];
|
||||
/** Persists the repository list. Config stays the source of truth. */
|
||||
setRepos: (repos: string[]) => void;
|
||||
/** JSON file holding each source's saved preference values. */
|
||||
preferencesFile: string;
|
||||
ensureBinaries: (onProgress: (progress: InstallProgress) => void) => Promise<BundleBinaries>;
|
||||
/** Sends mpv an IPC command; same transport the Jellyfin path uses. */
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
/** Brings mpv up if it is not already connected. Resolves false on failure. */
|
||||
ensureMpvConnected: () => Promise<boolean>;
|
||||
/**
|
||||
* Subscribe to mpv end-file events; returns the unsubscribe. Together with
|
||||
* readMpvProperty this lets playEpisode confirm playback really started
|
||||
* instead of reporting success the moment the commands were written.
|
||||
*/
|
||||
onPlaybackEndFile?: (listener: (event: PlaybackEndFileEvent) => 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 what is about to play. Called *before* `loadfile` so the title
|
||||
* and the episode's identity are already known when mpv reports the path
|
||||
* change — otherwise stats sees only the proxy URL and groups every stream
|
||||
* under its file extension.
|
||||
*/
|
||||
onPlaybackMetadata?: (metadata: AnimeStreamMetadata) => void;
|
||||
/** Lets tests drive the pause between `loadfile` and the track commands. */
|
||||
wait?: (ms: number) => Promise<void>;
|
||||
/** Overrides the filesystem/network the subtitle cache uses. Tests only. */
|
||||
subtitleCacheIo?: SubtitleCacheIo;
|
||||
onBridgeState: (state: AnimeBrowserBridgeState) => void;
|
||||
/**
|
||||
* Streams per-source progress while a search invoke is pending. Optional so
|
||||
* a host that has no window to push to can leave it out.
|
||||
*/
|
||||
onSearchUpdate?: (update: AnimeBrowserSearchUpdate) => void;
|
||||
preferredQuality?: () => string | undefined;
|
||||
log: (message: string) => void;
|
||||
}
|
||||
import type { BridgeAnimePage, BridgePreference } from '../../anime-bridge/types';
|
||||
import { createAnimeBrowserPlayback } from './anime-browser-playback';
|
||||
import type { AnimeBrowserRuntimeDeps } from './anime-browser-runtime-deps';
|
||||
export type { AnimeBrowserRuntimeDeps } from './anime-browser-runtime-deps';
|
||||
|
||||
const IDLE_STATE: AnimeBrowserBridgeState = { stage: 'idle', progress: null, message: null };
|
||||
|
||||
/** How long to let `loadfile` settle before adding external tracks. */
|
||||
const TRACK_ATTACH_DELAY_MS = 300;
|
||||
|
||||
export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
let bridgeState: AnimeBrowserBridgeState = IDLE_STATE;
|
||||
let sidecar: SidecarHandle | null = null;
|
||||
@@ -128,53 +56,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
let loadFailures: ExtensionLoadFailure[] = [];
|
||||
// Monotonic; identifies the newest browse so stale ones stop emitting.
|
||||
let searchToken = 0;
|
||||
// Temp directory holding the playing episode's downloaded subtitles. Kept
|
||||
// until the next episode replaces it, because alass reads it mid-playback.
|
||||
let subtitleCacheDir: string | null = null;
|
||||
const preferenceStore = new PreferenceStore(deps.preferencesFile);
|
||||
const wait =
|
||||
deps.wait ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
||||
|
||||
/**
|
||||
* Drop the previous episode's downloaded subtitles.
|
||||
*
|
||||
* Deliberately not done at end-file: alass reads those files for as long as
|
||||
* the episode is up, so they only go once another one replaces them.
|
||||
*/
|
||||
async function clearSubtitleCache(): Promise<void> {
|
||||
const previousDir = subtitleCacheDir;
|
||||
subtitleCacheDir = null;
|
||||
await removeSubtitleCache(previousDir, deps.subtitleCacheIo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the stream's subtitle tracks so mpv loads files instead of URLs.
|
||||
*
|
||||
* alass needs the reference track on disk, and the subsync picker rejects an
|
||||
* external track whose `external-filename` is not a real file, so a streamed
|
||||
* track cannot be used to retime anything.
|
||||
*/
|
||||
async function cacheStreamSubtitles(stream: {
|
||||
headers: Record<string, string>;
|
||||
subtitles: Array<{ url: string; lang: string }>;
|
||||
}): Promise<Array<{ url: string; lang: string }>> {
|
||||
const cached = await cacheSubtitleTracks({
|
||||
tracks: stream.subtitles,
|
||||
headers: stream.headers,
|
||||
io: deps.subtitleCacheIo,
|
||||
log: deps.log,
|
||||
});
|
||||
subtitleCacheDir = cached.dir;
|
||||
|
||||
const localCount = cached.tracks.filter((track) => track.local).length;
|
||||
if (cached.tracks.length > 0) {
|
||||
deps.log(
|
||||
`[anime-browser] cached ${localCount}/${cached.tracks.length} subtitle track(s) to disk` +
|
||||
(cached.dir ? ` in ${cached.dir}` : ''),
|
||||
);
|
||||
}
|
||||
return cached.tracks.map((track) => ({ url: track.url, lang: track.lang }));
|
||||
}
|
||||
|
||||
function setState(state: AnimeBrowserBridgeState): void {
|
||||
bridgeState = state;
|
||||
@@ -187,8 +69,19 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
const extension = extensions.find((candidate) => candidate.file === source.file);
|
||||
if (!extension) throw new Error(`Extension file missing for ${source.name}.`);
|
||||
// Saved values ride along on every call; the extension is stateless per request.
|
||||
const saved = await preferenceStore.get(source.id);
|
||||
return { ...toBridgeSource(extension, source.id), preferences: saved };
|
||||
const saved = await preferenceStore.get(source.pkg, source.bridgeId);
|
||||
return { ...toBridgeSource(extension, source.bridgeId), preferences: saved };
|
||||
}
|
||||
|
||||
function overlaySavedPreferences(
|
||||
schema: BridgePreference[],
|
||||
saved: BridgePreference[],
|
||||
): BridgePreference[] {
|
||||
let merged = schema;
|
||||
for (const preference of parsePreferences(saved)) {
|
||||
merged = applyPreferenceValue(merged, preference.key, preference.value);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,7 +104,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
);
|
||||
|
||||
setState({ stage: 'starting', progress: null, message: null });
|
||||
const handle = await startSidecar({
|
||||
const handle = await (deps.startSidecar ?? startSidecar)({
|
||||
binaries,
|
||||
onLog: (line) => deps.log(`[anime-bridge] ${line}`),
|
||||
});
|
||||
@@ -239,7 +132,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
});
|
||||
|
||||
try {
|
||||
const proxy = await startStreamStripProxy({
|
||||
const proxy = await (deps.startStreamStripProxy ?? startStreamStripProxy)({
|
||||
upstreamOrigin: () => sidecar?.baseUrl ?? handle.baseUrl,
|
||||
log: deps.log,
|
||||
});
|
||||
@@ -397,6 +290,13 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
};
|
||||
}
|
||||
|
||||
const playback = createAnimeBrowserPlayback({
|
||||
deps,
|
||||
bridge,
|
||||
sourceFor,
|
||||
stripProxy: () => stripProxy,
|
||||
});
|
||||
|
||||
return {
|
||||
getSnapshot(): AnimeBrowserSnapshot {
|
||||
return {
|
||||
@@ -501,8 +401,9 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
async getPreferences(sourceId: string): Promise<SourcePreferenceView[]> {
|
||||
const { client } = await bridge();
|
||||
const source = requireSource(sourceId);
|
||||
const schema = await client.getSourcePreferences(await sourceFor(source.id));
|
||||
return parsePreferences(schema);
|
||||
const bridgeSource = await sourceFor(source.id);
|
||||
const schema = await client.getSourcePreferences(bridgeSource);
|
||||
return parsePreferences(overlaySavedPreferences(schema, bridgeSource.preferences ?? []));
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -515,20 +416,21 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
value: string | string[] | boolean,
|
||||
): Promise<SourcePreferenceView[]> {
|
||||
const { client } = await bridge();
|
||||
const source = await sourceFor(sourceId);
|
||||
const extensionSource = requireSource(sourceId);
|
||||
const source = await sourceFor(extensionSource.id);
|
||||
|
||||
// Start from the extension's own schema so saved values never go stale
|
||||
// against an updated extension.
|
||||
const current =
|
||||
source.preferences && source.preferences.length > 0
|
||||
? source.preferences
|
||||
: await client.getSourcePreferences(source);
|
||||
const schema = await client.getSourcePreferences(source);
|
||||
const current = overlaySavedPreferences(schema, source.preferences ?? []);
|
||||
|
||||
const updated = applyPreferenceValue(current, key, value);
|
||||
await preferenceStore.set(sourceId, updated);
|
||||
await preferenceStore.set(extensionSource.pkg, extensionSource.bridgeId, updated);
|
||||
|
||||
const refreshed = await client.setSourcePreference({ ...source, preferences: updated }, key);
|
||||
if (refreshed.length > 0) await preferenceStore.set(sourceId, refreshed);
|
||||
if (refreshed.length > 0) {
|
||||
await preferenceStore.set(extensionSource.pkg, extensionSource.bridgeId, refreshed);
|
||||
}
|
||||
return parsePreferences(refreshed.length > 0 ? refreshed : updated);
|
||||
},
|
||||
|
||||
@@ -573,127 +475,16 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
}));
|
||||
},
|
||||
|
||||
async playEpisode(request: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> {
|
||||
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)
|
||||
// External tracks come off the same loopback proxy as the video, so
|
||||
// they need the same rebase onto the port the bridge really uses.
|
||||
.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 };
|
||||
}
|
||||
// HLS goes through the local strip proxy, which undoes fake-image
|
||||
// segment disguises mpv cannot decode around.
|
||||
const stream = stripProxy
|
||||
? { ...selected, url: routeHlsThroughProxy(selected.url, baseUrl, stripProxy.origin) }
|
||||
: selected;
|
||||
|
||||
if (!(await deps.ensureMpvConnected())) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'mpv is not running and could not be started.',
|
||||
quality: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Subscribed before loadfile so a fast failure cannot slip past it.
|
||||
const watch =
|
||||
deps.onPlaybackEndFile && deps.readMpvProperty
|
||||
? watchPlaybackOutcome({
|
||||
onEndFile: deps.onPlaybackEndFile,
|
||||
readProperty: deps.readMpvProperty,
|
||||
wait,
|
||||
})
|
||||
: 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;
|
||||
// Before loadfile: mpv's path change is what starts a stats session,
|
||||
// and it must find this already recorded.
|
||||
deps.onPlaybackMetadata?.(metadata);
|
||||
for (const command of buildPlaybackCommands({ stream, title })) {
|
||||
deps.sendMpvCommand(command);
|
||||
}
|
||||
// The old episode's subtitles are dead the moment this one loads,
|
||||
// whether or not the new one brings any of its own.
|
||||
await clearSubtitleCache();
|
||||
|
||||
if (stream.audios.length > 0 || stream.subtitles.length > 0) {
|
||||
deps.log(
|
||||
`[anime-browser] ${stream.audios.length} external audio, ` +
|
||||
`${stream.subtitles.length} external subtitle track(s)`,
|
||||
);
|
||||
// mpv attaches added tracks to the file that is loading, so give the
|
||||
// loadfile a moment to take effect first. Same pause the Jellyfin
|
||||
// subtitle preload uses. The download runs inside that pause.
|
||||
const [subtitles] = await Promise.all([
|
||||
cacheStreamSubtitles(stream),
|
||||
wait(TRACK_ATTACH_DELAY_MS),
|
||||
]);
|
||||
for (const command of buildTrackCommands({ ...stream, subtitles })) {
|
||||
deps.sendMpvCommand(command);
|
||||
}
|
||||
}
|
||||
|
||||
if (watch) {
|
||||
const outcome = await watch.wait();
|
||||
if (!outcome.ok) {
|
||||
deps.log(`[anime-browser] playback failed to start: ${outcome.error}`);
|
||||
return { ok: false, error: outcome.error, quality: null };
|
||||
}
|
||||
}
|
||||
|
||||
deps.showVisibleOverlay?.();
|
||||
deps.showMpvOsd?.(title);
|
||||
return { ok: true, error: null, quality: stream.quality || null };
|
||||
} finally {
|
||||
watch?.dispose();
|
||||
}
|
||||
} catch (error) {
|
||||
deps.log(`[anime-browser] playback failed: ${String(error)}`);
|
||||
return { ok: false, error: describeError(error), quality: null };
|
||||
}
|
||||
},
|
||||
playEpisode: playback.playEpisode,
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
const handle = sidecar;
|
||||
const proxy = stripProxy;
|
||||
const cacheDir = subtitleCacheDir;
|
||||
sidecar = null;
|
||||
stripProxy = null;
|
||||
starting = null;
|
||||
subtitleCacheDir = null;
|
||||
setState(IDLE_STATE);
|
||||
await removeSubtitleCache(cacheDir, deps.subtitleCacheIo);
|
||||
await playback.dispose();
|
||||
await proxy?.close();
|
||||
await handle?.stop();
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStreamPlaybackMetadataStore,
|
||||
matchRequestedStreamPlaybackMetadata,
|
||||
toAnilistMediaGuess,
|
||||
toJimakuMediaInfo,
|
||||
} from './stream-playback-metadata';
|
||||
@@ -42,6 +43,18 @@ test('the store stops answering once the player moves on', () => {
|
||||
assert.equal(store.match(metadata().mediaPath), null);
|
||||
});
|
||||
|
||||
test('an explicit target path does not inherit the current stream metadata', () => {
|
||||
const store = createStreamPlaybackMetadataStore();
|
||||
const current = metadata();
|
||||
store.set(current);
|
||||
|
||||
assert.equal(
|
||||
matchRequestedStreamPlaybackMetadata(store, '/home/user/Videos/other.mkv', current.mediaPath),
|
||||
null,
|
||||
);
|
||||
assert.equal(matchRequestedStreamPlaybackMetadata(store, null, current.mediaPath), current);
|
||||
});
|
||||
|
||||
test('toJimakuMediaInfo prefills the modals with the source-reported fields', () => {
|
||||
assert.deepEqual(toJimakuMediaInfo(metadata()), {
|
||||
title: 'Mushoku Tensei: Jobless Reincarnation',
|
||||
@@ -60,6 +73,12 @@ test('toJimakuMediaInfo drops to low confidence when there is no episode', () =>
|
||||
assert.equal(info.title, 'Mushoku Tensei: Jobless Reincarnation');
|
||||
});
|
||||
|
||||
test('toJimakuMediaInfo reports low confidence when a fractional episode is omitted', () => {
|
||||
const info = toJimakuMediaInfo(metadata({ episodeNumber: 6.5 }));
|
||||
assert.equal(info.episode, null);
|
||||
assert.equal(info.confidence, 'low');
|
||||
});
|
||||
|
||||
test('toAnilistMediaGuess reports the stream fields verbatim', () => {
|
||||
assert.deepEqual(toAnilistMediaGuess(metadata()), {
|
||||
title: 'Mushoku Tensei: Jobless Reincarnation',
|
||||
|
||||
@@ -41,6 +41,18 @@ export function createStreamPlaybackMetadataStore(): StreamPlaybackMetadataStore
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Match metadata for the path a caller requested, falling back to the active
|
||||
* player path only when the caller has no path of its own.
|
||||
*/
|
||||
export function matchRequestedStreamPlaybackMetadata(
|
||||
store: StreamPlaybackMetadataStore,
|
||||
requestedMediaPath: string | null,
|
||||
currentMediaPath: string | null,
|
||||
): AnimeStreamMetadata | null {
|
||||
return store.match(requestedMediaPath ?? currentMediaPath);
|
||||
}
|
||||
|
||||
/** AniList counts whole episodes, so a special numbered 6.5 cannot drive it. */
|
||||
function wholeEpisode(episode: number | null): number | null {
|
||||
return typeof episode === 'number' && Number.isInteger(episode) && episode > 0 ? episode : null;
|
||||
@@ -52,11 +64,12 @@ function wholeEpisode(episode: number | null): number | null {
|
||||
* modals may search on them without waiting for the user to confirm.
|
||||
*/
|
||||
export function toJimakuMediaInfo(metadata: AnimeStreamMetadata): JimakuMediaInfo {
|
||||
const episode = wholeEpisode(metadata.episodeNumber);
|
||||
return {
|
||||
title: metadata.seriesTitle,
|
||||
season: metadata.seasonNumber,
|
||||
episode: wholeEpisode(metadata.episodeNumber),
|
||||
confidence: metadata.episodeNumber !== null ? 'high' : 'low',
|
||||
episode,
|
||||
confidence: episode !== null ? 'high' : 'low',
|
||||
filename: metadata.displayTitle,
|
||||
rawTitle: metadata.displayTitle,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user