mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-02 19:21:34 -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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user