feat(anime): add anime browser powered by Aniyomi extensions

- Add `subminer anime` / `--anime` and a tray entry to open a browser that searches installed Aniyomi extension sources, shows cover art and episodes, and plays into mpv with overlay/mining attached
- Add an Extensions tab to add repos and install/update/remove sources, and per-source settings for sources needing config
- Support searching all sources at once with streaming, per-source results and status
- Prefer Japanese audio/subtitle tracks from the source and keep the primary subtitle slot reserved for Japanese
- Fix window/tray/Dock handling so the browser and mpv can be switched between without quitting the app or losing the Dock icon
- Add anime.repos, anime.extensionsDir, anime.preferredQuality config keys (no bundled repos or discovery)
This commit is contained in:
2026-07-31 17:16:49 -07:00
parent b204d4dd6e
commit e64ff1a0ee
117 changed files with 7565 additions and 529 deletions
+214
View File
@@ -0,0 +1,214 @@
import { BRIDGE_CONTEXT_KEY } from './types';
import type {
BridgeAnime,
BridgeAnimePage,
BridgeCapabilities,
BridgeEpisode,
BridgePreference,
BridgeSourceDescriptor,
BridgeVideo,
} from './types';
const EXTENSION_ID_HEADER = 'x-mangatan-extension-id';
const EXTENSION_ID_PATTERN = /^[0-9a-f]{64}$/;
export interface BridgeSource {
/** Base64-encoded extension APK. */
apkBase64: string;
/** Selects one source inside a multi-source (SourceFactory) APK. */
sourceId?: string;
preferences?: BridgePreference[];
}
export interface BridgeClientOptions {
/** Loopback base URL of the running bridge, e.g. `http://127.0.0.1:53112`. */
baseUrl: string;
fetchImpl?: typeof fetch;
}
/** The bridge reports extension failures as HTTP 200 with an error body. */
export class BridgeExtensionError extends Error {
readonly code?: number;
constructor(message: string, code?: number) {
super(message);
this.name = 'BridgeExtensionError';
this.code = code;
}
}
/**
* Client for the M-Extension-Server `/dalvik` RPC endpoint.
*
* The server caches uploaded APKs and returns a content hash, letting
* subsequent calls send that id instead of re-uploading megabytes of base64.
* A 409 means the cache was evicted, so the APK is resent once.
*/
export class AnimeBridgeClient {
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly extensionIds = new Map<string, string>();
constructor(options: BridgeClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
this.fetchImpl = options.fetchImpl ?? fetch;
}
async getCapabilities(): Promise<BridgeCapabilities> {
const response = await this.fetchImpl(`${this.baseUrl}/capabilities`);
if (!response.ok) {
throw new Error(`Anime bridge capabilities check failed (${response.status}).`);
}
return (await response.json()) as BridgeCapabilities;
}
/** True once the bridge is up and reports the features this client needs. */
async isReady(): Promise<boolean> {
try {
const capabilities = await this.getCapabilities();
return (
capabilities.mangatanMihonBridge === 1 &&
capabilities.sourceFactory === true &&
capabilities.preferenceCallbacks === true
);
} catch {
return false;
}
}
async searchAnime(
source: BridgeSource,
query: string,
page = 1,
filterList: unknown[] = [],
): Promise<BridgeAnimePage> {
return this.call<BridgeAnimePage>(source, 'getSearchAnime', {
page,
search: query,
filterList,
});
}
/**
* List the sources an extension APK provides. A single APK may expose many
* (a SourceFactory), so this is how a package becomes selectable entries.
*/
async listAnimeSources(source: BridgeSource): Promise<BridgeSourceDescriptor[]> {
return this.call<BridgeSourceDescriptor[]>(source, 'sourcesAnime', {});
}
/** The extension's own settings schema, with current values. */
async getSourcePreferences(source: BridgeSource): Promise<BridgePreference[]> {
return this.call<BridgePreference[]>(source, 'preferencesAnime', {});
}
/**
* Commit a preference change. The whole array is sent back with the edited
* entry, and `changedPreferenceKey` tells the extension which one moved so it
* can react (the Jellyfin source logs in when the address or password lands).
* Returns the extension's refreshed schema.
*/
async setSourcePreference(
source: BridgeSource,
changedPreferenceKey: string,
): Promise<BridgePreference[]> {
return this.call<BridgePreference[]>(source, 'setPreferenceAnime', {}, changedPreferenceKey);
}
/** Full metadata for one anime: description, cover art, genres, status. */
async getAnimeDetails(source: BridgeSource, animeUrl: string): Promise<BridgeAnime> {
return this.call<BridgeAnime>(source, 'getDetailsAnime', {
animeData: { url: animeUrl },
});
}
async getPopularAnime(source: BridgeSource, page = 1): Promise<BridgeAnimePage> {
return this.call<BridgeAnimePage>(source, 'getPopularAnime', { page });
}
async getEpisodeList(source: BridgeSource, animeUrl: string): Promise<BridgeEpisode[]> {
return this.call<BridgeEpisode[]>(source, 'getEpisodeList', {
animeData: { url: animeUrl },
});
}
async getVideoList(source: BridgeSource, episodeUrl: string): Promise<BridgeVideo[]> {
return this.call<BridgeVideo[]>(source, 'getVideoList', {
episodeData: { url: episodeUrl },
});
}
private buildPreferences(
source: BridgeSource,
changedPreferenceKey?: string,
): BridgePreference[] {
const context: BridgePreference = { key: BRIDGE_CONTEXT_KEY };
if (source.sourceId !== undefined) context.sourceId = source.sourceId;
if (changedPreferenceKey !== undefined) context.changedPreferenceKey = changedPreferenceKey;
return [...(source.preferences ?? []), context];
}
private async call<T>(
source: BridgeSource,
method: string,
extras: Record<string, unknown>,
changedPreferenceKey?: string,
): Promise<T> {
const cacheKey = source.sourceId ?? source.apkBase64;
const cachedId = this.extensionIds.get(cacheKey);
let response = await this.post(method, extras, source, cachedId, changedPreferenceKey);
if (response.status === 409 && cachedId !== undefined) {
// Server evicted the cached APK; upload it again.
this.extensionIds.delete(cacheKey);
response = await this.post(method, extras, source, undefined, changedPreferenceKey);
}
if (!response.ok) {
throw new Error(`Anime bridge ${method} failed (${response.status}).`);
}
const returnedId = response.headers.get(EXTENSION_ID_HEADER)?.trim();
if (returnedId && EXTENSION_ID_PATTERN.test(returnedId)) {
this.extensionIds.set(cacheKey, returnedId);
}
const body = (await response.json()) as T;
assertNoExtensionError(body, method);
return body;
}
private post(
method: string,
extras: Record<string, unknown>,
source: BridgeSource,
extensionId: string | undefined,
changedPreferenceKey?: string,
): Promise<Response> {
const payload: Record<string, unknown> = {
method,
...extras,
preferences: this.buildPreferences(source, changedPreferenceKey),
...(extensionId === undefined ? { data: source.apkBase64 } : { extensionId }),
};
return this.fetchImpl(`${this.baseUrl}/dalvik`, {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
Accept: 'application/json',
},
body: JSON.stringify(payload),
});
}
}
function assertNoExtensionError(body: unknown, method: string): void {
if (body === null || typeof body !== 'object' || Array.isArray(body)) return;
const error = (body as { error?: unknown }).error;
if (typeof error !== 'string') return;
const code = (body as { code?: unknown }).code;
throw new BridgeExtensionError(
`Anime bridge ${method} failed: ${error}`,
typeof code === 'number' ? code : undefined,
);
}