mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-01 19:21:34 -07:00
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:
@@ -0,0 +1,148 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { AnimeBridgeClient, BridgeExtensionError } from './bridge-client';
|
||||
import { BRIDGE_CONTEXT_KEY } from './types';
|
||||
|
||||
const EXTENSION_ID = 'a'.repeat(64);
|
||||
const source = { apkBase64: 'QVBLLUJZVEVT', sourceId: 'source-1' };
|
||||
|
||||
interface Recorded {
|
||||
url: string;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stubFetch(responder: (call: Recorded, index: number) => Response): {
|
||||
fetchImpl: typeof fetch;
|
||||
calls: Recorded[];
|
||||
} {
|
||||
const calls: Recorded[] = [];
|
||||
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const call: Recorded = {
|
||||
url: String(input),
|
||||
body: init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {},
|
||||
};
|
||||
calls.push(call);
|
||||
return responder(call, calls.length - 1);
|
||||
}) as typeof fetch;
|
||||
return { fetchImpl, calls };
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, extensionId?: string): Response {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' });
|
||||
if (extensionId) headers.set('x-mangatan-extension-id', extensionId);
|
||||
return new Response(JSON.stringify(body), { status: 200, headers });
|
||||
}
|
||||
|
||||
test('isReady requires every capability the client depends on', async () => {
|
||||
const ready = new AnimeBridgeClient({
|
||||
baseUrl: 'http://127.0.0.1:9',
|
||||
fetchImpl: stubFetch(() =>
|
||||
jsonResponse({ mangatanMihonBridge: 1, sourceFactory: true, preferenceCallbacks: true }),
|
||||
).fetchImpl,
|
||||
});
|
||||
assert.equal(await ready.isReady(), true);
|
||||
|
||||
const partial = new AnimeBridgeClient({
|
||||
baseUrl: 'http://127.0.0.1:9',
|
||||
fetchImpl: stubFetch(() => jsonResponse({ mangatanMihonBridge: 1, sourceFactory: true }))
|
||||
.fetchImpl,
|
||||
});
|
||||
assert.equal(await partial.isReady(), false);
|
||||
});
|
||||
|
||||
test('isReady reports false instead of throwing when the bridge is down', async () => {
|
||||
const client = new AnimeBridgeClient({
|
||||
baseUrl: 'http://127.0.0.1:9',
|
||||
fetchImpl: (async () => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
}) as typeof fetch,
|
||||
});
|
||||
assert.equal(await client.isReady(), false);
|
||||
});
|
||||
|
||||
test('getVideoList posts the APK and episode url with a bridge context preference', async () => {
|
||||
const { fetchImpl, calls } = stubFetch(() => jsonResponse([{ videoUrl: 'http://x/video/t' }]));
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9/', fetchImpl });
|
||||
|
||||
const videos = await client.getVideoList(source, 'https://origin.example/ep/1');
|
||||
|
||||
assert.equal(calls[0]?.url, 'http://127.0.0.1:9/dalvik');
|
||||
assert.equal(calls[0]?.body.method, 'getVideoList');
|
||||
assert.deepEqual(calls[0]?.body.episodeData, { url: 'https://origin.example/ep/1' });
|
||||
assert.equal(calls[0]?.body.data, source.apkBase64);
|
||||
assert.deepEqual(calls[0]?.body.preferences, [{ key: BRIDGE_CONTEXT_KEY, sourceId: 'source-1' }]);
|
||||
assert.equal(videos.length, 1);
|
||||
});
|
||||
|
||||
test('a cached extension id replaces the APK upload on later calls', async () => {
|
||||
const { fetchImpl, calls } = stubFetch(() => jsonResponse([], EXTENSION_ID));
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
|
||||
await client.getVideoList(source, 'https://origin.example/ep/1');
|
||||
await client.getVideoList(source, 'https://origin.example/ep/2');
|
||||
|
||||
assert.equal(calls[0]?.body.data, source.apkBase64);
|
||||
assert.equal(calls[0]?.body.extensionId, undefined);
|
||||
assert.equal(calls[1]?.body.data, undefined);
|
||||
assert.equal(calls[1]?.body.extensionId, EXTENSION_ID);
|
||||
});
|
||||
|
||||
test('a 409 re-uploads the APK once and succeeds', async () => {
|
||||
const { fetchImpl, calls } = stubFetch((call, index) => {
|
||||
if (index === 0) return jsonResponse([], EXTENSION_ID);
|
||||
// Cache evicted: reject the id-only call, accept the re-upload.
|
||||
if (call.body.extensionId !== undefined) return new Response('', { status: 409 });
|
||||
return jsonResponse([{ videoUrl: 'http://x/video/t' }], EXTENSION_ID);
|
||||
});
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
|
||||
await client.getVideoList(source, 'https://origin.example/ep/1');
|
||||
const videos = await client.getVideoList(source, 'https://origin.example/ep/2');
|
||||
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(calls[1]?.body.extensionId, EXTENSION_ID);
|
||||
assert.equal(calls[2]?.body.data, source.apkBase64);
|
||||
assert.equal(videos.length, 1);
|
||||
});
|
||||
|
||||
test('an error body on a 200 response raises BridgeExtensionError with the code', async () => {
|
||||
const { fetchImpl } = stubFetch(() => jsonResponse({ error: 'Cloudflare challenge', code: 403 }));
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
|
||||
await assert.rejects(
|
||||
() => client.getVideoList(source, 'https://origin.example/ep/1'),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof BridgeExtensionError);
|
||||
assert.equal(error.code, 403);
|
||||
assert.match(error.message, /Cloudflare challenge/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('searchAnime sends a 1-based page and returns the page payload', async () => {
|
||||
const { fetchImpl, calls } = stubFetch(() =>
|
||||
jsonResponse({ animes: [{ title: 'Example' }], hasNextPage: true }),
|
||||
);
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
|
||||
const page = await client.searchAnime(source, 'example');
|
||||
|
||||
assert.equal(calls[0]?.body.method, 'getSearchAnime');
|
||||
assert.equal(calls[0]?.body.page, 1);
|
||||
assert.equal(calls[0]?.body.search, 'example');
|
||||
assert.deepEqual(calls[0]?.body.filterList, []);
|
||||
assert.equal(page.hasNextPage, true);
|
||||
assert.equal(page.animes?.length, 1);
|
||||
});
|
||||
|
||||
test('getEpisodeList wraps the anime url in animeData', async () => {
|
||||
const { fetchImpl, calls } = stubFetch(() => jsonResponse([{ name: 'Episode 1', url: '/ep/1' }]));
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
|
||||
const episodes = await client.getEpisodeList(source, 'https://origin.example/anime/1');
|
||||
|
||||
assert.equal(calls[0]?.body.method, 'getEpisodeList');
|
||||
assert.deepEqual(calls[0]?.body.animeData, { url: 'https://origin.example/anime/1' });
|
||||
assert.equal(episodes[0]?.name, 'Episode 1');
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { installExtension, looksLikeApk, removeExtension } from './extension-installer';
|
||||
import type { RepoExtension } from './extension-repo';
|
||||
|
||||
const PKG = 'eu.kanade.tachiyomi.animeextension.all.example';
|
||||
|
||||
function apkBytes(payload = 'APK-BODY'): Uint8Array {
|
||||
// APKs are zip archives, so they start with the PK local-file-header magic.
|
||||
return new Uint8Array([0x50, 0x4b, 0x03, 0x04, ...new TextEncoder().encode(payload)]);
|
||||
}
|
||||
|
||||
function repoExtension(overrides: Partial<RepoExtension> = {}): RepoExtension {
|
||||
return {
|
||||
pkg: PKG,
|
||||
name: 'Example Source',
|
||||
lang: 'all',
|
||||
version: '1.2.3',
|
||||
versionCode: 12,
|
||||
nsfw: false,
|
||||
apkUrl: 'https://repo.example/anime/apk/example.apk',
|
||||
iconUrl: 'https://repo.example/anime/icon/example.png',
|
||||
repoUrl: 'https://repo.example/anime/index.min.json',
|
||||
sourceNames: ['Example'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function respondWith(bytes: Uint8Array, headers: Record<string, string> = {}): typeof fetch {
|
||||
// Uint8Array is a valid Response body at runtime; the DOM lib types disagree.
|
||||
const body = bytes as unknown as BodyInit;
|
||||
return (async () => new Response(body, { status: 200, headers })) as typeof fetch;
|
||||
}
|
||||
|
||||
test('looksLikeApk accepts the zip magic and rejects anything else', () => {
|
||||
assert.equal(looksLikeApk(apkBytes()), true);
|
||||
assert.equal(looksLikeApk(new TextEncoder().encode('<!DOCTYPE html>')), false);
|
||||
assert.equal(looksLikeApk(new Uint8Array([])), false);
|
||||
});
|
||||
|
||||
test('installExtension writes the apk named after its package', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const target = await installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension(),
|
||||
fetchImpl: respondWith(apkBytes()),
|
||||
});
|
||||
|
||||
assert.equal(target, path.join(dir, `${PKG}.apk`));
|
||||
assert.match((await readFile(target)).toString(), /APK-BODY/);
|
||||
});
|
||||
|
||||
test('installing again replaces the previous version in place', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
await installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension(),
|
||||
fetchImpl: respondWith(apkBytes('OLD')),
|
||||
});
|
||||
await installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension({ version: '2.0.0', versionCode: 20 }),
|
||||
fetchImpl: respondWith(apkBytes('NEW')),
|
||||
});
|
||||
|
||||
const contents = (await readFile(path.join(dir, `${PKG}.apk`))).toString();
|
||||
assert.match(contents, /NEW/);
|
||||
assert.doesNotMatch(contents, /OLD/);
|
||||
});
|
||||
|
||||
test('the extensions directory is created when missing', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const nested = path.join(root, 'does', 'not', 'exist');
|
||||
await installExtension({
|
||||
extensionsDir: nested,
|
||||
extension: repoExtension(),
|
||||
fetchImpl: respondWith(apkBytes()),
|
||||
});
|
||||
assert.equal(existsSync(path.join(nested, `${PKG}.apk`)), true);
|
||||
});
|
||||
|
||||
test('a non-ok response is reported with the extension name', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const fetchImpl = (async () => new Response('', { status: 404 })) as typeof fetch;
|
||||
|
||||
await assert.rejects(
|
||||
() => installExtension({ extensionsDir: dir, extension: repoExtension(), fetchImpl }),
|
||||
/Example Source.*404/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a response that is not an apk is rejected rather than written', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
// A misconfigured repo commonly serves an HTML error page instead.
|
||||
const fetchImpl = respondWith(new TextEncoder().encode('<!DOCTYPE html><html>404</html>'));
|
||||
|
||||
await assert.rejects(
|
||||
() => installExtension({ extensionsDir: dir, extension: repoExtension(), fetchImpl }),
|
||||
/did not download as an APK/,
|
||||
);
|
||||
assert.equal(existsSync(path.join(dir, `${PKG}.apk`)), false);
|
||||
});
|
||||
|
||||
test('an oversized download is refused by the declared length', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const fetchImpl = respondWith(apkBytes(), { 'content-length': '999999999' });
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension(),
|
||||
fetchImpl,
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
/larger than the 1024 byte limit/,
|
||||
);
|
||||
});
|
||||
|
||||
test('an oversized download is refused even when the length header lies', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const fetchImpl = respondWith(apkBytes('x'.repeat(4096)), { 'content-length': '10' });
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension(),
|
||||
fetchImpl,
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
/larger than the 1024 byte limit/,
|
||||
);
|
||||
assert.equal(existsSync(path.join(dir, `${PKG}.apk`)), false);
|
||||
});
|
||||
|
||||
test('removeExtension deletes the file and tolerates a missing one', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const file = path.join(dir, `${PKG}.apk`);
|
||||
await writeFile(file, 'x');
|
||||
|
||||
await removeExtension(dir, PKG);
|
||||
assert.equal(existsSync(file), false);
|
||||
await removeExtension(dir, PKG);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { extensionFileName, type RepoExtension } from './extension-repo';
|
||||
|
||||
/**
|
||||
* Downloads extension APKs into the extensions directory.
|
||||
*
|
||||
* Only URLs that came from a repository index the user configured are ever
|
||||
* fetched; nothing here discovers or suggests sources.
|
||||
*/
|
||||
|
||||
export interface InstallExtensionOptions {
|
||||
extensionsDir: string;
|
||||
extension: RepoExtension;
|
||||
fetchImpl?: typeof fetch;
|
||||
/** Guards against a mistyped repo serving something enormous. */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
/** APKs are a few MB; anything far past that is not an extension. */
|
||||
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
const APK_MAGIC = [0x50, 0x4b, 0x03, 0x04]; // "PK\x03\x04" — APKs are zip archives.
|
||||
|
||||
export function looksLikeApk(bytes: Uint8Array): boolean {
|
||||
return APK_MAGIC.every((byte, index) => bytes[index] === byte);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download one extension into `extensionsDir`, replacing any previous version.
|
||||
*
|
||||
* The file is named after the package so an update overwrites in place rather
|
||||
* than leaving two versions for the bridge to load.
|
||||
*/
|
||||
export async function installExtension(options: InstallExtensionOptions): Promise<string> {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
|
||||
const response = await fetchImpl(options.extension.apkUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Downloading ${options.extension.name} failed (${response.status}).`);
|
||||
}
|
||||
|
||||
const declared = Number(response.headers.get('content-length') ?? '0');
|
||||
if (declared > maxBytes) {
|
||||
throw new Error(`${options.extension.name} is larger than the ${maxBytes} byte limit.`);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (bytes.byteLength > maxBytes) {
|
||||
throw new Error(`${options.extension.name} is larger than the ${maxBytes} byte limit.`);
|
||||
}
|
||||
if (!looksLikeApk(bytes)) {
|
||||
throw new Error(`${options.extension.name} did not download as an APK.`);
|
||||
}
|
||||
|
||||
await mkdir(options.extensionsDir, { recursive: true });
|
||||
const target = path.join(options.extensionsDir, extensionFileName(options.extension.pkg));
|
||||
await writeFile(target, bytes);
|
||||
return target;
|
||||
}
|
||||
|
||||
/** Delete an installed extension. Missing files are treated as already gone. */
|
||||
export async function removeExtension(extensionsDir: string, pkg: string): Promise<void> {
|
||||
await rm(path.join(extensionsDir, extensionFileName(pkg)), { force: true });
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
extensionFileName,
|
||||
fetchRepoCatalogue,
|
||||
fetchRepoIndex,
|
||||
isValidRepoUrl,
|
||||
parseRepoIndex,
|
||||
repoBaseUrl,
|
||||
} from './extension-repo';
|
||||
|
||||
const INDEX = 'https://repo.example/anime/index.min.json';
|
||||
|
||||
function animeEntry(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
name: 'Aniyomi: Example Source',
|
||||
pkg: 'eu.kanade.tachiyomi.animeextension.all.example',
|
||||
apk: 'example-v1.2.3.apk',
|
||||
lang: 'all',
|
||||
code: 12,
|
||||
version: '1.2.3',
|
||||
nsfw: 0,
|
||||
sources: [{ name: 'Example', lang: 'en' }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('any https url naming a json index is accepted', () => {
|
||||
assert.equal(isValidRepoUrl(INDEX), true);
|
||||
assert.equal(isValidRepoUrl(' ' + INDEX + ' '), true);
|
||||
// Repos are free to name the index; index.min.json is only a convention.
|
||||
assert.equal(isValidRepoUrl('https://repo.example/anime/index.json'), true);
|
||||
assert.equal(
|
||||
isValidRepoUrl('https://manatan-community.github.io/extensions/video.min.json'),
|
||||
true,
|
||||
);
|
||||
// Plain http would let a network attacker swap the APK list.
|
||||
assert.equal(isValidRepoUrl('http://repo.example/anime/index.min.json'), false);
|
||||
assert.equal(isValidRepoUrl('https://repo.example/anime/'), false);
|
||||
assert.equal(isValidRepoUrl('https://repo.example/index.min.json.txt'), false);
|
||||
assert.equal(isValidRepoUrl('https://repo.example'), false);
|
||||
assert.equal(isValidRepoUrl(''), false);
|
||||
});
|
||||
|
||||
test('repoBaseUrl strips the index file name', () => {
|
||||
assert.equal(repoBaseUrl(INDEX), 'https://repo.example/anime');
|
||||
assert.equal(
|
||||
repoBaseUrl('https://manatan-community.github.io/extensions/video.min.json'),
|
||||
'https://manatan-community.github.io/extensions',
|
||||
);
|
||||
});
|
||||
|
||||
test('parseRepoIndex builds apk and icon urls from the repo root', () => {
|
||||
const [extension] = parseRepoIndex(INDEX, [animeEntry()]);
|
||||
|
||||
assert.equal(extension?.pkg, 'eu.kanade.tachiyomi.animeextension.all.example');
|
||||
assert.equal(extension?.apkUrl, 'https://repo.example/anime/apk/example-v1.2.3.apk');
|
||||
assert.equal(
|
||||
extension?.iconUrl,
|
||||
'https://repo.example/anime/icon/eu.kanade.tachiyomi.animeextension.all.example.png',
|
||||
);
|
||||
assert.equal(extension?.repoUrl, INDEX);
|
||||
assert.equal(extension?.versionCode, 12);
|
||||
assert.deepEqual(extension?.sourceNames, ['Example']);
|
||||
});
|
||||
|
||||
test('the Aniyomi name prefix is stripped', () => {
|
||||
const [extension] = parseRepoIndex(INDEX, [animeEntry()]);
|
||||
assert.equal(extension?.name, 'Example Source');
|
||||
});
|
||||
|
||||
test('manga packages are excluded', () => {
|
||||
const entries = [animeEntry(), animeEntry({ pkg: 'eu.kanade.tachiyomi.extension.en.somemanga' })];
|
||||
const parsed = parseRepoIndex(INDEX, entries);
|
||||
assert.equal(parsed.length, 1);
|
||||
assert.match(parsed[0]!.pkg, /animeextension/);
|
||||
});
|
||||
|
||||
test('malformed entries are skipped rather than failing the repo', () => {
|
||||
const parsed = parseRepoIndex(INDEX, [
|
||||
null,
|
||||
'nonsense',
|
||||
animeEntry({ apk: undefined }),
|
||||
animeEntry({ pkg: undefined }),
|
||||
animeEntry(),
|
||||
]);
|
||||
assert.equal(parsed.length, 1);
|
||||
});
|
||||
|
||||
test('parseRepoIndex tolerates a non-array payload', () => {
|
||||
assert.deepEqual(parseRepoIndex(INDEX, { message: 'Not Found' }), []);
|
||||
assert.deepEqual(parseRepoIndex(INDEX, null), []);
|
||||
});
|
||||
|
||||
test('missing optional fields fall back to safe defaults', () => {
|
||||
const [extension] = parseRepoIndex(INDEX, [
|
||||
{ pkg: 'eu.kanade.tachiyomi.animeextension.all.bare', apk: 'bare.apk' },
|
||||
]);
|
||||
assert.equal(extension?.name, 'eu.kanade.tachiyomi.animeextension.all.bare');
|
||||
assert.equal(extension?.lang, 'all');
|
||||
assert.equal(extension?.versionCode, 0);
|
||||
assert.equal(extension?.nsfw, false);
|
||||
assert.deepEqual(extension?.sourceNames, []);
|
||||
});
|
||||
|
||||
test('nsfw is read from the numeric flag', () => {
|
||||
assert.equal(parseRepoIndex(INDEX, [animeEntry({ nsfw: 1 })])[0]?.nsfw, true);
|
||||
assert.equal(parseRepoIndex(INDEX, [animeEntry({ nsfw: 0 })])[0]?.nsfw, false);
|
||||
});
|
||||
|
||||
test('fetchRepoIndex rejects an invalid url before making a request', async () => {
|
||||
let called = false;
|
||||
const fetchImpl = (async () => {
|
||||
called = true;
|
||||
return new Response('[]');
|
||||
}) as typeof fetch;
|
||||
|
||||
await assert.rejects(() => fetchRepoIndex('http://insecure/index.min.json', { fetchImpl }));
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test('fetchRepoIndex surfaces a non-ok response', async () => {
|
||||
const fetchImpl = (async () => new Response('', { status: 404 })) as typeof fetch;
|
||||
await assert.rejects(() => fetchRepoIndex(INDEX, { fetchImpl }), /404/);
|
||||
});
|
||||
|
||||
test('fetchRepoCatalogue merges repos and keeps the highest version code', async () => {
|
||||
const second = 'https://other.example/anime/index.min.json';
|
||||
const fetchImpl = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === INDEX) {
|
||||
return new Response(JSON.stringify([animeEntry({ code: 12, version: '1.2.3' })]));
|
||||
}
|
||||
return new Response(JSON.stringify([animeEntry({ code: 20, version: '2.0.0' })]));
|
||||
}) as typeof fetch;
|
||||
|
||||
const catalogue = await fetchRepoCatalogue([INDEX, second], { fetchImpl });
|
||||
|
||||
assert.equal(catalogue.extensions.length, 1);
|
||||
assert.equal(catalogue.extensions[0]?.versionCode, 20);
|
||||
assert.equal(catalogue.extensions[0]?.repoUrl, second);
|
||||
assert.deepEqual(catalogue.failures, []);
|
||||
});
|
||||
|
||||
test('one failing repo does not hide the others', async () => {
|
||||
const broken = 'https://broken.example/anime/index.min.json';
|
||||
const fetchImpl = (async (input: RequestInfo | URL) => {
|
||||
if (String(input) === broken) throw new Error('ENOTFOUND');
|
||||
return new Response(JSON.stringify([animeEntry()]));
|
||||
}) as typeof fetch;
|
||||
|
||||
const catalogue = await fetchRepoCatalogue([broken, INDEX], { fetchImpl });
|
||||
|
||||
assert.equal(catalogue.extensions.length, 1);
|
||||
assert.equal(catalogue.failures.length, 1);
|
||||
assert.equal(catalogue.failures[0]?.repoUrl, broken);
|
||||
assert.match(catalogue.failures[0]?.error ?? '', /ENOTFOUND/);
|
||||
});
|
||||
|
||||
test('an empty repo list yields an empty catalogue without any request', async () => {
|
||||
let called = false;
|
||||
const fetchImpl = (async () => {
|
||||
called = true;
|
||||
return new Response('[]');
|
||||
}) as typeof fetch;
|
||||
|
||||
const catalogue = await fetchRepoCatalogue([], { fetchImpl });
|
||||
assert.deepEqual(catalogue, { extensions: [], failures: [] });
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test('extensions are stored under their package name so updates replace in place', () => {
|
||||
assert.equal(
|
||||
extensionFileName('eu.kanade.tachiyomi.animeextension.all.example'),
|
||||
'eu.kanade.tachiyomi.animeextension.all.example.apk',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Client for Aniyomi-format extension repositories.
|
||||
*
|
||||
* SubMiner ships no repositories and performs no discovery. A repository only
|
||||
* exists once the user adds its index URL, and only extensions from those
|
||||
* repositories are ever listed or downloaded.
|
||||
*/
|
||||
|
||||
/** Aniyomi extension packages carry this prefix; manga packages are ignored. */
|
||||
const ANIME_PACKAGE_PREFIX = 'eu.kanade.tachiyomi.animeextension';
|
||||
|
||||
/**
|
||||
* Repos are identified by their index URL. The file name is not fixed:
|
||||
* `index.min.json` is the Aniyomi convention, but repositories publish under
|
||||
* other names too (e.g. `video.min.json`), so only https and a `.json` file
|
||||
* name are required.
|
||||
*/
|
||||
const INDEX_URL_PATTERN = /^https:\/\/[^\s/]+(?:\/[^\s/]*)*\/[^\s/]+\.json$/;
|
||||
|
||||
export interface RepoExtension {
|
||||
/** Package name, the stable identity of an extension across versions. */
|
||||
pkg: string;
|
||||
name: string;
|
||||
lang: string;
|
||||
version: string;
|
||||
/** Monotonic version code; the comparison basis for updates. */
|
||||
versionCode: number;
|
||||
nsfw: boolean;
|
||||
apkUrl: string;
|
||||
iconUrl: string;
|
||||
/** Index URL of the repo this came from. */
|
||||
repoUrl: string;
|
||||
/** Source names the package provides, when the index declares them. */
|
||||
sourceNames: string[];
|
||||
}
|
||||
|
||||
/** `true` when `url` is a usable Aniyomi index URL. */
|
||||
export function isValidRepoUrl(url: string): boolean {
|
||||
return INDEX_URL_PATTERN.test(url.trim());
|
||||
}
|
||||
|
||||
/** Strip the index file name to get the repo root. */
|
||||
export function repoBaseUrl(indexUrl: string): string {
|
||||
return indexUrl.trim().replace(/\/[^/]*$/, '');
|
||||
}
|
||||
|
||||
interface RawEntry {
|
||||
name?: unknown;
|
||||
pkg?: unknown;
|
||||
apk?: unknown;
|
||||
lang?: unknown;
|
||||
code?: unknown;
|
||||
version?: unknown;
|
||||
nsfw?: unknown;
|
||||
sources?: unknown;
|
||||
}
|
||||
|
||||
function readSourceNames(sources: unknown): string[] {
|
||||
if (!Array.isArray(sources)) return [];
|
||||
return sources
|
||||
.map((source) =>
|
||||
source !== null && typeof source === 'object'
|
||||
? (source as { name?: unknown }).name
|
||||
: undefined,
|
||||
)
|
||||
.filter((name): name is string => typeof name === 'string' && name.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an index payload into anime extensions.
|
||||
*
|
||||
* Entries that are malformed, or that are manga rather than anime packages,
|
||||
* are skipped rather than failing the whole repo.
|
||||
*/
|
||||
export function parseRepoIndex(indexUrl: string, payload: unknown): RepoExtension[] {
|
||||
if (!Array.isArray(payload)) return [];
|
||||
const base = repoBaseUrl(indexUrl);
|
||||
|
||||
const extensions: RepoExtension[] = [];
|
||||
for (const raw of payload as RawEntry[]) {
|
||||
if (raw === null || typeof raw !== 'object') continue;
|
||||
const pkg = typeof raw.pkg === 'string' ? raw.pkg : '';
|
||||
const apk = typeof raw.apk === 'string' ? raw.apk : '';
|
||||
if (!pkg.startsWith(ANIME_PACKAGE_PREFIX) || apk.length === 0) continue;
|
||||
|
||||
const versionCode = Number(raw.code);
|
||||
extensions.push({
|
||||
pkg,
|
||||
// Repo entries are prefixed "Aniyomi: "; the app supplies its own context.
|
||||
name: (typeof raw.name === 'string' ? raw.name : pkg).replace(/^Aniyomi:\s*/, ''),
|
||||
lang: typeof raw.lang === 'string' ? raw.lang : 'all',
|
||||
version: typeof raw.version === 'string' ? raw.version : '0',
|
||||
versionCode: Number.isFinite(versionCode) ? versionCode : 0,
|
||||
nsfw: Number(raw.nsfw) === 1,
|
||||
apkUrl: `${base}/apk/${apk}`,
|
||||
iconUrl: `${base}/icon/${pkg}.png`,
|
||||
repoUrl: indexUrl,
|
||||
sourceNames: readSourceNames(raw.sources),
|
||||
});
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
export interface FetchRepoOptions {
|
||||
fetchImpl?: typeof fetch;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Fetch and parse one repository index. */
|
||||
export async function fetchRepoIndex(
|
||||
indexUrl: string,
|
||||
options: FetchRepoOptions = {},
|
||||
): Promise<RepoExtension[]> {
|
||||
if (!isValidRepoUrl(indexUrl)) {
|
||||
throw new Error(`Not a valid repository index URL: ${indexUrl}`);
|
||||
}
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const response = await fetchImpl(indexUrl.trim(), {
|
||||
headers: { Accept: 'application/json' },
|
||||
...(options.signal ? { signal: options.signal } : {}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Repository returned ${response.status} for ${indexUrl}`);
|
||||
}
|
||||
return parseRepoIndex(indexUrl, await response.json());
|
||||
}
|
||||
|
||||
export interface RepoFetchFailure {
|
||||
repoUrl: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface RepoCatalogue {
|
||||
extensions: RepoExtension[];
|
||||
failures: RepoFetchFailure[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch every configured repository.
|
||||
*
|
||||
* When two repos publish the same package, the higher version code wins, so a
|
||||
* user's preferred repo ordering does not silently pin an older build.
|
||||
*/
|
||||
export async function fetchRepoCatalogue(
|
||||
indexUrls: string[],
|
||||
options: FetchRepoOptions = {},
|
||||
): Promise<RepoCatalogue> {
|
||||
const failures: RepoFetchFailure[] = [];
|
||||
const byPackage = new Map<string, RepoExtension>();
|
||||
|
||||
const results = await Promise.all(
|
||||
indexUrls.map(async (indexUrl) => {
|
||||
try {
|
||||
return { indexUrl, extensions: await fetchRepoIndex(indexUrl, options) };
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
repoUrl: indexUrl,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return { indexUrl, extensions: [] as RepoExtension[] };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
for (const { extensions } of results) {
|
||||
for (const extension of extensions) {
|
||||
const existing = byPackage.get(extension.pkg);
|
||||
if (!existing || extension.versionCode > existing.versionCode) {
|
||||
byPackage.set(extension.pkg, extension);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
extensions: [...byPackage.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
failures,
|
||||
};
|
||||
}
|
||||
|
||||
/** File name an extension is stored under, so updates replace in place. */
|
||||
export function extensionFileName(pkg: string): string {
|
||||
return `${pkg}.apk`;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
listExtensionSources,
|
||||
readInstalledExtensions,
|
||||
toBridgeSource,
|
||||
toInstalledExtensionViews,
|
||||
type ExtensionSource,
|
||||
type InstalledExtension,
|
||||
} from './extension-store';
|
||||
import type { AnimeBridgeClient } from './bridge-client';
|
||||
|
||||
async function makeExtensionDir(files: Record<string, string>): Promise<string> {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-ext-'));
|
||||
for (const [name, contents] of Object.entries(files)) {
|
||||
await writeFile(path.join(dir, name), contents);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function fakeClient(
|
||||
impl: (source: { apkBase64: string }) => Promise<unknown[]>,
|
||||
): AnimeBridgeClient {
|
||||
return { listAnimeSources: impl } as unknown as AnimeBridgeClient;
|
||||
}
|
||||
|
||||
test('readInstalledExtensions reads apks and base64-encodes them', async () => {
|
||||
const dir = await makeExtensionDir({ 'my-source.apk': 'APK-BYTES' });
|
||||
const extensions = await readInstalledExtensions(dir);
|
||||
|
||||
assert.equal(extensions.length, 1);
|
||||
assert.equal(extensions[0]?.fallbackName, 'my-source');
|
||||
assert.equal(Buffer.from(extensions[0]!.apkBase64, 'base64').toString(), 'APK-BYTES');
|
||||
});
|
||||
|
||||
test('readInstalledExtensions ignores non-apk files and subdirectories', async () => {
|
||||
const dir = await makeExtensionDir({ 'a.apk': 'A', 'notes.txt': 'x', 'b.APK': 'B' });
|
||||
await mkdir(path.join(dir, 'nested.apk'), { recursive: true });
|
||||
|
||||
const names = (await readInstalledExtensions(dir)).map((e) => e.fallbackName);
|
||||
// Sorted, case-insensitive extension match, directories excluded.
|
||||
assert.deepEqual(names, ['a', 'b']);
|
||||
});
|
||||
|
||||
test('readInstalledExtensions returns empty for a missing directory', async () => {
|
||||
assert.deepEqual(await readInstalledExtensions('/nonexistent/subminer/extensions'), []);
|
||||
});
|
||||
|
||||
test('toBridgeSource includes sourceId only when selecting inside a factory apk', () => {
|
||||
const extension: InstalledExtension = {
|
||||
file: '/x/a.apk',
|
||||
fallbackName: 'a',
|
||||
apkBase64: 'QQ==',
|
||||
};
|
||||
assert.deepEqual(toBridgeSource(extension), { apkBase64: 'QQ==' });
|
||||
assert.deepEqual(toBridgeSource(extension, 'src-1'), {
|
||||
apkBase64: 'QQ==',
|
||||
sourceId: 'src-1',
|
||||
});
|
||||
});
|
||||
|
||||
test('listExtensionSources flattens every source a factory apk provides', async () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/multi.apk', fallbackName: 'multi', apkBase64: 'QQ==' },
|
||||
];
|
||||
const client = fakeClient(async () => [
|
||||
{ id: 101, name: 'Source One', lang: 'en' },
|
||||
{ id: '102', name: 'Source Two', lang: 'ja' },
|
||||
]);
|
||||
|
||||
const sources = await listExtensionSources(client, extensions);
|
||||
|
||||
assert.equal(sources.length, 2);
|
||||
// Numeric ids are normalized to strings so they can key UI state.
|
||||
assert.equal(sources[0]?.id, '101');
|
||||
assert.equal(sources[0]?.name, 'Source One');
|
||||
assert.equal(sources[1]?.lang, 'ja');
|
||||
});
|
||||
|
||||
test('listExtensionSources falls back to the file name and a default language', async () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/my-ext.apk', fallbackName: 'my-ext', apkBase64: 'QQ==' },
|
||||
];
|
||||
const client = fakeClient(async () => [{ id: '1', name: ' ' }]);
|
||||
|
||||
const sources = await listExtensionSources(client, extensions);
|
||||
assert.equal(sources[0]?.name, 'my-ext');
|
||||
assert.equal(sources[0]?.lang, 'all');
|
||||
});
|
||||
|
||||
test('listExtensionSources drops descriptors with no usable id', async () => {
|
||||
const client = fakeClient(async () => [{ name: 'No Id' }, { id: '', name: 'Empty' }]);
|
||||
const sources = await listExtensionSources(client, [
|
||||
{ file: '/x/a.apk', fallbackName: 'a', apkBase64: 'QQ==' },
|
||||
]);
|
||||
assert.deepEqual(sources, []);
|
||||
});
|
||||
|
||||
test('toInstalledExtensionViews names an extension after the sources it provides', () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/multi.apk', fallbackName: 'multi', apkBase64: 'QQ==' },
|
||||
];
|
||||
const sources: ExtensionSource[] = [
|
||||
{ id: '1', name: 'One', lang: 'en', pkg: 'multi', file: '/x/multi.apk' },
|
||||
{ id: '2', name: 'Two', lang: 'ja', pkg: 'multi', file: '/x/multi.apk' },
|
||||
];
|
||||
|
||||
assert.deepEqual(toInstalledExtensionViews(extensions, sources, []), [
|
||||
{ pkg: 'multi', name: 'One, Two', langs: ['en', 'ja'], sourceCount: 2, error: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test('toInstalledExtensionViews lists an extension that loaded nothing, with its reason', () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/broken.apk', fallbackName: 'broken', apkBase64: 'QQ==' },
|
||||
];
|
||||
|
||||
// A broken APK is still installed, so it must stay listed and removable.
|
||||
assert.deepEqual(
|
||||
toInstalledExtensionViews(extensions, [], [{ pkg: 'broken', error: 'dex2jar failed' }]),
|
||||
[{ pkg: 'broken', name: 'broken', langs: [], sourceCount: 0, error: 'dex2jar failed' }],
|
||||
);
|
||||
});
|
||||
|
||||
test('one broken extension does not hide the working ones', async () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/broken.apk', fallbackName: 'broken', apkBase64: 'QQ==' },
|
||||
{ file: '/x/good.apk', fallbackName: 'good', apkBase64: 'Qg==' },
|
||||
];
|
||||
const failures: string[] = [];
|
||||
const client = fakeClient(async (source) => {
|
||||
if (source.apkBase64 === 'QQ==') throw new Error('dex2jar failed');
|
||||
return [{ id: '7', name: 'Good Source', lang: 'en' }];
|
||||
});
|
||||
|
||||
const sources = await listExtensionSources(client, extensions, (extension) => {
|
||||
failures.push(extension.fallbackName);
|
||||
});
|
||||
|
||||
assert.deepEqual(failures, ['broken']);
|
||||
assert.equal(sources.length, 1);
|
||||
assert.equal(sources[0]?.name, 'Good Source');
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { AnimeBridgeClient } from './bridge-client';
|
||||
import type { BridgeSource } from './bridge-client';
|
||||
import type { ExtensionLoadFailure, InstalledExtensionView } from '../types/anime-browser';
|
||||
|
||||
/**
|
||||
* Anime extensions are Aniyomi APKs the user supplies. They are read from a
|
||||
* directory rather than fetched from a hardcoded catalogue, so which sources
|
||||
* exist is entirely the user's choice.
|
||||
*/
|
||||
|
||||
export interface InstalledExtension {
|
||||
/** Absolute path to the .apk. */
|
||||
file: string;
|
||||
/** File name without extension, used when the bridge reports no name. */
|
||||
fallbackName: string;
|
||||
apkBase64: string;
|
||||
}
|
||||
|
||||
export interface ExtensionSource {
|
||||
/** Stable id: the bridge source id, which selects it inside a factory APK. */
|
||||
id: string;
|
||||
name: string;
|
||||
lang: string;
|
||||
pkg: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
/** Read every .apk in `directory`. A missing directory yields no extensions. */
|
||||
export async function readInstalledExtensions(directory: string): Promise<InstalledExtension[]> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const extensions: InstalledExtension[] = [];
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.apk')) continue;
|
||||
const file = path.join(directory, entry.name);
|
||||
const bytes = await readFile(file);
|
||||
extensions.push({
|
||||
file,
|
||||
fallbackName: entry.name.replace(/\.apk$/i, ''),
|
||||
apkBase64: bytes.toString('base64'),
|
||||
});
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe what is on disk, for the installed list in the Extensions tab.
|
||||
*
|
||||
* Built from the directory rather than from a repository catalogue: an APK
|
||||
* dropped in by hand, or one whose repository the user has since removed, is
|
||||
* still installed and must stay removable.
|
||||
*/
|
||||
export function toInstalledExtensionViews(
|
||||
extensions: InstalledExtension[],
|
||||
sources: ExtensionSource[],
|
||||
loadFailures: ExtensionLoadFailure[],
|
||||
): InstalledExtensionView[] {
|
||||
return extensions.map((extension) => {
|
||||
const provided = sources.filter((source) => source.file === extension.file);
|
||||
const names = [...new Set(provided.map((source) => source.name))];
|
||||
return {
|
||||
pkg: extension.fallbackName,
|
||||
name: names.length > 0 ? names.join(', ') : extension.fallbackName,
|
||||
langs: [...new Set(provided.map((source) => source.lang))],
|
||||
sourceCount: provided.length,
|
||||
error: loadFailures.find((failure) => failure.pkg === extension.fallbackName)?.error ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** The bridge payload for a specific source inside an extension. */
|
||||
export function toBridgeSource(extension: InstalledExtension, sourceId?: string): BridgeSource {
|
||||
return { apkBase64: extension.apkBase64, ...(sourceId ? { sourceId } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the bridge which sources each extension provides.
|
||||
*
|
||||
* An extension that fails to load is skipped rather than aborting the scan, so
|
||||
* one broken APK cannot hide every working one. Failures are reported through
|
||||
* `onError` for surfacing in the UI.
|
||||
*/
|
||||
export async function listExtensionSources(
|
||||
client: AnimeBridgeClient,
|
||||
extensions: InstalledExtension[],
|
||||
onError?: (extension: InstalledExtension, error: unknown) => void,
|
||||
): Promise<ExtensionSource[]> {
|
||||
const sources: ExtensionSource[] = [];
|
||||
|
||||
for (const extension of extensions) {
|
||||
try {
|
||||
const descriptors = await client.listAnimeSources(toBridgeSource(extension));
|
||||
for (const descriptor of descriptors) {
|
||||
const id = descriptor.id === undefined ? null : String(descriptor.id);
|
||||
if (id === null || id.length === 0) continue;
|
||||
sources.push({
|
||||
id,
|
||||
name: descriptor.name?.trim() || extension.fallbackName,
|
||||
lang: descriptor.lang ?? 'all',
|
||||
pkg: extension.fallbackName,
|
||||
file: extension.file,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
onError?.(extension, error);
|
||||
}
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseOkHttpHeaders, resolveStream, toMpvHeaderFields } from './headers';
|
||||
|
||||
test('parseOkHttpHeaders flattens the alternating name/value array', () => {
|
||||
const parsed = parseOkHttpHeaders({
|
||||
namesAndValues$okhttp: ['Referer', 'https://origin.example/', 'User-Agent', 'Aniyomi'],
|
||||
});
|
||||
assert.deepEqual(parsed, {
|
||||
Referer: 'https://origin.example/',
|
||||
'User-Agent': 'Aniyomi',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseOkHttpHeaders tolerates missing, empty, and odd-length input', () => {
|
||||
assert.deepEqual(parseOkHttpHeaders(undefined), {});
|
||||
assert.deepEqual(parseOkHttpHeaders({}), {});
|
||||
assert.deepEqual(parseOkHttpHeaders({ namesAndValues$okhttp: [] }), {});
|
||||
// A trailing name with no value is dropped rather than mapped to undefined.
|
||||
assert.deepEqual(parseOkHttpHeaders({ namesAndValues$okhttp: ['Referer'] }), {});
|
||||
});
|
||||
|
||||
test('toMpvHeaderFields joins entries and escapes commas in values', () => {
|
||||
const fields = toMpvHeaderFields({
|
||||
Referer: 'https://origin.example/',
|
||||
Cookie: 'a=1, b=2',
|
||||
});
|
||||
assert.equal(fields, 'Referer: https://origin.example/,Cookie: a=1\\, b=2');
|
||||
});
|
||||
|
||||
test('toMpvHeaderFields returns an empty string when there are no headers', () => {
|
||||
assert.equal(toMpvHeaderFields({}), '');
|
||||
});
|
||||
|
||||
test('resolveStream normalizes a bridge video into a playable stream', () => {
|
||||
const stream = resolveStream({
|
||||
url: 'https://origin.example/embed/1',
|
||||
quality: '1080p',
|
||||
videoUrl: 'http://127.0.0.1:8080/video/master-token',
|
||||
headers: { namesAndValues$okhttp: ['Referer', 'https://origin.example/'] },
|
||||
subtitleTracks: [{ url: 'http://127.0.0.1:8080/video/sub-token', lang: 'English' }],
|
||||
audioTracks: [{ url: 'http://127.0.0.1:8080/video/audio-token', lang: 'Japanese' }],
|
||||
});
|
||||
|
||||
assert.deepEqual(stream, {
|
||||
url: 'http://127.0.0.1:8080/video/master-token',
|
||||
quality: '1080p',
|
||||
headers: { Referer: 'https://origin.example/' },
|
||||
subtitles: [{ url: 'http://127.0.0.1:8080/video/sub-token', lang: 'English' }],
|
||||
audios: [{ url: 'http://127.0.0.1:8080/video/audio-token', lang: 'Japanese' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveStream returns null when the extension resolved no media url', () => {
|
||||
assert.equal(resolveStream({ url: 'https://origin.example/embed/1', quality: '1080p' }), null);
|
||||
assert.equal(resolveStream({ videoUrl: '' }), null);
|
||||
});
|
||||
|
||||
test('resolveStream drops tracks without a url and defaults a missing lang', () => {
|
||||
const stream = resolveStream({
|
||||
videoUrl: 'http://127.0.0.1:8080/video/master-token',
|
||||
subtitleTracks: [{ lang: 'English' }, { url: 'http://127.0.0.1:8080/video/sub-token' }],
|
||||
});
|
||||
assert.deepEqual(stream?.subtitles, [{ url: 'http://127.0.0.1:8080/video/sub-token', lang: '' }]);
|
||||
assert.equal(stream?.quality, '');
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { BridgeVideo, OkHttpHeaders, ResolvedStream } from './types';
|
||||
|
||||
/**
|
||||
* Flatten OkHttp's alternating `[name, value, name, value]` array into a map.
|
||||
* A trailing name with no value is dropped rather than mapped to undefined.
|
||||
*/
|
||||
export function parseOkHttpHeaders(headers: OkHttpHeaders | undefined): Record<string, string> {
|
||||
const flat = headers?.['namesAndValues$okhttp'];
|
||||
if (!Array.isArray(flat)) return {};
|
||||
|
||||
const parsed: Record<string, string> = {};
|
||||
for (let i = 0; i + 1 < flat.length; i += 2) {
|
||||
const name = flat[i];
|
||||
const value = flat[i + 1];
|
||||
if (typeof name === 'string' && typeof value === 'string') parsed[name] = value;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render headers as mpv's `--http-header-fields` string list. mpv splits
|
||||
* entries on commas, so commas inside a value must be escaped.
|
||||
*/
|
||||
export function toMpvHeaderFields(headers: Record<string, string>): string {
|
||||
return Object.entries(headers)
|
||||
.map(([name, value]) => `${name}: ${value.replace(/,/g, '\\,')}`)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
function normalizeTracks(
|
||||
tracks: Array<{ url?: string; lang?: string }> | undefined,
|
||||
): Array<{ url: string; lang: string }> {
|
||||
if (!Array.isArray(tracks)) return [];
|
||||
return tracks
|
||||
.filter((track): track is { url: string; lang?: string } => typeof track.url === 'string')
|
||||
.map((track) => ({ url: track.url, lang: track.lang ?? '' }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a bridge video into a playable stream. Returns null when the
|
||||
* extension produced no `videoUrl`, which happens for entries it failed to
|
||||
* resolve.
|
||||
*/
|
||||
export function resolveStream(video: BridgeVideo): ResolvedStream | null {
|
||||
if (typeof video.videoUrl !== 'string' || video.videoUrl.length === 0) return null;
|
||||
|
||||
return {
|
||||
url: video.videoUrl,
|
||||
quality: video.quality ?? '',
|
||||
headers: parseOkHttpHeaders(video.headers),
|
||||
subtitles: normalizeTracks(video.subtitleTracks),
|
||||
audios: normalizeTracks(video.audioTracks),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseAnimeStatus, resolveBridgeMediaUrl } from './media-url';
|
||||
|
||||
const BRIDGE = 'http://127.0.0.1:56037';
|
||||
|
||||
test('a loopback proxy url is rebased onto the live bridge port', () => {
|
||||
assert.equal(
|
||||
resolveBridgeMediaUrl(BRIDGE, 'http://127.0.0.1:8080/image/cover-uuid'),
|
||||
'http://127.0.0.1:56037/image/cover-uuid',
|
||||
);
|
||||
assert.equal(
|
||||
resolveBridgeMediaUrl(BRIDGE, 'http://localhost:8080/video/master-token'),
|
||||
'http://127.0.0.1:56037/video/master-token',
|
||||
);
|
||||
});
|
||||
|
||||
test('query strings and fragments survive rebasing', () => {
|
||||
assert.equal(
|
||||
resolveBridgeMediaUrl(BRIDGE, 'http://127.0.0.1:8080/video/token?quality=1080#t=30'),
|
||||
'http://127.0.0.1:56037/video/token?quality=1080#t=30',
|
||||
);
|
||||
});
|
||||
|
||||
test('ipv6 loopback is recognised', () => {
|
||||
assert.equal(
|
||||
resolveBridgeMediaUrl(BRIDGE, 'http://[::1]:8080/image/cover'),
|
||||
'http://127.0.0.1:56037/image/cover',
|
||||
);
|
||||
});
|
||||
|
||||
test('remote urls are left untouched', () => {
|
||||
const remote = 'https://cdn.example.com/covers/1.jpg';
|
||||
assert.equal(resolveBridgeMediaUrl(BRIDGE, remote), remote);
|
||||
});
|
||||
|
||||
test('loopback urls outside the proxy routes are left untouched', () => {
|
||||
// Only /image and /video are proxy routes; /capabilities is the server's own API.
|
||||
const other = 'http://127.0.0.1:8080/capabilities';
|
||||
assert.equal(resolveBridgeMediaUrl(BRIDGE, other), other);
|
||||
});
|
||||
|
||||
test('a base url without a scheme is assumed to be http', () => {
|
||||
assert.equal(
|
||||
resolveBridgeMediaUrl('127.0.0.1:56037', 'http://127.0.0.1:8080/image/cover'),
|
||||
'http://127.0.0.1:56037/image/cover',
|
||||
);
|
||||
});
|
||||
|
||||
test('unparseable input is returned unchanged rather than throwing', () => {
|
||||
assert.equal(resolveBridgeMediaUrl(BRIDGE, 'not a url'), 'not a url');
|
||||
assert.equal(
|
||||
resolveBridgeMediaUrl('', 'http://127.0.0.1:8080/image/c'),
|
||||
'http://127.0.0.1:8080/image/c',
|
||||
);
|
||||
assert.equal(resolveBridgeMediaUrl(BRIDGE, ''), '');
|
||||
});
|
||||
|
||||
test('parseAnimeStatus maps the SAnime constants', () => {
|
||||
assert.equal(parseAnimeStatus(1), 'ongoing');
|
||||
assert.equal(parseAnimeStatus(2), 'completed');
|
||||
assert.equal(parseAnimeStatus(4), 'publishing-finished');
|
||||
assert.equal(parseAnimeStatus(5), 'cancelled');
|
||||
assert.equal(parseAnimeStatus(6), 'on-hiatus');
|
||||
assert.equal(parseAnimeStatus(0), 'unknown');
|
||||
assert.equal(parseAnimeStatus(undefined), 'unknown');
|
||||
// 3 is unused in the SAnime constants.
|
||||
assert.equal(parseAnimeStatus(3), 'unknown');
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* The bridge returns cover art and video URLs pointing at its own loopback
|
||||
* media proxy, but the origin it embeds is not always the port we actually
|
||||
* started it on. Rebase those onto the live bridge origin, and leave any
|
||||
* genuinely remote URL untouched.
|
||||
*/
|
||||
|
||||
const PROXY_ROUTES = new Set(['image', 'video']);
|
||||
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
|
||||
|
||||
function isLoopbackProxyUrl(candidate: URL): boolean {
|
||||
if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:') return false;
|
||||
const host = candidate.hostname.toLowerCase();
|
||||
if (!LOOPBACK_HOSTS.has(host)) return false;
|
||||
const route = candidate.pathname.split('/').filter(Boolean)[0];
|
||||
return route !== undefined && PROXY_ROUTES.has(route);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a bridge media URL onto `bridgeBaseUrl`, preserving path and query.
|
||||
* Returns the input unchanged when it is not a loopback proxy URL, or when
|
||||
* either URL cannot be parsed.
|
||||
*/
|
||||
export function resolveBridgeMediaUrl(bridgeBaseUrl: string, mediaUrl: string): string {
|
||||
let media: URL;
|
||||
try {
|
||||
media = new URL(mediaUrl);
|
||||
} catch {
|
||||
return mediaUrl;
|
||||
}
|
||||
if (!isLoopbackProxyUrl(media)) return mediaUrl;
|
||||
|
||||
const normalizedBase = bridgeBaseUrl.includes('://') ? bridgeBaseUrl : `http://${bridgeBaseUrl}`;
|
||||
let base: URL;
|
||||
try {
|
||||
base = new URL(normalizedBase);
|
||||
} catch {
|
||||
return mediaUrl;
|
||||
}
|
||||
if (base.protocol !== 'http:' && base.protocol !== 'https:') return mediaUrl;
|
||||
if (base.hostname.length === 0) return mediaUrl;
|
||||
|
||||
const rebased = new URL(base.origin);
|
||||
rebased.pathname = media.pathname;
|
||||
rebased.search = media.search;
|
||||
rebased.hash = media.hash;
|
||||
return rebased.toString();
|
||||
}
|
||||
|
||||
/** Aniyomi's SAnime status constants. */
|
||||
export type AnimeStatus =
|
||||
| 'unknown'
|
||||
| 'ongoing'
|
||||
| 'completed'
|
||||
| 'publishing-finished'
|
||||
| 'cancelled'
|
||||
| 'on-hiatus';
|
||||
|
||||
export function parseAnimeStatus(status: number | undefined): AnimeStatus {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return 'ongoing';
|
||||
case 2:
|
||||
return 'completed';
|
||||
case 4:
|
||||
return 'publishing-finished';
|
||||
case 5:
|
||||
return 'cancelled';
|
||||
case 6:
|
||||
return 'on-hiatus';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildLoadfileOptions,
|
||||
buildPlaybackCommands,
|
||||
buildTrackCommands,
|
||||
normalizeLangTag,
|
||||
selectPreferredStream,
|
||||
} from './mpv-playback';
|
||||
import type { ResolvedStream } from './types';
|
||||
|
||||
function stream(overrides: Partial<ResolvedStream> = {}): ResolvedStream {
|
||||
return {
|
||||
url: 'http://127.0.0.1:8080/video/token',
|
||||
quality: '1080p',
|
||||
headers: {},
|
||||
subtitles: [],
|
||||
audios: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('loadfile options keep one visible track and never scan the filesystem', () => {
|
||||
const options = buildLoadfileOptions({ stream: stream() });
|
||||
for (const expected of [
|
||||
'sub-auto=no',
|
||||
'secondary-sid=no',
|
||||
'secondary-sub-visibility=no',
|
||||
'sub-visibility=yes',
|
||||
]) {
|
||||
assert.ok(options.split(',').includes(expected), `missing ${expected}`);
|
||||
}
|
||||
// The source's own subtitles are the only ones this path gets, so they must
|
||||
// not be suppressed the way the Jellyfin path suppresses them.
|
||||
assert.ok(!options.split(',').includes('sid=no'));
|
||||
// Comma-separated values would split the option list, so the language
|
||||
// preferences ride as properties instead.
|
||||
assert.ok(!options.includes('alang'));
|
||||
assert.ok(!options.includes('slang'));
|
||||
});
|
||||
|
||||
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 }) });
|
||||
|
||||
// Verified against mpv 0.41: the unescaped form yields an empty header list.
|
||||
const fields = 'Referer: https://a.test/,User-Agent: X';
|
||||
assert.ok(options.includes(`http-header-fields=%${fields.length}%${fields}`));
|
||||
});
|
||||
|
||||
test('the escape length counts the full header string including separators', () => {
|
||||
const options = buildLoadfileOptions({
|
||||
stream: stream({ headers: { Cookie: 'a=1, b=2' } }),
|
||||
});
|
||||
// The comma inside the value is backslash-escaped first, so the length grows.
|
||||
const fields = 'Cookie: a=1\\, b=2';
|
||||
assert.ok(options.includes(`%${fields.length}%${fields}`));
|
||||
});
|
||||
|
||||
test('no header option is emitted when the stream carries no headers', () => {
|
||||
const options = buildLoadfileOptions({ stream: stream() });
|
||||
assert.ok(!options.includes('http-header-fields'));
|
||||
});
|
||||
|
||||
test('a positive start position is appended, zero is omitted', () => {
|
||||
assert.ok(buildLoadfileOptions({ stream: stream(), startSeconds: 42 }).includes('start=42'));
|
||||
assert.ok(!buildLoadfileOptions({ stream: stream(), startSeconds: 0 }).includes('start='));
|
||||
assert.ok(!buildLoadfileOptions({ stream: stream() }).includes('start='));
|
||||
});
|
||||
|
||||
test('playback commands set the language preference before loading the file', () => {
|
||||
const commands = buildPlaybackCommands({ stream: stream(), title: 'Example - 01' });
|
||||
|
||||
assert.deepEqual(commands[0], ['script-message', 'subminer-managed-subtitles-loading']);
|
||||
// Japanese first, so a multi-audio stream never starts on the dub. slang is
|
||||
// Japanese-only: an English track belongs in the secondary slot, which the
|
||||
// secondarySub auto-load fills by language tag.
|
||||
assert.deepEqual(commands[1], ['set_property', 'alang', 'ja,jpn,jp,japanese']);
|
||||
assert.deepEqual(commands[2], ['set_property', 'slang', 'ja,jpn,jp,japanese']);
|
||||
assert.equal(commands[3]?.[0], 'loadfile');
|
||||
assert.equal(commands[3]?.[1], 'http://127.0.0.1:8080/video/token');
|
||||
assert.equal(commands[3]?.[2], 'replace');
|
||||
assert.equal(commands[3]?.[3], -1);
|
||||
assert.deepEqual(commands[4], ['set_property', 'force-media-title', 'Example - 01']);
|
||||
});
|
||||
|
||||
test('force-media-title is skipped when there is no title', () => {
|
||||
assert.equal(buildPlaybackCommands({ stream: stream() }).length, 4);
|
||||
assert.equal(buildPlaybackCommands({ stream: stream(), title: '' }).length, 4);
|
||||
});
|
||||
|
||||
test('external audio tracks are added, with the Japanese one selected', () => {
|
||||
const commands = buildTrackCommands(
|
||||
stream({
|
||||
audios: [
|
||||
{ url: 'http://host/en.m4a', lang: 'en' },
|
||||
{ url: 'http://host/ja.m4a', lang: 'ja' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
['audio-add', 'http://host/en.m4a', 'auto', 'en', 'en'],
|
||||
['audio-add', 'http://host/ja.m4a', 'select', 'ja', 'ja'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('external audio is left unselected when none of it is Japanese', () => {
|
||||
// alang already picked a track off the container; do not override it.
|
||||
const commands = buildTrackCommands(
|
||||
stream({ audios: [{ url: 'http://host/en.m4a', lang: 'eng' }] }),
|
||||
);
|
||||
|
||||
assert.deepEqual(commands, [['audio-add', 'http://host/en.m4a', 'auto', 'eng', 'en']]);
|
||||
});
|
||||
|
||||
test('only a Japanese subtitle track is selected as primary', () => {
|
||||
const japanese = buildTrackCommands(
|
||||
stream({
|
||||
subtitles: [
|
||||
{ url: 'http://host/en.vtt', lang: 'English' },
|
||||
{ url: 'http://host/ja.vtt', lang: 'Japanese' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(japanese[1], ['sub-add', 'http://host/ja.vtt', 'select', 'Japanese', 'ja']);
|
||||
assert.equal(japanese[0]?.[2], 'auto');
|
||||
|
||||
// English is the user's *secondary* language; it must not take the primary
|
||||
// slot. It rides in unselected, tagged so the secondarySub auto-load can
|
||||
// route it to secondary-sid.
|
||||
const englishOnly = buildTrackCommands(
|
||||
stream({ subtitles: [{ url: 'http://host/en.vtt', lang: 'English' }] }),
|
||||
);
|
||||
assert.deepEqual(englishOnly, [['sub-add', 'http://host/en.vtt', 'auto', 'English', 'en']]);
|
||||
});
|
||||
|
||||
test('language labels normalize to the tags users configure', () => {
|
||||
assert.equal(normalizeLangTag('English'), 'en');
|
||||
assert.equal(normalizeLangTag('eng'), 'en');
|
||||
assert.equal(normalizeLangTag('en-US'), 'en');
|
||||
assert.equal(normalizeLangTag('Japanese'), 'ja');
|
||||
assert.equal(normalizeLangTag('jpn'), 'ja');
|
||||
assert.equal(normalizeLangTag('Português'), 'pt');
|
||||
// Unknown labels pass through untouched rather than being guessed at.
|
||||
assert.equal(normalizeLangTag('Klingon'), 'Klingon');
|
||||
assert.equal(normalizeLangTag(''), '');
|
||||
});
|
||||
|
||||
test('unlabelled tracks still get a usable menu title, duplicates are dropped', () => {
|
||||
const commands = buildTrackCommands(
|
||||
stream({
|
||||
subtitles: [
|
||||
{ url: 'http://host/a.vtt', lang: '' },
|
||||
{ url: 'http://host/a.vtt', lang: '' },
|
||||
{ url: 'http://host/b.vtt', lang: '' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
['sub-add', 'http://host/a.vtt', 'auto', 'Subtitle 1', ''],
|
||||
['sub-add', 'http://host/b.vtt', 'auto', 'Subtitle 2', ''],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a stream with no external tracks emits no track commands', () => {
|
||||
assert.deepEqual(buildTrackCommands(stream()), []);
|
||||
});
|
||||
|
||||
test('selectPreferredStream skips dub entries in favour of the original audio', () => {
|
||||
const streams = [
|
||||
stream({ quality: '1080p (Dub)' }),
|
||||
stream({ quality: '720p (Sub)' }),
|
||||
stream({ quality: '480p (Dub)' }),
|
||||
];
|
||||
|
||||
// Language beats the quality hint: a 1080p dub is the wrong file, not a
|
||||
// better one.
|
||||
assert.equal(selectPreferredStream(streams)?.quality, '720p (Sub)');
|
||||
assert.equal(selectPreferredStream(streams, '1080')?.quality, '720p (Sub)');
|
||||
});
|
||||
|
||||
test('selectPreferredStream prefers an entry carrying a Japanese audio track', () => {
|
||||
const streams = [
|
||||
stream({ quality: '1080p' }),
|
||||
stream({ quality: '720p', audios: [{ url: 'http://host/ja.m4a', lang: 'ja' }] }),
|
||||
];
|
||||
|
||||
assert.equal(selectPreferredStream(streams)?.quality, '720p');
|
||||
});
|
||||
|
||||
test('an all-dub list still plays rather than failing', () => {
|
||||
const streams = [stream({ quality: '1080p Dub' }), stream({ quality: '720p Dub' })];
|
||||
|
||||
assert.equal(selectPreferredStream(streams)?.quality, '1080p Dub');
|
||||
assert.equal(selectPreferredStream(streams, '720')?.quality, '720p Dub');
|
||||
});
|
||||
|
||||
test('selectPreferredStream honours a quality hint, else takes the first', () => {
|
||||
const streams = [stream({ quality: '360p' }), stream({ quality: '1080p' })];
|
||||
|
||||
assert.equal(selectPreferredStream(streams, '1080')?.quality, '1080p');
|
||||
assert.equal(selectPreferredStream(streams, '1080P')?.quality, '1080p');
|
||||
// Extensions label streams with the host name, so the hint matches a substring.
|
||||
const decorated = [
|
||||
stream({ quality: 'Doodstream - 360p' }),
|
||||
stream({ quality: 'Vidhide - 720p' }),
|
||||
];
|
||||
assert.equal(selectPreferredStream(decorated, '720')?.quality, 'Vidhide - 720p');
|
||||
// Extensions pre-sort by their own preference, so the first entry wins.
|
||||
assert.equal(selectPreferredStream(streams)?.quality, '360p');
|
||||
// A hint that matches nothing falls back rather than failing.
|
||||
assert.equal(selectPreferredStream(streams, '4k')?.quality, '360p');
|
||||
});
|
||||
|
||||
test('selectPreferredStream returns null for an empty list', () => {
|
||||
assert.equal(selectPreferredStream([]), null);
|
||||
assert.equal(selectPreferredStream([], '1080p'), null);
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { toMpvHeaderFields } from './headers';
|
||||
import type { ResolvedStream } from './types';
|
||||
|
||||
/**
|
||||
* Japanese first, always, and for subtitles Japanese *only*: the primary slot
|
||||
* belongs to the language being mined, and an English track belongs in the
|
||||
* secondary slot, where the `secondarySub` machinery puts it by language tag.
|
||||
* For audio, mpv falls back to the first track when nothing matches, so an
|
||||
* English-only release still plays.
|
||||
*/
|
||||
export const JAPANESE_LANGUAGE_PREFERENCE = 'ja,jpn,jp,japanese';
|
||||
|
||||
/**
|
||||
* mpv must not scan the filesystem for sidecar subtitles when the "file" is a
|
||||
* network stream, and the secondary slot stays empty so the overlay only ever
|
||||
* reads one track. Everything else is left to normal track selection, driven
|
||||
* by the language preferences above.
|
||||
*
|
||||
* `alang`/`slang` are set as properties instead of file-local options: their
|
||||
* values are comma-separated lists, and a comma inside a `loadfile` option
|
||||
* value splits the option list.
|
||||
*/
|
||||
const BASE_LOADFILE_OPTIONS = [
|
||||
'sub-auto=no',
|
||||
'secondary-sid=no',
|
||||
'secondary-sub-visibility=no',
|
||||
'sub-visibility=yes',
|
||||
];
|
||||
|
||||
/** Matches a language tag or a label such as "Japanese (Sub)" or "[JPN]". */
|
||||
const JAPANESE_PATTERN = /(^|[^a-z])(ja|jp|jpn|japanese|日本語)([^a-z]|$)/i;
|
||||
/** Extensions label dub entries in the quality string, e.g. "1080p (Dub)". */
|
||||
const DUB_PATTERN = /(^|[^a-z])(dub|dubbed|dublado|latino|castellano)([^a-z]|$)/i;
|
||||
/** The counterpart label for original-audio entries, e.g. "SUB - 1080p". */
|
||||
const SUBBED_PATTERN = /(^|[^a-z])(sub|subbed|softsub|hardsub|subtitulado|raw)([^a-z]|$)/i;
|
||||
|
||||
export type MpvCommand = Array<string | number>;
|
||||
|
||||
export interface BuildPlaybackOptions {
|
||||
stream: ResolvedStream;
|
||||
/** Shown as the mpv window/OSD title. */
|
||||
title?: string;
|
||||
/** Resume position in seconds. */
|
||||
startSeconds?: number;
|
||||
}
|
||||
|
||||
export function isJapaneseTag(value: string): boolean {
|
||||
return JAPANESE_PATTERN.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the mpv `loadfile` option string for a stream.
|
||||
*
|
||||
* Headers ride as `file-local-options/http-header-fields` so they apply to this
|
||||
* file only, and so SubMiner's Anki media path can read them back off mpv when
|
||||
* generating card audio and screenshots. Tracks added later with `sub-add` /
|
||||
* `audio-add` inherit them too, which is how external tracks on an
|
||||
* authenticated host stay reachable.
|
||||
*/
|
||||
export function buildLoadfileOptions(options: BuildPlaybackOptions): string {
|
||||
const parts = [...BASE_LOADFILE_OPTIONS];
|
||||
|
||||
const headerFields = toMpvHeaderFields(options.stream.headers);
|
||||
if (headerFields.length > 0) {
|
||||
// Escape the mpv option-list separators so a header never splits the list.
|
||||
parts.push(`http-header-fields=${escapeOptionValue(headerFields)}`);
|
||||
}
|
||||
|
||||
if (options.startSeconds !== undefined && options.startSeconds > 0) {
|
||||
parts.push(`start=${options.startSeconds}`);
|
||||
}
|
||||
|
||||
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
|
||||
* embedded separators in option values.
|
||||
*/
|
||||
function escapeOptionValue(value: string): string {
|
||||
return `%${value.length}%${value}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered mpv commands that start playback of a resolved stream.
|
||||
*
|
||||
* The plugin is told subtitles are being managed before the file loads, so the
|
||||
* overlay does not flash the source's own tracks during the swap.
|
||||
*/
|
||||
export function buildPlaybackCommands(options: BuildPlaybackOptions): MpvCommand[] {
|
||||
const commands: MpvCommand[] = [
|
||||
['script-message', 'subminer-managed-subtitles-loading'],
|
||||
['set_property', 'alang', JAPANESE_LANGUAGE_PREFERENCE],
|
||||
['set_property', 'slang', JAPANESE_LANGUAGE_PREFERENCE],
|
||||
['loadfile', options.stream.url, 'replace', -1, buildLoadfileOptions(options)],
|
||||
];
|
||||
|
||||
if (options.title !== undefined && options.title.length > 0) {
|
||||
commands.push(['set_property', 'force-media-title', options.title]);
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commands that attach the extension's external audio and subtitle tracks.
|
||||
*
|
||||
* These must be sent *after* the file is loading, so they are separate from
|
||||
* {@link buildPlaybackCommands}. Every track is added — even the ones we do not
|
||||
* select — so they show up in mpv's track menu and can be switched by hand.
|
||||
*/
|
||||
export function buildTrackCommands(stream: ResolvedStream): MpvCommand[] {
|
||||
return [
|
||||
...buildAddTrackCommands('audio-add', stream.audios, 'Audio'),
|
||||
...buildAddTrackCommands('sub-add', stream.subtitles, 'Subtitle'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a Japanese track is ever selected outright — the primary slot is for
|
||||
* the mining language. A non-Japanese track is added unselected: for audio,
|
||||
* `alang`'s pick off the container stands; for subtitles, the `secondarySub`
|
||||
* auto-load matches the track's language tag against the user's configured
|
||||
* secondary languages and routes it to `secondary-sid` instead.
|
||||
*/
|
||||
function buildAddTrackCommands(
|
||||
command: 'audio-add' | 'sub-add',
|
||||
tracks: Array<{ url: string; lang: string }>,
|
||||
kind: 'Audio' | 'Subtitle',
|
||||
): MpvCommand[] {
|
||||
const unique = dedupeByUrl(tracks);
|
||||
const selected = unique.findIndex((track) => isJapaneseTag(track.lang));
|
||||
|
||||
return unique.map((track, index) => [
|
||||
command,
|
||||
track.url,
|
||||
index === selected ? 'select' : 'auto',
|
||||
track.lang || `${kind} ${index + 1}`,
|
||||
normalizeLangTag(track.lang),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Extension language labels mapped to the tags users put in config. */
|
||||
const LANG_TAG_BY_LABEL: Record<string, string> = {
|
||||
japanese: 'ja',
|
||||
日本語: 'ja',
|
||||
english: 'en',
|
||||
eng: 'en',
|
||||
spanish: 'es',
|
||||
español: 'es',
|
||||
portuguese: 'pt',
|
||||
português: 'pt',
|
||||
french: 'fr',
|
||||
français: 'fr',
|
||||
german: 'de',
|
||||
deutsch: 'de',
|
||||
italian: 'it',
|
||||
italiano: 'it',
|
||||
indonesian: 'id',
|
||||
arabic: 'ar',
|
||||
russian: 'ru',
|
||||
korean: 'ko',
|
||||
chinese: 'zh',
|
||||
thai: 'th',
|
||||
vietnamese: 'vi',
|
||||
};
|
||||
|
||||
/**
|
||||
* mpv's `lang` field is what SubMiner's secondary-subtitle auto-load compares
|
||||
* against `secondarySub.secondarySubLanguages`, so a label like "English" must
|
||||
* become the tag a user would actually configure. Unknown labels pass through;
|
||||
* matching is best-effort, and the raw label stays visible as the track title.
|
||||
*/
|
||||
export function normalizeLangTag(lang: string): string {
|
||||
const trimmed = lang.trim();
|
||||
if (isJapaneseTag(trimmed)) return 'ja';
|
||||
const mapped = LANG_TAG_BY_LABEL[trimmed.toLowerCase()];
|
||||
if (mapped !== undefined) return mapped;
|
||||
if (/^[A-Za-z]{2,3}([-_][A-Za-z0-9]+)?$/.test(trimmed)) {
|
||||
return trimmed.split(/[-_]/, 1)[0]?.toLowerCase() ?? trimmed.toLowerCase();
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function dedupeByUrl(
|
||||
tracks: Array<{ url: string; lang: string }>,
|
||||
): Array<{ url: string; lang: string }> {
|
||||
const seen = new Set<string>();
|
||||
return tracks.filter((track) => {
|
||||
if (track.url.length === 0 || seen.has(track.url)) return false;
|
||||
seen.add(track.url);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank a stream by how likely it is to carry Japanese audio.
|
||||
*
|
||||
* Sources commonly return the dub and the original as separate entries rather
|
||||
* than as two audio tracks of one entry, so the choice of *entry* is the first
|
||||
* place a dub can slip in.
|
||||
*/
|
||||
function scoreStream(stream: ResolvedStream): number {
|
||||
if (stream.audios.some((audio) => isJapaneseTag(audio.lang))) return 2;
|
||||
const label = stream.quality;
|
||||
if (isJapaneseTag(label) || SUBBED_PATTERN.test(label)) return 1;
|
||||
if (DUB_PATTERN.test(label)) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the best stream from an extension's video list.
|
||||
*
|
||||
* Japanese audio outranks the quality hint — a 1080p dub is the wrong file, not
|
||||
* a better one. Within the surviving entries the hint decides, and otherwise
|
||||
* the extension's own ordering does.
|
||||
*/
|
||||
export function selectPreferredStream(
|
||||
streams: ResolvedStream[],
|
||||
preferredQuality?: string,
|
||||
): ResolvedStream | null {
|
||||
if (streams.length === 0) return null;
|
||||
|
||||
const best = Math.max(...streams.map(scoreStream));
|
||||
const candidates = streams.filter((stream) => scoreStream(stream) === best);
|
||||
|
||||
if (preferredQuality !== undefined && preferredQuality.length > 0) {
|
||||
const needle = preferredQuality.toLowerCase();
|
||||
const match = candidates.find((stream) => stream.quality.toLowerCase().includes(needle));
|
||||
if (match) return match;
|
||||
}
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { interleave, mapSourcesConcurrently } from './multi-source-search';
|
||||
|
||||
const source = (id: string) => ({ id, name: `Source ${id}` });
|
||||
|
||||
test('mapSourcesConcurrently returns results in source order, not completion order', async () => {
|
||||
const sources = [source('a'), source('b'), source('c')];
|
||||
const delays: Record<string, number> = { a: 20, b: 0, c: 10 };
|
||||
|
||||
const { results, failures } = await mapSourcesConcurrently(sources, async (target) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, delays[target.id]));
|
||||
return target.id;
|
||||
});
|
||||
|
||||
assert.deepEqual(results, ['a', 'b', 'c']);
|
||||
assert.deepEqual(failures, []);
|
||||
});
|
||||
|
||||
test('a failing source is reported without losing the others', async () => {
|
||||
const sources = [source('a'), source('b'), source('c')];
|
||||
|
||||
const { results, failures } = await mapSourcesConcurrently(sources, async (target) => {
|
||||
if (target.id === 'b') throw new Error('login required');
|
||||
return target.id;
|
||||
});
|
||||
|
||||
assert.deepEqual(results, ['a', 'c']);
|
||||
assert.deepEqual(failures, [{ sourceId: 'b', sourceName: 'Source b', error: 'login required' }]);
|
||||
});
|
||||
|
||||
test('mapSourcesConcurrently never runs more than the concurrency limit at once', async () => {
|
||||
const sources = ['a', 'b', 'c', 'd', 'e'].map(source);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
|
||||
await mapSourcesConcurrently(
|
||||
sources,
|
||||
async () => {
|
||||
running += 1;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
running -= 1;
|
||||
},
|
||||
2,
|
||||
);
|
||||
|
||||
assert.equal(peak, 2);
|
||||
});
|
||||
|
||||
test('mapSourcesConcurrently handles an empty source list', async () => {
|
||||
const { results, failures } = await mapSourcesConcurrently([], async () => 'x');
|
||||
assert.deepEqual(results, []);
|
||||
assert.deepEqual(failures, []);
|
||||
});
|
||||
|
||||
test('interleave takes one from each source before taking a second', () => {
|
||||
assert.deepEqual(interleave([['a1', 'a2', 'a3'], ['b1'], ['c1', 'c2']]), [
|
||||
'a1',
|
||||
'b1',
|
||||
'c1',
|
||||
'a2',
|
||||
'c2',
|
||||
'a3',
|
||||
]);
|
||||
});
|
||||
|
||||
test('interleave ignores empty groups', () => {
|
||||
assert.deepEqual(interleave([[], ['b1', 'b2'], []]), ['b1', 'b2']);
|
||||
assert.deepEqual(interleave([]), []);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { SourceSearchFailure } from '../types/anime-browser';
|
||||
|
||||
/**
|
||||
* Running one query against every installed source at once.
|
||||
*
|
||||
* Each source is a separate extension behind the same single-threaded bridge,
|
||||
* so the fan-out is bounded rather than unleashed: a dozen extensions all
|
||||
* uploading and searching at once starves the ones the user is waiting on.
|
||||
*/
|
||||
|
||||
/** Enough to hide the latency of a slow source without queueing the bridge. */
|
||||
const DEFAULT_CONCURRENCY = 4;
|
||||
|
||||
export interface SourceTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface FanOutResult<T> {
|
||||
/** One entry per source that succeeded, in source order. */
|
||||
results: T[];
|
||||
/** One entry per source that threw, in source order. */
|
||||
failures: SourceSearchFailure[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `task` against every source, at most `concurrency` at a time.
|
||||
*
|
||||
* A source that throws becomes a failure instead of rejecting the whole call —
|
||||
* one misconfigured extension must not hide every other source's results.
|
||||
*/
|
||||
export async function mapSourcesConcurrently<S extends SourceTarget, T>(
|
||||
sources: S[],
|
||||
task: (source: S) => Promise<T>,
|
||||
concurrency: number = DEFAULT_CONCURRENCY,
|
||||
): Promise<FanOutResult<T>> {
|
||||
// Slots keep the output in source order regardless of completion order, so
|
||||
// the same query lays out the same way twice.
|
||||
const results: Array<{ value: T } | null> = sources.map(() => null);
|
||||
const failures: Array<SourceSearchFailure | null> = sources.map(() => null);
|
||||
let next = 0;
|
||||
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
const index = next;
|
||||
next += 1;
|
||||
const source = sources[index];
|
||||
if (!source) return;
|
||||
try {
|
||||
results[index] = { value: await task(source) };
|
||||
} catch (error) {
|
||||
failures[index] = {
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workers = Math.max(1, Math.min(concurrency, sources.length));
|
||||
await Promise.all(Array.from({ length: workers }, () => worker()));
|
||||
|
||||
return {
|
||||
results: results
|
||||
.filter((slot): slot is { value: T } => slot !== null)
|
||||
.map((slot) => slot.value),
|
||||
failures: failures.filter((slot): slot is SourceSearchFailure => slot !== null),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Round-robin merge, so the grid opens with one hit from each source rather
|
||||
* than the whole of the first source before the second one starts.
|
||||
*/
|
||||
export function interleave<T>(groups: T[][]): T[] {
|
||||
const merged: T[] = [];
|
||||
const longest = groups.reduce((max, group) => Math.max(max, group.length), 0);
|
||||
for (let index = 0; index < longest; index += 1) {
|
||||
for (const group of groups) {
|
||||
if (index < group.length) merged.push(group[index] as T);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { BridgePreference } from './types';
|
||||
|
||||
/**
|
||||
* Persists each source's preference array verbatim, keyed by bridge source id.
|
||||
*
|
||||
* Extensions keep credentials in here (the Jellyfin source stores a password),
|
||||
* so the file is written with owner-only permissions.
|
||||
*/
|
||||
export class PreferenceStore {
|
||||
private readonly file: string;
|
||||
private cache: Record<string, BridgePreference[]> | null = null;
|
||||
|
||||
constructor(file: string) {
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
private async load(): Promise<Record<string, BridgePreference[]>> {
|
||||
if (this.cache !== null) return this.cache;
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(this.file, 'utf8')) as unknown;
|
||||
this.cache =
|
||||
parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, BridgePreference[]>)
|
||||
: {};
|
||||
} catch {
|
||||
// Missing or corrupt file starts empty rather than blocking the browser.
|
||||
this.cache = {};
|
||||
}
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
async get(sourceId: string): Promise<BridgePreference[]> {
|
||||
const all = await this.load();
|
||||
return all[sourceId] ?? [];
|
||||
}
|
||||
|
||||
async set(sourceId: string, preferences: BridgePreference[]): Promise<void> {
|
||||
const all = await this.load();
|
||||
all[sourceId] = preferences;
|
||||
await this.persist(all);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every saved value whose key starts with `prefix`.
|
||||
*
|
||||
* Removing an extension should not leave its credentials on disk, and a
|
||||
* source id is not knowable once the APK is gone — so callers pass the
|
||||
* package name and this clears anything recorded under it.
|
||||
*/
|
||||
async clear(prefix: string): Promise<void> {
|
||||
const all = await this.load();
|
||||
let changed = false;
|
||||
for (const key of Object.keys(all)) {
|
||||
if (key === prefix || key.startsWith(`${prefix}:`)) {
|
||||
delete all[key];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) await this.persist(all);
|
||||
}
|
||||
|
||||
private async persist(all: Record<string, BridgePreference[]>): Promise<void> {
|
||||
await mkdir(path.dirname(this.file), { recursive: true });
|
||||
await writeFile(this.file, JSON.stringify(all, null, 2), { mode: 0o600 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyPreferenceValue,
|
||||
isSecretPreference,
|
||||
parsePreferences,
|
||||
type SourcePreferenceView,
|
||||
} from './preferences';
|
||||
import type { BridgePreference } from './types';
|
||||
|
||||
// Shapes taken from the real Jellyfin extension's preferencesAnime response.
|
||||
const RAW: BridgePreference[] = [
|
||||
{
|
||||
key: 'host_url',
|
||||
editTextPreference: {
|
||||
title: 'Address',
|
||||
summary: 'The server address',
|
||||
value: '',
|
||||
text: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'password',
|
||||
editTextPreference: { title: 'Password', summary: 'The user account password', value: '' },
|
||||
},
|
||||
{
|
||||
key: 'pref_quality',
|
||||
listPreference: {
|
||||
title: 'Preferred quality',
|
||||
summary: 'Preferred quality.',
|
||||
valueIndex: 0,
|
||||
entries: ['Source', '20 Mbps'],
|
||||
entryValues: ['source', '20000000'],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'pref_episode_details_key',
|
||||
multiSelectListPreference: {
|
||||
title: 'Additional details for episodes',
|
||||
values: [],
|
||||
entries: ['Overview', 'Runtime'],
|
||||
entryValues: ['overview', 'runtime'],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'pref_trust_cert',
|
||||
switchPreferenceCompat: { title: 'Trust certificate', value: false },
|
||||
},
|
||||
];
|
||||
|
||||
function view(views: SourcePreferenceView[], key: string): SourcePreferenceView {
|
||||
const found = views.find((candidate) => candidate.key === key);
|
||||
assert.ok(found, `missing preference ${key}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
test('parsePreferences flattens each widget type', () => {
|
||||
const views = parsePreferences(RAW);
|
||||
assert.equal(views.length, 5);
|
||||
|
||||
assert.equal(view(views, 'host_url').kind, 'text');
|
||||
assert.equal(view(views, 'host_url').title, 'Address');
|
||||
assert.equal(view(views, 'host_url').value, '');
|
||||
|
||||
const quality = view(views, 'pref_quality');
|
||||
assert.equal(quality.kind, 'list');
|
||||
// valueIndex 0 resolves through entryValues, not entries.
|
||||
assert.equal(quality.value, 'source');
|
||||
assert.deepEqual(quality.entries, ['Source', '20 Mbps']);
|
||||
|
||||
assert.deepEqual(view(views, 'pref_episode_details_key').value, []);
|
||||
assert.equal(view(views, 'pref_trust_cert').value, false);
|
||||
});
|
||||
|
||||
test('parsePreferences skips the bridge context entry and unknown widgets', () => {
|
||||
const views = parsePreferences([
|
||||
{ key: '__mangatan_bridge_context__', sourceId: '1' },
|
||||
{ key: 'mystery', someFutureWidget: { title: 'X' } },
|
||||
...RAW.slice(0, 1),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
views.map((v) => v.key),
|
||||
['host_url'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a list preference with no selection reads as empty', () => {
|
||||
const views = parsePreferences([
|
||||
{
|
||||
key: 'library_pref',
|
||||
listPreference: {
|
||||
title: 'Select media library',
|
||||
valueIndex: -1,
|
||||
entries: [],
|
||||
entryValues: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.equal(view(views, 'library_pref').value, '');
|
||||
});
|
||||
|
||||
test('applyPreferenceValue writes text into both value and text', () => {
|
||||
const updated = applyPreferenceValue(RAW, 'host_url', 'https://media.example');
|
||||
const body = updated.find((e) => e.key === 'host_url')!.editTextPreference as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assert.equal(body.value, 'https://media.example');
|
||||
assert.equal(body.text, 'https://media.example');
|
||||
// Other entries are untouched.
|
||||
assert.equal(parsePreferences(updated).length, RAW.length);
|
||||
});
|
||||
|
||||
test('applyPreferenceValue moves a list preference by entry value', () => {
|
||||
const updated = applyPreferenceValue(RAW, 'pref_quality', '20000000');
|
||||
const body = updated.find((e) => e.key === 'pref_quality')!.listPreference as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assert.equal(body.valueIndex, 1);
|
||||
assert.equal(parsePreferences(updated).find((v) => v.key === 'pref_quality')?.value, '20000000');
|
||||
});
|
||||
|
||||
test('applyPreferenceValue handles multi-select and switch widgets', () => {
|
||||
const multi = applyPreferenceValue(RAW, 'pref_episode_details_key', ['overview']);
|
||||
assert.deepEqual(
|
||||
parsePreferences(multi).find((v) => v.key === 'pref_episode_details_key')?.value,
|
||||
['overview'],
|
||||
);
|
||||
|
||||
const toggled = applyPreferenceValue(RAW, 'pref_trust_cert', true);
|
||||
assert.equal(parsePreferences(toggled).find((v) => v.key === 'pref_trust_cert')?.value, true);
|
||||
});
|
||||
|
||||
test('applyPreferenceValue leaves unknown keys alone', () => {
|
||||
assert.deepEqual(applyPreferenceValue(RAW, 'not-a-key', 'x'), RAW);
|
||||
});
|
||||
|
||||
test('secrets are recognised by key or title', () => {
|
||||
const views = parsePreferences(RAW);
|
||||
assert.equal(isSecretPreference(view(views, 'password')), true);
|
||||
assert.equal(isSecretPreference(view(views, 'host_url')), false);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { BridgePreference } from './types';
|
||||
|
||||
/**
|
||||
* Extension preferences arrive as Android preference objects, one wrapper key
|
||||
* per widget type. They are stored and sent back verbatim so the extension sees
|
||||
* exactly the shape it produced; only the value field is edited.
|
||||
*/
|
||||
|
||||
export type PreferenceKind = 'text' | 'list' | 'multi' | 'switch';
|
||||
|
||||
/** A preference flattened for rendering. */
|
||||
export interface SourcePreferenceView {
|
||||
key: string;
|
||||
kind: PreferenceKind;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
/** Current value: string for text/list, string[] for multi, boolean for switch. */
|
||||
value: string | string[] | boolean;
|
||||
/** Display labels, parallel to entryValues. Empty for text/switch. */
|
||||
entries: string[];
|
||||
entryValues: string[];
|
||||
}
|
||||
|
||||
const WIDGETS = {
|
||||
editTextPreference: 'text',
|
||||
listPreference: 'list',
|
||||
multiSelectListPreference: 'multi',
|
||||
switchPreferenceCompat: 'switch',
|
||||
checkBoxPreference: 'switch',
|
||||
} as const satisfies Record<string, PreferenceKind>;
|
||||
|
||||
type WidgetName = keyof typeof WIDGETS;
|
||||
|
||||
function widgetOf(
|
||||
entry: BridgePreference,
|
||||
): { name: WidgetName; body: Record<string, unknown> } | null {
|
||||
for (const name of Object.keys(WIDGETS) as WidgetName[]) {
|
||||
const body = entry[name];
|
||||
if (body !== null && typeof body === 'object') {
|
||||
return { name, body: body as Record<string, unknown> };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === 'string')
|
||||
: [];
|
||||
}
|
||||
|
||||
/** Flatten bridge preference entries for display. Unknown widgets are skipped. */
|
||||
export function parsePreferences(raw: BridgePreference[]): SourcePreferenceView[] {
|
||||
const views: SourcePreferenceView[] = [];
|
||||
|
||||
for (const entry of raw) {
|
||||
if (typeof entry.key !== 'string' || entry.key.startsWith('__')) continue;
|
||||
const widget = widgetOf(entry);
|
||||
if (!widget) continue;
|
||||
|
||||
const { body } = widget;
|
||||
const kind = WIDGETS[widget.name];
|
||||
const entries = stringList(body.entries);
|
||||
const entryValues = stringList(body.entryValues);
|
||||
|
||||
let value: string | string[] | boolean;
|
||||
if (kind === 'multi') {
|
||||
value = stringList(body.values);
|
||||
} else if (kind === 'switch') {
|
||||
value = body.value === true;
|
||||
} else if (kind === 'list') {
|
||||
const index = typeof body.valueIndex === 'number' ? body.valueIndex : -1;
|
||||
// valueIndex is -1 when the extension has no selection yet.
|
||||
value = index >= 0 && index < entryValues.length ? entryValues[index]! : '';
|
||||
} else {
|
||||
value = typeof body.value === 'string' ? body.value : '';
|
||||
}
|
||||
|
||||
views.push({
|
||||
key: entry.key,
|
||||
kind,
|
||||
title: typeof body.title === 'string' ? body.title : entry.key,
|
||||
summary: typeof body.summary === 'string' ? body.summary : null,
|
||||
value,
|
||||
entries,
|
||||
entryValues,
|
||||
});
|
||||
}
|
||||
|
||||
return views;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a copy of `raw` with one preference's value replaced, in whichever
|
||||
* fields that widget type reads. Unknown keys are returned unchanged.
|
||||
*/
|
||||
export function applyPreferenceValue(
|
||||
raw: BridgePreference[],
|
||||
key: string,
|
||||
value: string | string[] | boolean,
|
||||
): BridgePreference[] {
|
||||
return raw.map((entry) => {
|
||||
if (entry.key !== key) return entry;
|
||||
const widget = widgetOf(entry);
|
||||
if (!widget) return entry;
|
||||
|
||||
const body = { ...widget.body };
|
||||
const kind = WIDGETS[widget.name];
|
||||
|
||||
if (kind === 'multi') {
|
||||
body.values = Array.isArray(value) ? value : [];
|
||||
} else if (kind === 'switch') {
|
||||
body.value = value === true;
|
||||
} else if (kind === 'list') {
|
||||
const entryValues = stringList(body.entryValues);
|
||||
const index = entryValues.indexOf(String(value));
|
||||
body.valueIndex = index;
|
||||
if (index >= 0) body.value = entryValues[index];
|
||||
} else {
|
||||
// editTextPreference carries the same string in both value and text.
|
||||
body.value = String(value);
|
||||
body.text = String(value);
|
||||
}
|
||||
|
||||
return { ...entry, [widget.name]: body };
|
||||
});
|
||||
}
|
||||
|
||||
/** True when the preference should be masked in the UI and in logs. */
|
||||
export function isSecretPreference(view: SourcePreferenceView): boolean {
|
||||
return /password|token|api[-_ ]?key|secret/i.test(`${view.key} ${view.title}`);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
findBundleBinaries,
|
||||
PINNED_BUNDLE_SHA256,
|
||||
PINNED_BUNDLE_TAG,
|
||||
resolveBundleAssetName,
|
||||
selectBundleAsset,
|
||||
sha256,
|
||||
verifyPinnedBundle,
|
||||
} from './sidecar-bundle';
|
||||
|
||||
test('resolveBundleAssetName maps supported platform/arch pairs', () => {
|
||||
assert.equal(resolveBundleAssetName('darwin', 'arm64'), 'macOS-arm64-bundle.zip');
|
||||
assert.equal(resolveBundleAssetName('darwin', 'x64'), 'macOS-x64-bundle.zip');
|
||||
assert.equal(resolveBundleAssetName('linux', 'x64'), 'linux-x64-bundle.zip');
|
||||
assert.equal(resolveBundleAssetName('win32', 'x64'), 'windows-x64-bundle.zip');
|
||||
});
|
||||
|
||||
test('resolveBundleAssetName returns null for unpublished combinations', () => {
|
||||
assert.equal(resolveBundleAssetName('linux', 'arm64'), null);
|
||||
assert.equal(resolveBundleAssetName('win32', 'arm64'), null);
|
||||
assert.equal(resolveBundleAssetName('freebsd', 'x64'), null);
|
||||
});
|
||||
|
||||
test('selectBundleAsset skips releases without a matching asset', () => {
|
||||
const releases = [
|
||||
// The iOS runtime release carries no desktop bundle.
|
||||
{ tag_name: 'ios-runtime-v7', assets: [{ name: 'MExtensionServer-ios.jar' }] },
|
||||
{
|
||||
tag_name: 'v1.0.6.0',
|
||||
assets: [
|
||||
{
|
||||
name: 'macOS-arm64-bundle.zip',
|
||||
browser_download_url: 'https://example.test/macOS-arm64-bundle.zip',
|
||||
size: 133_058_560,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const asset = selectBundleAsset(releases, 'macOS-arm64-bundle.zip');
|
||||
assert.equal(asset?.tagName, 'v1.0.6.0');
|
||||
assert.equal(asset?.downloadUrl, 'https://example.test/macOS-arm64-bundle.zip');
|
||||
assert.equal(asset?.sizeBytes, 133_058_560);
|
||||
});
|
||||
|
||||
test('selectBundleAsset returns null when nothing matches', () => {
|
||||
assert.equal(selectBundleAsset([{ tag_name: 'v1', assets: [] }], 'linux-x64-bundle.zip'), null);
|
||||
assert.equal(selectBundleAsset([], 'linux-x64-bundle.zip'), null);
|
||||
assert.equal(selectBundleAsset({ message: 'rate limited' }, 'linux-x64-bundle.zip'), null);
|
||||
});
|
||||
|
||||
test('findBundleBinaries locates the nested jre and jar', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'subminer-bundle-'));
|
||||
await mkdir(path.join(root, 'jre', 'jre', 'bin'), { recursive: true });
|
||||
await writeFile(path.join(root, 'jre', 'jre', 'bin', 'java'), '');
|
||||
await writeFile(path.join(root, 'MExtensionServer-1.0.6.0.jar'), '');
|
||||
|
||||
const binaries = await findBundleBinaries(root);
|
||||
assert.equal(binaries?.javaPath, path.join(root, 'jre', 'jre', 'bin', 'java'));
|
||||
assert.equal(binaries?.jarPath, path.join(root, 'MExtensionServer-1.0.6.0.jar'));
|
||||
});
|
||||
|
||||
test('findBundleBinaries prefers the shallowest java when a nested copy exists', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'subminer-bundle-'));
|
||||
await mkdir(path.join(root, 'bin'), { recursive: true });
|
||||
await mkdir(path.join(root, 'bin', 'nested', 'bin'), { recursive: true });
|
||||
await writeFile(path.join(root, 'bin', 'java'), '');
|
||||
await writeFile(path.join(root, 'bin', 'nested', 'bin', 'java'), '');
|
||||
await writeFile(path.join(root, 'MExtensionServer.jar'), '');
|
||||
|
||||
const binaries = await findBundleBinaries(root);
|
||||
assert.equal(binaries?.javaPath, path.join(root, 'bin', 'java'));
|
||||
});
|
||||
|
||||
test('findBundleBinaries returns null when the bundle is incomplete', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'subminer-bundle-'));
|
||||
await writeFile(path.join(root, 'MExtensionServer.jar'), '');
|
||||
assert.equal(await findBundleBinaries(root), null);
|
||||
});
|
||||
|
||||
test('sha256 produces lowercase hex digests matching known vectors', () => {
|
||||
const encode = (value: string) => new TextEncoder().encode(value);
|
||||
assert.equal(
|
||||
sha256(encode('')),
|
||||
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||||
);
|
||||
assert.equal(
|
||||
sha256(encode('subminer')),
|
||||
'f3b7fdb2037add4cd8f122c090a727243b46b1b9d8a6c379f71573e2df120885',
|
||||
);
|
||||
});
|
||||
|
||||
test('verifyPinnedBundle accepts a matching hash and rejects a mismatch', () => {
|
||||
const asset = 'macOS-arm64-bundle.zip';
|
||||
const wrong = verifyPinnedBundle(asset, new TextEncoder().encode('not the bundle'));
|
||||
assert.equal(wrong.ok, false);
|
||||
assert.match((wrong as { reason: string }).reason, /Checksum mismatch/);
|
||||
});
|
||||
|
||||
test('verifyPinnedBundle refuses an asset that has no pin', () => {
|
||||
const result = verifyPinnedBundle('linux-x64-bundle.zip', new Uint8Array([1, 2, 3]));
|
||||
assert.equal(result.ok, false);
|
||||
assert.match((result as { reason: string }).reason, /No pinned checksum/);
|
||||
});
|
||||
|
||||
test('the pinned tag and macOS arm64 hash are the verified release', () => {
|
||||
assert.equal(PINNED_BUNDLE_TAG, 'v1.0.6.0');
|
||||
assert.equal(
|
||||
PINNED_BUNDLE_SHA256['macOS-arm64-bundle.zip'],
|
||||
'5f4fb03abfe88bc46ddf5f4d8221156ee2d66b9cbad7c4bc3ade4baf3a4266e6',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Locates the M-Extension-Server release bundle for the host platform. Each
|
||||
* bundle ships a matching JRE alongside the server JAR, so no system JDK is
|
||||
* required.
|
||||
*/
|
||||
|
||||
export const BUNDLE_RELEASES_URL =
|
||||
'https://api.github.com/repos/1Selxo/M-Extension-Server/releases?page=1&per_page=10';
|
||||
|
||||
/**
|
||||
* The bridge release this integration was verified against.
|
||||
*
|
||||
* Upstream publishes no checksums for the desktop bundles, so we pin a tag and
|
||||
* a hash we computed ourselves rather than tracking "latest". Bumping this
|
||||
* means downloading the new asset, verifying it starts and reports the
|
||||
* capabilities in `AnimeBridgeClient.isReady`, then updating both fields.
|
||||
*/
|
||||
export const PINNED_BUNDLE_TAG = 'v1.0.6.0';
|
||||
|
||||
/** SHA-256 of each pinned asset, keyed by release asset name. */
|
||||
export const PINNED_BUNDLE_SHA256: Readonly<Record<string, string>> = {
|
||||
'macOS-arm64-bundle.zip': '5f4fb03abfe88bc46ddf5f4d8221156ee2d66b9cbad7c4bc3ade4baf3a4266e6',
|
||||
};
|
||||
|
||||
/**
|
||||
* Check a downloaded asset against its pin. Assets we have not verified
|
||||
* ourselves are rejected rather than trusted, so an unpinned platform fails
|
||||
* loudly instead of silently running an unchecked binary.
|
||||
*/
|
||||
export function verifyPinnedBundle(
|
||||
assetName: string,
|
||||
bytes: Uint8Array,
|
||||
): { ok: true } | { ok: false; reason: string } {
|
||||
const expected = PINNED_BUNDLE_SHA256[assetName];
|
||||
if (expected === undefined) {
|
||||
return { ok: false, reason: `No pinned checksum for ${assetName}; refusing to run it.` };
|
||||
}
|
||||
const actual = sha256(bytes);
|
||||
if (actual !== expected) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}.`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Release asset name for a platform/arch pair, or null when unsupported. */
|
||||
export function resolveBundleAssetName(platform: string, arch: string): string | null {
|
||||
if (platform === 'linux') return arch === 'x64' ? 'linux-x64-bundle.zip' : null;
|
||||
if (platform === 'win32') return arch === 'x64' ? 'windows-x64-bundle.zip' : null;
|
||||
if (platform === 'darwin') {
|
||||
if (arch === 'arm64') return 'macOS-arm64-bundle.zip';
|
||||
if (arch === 'x64') return 'macOS-x64-bundle.zip';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface BundleBinaries {
|
||||
javaPath: string;
|
||||
jarPath: string;
|
||||
}
|
||||
|
||||
async function walk(dir: string, depth: number, onFile: (file: string) => void): Promise<void> {
|
||||
if (depth < 0) return;
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) await walk(full, depth - 1, onFile);
|
||||
else onFile(full);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the bundled `java` executable and `MExtensionServer-*.jar` inside an
|
||||
* extracted bundle. The archive nests them a few levels deep and the exact
|
||||
* layout differs per platform, so this searches rather than assuming a path.
|
||||
*/
|
||||
export async function findBundleBinaries(rootDir: string): Promise<BundleBinaries | null> {
|
||||
const javaCandidates: string[] = [];
|
||||
const jarCandidates: string[] = [];
|
||||
|
||||
await walk(rootDir, 6, (file) => {
|
||||
const base = path.basename(file);
|
||||
if (base === 'java' || base === 'java.exe') javaCandidates.push(file);
|
||||
else if (/^MExtensionServer.*\.jar$/.test(base)) jarCandidates.push(file);
|
||||
});
|
||||
|
||||
// Prefer the shallowest match so a nested duplicate never shadows the real one.
|
||||
const byDepth = (a: string, b: string) => a.split(path.sep).length - b.split(path.sep).length;
|
||||
const javaPath = javaCandidates.sort(byDepth)[0];
|
||||
const jarPath = jarCandidates.sort(byDepth)[0];
|
||||
if (!javaPath || !jarPath) return null;
|
||||
return { javaPath, jarPath };
|
||||
}
|
||||
|
||||
/** Verify a downloaded archive against a pinned SHA-256, as Mangatan does. */
|
||||
export function sha256(bytes: Uint8Array): string {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
export async function isExecutableFile(file: string): Promise<boolean> {
|
||||
try {
|
||||
const info = await stat(file);
|
||||
return info.isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface BundleAsset {
|
||||
tagName: string;
|
||||
assetName: string;
|
||||
downloadUrl: string;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
interface GithubRelease {
|
||||
tag_name?: string;
|
||||
assets?: Array<{ name?: string; browser_download_url?: string; size?: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the newest release carrying an asset for this platform. Releases are
|
||||
* returned newest-first, and some (the iOS runtime) carry no desktop bundle.
|
||||
*/
|
||||
export function selectBundleAsset(releases: unknown, assetName: string): BundleAsset | null {
|
||||
if (!Array.isArray(releases)) return null;
|
||||
for (const release of releases as GithubRelease[]) {
|
||||
const asset = release.assets?.find((candidate) => candidate.name === assetName);
|
||||
if (asset?.browser_download_url && release.tag_name) {
|
||||
return {
|
||||
tagName: release.tag_name,
|
||||
assetName,
|
||||
downloadUrl: asset.browser_download_url,
|
||||
sizeBytes: asset.size ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { createServer } from 'node:net';
|
||||
import { AnimeBridgeClient } from './bridge-client';
|
||||
import type { BundleBinaries } from './sidecar-bundle';
|
||||
|
||||
/** Cold start includes JVM boot plus AndroidCompat init; be generous. */
|
||||
export const DEFAULT_READY_TIMEOUT_MS = 30_000;
|
||||
const READY_POLL_INTERVAL_MS = 500;
|
||||
|
||||
/** 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) => {
|
||||
const server = createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === 'string') {
|
||||
server.close();
|
||||
reject(new Error('Could not allocate a loopback port for the anime bridge.'));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface SidecarHandle {
|
||||
baseUrl: string;
|
||||
port: number;
|
||||
client: AnimeBridgeClient;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface StartSidecarOptions {
|
||||
binaries: BundleBinaries;
|
||||
port?: number;
|
||||
readyTimeoutMs?: number;
|
||||
spawnImpl?: typeof spawn;
|
||||
onLog?: (line: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the bridge and wait until it reports the capabilities this client
|
||||
* needs. The desktop launch contract is `java -jar MExtensionServer.jar <port>`,
|
||||
* run from the JAR's own directory.
|
||||
*/
|
||||
export async function startSidecar(options: StartSidecarOptions): Promise<SidecarHandle> {
|
||||
const { binaries } = options;
|
||||
const port = options.port ?? (await allocatePort());
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const spawnProcess = options.spawnImpl ?? spawn;
|
||||
|
||||
const child: ChildProcess = spawnProcess(
|
||||
binaries.javaPath,
|
||||
['-jar', binaries.jarPath, String(port)],
|
||||
{
|
||||
cwd: path.dirname(binaries.jarPath),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
);
|
||||
|
||||
const log = options.onLog;
|
||||
if (log) {
|
||||
child.stdout?.on('data', (chunk: Buffer) => log(chunk.toString().trimEnd()));
|
||||
child.stderr?.on('data', (chunk: Buffer) => log(chunk.toString().trimEnd()));
|
||||
}
|
||||
|
||||
let exited: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
||||
child.once('exit', (code, signal) => {
|
||||
exited = { code, signal };
|
||||
});
|
||||
|
||||
const stop = async (): Promise<void> => {
|
||||
if (exited !== null) return;
|
||||
// Graceful first: the server exposes a shutdown endpoint.
|
||||
try {
|
||||
await fetch(`${baseUrl}/stop`, { signal: AbortSignal.timeout(2000) });
|
||||
} catch {
|
||||
// Falling through to a signal is fine; the endpoint may already be gone.
|
||||
}
|
||||
if (exited === null) child.kill();
|
||||
};
|
||||
|
||||
const client = new AnimeBridgeClient({ baseUrl });
|
||||
const deadline = Date.now() + (options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (exited !== null) {
|
||||
const { code, signal } = exited as { code: number | null; signal: NodeJS.Signals | null };
|
||||
throw new Error(
|
||||
`Anime bridge exited before becoming ready (code ${code}, signal ${signal}).`,
|
||||
);
|
||||
}
|
||||
if (await client.isReady()) return { baseUrl, port, client, stop };
|
||||
await new Promise((resolve) => setTimeout(resolve, READY_POLL_INTERVAL_MS));
|
||||
}
|
||||
|
||||
await stop();
|
||||
throw new Error(
|
||||
`Anime bridge did not become ready within ${options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS}ms.`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Wire types for the M-Extension-Server bridge, which runs Aniyomi
|
||||
* (`eu.kanade.tachiyomi.animeextension`) APKs on a desktop JVM and exposes
|
||||
* them over loopback HTTP.
|
||||
*
|
||||
* Field names mirror the server's JSON exactly, including Kotlin/OkHttp
|
||||
* internals that leak into the payload (see `OkHttpHeaders`).
|
||||
*/
|
||||
|
||||
/** Marker key the server uses to select a source inside a SourceFactory APK. */
|
||||
export const BRIDGE_CONTEXT_KEY = '__mangatan_bridge_context__';
|
||||
|
||||
/** Handshake shape from `GET /capabilities`. */
|
||||
export interface BridgeCapabilities {
|
||||
mangatanMihonBridge?: number;
|
||||
sourceFactory?: boolean;
|
||||
preferenceCallbacks?: boolean;
|
||||
youtubeResolver?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* OkHttp serializes `Headers` as a flat alternating name/value array under an
|
||||
* internal field name. Kept verbatim so parsing stays honest about the source.
|
||||
*/
|
||||
export interface OkHttpHeaders {
|
||||
namesAndValues$okhttp?: string[];
|
||||
}
|
||||
|
||||
export interface BridgeTrack {
|
||||
url?: string;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
/** One playable stream returned by an extension's `getVideoList`. */
|
||||
export interface BridgeVideo {
|
||||
/** Page/embed URL the stream was extracted from. */
|
||||
url?: string;
|
||||
/** Display label, e.g. "1080p". */
|
||||
quality?: string;
|
||||
/**
|
||||
* Playable media URL. Normally a `/video/<token>` proxy URL on the bridge
|
||||
* itself, valid only while that server process is alive.
|
||||
*/
|
||||
videoUrl?: string;
|
||||
headers?: OkHttpHeaders;
|
||||
audioTracks?: BridgeTrack[];
|
||||
subtitleTracks?: BridgeTrack[];
|
||||
}
|
||||
|
||||
export interface BridgeEpisode {
|
||||
name?: string;
|
||||
url?: string;
|
||||
date_upload?: number;
|
||||
scanlator?: string;
|
||||
episode_number?: number;
|
||||
}
|
||||
|
||||
export interface BridgeAnime {
|
||||
url?: string;
|
||||
title?: string;
|
||||
artist?: string;
|
||||
author?: string;
|
||||
description?: string;
|
||||
genres?: string[];
|
||||
status?: number;
|
||||
thumbnail_url?: string;
|
||||
}
|
||||
|
||||
/** One source inside an extension APK. A SourceFactory APK yields several. */
|
||||
export interface BridgeSourceDescriptor {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
lang?: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export interface BridgeAnimePage {
|
||||
animes?: BridgeAnime[];
|
||||
hasNextPage?: boolean;
|
||||
}
|
||||
|
||||
/** The server reports failures as HTTP 200 with an error body. */
|
||||
export interface BridgeErrorBody {
|
||||
error?: string;
|
||||
code?: number;
|
||||
}
|
||||
|
||||
/** A source preference entry, passed through to the extension unchanged. */
|
||||
export interface BridgePreference {
|
||||
key: string;
|
||||
[field: string]: unknown;
|
||||
}
|
||||
|
||||
/** Normalized stream, ready to hand to mpv. */
|
||||
export interface ResolvedStream {
|
||||
url: string;
|
||||
quality: string;
|
||||
headers: Record<string, string>;
|
||||
subtitles: Array<{ url: string; lang: string }>;
|
||||
audios: Array<{ url: string; lang: string }>;
|
||||
}
|
||||
Reference in New Issue
Block a user