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

- Add `subminer anime` / `--anime` and a tray entry to open a browser that searches installed Aniyomi extension sources, shows cover art and episodes, and plays into mpv with overlay/mining attached
- Add an Extensions tab to add repos and install/update/remove sources, and per-source settings for sources needing config
- Support searching all sources at once with streaming, per-source results and status
- Prefer Japanese audio/subtitle tracks from the source and keep the primary subtitle slot reserved for Japanese
- Fix window/tray/Dock handling so the browser and mpv can be switched between without quitting the app or losing the Dock icon
- Add anime.repos, anime.extensionsDir, anime.preferredQuality config keys (no bundled repos or discovery)
This commit is contained in:
2026-07-31 17:16:49 -07:00
parent 5b8848518a
commit c5140b0f7b
117 changed files with 7563 additions and 527 deletions
+148
View File
@@ -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');
});
+214
View File
@@ -0,0 +1,214 @@
import { BRIDGE_CONTEXT_KEY } from './types';
import type {
BridgeAnime,
BridgeAnimePage,
BridgeCapabilities,
BridgeEpisode,
BridgePreference,
BridgeSourceDescriptor,
BridgeVideo,
} from './types';
const EXTENSION_ID_HEADER = 'x-mangatan-extension-id';
const EXTENSION_ID_PATTERN = /^[0-9a-f]{64}$/;
export interface BridgeSource {
/** Base64-encoded extension APK. */
apkBase64: string;
/** Selects one source inside a multi-source (SourceFactory) APK. */
sourceId?: string;
preferences?: BridgePreference[];
}
export interface BridgeClientOptions {
/** Loopback base URL of the running bridge, e.g. `http://127.0.0.1:53112`. */
baseUrl: string;
fetchImpl?: typeof fetch;
}
/** The bridge reports extension failures as HTTP 200 with an error body. */
export class BridgeExtensionError extends Error {
readonly code?: number;
constructor(message: string, code?: number) {
super(message);
this.name = 'BridgeExtensionError';
this.code = code;
}
}
/**
* Client for the M-Extension-Server `/dalvik` RPC endpoint.
*
* The server caches uploaded APKs and returns a content hash, letting
* subsequent calls send that id instead of re-uploading megabytes of base64.
* A 409 means the cache was evicted, so the APK is resent once.
*/
export class AnimeBridgeClient {
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly extensionIds = new Map<string, string>();
constructor(options: BridgeClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
this.fetchImpl = options.fetchImpl ?? fetch;
}
async getCapabilities(): Promise<BridgeCapabilities> {
const response = await this.fetchImpl(`${this.baseUrl}/capabilities`);
if (!response.ok) {
throw new Error(`Anime bridge capabilities check failed (${response.status}).`);
}
return (await response.json()) as BridgeCapabilities;
}
/** True once the bridge is up and reports the features this client needs. */
async isReady(): Promise<boolean> {
try {
const capabilities = await this.getCapabilities();
return (
capabilities.mangatanMihonBridge === 1 &&
capabilities.sourceFactory === true &&
capabilities.preferenceCallbacks === true
);
} catch {
return false;
}
}
async searchAnime(
source: BridgeSource,
query: string,
page = 1,
filterList: unknown[] = [],
): Promise<BridgeAnimePage> {
return this.call<BridgeAnimePage>(source, 'getSearchAnime', {
page,
search: query,
filterList,
});
}
/**
* List the sources an extension APK provides. A single APK may expose many
* (a SourceFactory), so this is how a package becomes selectable entries.
*/
async listAnimeSources(source: BridgeSource): Promise<BridgeSourceDescriptor[]> {
return this.call<BridgeSourceDescriptor[]>(source, 'sourcesAnime', {});
}
/** The extension's own settings schema, with current values. */
async getSourcePreferences(source: BridgeSource): Promise<BridgePreference[]> {
return this.call<BridgePreference[]>(source, 'preferencesAnime', {});
}
/**
* Commit a preference change. The whole array is sent back with the edited
* entry, and `changedPreferenceKey` tells the extension which one moved so it
* can react (the Jellyfin source logs in when the address or password lands).
* Returns the extension's refreshed schema.
*/
async setSourcePreference(
source: BridgeSource,
changedPreferenceKey: string,
): Promise<BridgePreference[]> {
return this.call<BridgePreference[]>(source, 'setPreferenceAnime', {}, changedPreferenceKey);
}
/** Full metadata for one anime: description, cover art, genres, status. */
async getAnimeDetails(source: BridgeSource, animeUrl: string): Promise<BridgeAnime> {
return this.call<BridgeAnime>(source, 'getDetailsAnime', {
animeData: { url: animeUrl },
});
}
async getPopularAnime(source: BridgeSource, page = 1): Promise<BridgeAnimePage> {
return this.call<BridgeAnimePage>(source, 'getPopularAnime', { page });
}
async getEpisodeList(source: BridgeSource, animeUrl: string): Promise<BridgeEpisode[]> {
return this.call<BridgeEpisode[]>(source, 'getEpisodeList', {
animeData: { url: animeUrl },
});
}
async getVideoList(source: BridgeSource, episodeUrl: string): Promise<BridgeVideo[]> {
return this.call<BridgeVideo[]>(source, 'getVideoList', {
episodeData: { url: episodeUrl },
});
}
private buildPreferences(
source: BridgeSource,
changedPreferenceKey?: string,
): BridgePreference[] {
const context: BridgePreference = { key: BRIDGE_CONTEXT_KEY };
if (source.sourceId !== undefined) context.sourceId = source.sourceId;
if (changedPreferenceKey !== undefined) context.changedPreferenceKey = changedPreferenceKey;
return [...(source.preferences ?? []), context];
}
private async call<T>(
source: BridgeSource,
method: string,
extras: Record<string, unknown>,
changedPreferenceKey?: string,
): Promise<T> {
const cacheKey = source.sourceId ?? source.apkBase64;
const cachedId = this.extensionIds.get(cacheKey);
let response = await this.post(method, extras, source, cachedId, changedPreferenceKey);
if (response.status === 409 && cachedId !== undefined) {
// Server evicted the cached APK; upload it again.
this.extensionIds.delete(cacheKey);
response = await this.post(method, extras, source, undefined, changedPreferenceKey);
}
if (!response.ok) {
throw new Error(`Anime bridge ${method} failed (${response.status}).`);
}
const returnedId = response.headers.get(EXTENSION_ID_HEADER)?.trim();
if (returnedId && EXTENSION_ID_PATTERN.test(returnedId)) {
this.extensionIds.set(cacheKey, returnedId);
}
const body = (await response.json()) as T;
assertNoExtensionError(body, method);
return body;
}
private post(
method: string,
extras: Record<string, unknown>,
source: BridgeSource,
extensionId: string | undefined,
changedPreferenceKey?: string,
): Promise<Response> {
const payload: Record<string, unknown> = {
method,
...extras,
preferences: this.buildPreferences(source, changedPreferenceKey),
...(extensionId === undefined ? { data: source.apkBase64 } : { extensionId }),
};
return this.fetchImpl(`${this.baseUrl}/dalvik`, {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
Accept: 'application/json',
},
body: JSON.stringify(payload),
});
}
}
function assertNoExtensionError(body: unknown, method: string): void {
if (body === null || typeof body !== 'object' || Array.isArray(body)) return;
const error = (body as { error?: unknown }).error;
if (typeof error !== 'string') return;
const code = (body as { code?: unknown }).code;
throw new BridgeExtensionError(
`Anime bridge ${method} failed: ${error}`,
typeof code === 'number' ? code : undefined,
);
}
@@ -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);
});
+66
View File
@@ -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 });
}
+177
View File
@@ -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',
);
});
+183
View File
@@ -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`;
}
+146
View File
@@ -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');
});
+117
View File
@@ -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;
}
+66
View File
@@ -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, '');
});
+54
View File
@@ -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),
};
}
+69
View File
@@ -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');
});
+74
View File
@@ -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';
}
}
+220
View File
@@ -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);
});
+234
View File
@@ -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([]), []);
});
+85
View File
@@ -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;
}
+68
View File
@@ -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 });
}
}
+143
View File
@@ -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);
});
+132
View File
@@ -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}`);
}
+117
View File
@@ -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',
);
});
+150
View File
@@ -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;
}
+104
View File
@@ -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.`,
);
}
+101
View File
@@ -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 }>;
}
+505
View File
@@ -0,0 +1,505 @@
import { describe, el } from './dom';
import { sourceOptionLabel, summarizeSearch } from './format';
import { applySearchUpdate, idleSearchProgress, summarizeProgress } from './search-progress';
import { createExtensionsPanel } from './extensions-panel';
import { renderPreferences, renderPreferencesUnavailable } from './preferences-fields';
import { ALL_SOURCES_ID } from '../types/anime-browser';
import type {
AnimeBrowserAPI,
AnimeBrowserBridgeState,
AnimeBrowserEntry,
AnimeBrowserEpisode,
AnimeBrowserSource,
} from '../types/anime-browser';
declare global {
interface Window {
animeBrowserAPI: AnimeBrowserAPI;
}
}
const api = window.animeBrowserAPI;
const searchForm = el<HTMLFormElement>('search-form');
const searchInput = el<HTMLInputElement>('search-input');
const searchButton = el<HTMLButtonElement>('search-button');
const sourceSelect = el<HTMLSelectElement>('source-select');
const grid = el<HTMLDivElement>('grid');
const gridEmpty = el<HTMLParagraphElement>('grid-empty');
const results = el<HTMLElement>('results');
const detail = el<HTMLElement>('detail');
const detailBack = el<HTMLButtonElement>('detail-back');
const detailCover = el<HTMLImageElement>('detail-cover');
const detailTitle = el<HTMLHeadingElement>('detail-title');
const detailChips = el<HTMLDivElement>('detail-chips');
const detailDescription = el<HTMLParagraphElement>('detail-description');
const episodes = el<HTMLOListElement>('episodes');
const episodesCount = el<HTMLSpanElement>('episodes-count');
const banner = el<HTMLDivElement>('bridge-banner');
const bannerMessage = el<HTMLSpanElement>('bridge-message');
const bannerMeter = el<HTMLSpanElement>('bridge-meter');
const bannerMeterFill = el<HTMLElement>('bridge-meter-fill');
const statusMessage = el<HTMLSpanElement>('status-message');
const browseTab = el<HTMLButtonElement>('tab-browse');
const extensionsTab = el<HTMLButtonElement>('tab-extensions');
const settingsTab = el<HTMLButtonElement>('tab-settings');
const layout = el<HTMLElement>('layout');
const extensionsPanel = el<HTMLElement>('extensions');
const settingsPanel = el<HTMLElement>('settings');
const settingsFields = el<HTMLDivElement>('settings-fields');
const settingsTitle = el<HTMLHeadingElement>('settings-title');
/** The anime the detail page is showing, with the source that produced it. */
let selectedAnime: { url: string; title: string; sourceId: string } | null = null;
/** Where the results grid was scrolled to before the detail page covered it. */
let resultsScrollTop = 0;
/* ---------- tabs ---------- */
type View = 'browse' | 'extensions' | 'settings';
let currentView: View = 'browse';
/**
* Show one view at a time. The panels used to sit above the results, which left
* a long extension list scrolling inside a sliver of the window; as tabs each
* one gets the whole content region.
*/
function setView(view: View): void {
currentView = view;
layout.classList.toggle('hidden', view !== 'browse');
extensionsPanel.classList.toggle('hidden', view !== 'extensions');
settingsPanel.classList.toggle('hidden', view !== 'settings');
browseTab.setAttribute('aria-selected', String(view === 'browse'));
extensionsTab.setAttribute('aria-selected', String(view === 'extensions'));
settingsTab.setAttribute('aria-selected', String(view === 'settings'));
}
function setStatus(message: string, tone: 'info' | 'ok' | 'error' = 'info'): void {
statusMessage.textContent = message;
statusMessage.parentElement?.setAttribute('data-tone', tone);
}
const BRIDGE_LABELS: Record<AnimeBrowserBridgeState['stage'], string> = {
idle: 'Starting the extension bridge',
locating: 'Looking up the extension bridge release',
downloading: 'Downloading the extension bridge',
verifying: 'Verifying the download',
extracting: 'Unpacking the extension bridge',
starting: 'Starting the extension bridge',
ready: 'Bridge ready',
failed: 'Bridge failed to start',
};
const BUSY_STAGES = new Set([
'idle',
'locating',
'downloading',
'verifying',
'extracting',
'starting',
]);
function renderBridgeState(state: AnimeBrowserBridgeState): void {
const busy = BUSY_STAGES.has(state.stage);
banner.dataset.stage = state.stage;
banner.dataset.busy = String(busy);
// Once ready with nothing to report, the banner has nothing to say.
const hide = state.stage === 'ready' && state.message === null;
banner.classList.toggle('hidden', hide);
bannerMessage.textContent = state.message ?? BRIDGE_LABELS[state.stage];
const showMeter = state.progress !== null;
bannerMeter.classList.toggle('hidden', !showMeter);
if (state.progress !== null) {
bannerMeterFill.style.width = `${Math.round(state.progress * 100)}%`;
}
const ready = state.stage === 'ready';
searchInput.disabled = !ready;
searchButton.disabled = !ready;
}
/* ---------- source picker ---------- */
/**
* The picker lists every installed source, plus an "All sources" entry that
* searches them together. That entry only earns its place with more than one
* source installed.
*/
function renderSources(sources: AnimeBrowserSource[], selectedId: string | null): void {
const options: HTMLOptionElement[] = [];
if (sources.length > 1) {
const all = document.createElement('option');
all.value = ALL_SOURCES_ID;
all.textContent = `All sources (${sources.length})`;
all.selected = selectedId === ALL_SOURCES_ID;
options.push(all);
}
for (const source of sources) {
const option = document.createElement('option');
option.value = source.id;
option.textContent = sourceOptionLabel(source);
option.selected = source.id === selectedId;
options.push(option);
}
sourceSelect.replaceChildren(...options);
sourceSelect.disabled = options.length <= 1;
}
function searchingAllSources(): boolean {
return sourceSelect.value === ALL_SOURCES_ID;
}
/* ---------- results ---------- */
function createCard(entry: AnimeBrowserEntry, showSource: boolean): HTMLButtonElement {
const card = document.createElement('button');
card.type = 'button';
card.className = 'card';
const art = document.createElement('div');
art.className = 'card-art';
if (entry.thumbnailUrl) {
const img = document.createElement('img');
img.src = entry.thumbnailUrl;
img.alt = '';
img.loading = 'lazy';
// A dead cover should fall back to the initial, not a broken-image icon.
img.addEventListener('error', () => {
img.remove();
art.classList.add('is-empty');
});
art.append(img);
} else {
art.classList.add('is-empty');
}
art.dataset.initial = entry.title.trim().charAt(0).toUpperCase() || '?';
if (showSource) {
const badge = document.createElement('span');
badge.className = 'card-source';
badge.textContent = entry.sourceName;
art.append(badge);
}
const title = document.createElement('div');
title.className = 'card-title';
title.textContent = entry.title;
card.append(art, title);
card.title = showSource ? `${entry.title}${entry.sourceName}` : entry.title;
card.addEventListener('click', () => {
void openDetail(entry);
});
return card;
}
function renderEntries(entries: AnimeBrowserEntry[], emptyMessage: string): void {
// Which source a cover came from only matters when they are mixed together.
const showSource = searchingAllSources();
grid.replaceChildren(...entries.map((entry) => createCard(entry, showSource)));
const empty = entries.length === 0;
gridEmpty.classList.toggle('hidden', !empty);
gridEmpty.textContent = emptyMessage;
}
/** Streamed results land at the end of the grid, in arrival order. */
function appendEntries(entries: AnimeBrowserEntry[]): void {
const showSource = searchingAllSources();
grid.append(...entries.map((entry) => createCard(entry, showSource)));
}
function formatEpisodeIndex(episode: AnimeBrowserEpisode, fallbackIndex: number): string {
const value = episode.number ?? fallbackIndex;
return Number.isInteger(value) ? String(value).padStart(2, '0') : value.toFixed(1);
}
function renderEpisodes(list: AnimeBrowserEpisode[]): void {
episodesCount.textContent = list.length === 0 ? '' : `${list.length}`;
episodes.replaceChildren(
...list.map((episode, index) => {
const item = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.className = 'cue';
const cueIndex = document.createElement('span');
cueIndex.className = 'cue-index';
cueIndex.textContent = formatEpisodeIndex(episode, list.length - index);
const name = document.createElement('span');
name.className = 'cue-name';
name.textContent = episode.name;
if (episode.uploadedAt !== null) {
const sub = document.createElement('span');
sub.className = 'cue-sub';
sub.textContent = new Date(episode.uploadedAt).toISOString().slice(0, 10);
name.append(sub);
}
button.append(cueIndex, name);
button.addEventListener('click', () => {
void playEpisode(button, episode);
});
item.append(button);
return item;
}),
);
}
/**
* The detail page replaces the results grid rather than squeezing in beside
* it. The grid stays in the DOM with its scroll position remembered, so Back
* returns to the same results without re-running the search.
*/
async function openDetail(entry: AnimeBrowserEntry): Promise<void> {
selectedAnime = { url: entry.url, title: entry.title, sourceId: entry.sourceId };
resultsScrollTop = results.scrollTop;
results.classList.add('hidden');
detail.classList.remove('hidden');
detail.scrollTop = 0;
detailTitle.textContent = entry.title;
detailDescription.textContent = 'Loading…';
detailChips.replaceChildren();
episodes.replaceChildren();
episodesCount.textContent = '';
detailCover.src = entry.thumbnailUrl ?? '';
try {
// Always ask the entry's own source: after an all-sources search the
// picker's selection says nothing about where this cover came from.
const [details, episodeList] = await Promise.all([
api.getDetails(entry.url, entry.sourceId),
api.getEpisodes(entry.url, entry.sourceId),
]);
detailTitle.textContent = details.title;
detailDescription.textContent = details.description ?? 'No description from this source.';
if (details.thumbnailUrl) detailCover.src = details.thumbnailUrl;
const chips: HTMLSpanElement[] = [];
const source = document.createElement('span');
source.className = 'chip source';
source.textContent = entry.sourceName;
chips.push(source);
if (details.status !== 'unknown') {
const status = document.createElement('span');
status.className = 'chip status';
status.textContent = details.status.replace(/-/g, ' ');
chips.push(status);
}
for (const genre of details.genres.slice(0, 6)) {
const chip = document.createElement('span');
chip.className = 'chip';
chip.textContent = genre;
chips.push(chip);
}
detailChips.replaceChildren(...chips);
renderEpisodes(episodeList);
setStatus(`${details.title} · ${episodeList.length} episodes`);
} catch (error) {
detailDescription.textContent = '';
setStatus(describe(error), 'error');
}
}
function closeDetail(): void {
detail.classList.add('hidden');
results.classList.remove('hidden');
results.scrollTop = resultsScrollTop;
selectedAnime = null;
}
async function playEpisode(button: HTMLButtonElement, episode: AnimeBrowserEpisode): Promise<void> {
if (!selectedAnime) return;
for (const other of episodes.querySelectorAll<HTMLButtonElement>('.cue')) {
other.removeAttribute('data-state');
}
button.dataset.state = 'loading';
setStatus(`Resolving ${episode.name}`);
const result = await api.playEpisode({
sourceId: selectedAnime.sourceId,
animeUrl: selectedAnime.url,
animeTitle: selectedAnime.title,
episodeUrl: episode.url,
episodeName: episode.name,
});
if (result.ok) {
button.dataset.state = 'playing';
setStatus(
result.quality ? `Playing ${episode.name} · ${result.quality}` : `Playing ${episode.name}`,
'ok',
);
} else {
button.removeAttribute('data-state');
setStatus(result.error ?? 'Could not play that episode.', 'error');
}
}
/* ---------- streamed search ---------- */
/**
* Results are pushed per source while the search call is still pending, so a
* fast source is on screen before a slow one answers. The grid is built from
* those pushes; the awaited result then settles the final status line (and
* backfills the grid if no push ever arrived).
*/
let progress = idleSearchProgress();
/** Orders runSearch calls so a slow search cannot finish over a newer one. */
let searchRequest = 0;
api.onSearchUpdate((update) => {
const applied = applySearchUpdate(progress, update);
if (!applied) return; // A superseded search; let it run out quietly.
progress = applied.progress;
if (applied.started) {
grid.replaceChildren();
gridEmpty.classList.add('hidden');
return;
}
if (applied.entries.length > 0) appendEntries(applied.entries);
if (!progress.done) {
setStatus(summarizeProgress(progress), progress.failures.length > 0 ? 'error' : 'info');
}
});
async function runSearch(query: string): Promise<void> {
const request = ++searchRequest;
// A new search means new results; leave the detail page for them.
if (!detail.classList.contains('hidden')) closeDetail();
setStatus(query ? `Searching for “${query}”…` : 'Loading popular…');
grid.replaceChildren();
gridEmpty.classList.add('hidden');
try {
const result = query ? await api.search(query) : await api.getPopular();
// A newer search owns the grid now; this one's result is history.
if (request !== searchRequest) return;
// Every source failing is an error, not an empty result set.
if (result.entries.length === 0 && result.failures.length > 0) {
const first = result.failures[0];
renderEntries([], `${first?.sourceName}: ${first?.error}`);
setStatus(summarizeSearch(result), 'error');
return;
}
// The stream already filled the grid; only render from the result when no
// update arrived (covers a host without streaming wired up).
if (grid.childElementCount === 0 || result.entries.length === 0) {
renderEntries(
result.entries,
query ? `Nothing found for “${query}”.` : 'This source returned nothing.',
);
}
setStatus(summarizeSearch(result), result.failures.length > 0 ? 'error' : 'info');
} catch (error) {
if (request !== searchRequest) return;
renderEntries([], describe(error));
setStatus(describe(error), 'error');
}
}
/* ---------- source settings ---------- */
async function openSettings(): Promise<void> {
const option = sourceSelect.selectedOptions[0];
settingsTitle.textContent = option ? `${option.textContent} settings` : 'Source settings';
settingsFields.replaceChildren();
setView('settings');
// Settings belong to one extension, so there is nothing coherent to show for
// "All sources".
if (searchingAllSources()) {
renderPreferencesUnavailable(
settingsFields,
'Pick a single source in the Source picker to edit its settings.',
);
return;
}
const sourceId = sourceSelect.value;
try {
renderPreferences(settingsFields, await api.getPreferences(sourceId), (key, value) =>
api.setPreference(sourceId, key, value),
);
} catch (error) {
setStatus(describe(error), 'error');
setView('browse');
}
}
/* ---------- wiring ---------- */
async function refreshSources(): Promise<void> {
const snapshot = await api.getSnapshot();
renderSources(snapshot.sources, snapshot.selectedSourceId);
}
const extensions = createExtensionsPanel({ api, setStatus, onSourcesChanged: refreshSources });
browseTab.addEventListener('click', () => setView('browse'));
settingsTab.addEventListener('click', () => void openSettings());
extensionsTab.addEventListener('click', () => {
setView('extensions');
void extensions.refresh();
});
searchForm.addEventListener('submit', (event) => {
event.preventDefault();
// Searching is a browse action, whichever tab it was typed from.
setView('browse');
void runSearch(searchInput.value.trim());
});
sourceSelect.addEventListener('change', () => {
void (async () => {
await api.selectSource(sourceSelect.value);
// Settings belong to the source, so reload them rather than showing stale fields.
if (currentView === 'settings') await openSettings();
await runSearch(searchInput.value.trim());
})();
});
detailBack.addEventListener('click', closeDetail);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !detail.classList.contains('hidden')) {
closeDetail();
}
});
api.onBridgeState(renderBridgeState);
void (async () => {
renderBridgeState({ stage: 'idle', progress: null, message: null });
const state = await api.ensureBridge();
renderBridgeState(state);
const snapshot = await api.getSnapshot();
renderSources(snapshot.sources, snapshot.selectedSourceId);
if (state.stage === 'ready' && snapshot.sources.length > 0) {
searchInput.focus();
await runSearch('');
} else if (state.stage === 'ready') {
setStatus(state.message ?? 'No extensions installed.', 'error');
} else {
setStatus(state.message ?? 'The extension bridge is not available.', 'error');
}
})();
+13
View File
@@ -0,0 +1,13 @@
/** Small helpers shared by the anime browser's panels. */
export function el<T extends HTMLElement>(id: string): T {
const node = document.getElementById(id);
if (!node) throw new Error(`Missing element #${id}`);
return node as T;
}
export function describe(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
// Electron wraps handler errors; keep only the useful tail.
return message.replace(/^Error invoking remote method '[^']+':\s*/, '').replace(/^Error:\s*/, '');
}
+280
View File
@@ -0,0 +1,280 @@
import { describe, el } from './dom';
import { describeInstalled } from './format';
import type {
AnimeBrowserAPI,
AvailableExtension,
InstalledExtensionView,
} from '../types/anime-browser';
/**
* The Extensions tab: what is installed, which repositories feed it, and what
* those repositories still offer.
*
* Installed extensions get their own section at the top, listed from the
* extensions directory rather than from a repository catalogue — an APK dropped
* in by hand, or one whose repository has since been removed, is still
* installed and has to stay removable.
*/
export interface ExtensionsPanelOptions {
api: AnimeBrowserAPI;
setStatus: (message: string, tone?: 'info' | 'ok' | 'error') => void;
/** Called after an install or removal, so the source picker keeps up. */
onSourcesChanged: () => Promise<void>;
}
interface RowAction {
label: string;
primary?: boolean;
onClick: () => void | Promise<void>;
}
interface RowOptions {
name: string;
sub: string;
tags?: Array<{ text: string; className: string }>;
actions?: RowAction[];
isError?: boolean;
}
function extensionRow(options: RowOptions): HTMLDivElement {
const row = document.createElement('div');
row.className = options.isError ? 'ext-row is-error' : 'ext-row';
const main = document.createElement('div');
main.className = 'ext-main';
const name = document.createElement('div');
name.className = 'ext-name';
name.textContent = options.name;
const sub = document.createElement('div');
sub.className = 'ext-sub';
sub.textContent = options.sub;
main.append(name, sub);
row.append(main);
for (const tag of options.tags ?? []) {
const chip = document.createElement('span');
chip.className = `ext-tag ${tag.className}`;
chip.textContent = tag.text;
row.append(chip);
}
for (const action of options.actions ?? []) {
const button = document.createElement('button');
button.type = 'button';
button.className = action.primary ? 'primary-button' : 'ghost-button';
button.textContent = action.label;
button.addEventListener('click', () => {
button.disabled = true;
void Promise.resolve(action.onClick()).finally(() => {
button.disabled = false;
});
});
row.append(button);
}
return row;
}
function emptyNote(text: string): HTMLParagraphElement {
const empty = document.createElement('p');
empty.className = 'ext-empty';
empty.textContent = text;
return empty;
}
export function createExtensionsPanel(options: ExtensionsPanelOptions) {
const { api, setStatus, onSourcesChanged } = options;
const extensionsDirLabel = el<HTMLSpanElement>('extensions-dir');
const installedList = el<HTMLDivElement>('installed-list');
const installedCount = el<HTMLSpanElement>('installed-count');
const availableList = el<HTMLDivElement>('extensions-list');
const repoInput = el<HTMLInputElement>('repo-input');
const repoAddButton = el<HTMLButtonElement>('repo-add');
const repoList = el<HTMLDivElement>('repo-list');
async function afterChange(extensionName: string, verb: string): Promise<void> {
await refresh();
await onSourcesChanged();
setStatus(`${extensionName} ${verb}`, 'ok');
}
function renderInstalled(
installed: InstalledExtensionView[],
offeredPkgs: Set<string>,
extensionsDir: string,
): void {
installedCount.textContent = installed.length === 0 ? '' : String(installed.length);
if (installed.length === 0) {
installedList.replaceChildren(
emptyNote(
`Nothing installed yet. Add a repository below, or drop .apk files in ${extensionsDir}.`,
),
);
return;
}
installedList.replaceChildren(
...installed.map((view) => {
const actions: RowAction[] = [];
// Only offer an update for an extension a configured repository still
// carries; reinstalling overwrites the APK in place.
if (offeredPkgs.has(view.pkg)) {
actions.push({
label: 'Update',
onClick: async () => {
setStatus(`Updating ${view.name}`);
try {
await api.installExtension(view.pkg);
await afterChange(view.name, 'updated');
} catch (error) {
setStatus(describe(error), 'error');
}
},
});
}
actions.push({
label: 'Remove',
onClick: async () => {
setStatus(`Removing ${view.name}`);
try {
await api.removeExtension(view.pkg);
await afterChange(view.name, 'removed');
} catch (error) {
setStatus(describe(error), 'error');
}
},
});
return extensionRow({
name: view.name,
sub: view.error ?? describeInstalled(view),
isError: view.error !== null,
tags: view.error === null ? [] : [{ text: 'failed', className: 'nsfw' }],
actions,
});
}),
);
}
function renderRepos(repos: string[]): void {
repoList.replaceChildren(
...repos.map((repoUrl) =>
extensionRow({
name: repoUrl.replace(/^https:\/\//, '').replace(/\/[^/]*\.json$/, ''),
sub: repoUrl,
actions: [
{
label: 'Remove',
onClick: async () => {
await api.removeRepo(repoUrl);
await refresh();
},
},
],
}),
),
);
}
function renderAvailable(
available: AvailableExtension[],
repoFailures: Array<{ name: string; error: string }>,
hasRepos: boolean,
): void {
const rows: HTMLElement[] = repoFailures.map((failure) =>
extensionRow({ name: failure.name, sub: failure.error, isError: true }),
);
for (const extension of available) {
rows.push(
extensionRow({
name: extension.name,
sub: `${extension.lang} · v${extension.version}`,
tags: extension.nsfw ? [{ text: '18+', className: 'nsfw' }] : [],
actions: [
{
label: 'Install',
primary: true,
onClick: async () => {
setStatus(`Installing ${extension.name}`);
try {
await api.installExtension(extension.pkg);
await afterChange(extension.name, 'installed');
} catch (error) {
setStatus(describe(error), 'error');
}
},
},
],
}),
);
}
if (rows.length === 0) {
rows.push(
emptyNote(
hasRepos
? 'Every extension the configured repositories offer is already installed.'
: 'No repository configured, so there is nothing to install from.',
),
);
}
availableList.replaceChildren(...rows);
}
async function refresh(): Promise<void> {
const snapshot = await api.getSnapshot();
extensionsDirLabel.textContent = snapshot.extensionsDir;
renderRepos(snapshot.repos);
const repoFailures: Array<{ name: string; error: string }> = [];
let available;
try {
available = await api.listAvailableExtensions();
} catch (error) {
repoFailures.push({ name: 'Repository error', error: describe(error) });
available = { extensions: [], failures: [] };
}
for (const failure of available.failures) {
repoFailures.push({ name: failure.repoUrl, error: failure.error });
}
const offeredPkgs = new Set(available.extensions.map((extension) => extension.pkg));
renderInstalled(snapshot.installed, offeredPkgs, snapshot.extensionsDir);
renderAvailable(
// Installed extensions have their own section; leaving them here too
// would list every one of them twice.
available.extensions.filter((extension) => !extension.installed),
repoFailures,
snapshot.repos.length > 0,
);
}
repoAddButton.addEventListener('click', () => {
void (async () => {
const url = repoInput.value.trim();
if (url.length === 0) return;
try {
await api.addRepo(url);
repoInput.value = '';
setStatus('Repository added');
await refresh();
} catch (error) {
setStatus(describe(error), 'error');
}
})();
});
repoInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
repoAddButton.click();
}
});
return { refresh };
}
+60
View File
@@ -0,0 +1,60 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { describeInstalled, sourceOptionLabel, summarizeSearch } from './format';
import type { AnimeBrowserSearchResult } from '../types/anime-browser';
const result = (
entryCount: number,
failures: AnimeBrowserSearchResult['failures'] = [],
): AnimeBrowserSearchResult => ({
entries: Array.from({ length: entryCount }, (_unused, index) => ({
url: `/a/${index}`,
title: `Anime ${index}`,
thumbnailUrl: null,
sourceId: 's',
sourceName: 'Source',
})),
hasNextPage: false,
failures,
});
test('sourceOptionLabel omits the language for an all-language source', () => {
assert.equal(sourceOptionLabel({ id: '1', name: 'Nyaa', lang: 'ja', pkg: 'p' }), 'Nyaa (ja)');
assert.equal(sourceOptionLabel({ id: '2', name: 'Jellyfin', lang: 'all', pkg: 'p' }), 'Jellyfin');
});
test('summarizeSearch counts results and singularizes one', () => {
assert.equal(summarizeSearch(result(4)), '4 results');
assert.equal(summarizeSearch(result(1)), '1 result');
assert.equal(summarizeSearch(result(0)), '0 results');
});
test('summarizeSearch names the sources that failed alongside the ones that answered', () => {
const summary = summarizeSearch(
result(6, [
{ sourceId: 'a', sourceName: 'Alpha', error: 'login required' },
{ sourceId: 'b', sourceName: 'Beta', error: 'timed out' },
]),
);
assert.equal(summary, '6 results · 2 unavailable: Alpha, Beta');
});
test('describeInstalled reports sources and languages when the extension loaded', () => {
assert.equal(
describeInstalled({
pkg: 'multi',
name: 'One, Two',
langs: ['en', 'ja'],
sourceCount: 2,
error: null,
}),
'multi · 2 sources · en, ja',
);
});
test('describeInstalled falls back to the package alone when nothing loaded', () => {
assert.equal(
describeInstalled({ pkg: 'broken', name: 'broken', langs: [], sourceCount: 0, error: 'boom' }),
'broken',
);
});
+35
View File
@@ -0,0 +1,35 @@
import type {
AnimeBrowserSearchResult,
AnimeBrowserSource,
InstalledExtensionView,
} from '../types/anime-browser';
/** Display strings for the anime browser, kept separate from the DOM. */
/** `Nyaa (ja)`, or just the name for a source that serves every language. */
export function sourceOptionLabel(source: AnimeBrowserSource): string {
return source.lang === 'all' ? source.name : `${source.name} (${source.lang})`;
}
/**
* The status line after a search.
*
* An all-sources search can half-succeed, so the sources that failed are named
* rather than folded into a count the user cannot act on.
*/
export function summarizeSearch(result: AnimeBrowserSearchResult): string {
const count = `${result.entries.length} result${result.entries.length === 1 ? '' : 's'}`;
if (result.failures.length === 0) return count;
const names = result.failures.map((failure) => failure.sourceName).join(', ');
return `${count} · ${result.failures.length} unavailable: ${names}`;
}
/** `pkg · 3 sources · en, ja`, trimmed to what the extension actually reported. */
export function describeInstalled(view: InstalledExtensionView): string {
const parts = [view.pkg];
if (view.sourceCount > 0) {
parts.push(`${view.sourceCount} source${view.sourceCount === 1 ? '' : 's'}`);
}
if (view.langs.length > 0) parts.push(view.langs.join(', '));
return parts.join(' · ');
}
+158
View File
@@ -0,0 +1,158 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob: http://127.0.0.1:* http://localhost:* https:; object-src 'none'; base-uri 'self';"
/>
<title>SubMiner Anime</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<header class="topbar">
<div class="brand-block">
<div class="brand-title">SubMiner</div>
<div class="brand-subtitle">Anime</div>
</div>
<form class="search-form" id="search-form" role="search">
<input
class="text-input search-input"
id="search-input"
type="search"
placeholder="Search anime"
autocomplete="off"
aria-label="Search anime"
/>
<button class="primary-button" id="search-button" type="submit">Search</button>
</form>
<label class="source-picker">
<span class="source-label">Source</span>
<select class="text-input" id="source-select" aria-label="Extension source"></select>
</label>
<nav class="tabs" role="tablist" aria-label="View">
<button
class="tab"
id="tab-browse"
type="button"
role="tab"
aria-controls="layout"
aria-selected="true"
>
Browse
</button>
<button
class="tab"
id="tab-extensions"
type="button"
role="tab"
aria-controls="extensions"
aria-selected="false"
>
Extensions
</button>
<button
class="tab"
id="tab-settings"
type="button"
role="tab"
aria-controls="settings"
aria-selected="false"
>
Source settings
</button>
</nav>
</header>
<div class="bridge-banner hidden" id="bridge-banner" role="status" aria-live="polite">
<span class="bridge-dot" id="bridge-dot"></span>
<span class="bridge-message" id="bridge-message"></span>
<span class="bridge-meter hidden" id="bridge-meter"><i id="bridge-meter-fill"></i></span>
</div>
<section class="settings hidden" id="settings" role="tabpanel" aria-labelledby="tab-settings">
<div class="settings-head">
<h2 class="settings-title" id="settings-title">Source settings</h2>
<span class="settings-note">Saved as you change them.</span>
</div>
<div class="settings-fields" id="settings-fields"></div>
</section>
<section
class="settings hidden"
id="extensions"
role="tabpanel"
aria-labelledby="tab-extensions"
>
<div class="settings-head">
<h2 class="settings-title">Extensions</h2>
<span class="settings-note" id="extensions-dir"></span>
</div>
<h3 class="ext-group-title">
Installed <span class="ext-group-count" id="installed-count"></span>
</h3>
<div class="ext-list" id="installed-list" aria-label="Installed extensions"></div>
<h3 class="ext-group-title">Repositories</h3>
<div class="repo-add">
<input
class="text-input"
id="repo-input"
type="url"
placeholder="https://example.org/repo/index.min.json"
aria-label="Repository index URL"
/>
<button class="primary-button" id="repo-add" type="button">Add repository</button>
</div>
<p class="repo-hint" id="repo-hint">
SubMiner ships no repositories. Add any https URL to a repository's <code>.json</code> index
to install extensions from it, or drop <code>.apk</code> files in the extensions directory.
</p>
<div class="ext-list" id="repo-list" aria-label="Repositories"></div>
<h3 class="ext-group-title">Available</h3>
<div class="ext-list" id="extensions-list" aria-label="Available extensions"></div>
</section>
<main class="layout" id="layout" role="tabpanel" aria-labelledby="tab-browse">
<section class="results" id="results" aria-label="Results">
<div class="grid" id="grid"></div>
<p class="empty hidden" id="grid-empty"></p>
</section>
<section class="detail hidden" id="detail" aria-label="Details">
<div class="detail-body">
<button class="ghost-button detail-back" id="detail-back" type="button">
← Back to results
</button>
<div class="detail-head">
<img class="detail-cover" id="detail-cover" alt="" />
<div class="detail-meta">
<h2 class="detail-title" id="detail-title"></h2>
<div class="detail-chips" id="detail-chips"></div>
<p class="detail-description" id="detail-description"></p>
</div>
</div>
<div class="episodes-head">
<h3 class="episodes-title">Episodes</h3>
<span class="episodes-count" id="episodes-count"></span>
</div>
<ol class="cue-rail" id="episodes"></ol>
</div>
</section>
</main>
<footer class="statusbar">
<span id="status-message"></span>
</footer>
<script type="module" src="./animeui.js"></script>
</body>
</html>
+143
View File
@@ -0,0 +1,143 @@
import { describe } from './dom';
import type { SourcePreferenceView } from '../types/anime-browser';
/**
* Renders one extension's settings schema as form fields.
*
* The extension owns the schema, so a commit hands back a refreshed one and the
* whole panel re-renders from it — the Jellyfin source fills in its library
* picker only after a successful login, and that has to show up.
*/
export type PreferenceCommit = (
key: string,
value: string | string[] | boolean,
) => Promise<SourcePreferenceView[]>;
/** Masked in the UI so a shoulder-surfer cannot read a stored password. */
function isSecretKey(view: SourcePreferenceView): boolean {
return /password|token|api[-_ ]?key|secret/i.test(`${view.key} ${view.title}`);
}
function renderPreferenceField(
container: HTMLElement,
view: SourcePreferenceView,
commit: PreferenceCommit,
): HTMLElement {
const field = document.createElement('div');
field.className = 'field';
const label = document.createElement('label');
label.className = 'field-label';
label.textContent = view.title;
field.append(label);
if (view.summary) {
const summary = document.createElement('span');
summary.className = 'field-summary';
summary.textContent = view.summary;
field.append(summary);
}
const state = document.createElement('span');
state.className = 'field-state';
const save = async (value: string | string[] | boolean): Promise<void> => {
state.removeAttribute('data-tone');
state.textContent = 'Saving…';
try {
const refreshed = await commit(view.key, value);
state.dataset.tone = 'ok';
state.textContent = 'Saved';
renderPreferences(container, refreshed, commit);
} catch (error) {
state.dataset.tone = 'error';
state.textContent = describe(error);
}
};
const row = document.createElement('div');
row.className = 'field-row';
if (view.kind === 'switch') {
const wrapper = document.createElement('label');
wrapper.className = 'field-check';
const box = document.createElement('input');
box.type = 'checkbox';
box.checked = view.value === true;
box.addEventListener('change', () => void save(box.checked));
wrapper.append(box, document.createTextNode('Enabled'));
row.append(wrapper);
} else if (view.kind === 'list') {
const select = document.createElement('select');
select.className = 'text-input';
for (const [index, entryValue] of view.entryValues.entries()) {
const option = document.createElement('option');
option.value = entryValue;
option.textContent = view.entries[index] ?? entryValue;
option.selected = entryValue === view.value;
select.append(option);
}
if (view.entryValues.length === 0) {
select.disabled = true;
const option = document.createElement('option');
option.textContent = 'Nothing to choose yet';
select.append(option);
}
select.addEventListener('change', () => void save(select.value));
row.append(select);
} else if (view.kind === 'multi') {
const group = document.createElement('div');
group.className = 'field-multi';
const selected = new Set(Array.isArray(view.value) ? view.value : []);
for (const [index, entryValue] of view.entryValues.entries()) {
const wrapper = document.createElement('label');
wrapper.className = 'field-check';
const box = document.createElement('input');
box.type = 'checkbox';
box.checked = selected.has(entryValue);
box.addEventListener('change', () => {
if (box.checked) selected.add(entryValue);
else selected.delete(entryValue);
void save([...selected]);
});
wrapper.append(box, document.createTextNode(view.entries[index] ?? entryValue));
group.append(wrapper);
}
row.append(group);
} else {
const input = document.createElement('input');
input.className = 'text-input';
input.type = isSecretKey(view) ? 'password' : 'text';
input.value = typeof view.value === 'string' ? view.value : '';
// Commit on blur/Enter rather than per keystroke; each save round-trips
// to the extension and may trigger a login.
input.addEventListener('change', () => void save(input.value));
row.append(input);
}
field.append(row, state);
return field;
}
export function renderPreferences(
container: HTMLElement,
views: SourcePreferenceView[],
commit: PreferenceCommit,
): void {
container.replaceChildren(...views.map((view) => renderPreferenceField(container, view, commit)));
if (views.length === 0) {
const empty = document.createElement('p');
empty.className = 'field-summary';
empty.textContent = 'This source has no settings.';
container.append(empty);
}
}
/** Shown in place of the fields when the source picker is on "All sources". */
export function renderPreferencesUnavailable(container: HTMLElement, message: string): void {
const note = document.createElement('p');
note.className = 'field-summary';
note.textContent = message;
container.replaceChildren(note);
}
+103
View File
@@ -0,0 +1,103 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applySearchUpdate, idleSearchProgress, summarizeProgress } from './search-progress';
import type { AnimeBrowserEntry, SourceSearchFailure } from '../types/anime-browser';
const entry = (title: string): AnimeBrowserEntry => ({
url: `/a/${title}`,
title,
thumbnailUrl: null,
sourceId: 's1',
sourceName: 'Source One',
});
const failure: SourceSearchFailure = {
sourceId: 's2',
sourceName: 'Source Two',
error: 'login required',
};
test('a search accumulates results and failures update by update', () => {
let progress = idleSearchProgress();
let applied = applySearchUpdate(progress, { kind: 'start', token: 1, sourceCount: 3 });
assert.ok(applied);
assert.equal(applied.started, true);
progress = applied.progress;
applied = applySearchUpdate(progress, {
kind: 'result',
token: 1,
sourceId: 's1',
sourceName: 'Source One',
entries: [entry('A'), entry('B')],
});
assert.ok(applied);
assert.deepEqual(
applied.entries.map((item) => item.title),
['A', 'B'],
);
progress = applied.progress;
applied = applySearchUpdate(progress, { kind: 'failure', token: 1, failure });
assert.ok(applied);
progress = applied.progress;
assert.equal(progress.entryCount, 2);
assert.equal(progress.sourcesDone, 2);
assert.deepEqual(progress.failures, [failure]);
assert.equal(progress.done, false);
applied = applySearchUpdate(progress, { kind: 'done', token: 1 });
assert.ok(applied);
assert.equal(applied.progress.done, true);
});
test('updates from a superseded search are dropped entirely', () => {
let progress = idleSearchProgress();
progress = applySearchUpdate(progress, { kind: 'start', token: 1, sourceCount: 2 })!.progress;
progress = applySearchUpdate(progress, { kind: 'start', token: 2, sourceCount: 2 })!.progress;
// The first search's straggler results and completion must not touch token 2.
assert.equal(
applySearchUpdate(progress, {
kind: 'result',
token: 1,
sourceId: 's1',
sourceName: 'Source One',
entries: [entry('stale')],
}),
null,
);
assert.equal(applySearchUpdate(progress, { kind: 'done', token: 1 }), null);
assert.equal(progress.entryCount, 0);
});
test('an older start cannot reset a newer search', () => {
let progress = idleSearchProgress();
progress = applySearchUpdate(progress, { kind: 'start', token: 5, sourceCount: 1 })!.progress;
assert.equal(applySearchUpdate(progress, { kind: 'start', token: 4, sourceCount: 9 }), null);
assert.equal(applySearchUpdate(progress, { kind: 'start', token: 5, sourceCount: 9 }), null);
assert.equal(progress.sourceCount, 1);
});
test('summarizeProgress counts sources and names failures', () => {
let progress = idleSearchProgress();
progress = applySearchUpdate(progress, { kind: 'start', token: 1, sourceCount: 5 })!.progress;
progress = applySearchUpdate(progress, {
kind: 'result',
token: 1,
sourceId: 's1',
sourceName: 'Source One',
entries: [entry('A')],
})!.progress;
assert.equal(summarizeProgress(progress), 'Searching… 1/5 sources · 1 result');
progress = applySearchUpdate(progress, { kind: 'failure', token: 1, failure })!.progress;
assert.equal(
summarizeProgress(progress),
'Searching… 2/5 sources · 1 result · unavailable: Source Two',
);
});
+106
View File
@@ -0,0 +1,106 @@
import type { AnimeBrowserEntry, AnimeBrowserSearchUpdate } from '../types/anime-browser';
import type { SourceSearchFailure } from '../types/anime-browser';
/**
* Streamed-search bookkeeping, kept apart from the DOM so the staleness rules
* are testable.
*
* Updates stream in while earlier searches may still be resolving; a search is
* identified by its token and only the newest one may touch the grid. Tokens
* are emitted in start order over an ordered channel, so "newest" is simply
* the highest token seen.
*/
export interface SearchProgress {
token: number;
sourceCount: number;
sourcesDone: number;
entryCount: number;
failures: SourceSearchFailure[];
done: boolean;
}
const IDLE: SearchProgress = {
token: -1,
sourceCount: 0,
sourcesDone: 0,
entryCount: 0,
failures: [],
done: false,
};
export interface AppliedUpdate {
progress: SearchProgress;
/** Entries this update contributed; append them to the grid. */
entries: AnimeBrowserEntry[];
/** True when this update began a new search; clear the grid first. */
started: boolean;
}
export function idleSearchProgress(): SearchProgress {
return { ...IDLE, failures: [] };
}
/**
* Fold one update into the current progress. Returns null for an update from
* a superseded search, which the caller must ignore entirely.
*/
export function applySearchUpdate(
current: SearchProgress,
update: AnimeBrowserSearchUpdate,
): AppliedUpdate | null {
if (update.kind === 'start') {
// An older search's start (or a replay) must not reset the newer one.
if (update.token <= current.token) return null;
return {
progress: {
token: update.token,
sourceCount: update.sourceCount,
sourcesDone: 0,
entryCount: 0,
failures: [],
done: false,
},
entries: [],
started: true,
};
}
if (update.token !== current.token) return null;
if (update.kind === 'result') {
return {
progress: {
...current,
sourcesDone: current.sourcesDone + 1,
entryCount: current.entryCount + update.entries.length,
},
entries: update.entries,
started: false,
};
}
if (update.kind === 'failure') {
return {
progress: {
...current,
sourcesDone: current.sourcesDone + 1,
failures: [...current.failures, update.failure],
},
entries: [],
started: false,
};
}
return { progress: { ...current, done: true }, entries: [], started: false };
}
/** `Searching… 3/5 sources · 42 results`, with failures named once they exist. */
export function summarizeProgress(progress: SearchProgress): string {
const counts = `${progress.sourcesDone}/${progress.sourceCount} sources · ${progress.entryCount} result${progress.entryCount === 1 ? '' : 's'}`;
const failed =
progress.failures.length === 0
? ''
: ` · unavailable: ${progress.failures.map((failure) => failure.sourceName).join(', ')}`;
return `Searching… ${counts}${failed}`;
}
+908
View File
@@ -0,0 +1,908 @@
@font-face {
font-family: 'M PLUS 1';
src: url('./fonts/MPLUS1[wght].ttf') format('truetype');
font-weight: 100 900;
font-display: swap;
}
:root {
/* Catppuccin Macchiato */
--ctp-mauve: #c6a0f6;
--ctp-red: #ed8796;
--ctp-peach: #f5a97f;
--ctp-yellow: #eed49f;
--ctp-green: #a6da95;
--ctp-sky: #91d7e3;
--ctp-blue: #8aadf4;
--ctp-lavender: #b7bdf8;
--ctp-text: #cad3f5;
--ctp-subtext0: #a5adcb;
--ctp-overlay1: #8087a2;
--ctp-surface0: #363a4f;
--ctp-base: #24273a;
--ctp-mantle: #1e2030;
--ctp-crust: #181926;
--bg: var(--ctp-base);
--panel: rgba(36, 39, 58, 0.85);
--panel-elevated: rgba(54, 58, 79, 0.55);
--line: rgba(110, 115, 141, 0.28);
--text: var(--ctp-text);
--muted: var(--ctp-subtext0);
--faint: var(--ctp-overlay1);
--accent: var(--ctp-blue);
--accent-strong: var(--ctp-lavender);
/* Amber marks the mining action: the thing you highlight to look up. */
--mine: var(--ctp-yellow);
--danger: var(--ctp-red);
--ok: var(--ctp-green);
--shadow: rgba(0, 0, 0, 0.42);
--mono: 'SF Mono', 'Cascadia Code', 'JetBrains Mono', 'Fira Code', monospace;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
overflow: hidden;
}
body {
display: flex;
flex-direction: column;
background: var(--bg);
color: var(--text);
font-family:
'M PLUS 1', 'Avenir Next', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Yu Gothic', sans-serif;
font-size: 14px;
line-height: 1.55;
}
.hidden {
display: none !important;
}
/* ---------- top bar ---------- */
.topbar {
display: flex;
align-items: center;
gap: 20px;
padding: 14px 20px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, var(--ctp-mantle), var(--ctp-base));
flex: none;
}
.brand-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--faint);
}
.brand-subtitle {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.1;
}
.search-form {
display: flex;
gap: 8px;
flex: 1 1 auto;
max-width: 560px;
}
.text-input {
width: 100%;
padding: 9px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--ctp-crust);
color: var(--text);
font: inherit;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.text-input:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(138, 173, 244, 0.22);
}
.primary-button,
.ghost-button {
padding: 9px 16px;
border-radius: 10px;
border: 1px solid transparent;
font: inherit;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition:
background 0.15s ease,
border-color 0.15s ease,
transform 0.1s ease;
}
.primary-button {
background: var(--accent);
color: var(--ctp-crust);
}
.primary-button:hover:not(:disabled) {
background: var(--accent-strong);
}
.ghost-button {
background: transparent;
border-color: var(--line);
color: var(--muted);
}
.ghost-button:hover:not(:disabled) {
color: var(--text);
border-color: var(--faint);
}
.primary-button:disabled,
.ghost-button:disabled {
opacity: 0.5;
cursor: default;
}
.primary-button:active:not(:disabled) {
transform: translateY(1px);
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.tabs {
display: flex;
gap: 4px;
flex: none;
padding: 3px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--ctp-crust);
}
.tab {
padding: 8px 14px;
border: 1px solid transparent;
border-radius: 9px;
background: transparent;
color: var(--muted);
font: inherit;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
}
.tab:hover[aria-selected='false'] {
color: var(--text);
}
.tab[aria-selected='true'] {
background: var(--panel-elevated);
border-color: var(--line);
color: var(--text);
}
.source-picker {
display: flex;
flex-direction: column;
gap: 3px;
flex: none;
min-width: 190px;
}
.source-label {
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--faint);
}
/* ---------- bridge banner ---------- */
.bridge-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 20px;
border-bottom: 1px solid var(--line);
background: var(--panel-elevated);
font-size: 13px;
color: var(--muted);
flex: none;
}
.bridge-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--faint);
flex: none;
}
.bridge-banner[data-stage='ready'] .bridge-dot {
background: var(--ok);
}
.bridge-banner[data-stage='failed'] .bridge-dot {
background: var(--danger);
}
.bridge-banner[data-busy='true'] .bridge-dot {
background: var(--accent);
animation: pulse 1.1s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 0.35;
}
50% {
opacity: 1;
}
}
.bridge-meter {
width: 160px;
height: 4px;
border-radius: 2px;
background: var(--ctp-crust);
overflow: hidden;
}
.bridge-meter i {
display: block;
height: 100%;
width: 0;
background: var(--accent);
transition: width 0.2s ease;
}
/* ---------- layout ---------- */
.layout {
display: flex;
flex: 1 1 0;
min-height: 0;
}
.results {
flex: 1 1 auto;
min-width: 0;
overflow-y: auto;
padding: 20px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
gap: 18px;
}
.empty {
margin: 48px auto;
max-width: 44ch;
text-align: center;
color: var(--muted);
}
/* ---------- cover cards ---------- */
.card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0;
border: none;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
animation: card-in 0.32s ease both;
}
@keyframes card-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card-art {
position: relative;
aspect-ratio: 2 / 3;
border-radius: 12px;
overflow: hidden;
background: var(--ctp-crust);
border: 1px solid var(--line);
box-shadow: 0 10px 28px -18px var(--shadow);
transition:
border-color 0.18s ease,
transform 0.18s ease;
}
.card:hover .card-art,
.card:focus-visible .card-art {
border-color: var(--accent);
transform: translateY(-3px);
}
.card-art img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.card-art.is-empty::after {
content: attr(data-initial);
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 30px;
font-weight: 600;
color: var(--faint);
}
/* Which source a cover came from, shown only in an all-sources result. */
.card-source {
position: absolute;
left: 6px;
bottom: 6px;
right: 6px;
padding: 3px 7px;
border-radius: 7px;
background: rgba(24, 25, 38, 0.82);
backdrop-filter: blur(6px);
color: var(--muted);
font-size: 10px;
letter-spacing: 0.04em;
text-transform: uppercase;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-title {
font-size: 13px;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ---------- detail page ---------- */
/*
* The detail view is a page, not a sidebar: it takes over the whole content
* region while the results grid waits, hidden, behind the Back button.
*/
.detail {
flex: 1 1 auto;
min-width: 0;
background: var(--panel);
overflow-y: auto;
padding: 20px clamp(20px, 5vw, 56px) 32px;
animation: detail-in 0.28s ease both;
}
@keyframes detail-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.detail-body {
max-width: 1040px;
margin: 0 auto;
}
.detail-back {
padding: 6px 14px;
font-size: 12px;
margin-bottom: 20px;
}
.detail-head {
display: flex;
gap: clamp(18px, 3vw, 36px);
margin-bottom: 28px;
}
.detail-cover {
flex: none;
width: clamp(140px, 18vw, 232px);
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--ctp-crust);
box-shadow: 0 18px 40px -24px var(--shadow);
}
.detail-title {
margin: 0 0 10px;
font-size: clamp(19px, 2.4vw, 27px);
font-weight: 600;
line-height: 1.25;
}
.detail-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.chip {
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--line);
font-size: 11px;
color: var(--muted);
}
.chip.status {
border-color: rgba(166, 218, 149, 0.4);
color: var(--ok);
}
/* Which extension answered — the one chip that is always present. */
.chip.source {
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
color: var(--accent);
}
.detail-description {
margin: 0;
font-size: 13px;
max-width: 72ch;
color: var(--muted);
white-space: pre-wrap;
}
.episodes-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--line);
}
.episodes-title {
margin: 0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--faint);
}
.episodes-count {
font-family: var(--mono);
font-size: 11px;
color: var(--faint);
}
/*
* The cue rail: episodes read as subtitle cues on a timeline, because that is
* what they are about to become. The rail is the spine, the index is the cue
* number, and the title is the cue text.
*/
.cue-rail {
list-style: none;
margin: 0;
padding: 0 0 0 2px;
position: relative;
}
.cue-rail::before {
content: '';
position: absolute;
left: 41px;
top: 6px;
bottom: 6px;
width: 1px;
background: var(--line);
}
.cue {
position: relative;
display: grid;
grid-template-columns: 40px 1fr;
gap: 14px;
align-items: baseline;
width: 100%;
padding: 9px 10px 9px 0;
border: none;
border-radius: 8px;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background 0.14s ease;
}
.cue::after {
content: '';
position: absolute;
left: 37px;
top: 15px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--ctp-surface0);
border: 1px solid var(--line);
transition:
background 0.14s ease,
border-color 0.14s ease;
}
.cue:hover,
.cue:focus-visible {
background: var(--panel-elevated);
}
.cue:hover::after,
.cue:focus-visible::after {
background: var(--mine);
border-color: var(--mine);
}
.cue-index {
font-family: var(--mono);
font-size: 12px;
color: var(--faint);
text-align: right;
font-variant-numeric: tabular-nums;
}
.cue-name {
font-size: 13px;
line-height: 1.4;
}
.cue-sub {
display: block;
font-family: var(--mono);
font-size: 11px;
color: var(--faint);
margin-top: 2px;
}
.cue[data-state='loading'] {
background: var(--panel-elevated);
}
.cue[data-state='loading']::after {
background: var(--accent);
border-color: var(--accent);
animation: pulse 1.1s ease-in-out infinite;
}
.cue[data-state='playing']::after {
background: var(--mine);
border-color: var(--mine);
}
/* ---------- status bar ---------- */
.statusbar {
flex: none;
padding: 7px 20px;
border-top: 1px solid var(--line);
background: var(--ctp-mantle);
font-size: 12px;
color: var(--faint);
min-height: 30px;
}
.statusbar[data-tone='error'] {
color: var(--danger);
}
.statusbar[data-tone='ok'] {
color: var(--ok);
}
/* ---------- scrollbars ---------- */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--ctp-surface0);
border-radius: 6px;
border: 2px solid var(--bg);
}
::-webkit-scrollbar-thumb:hover {
background: var(--faint);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ---------- source settings ---------- */
.settings {
/* A tab panel owns the whole content region; only one is ever visible. */
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
padding: 16px 20px 18px;
background: var(--panel-elevated);
}
.settings-head {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 12px;
}
.settings-title {
margin: 0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--faint);
}
.settings-note {
font-size: 12px;
color: var(--faint);
}
.settings-fields {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px 20px;
}
.field {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.field-label {
font-size: 12px;
font-weight: 600;
}
.field-summary {
font-size: 11px;
color: var(--faint);
line-height: 1.4;
}
.field-row {
display: flex;
align-items: center;
gap: 8px;
}
.field-row .text-input {
flex: 1 1 auto;
min-width: 0;
}
.field-multi {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.field-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--muted);
cursor: pointer;
}
.field-state {
font-size: 11px;
color: var(--faint);
min-height: 14px;
}
.field-state[data-tone='ok'] {
color: var(--ok);
}
.field-state[data-tone='error'] {
color: var(--danger);
}
/* ---------- extensions panel ---------- */
.repo-add {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.repo-add .text-input {
flex: 1 1 auto;
}
.repo-hint {
margin: 0 0 14px;
font-size: 12px;
color: var(--faint);
max-width: 78ch;
}
.repo-hint code {
font-family: var(--mono);
font-size: 11px;
}
.ext-group-title {
display: flex;
align-items: baseline;
gap: 8px;
margin: 0 0 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--faint);
}
/* A rule between the groups, but not above the first one. */
.ext-list + .ext-group-title {
margin-top: 6px;
padding-top: 14px;
border-top: 1px solid var(--line);
}
.ext-group-count {
font-size: 11px;
letter-spacing: 0.06em;
color: var(--muted);
}
.ext-group-count:empty {
display: none;
}
.ext-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.ext-list:not(:empty) {
margin-bottom: 14px;
}
.ext-row {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--ctp-crust);
}
.ext-row.is-error {
border-color: rgba(237, 135, 150, 0.45);
}
.ext-main {
flex: 1 1 auto;
min-width: 0;
}
.ext-name {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ext-sub {
font-family: var(--mono);
font-size: 11px;
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ext-row.is-error .ext-sub {
color: var(--danger);
white-space: normal;
}
.ext-tag {
flex: none;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--line);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--faint);
}
.ext-tag.installed {
border-color: rgba(166, 218, 149, 0.4);
color: var(--ok);
}
.ext-tag.nsfw {
border-color: rgba(237, 135, 150, 0.4);
color: var(--danger);
}
.ext-row .ghost-button,
.ext-row .primary-button {
flex: none;
padding: 5px 12px;
font-size: 12px;
}
.ext-empty {
padding: 14px 2px;
font-size: 13px;
color: var(--muted);
}
+7
View File
@@ -15,6 +15,7 @@ export interface CliArgs {
yomitan: boolean;
settings: boolean;
syncWindow: boolean;
animeBrowser: boolean;
setup: boolean;
show: boolean;
hide: boolean;
@@ -125,6 +126,7 @@ export function parseArgs(argv: string[]): CliArgs {
yomitan: false,
settings: false,
syncWindow: false,
animeBrowser: false,
setup: false,
show: false,
hide: false,
@@ -274,6 +276,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--yomitan') args.yomitan = true;
else if (arg === '--settings') args.settings = true;
else if (arg === '--sync-window') args.syncWindow = true;
else if (arg === '--anime') args.animeBrowser = true;
else if (arg === '--setup') args.setup = true;
else if (arg === '--show') args.show = true;
else if (arg === '--hide') args.hide = true;
@@ -549,6 +552,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.yomitan ||
args.settings ||
args.syncWindow ||
args.animeBrowser ||
args.setup ||
args.show ||
args.hide ||
@@ -628,6 +632,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.yomitan &&
!args.settings &&
!args.syncWindow &&
!args.animeBrowser &&
!args.setup &&
!args.show &&
!args.hide &&
@@ -700,6 +705,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.yomitan ||
args.settings ||
args.syncWindow ||
args.animeBrowser ||
args.setup ||
args.copySubtitle ||
args.copySubtitleMultiple ||
@@ -758,6 +764,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
!args.togglePrimarySubtitleBar &&
!args.settings &&
!args.syncWindow &&
!args.animeBrowser &&
!args.show &&
!args.hide &&
!args.setup &&
+1
View File
@@ -81,6 +81,7 @@ ${B}Jellyfin${R}
${B}Stats sync${R}
--sync-window Open the stats sync window
--anime Open the anime browser
--sync-cli sync ${D}[host] [opts]${R} Headless stats sync ${D}(same commands as "subminer sync";${R}
${D}run SubMiner --sync-cli --help for details)${R}
+2
View File
@@ -39,6 +39,7 @@ const {
} = CORE_DEFAULT_CONFIG;
const {
ankiConnect,
anime,
jimaku,
tsukihime,
anilist,
@@ -72,6 +73,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
subtitleStyle,
subtitleSidebar,
auto_start_overlay,
anime,
jimaku,
tsukihime,
anilist,
@@ -4,6 +4,7 @@ import { getDefaultMpvSocketPath } from '../../shared/mpv-socket-path';
export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
ResolvedConfig,
| 'ankiConnect'
| 'anime'
| 'jimaku'
| 'tsukihime'
| 'anilist'
@@ -95,6 +96,13 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
wordCardKind: 'word-and-sentence',
},
},
anime: {
// Empty by default: SubMiner ships no extension repositories and performs
// no discovery. Sources exist only once the user adds a repo or an APK.
extensionsDir: '',
repos: [],
preferredQuality: '',
},
jimaku: {
apiBaseUrl: 'https://jimaku.cc',
apiKey: '',
@@ -566,6 +566,27 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.mpv.aniskipButtonKey,
description: 'mpv key used to skip the detected intro while the skip prompt is visible.',
},
{
path: 'anime.extensionsDir',
kind: 'string',
defaultValue: defaultConfig.anime.extensionsDir,
description:
'Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.',
},
{
path: 'anime.repos',
kind: 'array',
defaultValue: defaultConfig.anime.repos,
description:
'Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.',
},
{
path: 'anime.preferredQuality',
kind: 'string',
defaultValue: defaultConfig.anime.preferredQuality,
description:
'Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.',
},
{
path: 'jellyfin.enabled',
kind: 'boolean',
@@ -141,6 +141,15 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
],
key: 'ankiConnect',
},
{
title: 'Anime Browser',
description: [
'Anime browser sources. SubMiner ships no extension repositories and bundles no sources;',
'add a repository index URL here (or drop .apk files in the extensions directory) to have any.',
],
notes: ['Hot-reload: anime changes apply the next time the anime browser opens.'],
key: 'anime',
},
{
title: 'Jimaku',
description: ['Jimaku API configuration and defaults.'],
+46
View File
@@ -3,6 +3,7 @@ import * as path from 'node:path';
import { MPV_LAUNCH_MODE_VALUES, parseMpvLaunchMode } from '../../shared/mpv-launch-mode';
import { ResolveContext } from './context';
import { asBoolean, asNumber, asString, isObject } from './shared';
import { isValidRepoUrl } from '../../anime-bridge/extension-repo';
function normalizeExternalProfilePath(value: string): string {
const trimmed = value.trim();
@@ -343,6 +344,51 @@ export function applyIntegrationConfig(context: ResolveContext): void {
warn('mpv', src.mpv, resolved.mpv, 'Expected object.');
}
if (isObject(src.anime)) {
const extensionsDir = asString(src.anime.extensionsDir);
if (extensionsDir !== undefined) {
resolved.anime.extensionsDir = normalizeExternalProfilePath(extensionsDir);
} else if (src.anime.extensionsDir !== undefined) {
warn(
'anime.extensionsDir',
src.anime.extensionsDir,
resolved.anime.extensionsDir,
'Expected string.',
);
}
const preferredQuality = asString(src.anime.preferredQuality);
if (preferredQuality !== undefined) {
resolved.anime.preferredQuality = preferredQuality.trim();
} else if (src.anime.preferredQuality !== undefined) {
warn(
'anime.preferredQuality',
src.anime.preferredQuality,
resolved.anime.preferredQuality,
'Expected string.',
);
}
if (Array.isArray(src.anime.repos)) {
const repos: string[] = [];
for (const entry of src.anime.repos) {
const url = asString(entry)?.trim();
// Rejecting here rather than at fetch time keeps a bad entry from
// silently doing nothing, and blocks plain http downgrades.
if (url !== undefined && isValidRepoUrl(url)) {
if (!repos.includes(url)) repos.push(url);
} else {
warn('anime.repos[]', entry, null, 'Expected an https URL to a .json index file.');
}
}
resolved.anime.repos = repos;
} else if (src.anime.repos !== undefined) {
warn('anime.repos', src.anime.repos, resolved.anime.repos, 'Expected array of strings.');
}
} else if (src.anime !== undefined) {
warn('anime', src.anime, resolved.anime, 'Expected object.');
}
if (isObject(src.jellyfin)) {
const enabled = asBoolean(src.jellyfin.enabled);
if (enabled !== undefined) {
+1
View File
@@ -17,6 +17,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
yomitan: false,
settings: false,
syncWindow: false,
animeBrowser: false,
setup: false,
show: false,
hide: false,
+2 -1
View File
@@ -186,7 +186,8 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
(!deps.isDarwinPlatform() ||
initialArgs.settings ||
initialArgs.setup ||
initialArgs.syncWindow)
initialArgs.syncWindow ||
initialArgs.animeBrowser)
) {
deps.quitApp();
}
+5
View File
@@ -22,6 +22,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
yomitan: false,
settings: false,
syncWindow: false,
animeBrowser: false,
setup: false,
show: false,
hide: false,
@@ -142,6 +143,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
openSyncUiWindow: () => {
calls.push('openSyncUiWindow');
},
openAnimeBrowserWindow: () => {
calls.push('openAnimeBrowserWindow');
},
openFirstRunSetup: (force?: boolean) => {
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
},
@@ -665,6 +669,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openAnimeBrowserWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
+5
View File
@@ -45,6 +45,7 @@ export interface CliCommandServiceDeps {
openYomitanSettingsDelayed: (delayMs: number) => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openAnimeBrowserWindow: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -172,6 +173,7 @@ interface UiCliRuntime {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openAnimeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -277,6 +279,7 @@ export function createCliCommandDepsRuntime(
},
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
openSyncUiWindow: options.ui.openSyncUiWindow,
openAnimeBrowserWindow: options.ui.openAnimeBrowserWindow,
setVisibleOverlayVisible: options.overlay.setVisible,
copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
startPendingMultiCopy: options.mining.startPendingMultiCopy,
@@ -422,6 +425,8 @@ export function handleCliCommand(
deps.openConfigSettingsWindow();
} else if (args.syncWindow) {
deps.openSyncUiWindow();
} else if (args.animeBrowser) {
deps.openAnimeBrowserWindow();
} else if (args.show || args.showVisibleOverlay) {
deps.setVisibleOverlayVisible(true);
} else if (args.hide || args.hideVisibleOverlay) {
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import test, { beforeEach } from 'node:test';
import {
isDockIconRetained,
releaseDockIcon,
resetDockIconRetentionForTests,
retainDockIcon,
} from './dock-icon-visibility';
function createDockRecorder() {
const calls: string[] = [];
return {
calls,
dock: {
show: () => {
calls.push('show');
},
hide: () => {
calls.push('hide');
},
},
};
}
beforeEach(() => {
resetDockIconRetentionForTests();
});
test('retainDockIcon shows the dock once and marks retention on darwin', () => {
const recorder = createDockRecorder();
retainDockIcon({ dock: recorder.dock, platform: 'darwin' });
retainDockIcon({ dock: recorder.dock, platform: 'darwin' });
assert.deepEqual(recorder.calls, ['show']);
assert.equal(isDockIconRetained(), true);
});
test('releaseDockIcon rehides only when the last retainer releases and overlay still needs it', () => {
const recorder = createDockRecorder();
retainDockIcon({ dock: recorder.dock, platform: 'darwin' });
retainDockIcon({ dock: recorder.dock, platform: 'darwin' });
releaseDockIcon({ dock: recorder.dock, platform: 'darwin', shouldRehide: () => true });
assert.deepEqual(recorder.calls, ['show']);
assert.equal(isDockIconRetained(), true);
releaseDockIcon({ dock: recorder.dock, platform: 'darwin', shouldRehide: () => true });
assert.deepEqual(recorder.calls, ['show', 'hide']);
assert.equal(isDockIconRetained(), false);
});
test('releaseDockIcon leaves the dock visible when no overlay needs the accessory transform', () => {
const recorder = createDockRecorder();
retainDockIcon({ dock: recorder.dock, platform: 'darwin' });
releaseDockIcon({ dock: recorder.dock, platform: 'darwin', shouldRehide: () => false });
assert.deepEqual(recorder.calls, ['show']);
assert.equal(isDockIconRetained(), false);
});
test('release without retain and non-darwin platforms are no-ops', () => {
const recorder = createDockRecorder();
releaseDockIcon({ dock: recorder.dock, platform: 'darwin', shouldRehide: () => true });
retainDockIcon({ dock: recorder.dock, platform: 'linux' });
releaseDockIcon({ dock: recorder.dock, platform: 'win32', shouldRehide: () => true });
assert.deepEqual(recorder.calls, []);
assert.equal(isDockIconRetained(), false);
});
+49
View File
@@ -0,0 +1,49 @@
// macOS-only: Electron's setVisibleOnAllWorkspaces(..., { visibleOnFullScreen: true })
// transforms the whole app into a UIElement (accessory) process so overlay windows can
// float above fullscreen Spaces. That side effect removes the Dock icon and the app's
// Cmd+Tab entry, making regular windows (anime browser, settings) unreachable.
//
// While at least one regular window "retains" the Dock icon, overlay/stats level
// assertions must pass skipTransformProcessType so they don't yank the app back to
// accessory mode behind the user's back.
type DockLike = {
show: () => Promise<void> | void;
hide: () => void;
};
let dockIconRetainCount = 0;
export function isDockIconRetained(): boolean {
return dockIconRetainCount > 0;
}
export function retainDockIcon(options: {
dock: DockLike | null | undefined;
platform?: NodeJS.Platform;
}): void {
if ((options.platform ?? process.platform) !== 'darwin') return;
dockIconRetainCount += 1;
if (dockIconRetainCount === 1) {
void options.dock?.show();
}
}
export function releaseDockIcon(options: {
dock: DockLike | null | undefined;
platform?: NodeJS.Platform;
// True when an overlay window still needs the accessory transform to float
// above fullscreen Spaces; false leaves the Dock icon visible.
shouldRehide: () => boolean;
}): void {
if ((options.platform ?? process.platform) !== 'darwin') return;
if (dockIconRetainCount === 0) return;
dockIconRetainCount -= 1;
if (dockIconRetainCount === 0 && options.shouldRehide()) {
options.dock?.hide();
}
}
export function resetDockIconRetentionForTests(): void {
dockIconRetainCount = 0;
}
+7 -1
View File
@@ -15,6 +15,7 @@ import {
type HyprlandPlacementStatus,
} from './hyprland-window-placement';
import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options';
import { isDockIconRetained } from './dock-icon-visibility';
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
export { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
@@ -79,7 +80,12 @@ export function updateOverlayWindowBounds(
export function ensureOverlayWindowLevel(window: BrowserWindow): void {
if (process.platform === 'darwin') {
window.setAlwaysOnTop(true, 'screen-saver', 1);
window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
// While a regular window (anime browser, ...) holds the Dock icon, skip the
// accessory process transform so the app stays in the Dock and Cmd+Tab.
window.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true,
skipTransformProcessType: isDockIconRetained(),
});
window.setFullScreenable(false);
window.moveTop();
return;
@@ -17,6 +17,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
yomitan: false,
settings: false,
syncWindow: false,
animeBrowser: false,
setup: false,
show: false,
hide: false,
+5 -1
View File
@@ -4,6 +4,7 @@ import type {
MessageBoxSyncOptions,
} from 'electron';
import type { WindowGeometry } from '../../types';
import { isDockIconRetained } from './dock-icon-visibility';
const DEFAULT_STATS_WINDOW_WIDTH = 900;
const DEFAULT_STATS_WINDOW_HEIGHT = 700;
@@ -114,7 +115,10 @@ export function promoteStatsWindowLevel(
): void {
if (platform === 'darwin') {
window.setAlwaysOnTop(true, 'screen-saver', 2);
window.setVisibleOnAllWorkspaces?.(true, { visibleOnFullScreen: true });
window.setVisibleOnAllWorkspaces?.(true, {
visibleOnFullScreen: true,
skipTransformProcessType: isDockIconRetained(),
});
window.setFullScreenable?.(false);
window.moveTop();
return;
+101
View File
@@ -424,6 +424,7 @@ import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher
import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
import { createJellyfinTokenStore } from './core/services/jellyfin-token-store';
import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc';
import { releaseDockIcon, retainDockIcon } from './core/services/dock-icon-visibility';
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
import { dispatchSessionAction as dispatchSessionActionCore } from './core/services/session-actions';
import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts';
@@ -527,7 +528,11 @@ import {
createCreateFirstRunSetupWindowHandler,
createCreateJellyfinSetupWindowHandler,
createCreateSyncUiWindowHandler,
createCreateAnimeBrowserWindowHandler,
} from './main/runtime/setup-window-factory';
import { createAnimeBrowserRuntime } from './main/runtime/anime-browser-runtime';
import { registerAnimeBrowserIpcHandlers } from './main/runtime/anime-browser-ipc-handlers';
import { ensureBridgeBinaries } from './main/runtime/anime-bridge-installer';
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
import { createOpenConfigSettingsWindowHandler } from './main/runtime/config-settings-window';
import { createSyncUiRuntime } from './main/runtime/sync-ui-runtime';
@@ -2913,6 +2918,7 @@ const {
runJellyfinCommand,
openJellyfinSetupWindow,
getJellyfinClientInfo,
ensureMpvConnectedForPlayback,
} = composeJellyfinRuntimeHandlers({
getResolvedJellyfinConfigMainDeps: {
getResolvedConfig: () => configService.getConfig(),
@@ -3204,6 +3210,99 @@ const {
},
});
const DEFAULT_ANIME_EXTENSIONS_DIR = path.join(USER_DATA_PATH, 'anime-extensions');
function resolveAnimeExtensionsDir(): string {
const configured = configService.getConfig().anime?.extensionsDir?.trim();
return configured && configured.length > 0 ? configured : DEFAULT_ANIME_EXTENSIONS_DIR;
}
const animeBrowserRuntime = createAnimeBrowserRuntime({
extensionsDir: () => resolveAnimeExtensionsDir(),
repos: () => configService.getConfig().anime?.repos ?? [],
setRepos: (repos) => {
configService.patchRawConfig({ anime: { repos } });
},
preferredQuality: () => configService.getConfig().anime?.preferredQuality || undefined,
preferencesFile: path.join(USER_DATA_PATH, 'anime-source-preferences.json'),
ensureBinaries: (onProgress) =>
ensureBridgeBinaries({
installDir: path.join(USER_DATA_PATH, 'anime-bridge'),
onProgress,
}),
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
ensureMpvConnected: () => ensureMpvConnectedForPlayback(),
showVisibleOverlay: () => {
// Launching a video turns a browse-only instance into a regular SubMiner
// playback session: tray icon plus the overlay runtime that
// setVisibleOverlayVisible initializes on demand.
ensureTrayHandler();
setVisibleOverlayVisible(true);
},
showMpvOsd: (text) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(text, { title: 'Anime' }),
onBridgeState: (state) => {
const window = appState.animeBrowserWindow;
if (window && !window.isDestroyed()) {
window.webContents.send(IPC_CHANNELS.event.animeBrowserBridgeState, state);
}
},
onSearchUpdate: (update) => {
const window = appState.animeBrowserWindow;
if (window && !window.isDestroyed()) {
window.webContents.send(IPC_CHANNELS.event.animeBrowserSearchUpdate, update);
}
},
log: (message) => logger.info(message),
});
registerAnimeBrowserIpcHandlers({ ipcMain, runtime: animeBrowserRuntime });
let animeBrowserDockIconRetained = false;
const openAnimeBrowserWindowBase = createOpenConfigSettingsWindowHandler({
getSettingsWindow: () => appState.animeBrowserWindow,
setSettingsWindow: (window) => {
appState.animeBrowserWindow = window as BrowserWindow | null;
},
createSettingsWindow: createCreateAnimeBrowserWindowHandler({
createBrowserWindow: (options) => new BrowserWindow(options),
preloadPath: path.join(__dirname, 'preload-animeui.js'),
}),
settingsHtmlPath: path.join(__dirname, 'animeui', 'index.html'),
promoteSettingsWindowAboveOverlay: (window) =>
promoteSettingsWindowAboveOverlay(window as BrowserWindow),
onClosed: () => {
animeBrowserDockIconRetained = false;
releaseDockIcon({
dock: app.dock,
shouldRehide: () => {
const mainWindow = overlayManager.getMainWindow();
return Boolean(mainWindow && !mainWindow.isDestroyed());
},
});
// The bridge holds the stream-proxy tokens mpv is playing through, so it
// must outlive the window. Only tear it down when the window was the
// app's whole reason to be running — and once a video has launched, the
// instance has become a regular playback session (tray, overlay, mpv
// stream), so closing the browser must not kill it either.
if (appState.initialArgs?.animeBrowser && !appState.mpvClient?.connected) {
void animeBrowserRuntime.dispose().finally(() => requestAppQuit());
}
},
log: (message) => logger.error(message),
});
const openAnimeBrowserWindowHandler = (): boolean => {
const opened = openAnimeBrowserWindowBase();
if (opened) {
if (!animeBrowserDockIconRetained) {
animeBrowserDockIconRetained = true;
retainDockIcon({ dock: app.dock });
}
ensureTrayHandler();
}
return opened;
};
const maybeFocusExistingFirstRunSetupWindow = createMaybeFocusExistingFirstRunSetupWindowHandler({
getSetupWindow: () => appState.firstRunSetupWindow,
});
@@ -5861,6 +5960,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
openSyncUiWindow: () => openSyncUiWindowHandler(),
openAnimeBrowserWindow: () => openAnimeBrowserWindowHandler(),
cycleSecondarySubMode: () => cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
@@ -6113,6 +6213,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
openSyncUiWindow: () => openSyncUiWindowHandler(),
openAnimeBrowserWindow: () => openAnimeBrowserWindowHandler(),
exportLogs: () => {
void exportLogsFromTray();
},
+2
View File
@@ -51,6 +51,7 @@ export interface CliCommandRuntimeServiceContext {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openAnimeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -137,6 +138,7 @@ function createCliCommandDepsFromContext(
openYomitanSettings: context.openYomitanSettings,
openConfigSettingsWindow: context.openConfigSettingsWindow,
openSyncUiWindow: context.openSyncUiWindow,
openAnimeBrowserWindow: context.openAnimeBrowserWindow,
cycleSecondarySubMode: context.cycleSecondarySubMode,
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
printHelp: context.printHelp,
+2
View File
@@ -211,6 +211,7 @@ export interface CliCommandRuntimeServiceDepsParams {
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow'];
openSyncUiWindow: CliCommandDepsRuntimeOptions['ui']['openSyncUiWindow'];
openAnimeBrowserWindow: CliCommandDepsRuntimeOptions['ui']['openAnimeBrowserWindow'];
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp'];
@@ -416,6 +417,7 @@ export function createCliCommandRuntimeServiceDeps(
openYomitanSettings: params.ui.openYomitanSettings,
openConfigSettingsWindow: params.ui.openConfigSettingsWindow,
openSyncUiWindow: params.ui.openSyncUiWindow,
openAnimeBrowserWindow: params.ui.openAnimeBrowserWindow,
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
printHelp: params.ui.printHelp,
+163
View File
@@ -0,0 +1,163 @@
import { spawn } from 'node:child_process';
import { chmod, mkdir, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import {
BUNDLE_RELEASES_URL,
findBundleBinaries,
resolveBundleAssetName,
selectBundleAsset,
verifyPinnedBundle,
type BundleBinaries,
} from '../../anime-bridge/sidecar-bundle';
/**
* Downloads and unpacks the M-Extension-Server bundle that runs Aniyomi
* extension APKs. The bundle ships its own JRE, so no system Java is needed.
*/
export type InstallStage = 'locating' | 'downloading' | 'verifying' | 'extracting';
export interface InstallProgress {
stage: InstallStage;
/** 0-1 during download, otherwise null. */
progress: number | null;
}
export interface EnsureBridgeOptions {
/** Directory the bundle is unpacked into, e.g. `<userData>/anime-bridge`. */
installDir: string;
platform?: string;
arch?: string;
fetchImpl?: typeof fetch;
onProgress?: (progress: InstallProgress) => void;
}
/** Extract a zip without adding a dependency, mirroring scripts/build-yomitan.mjs. */
async function extractZip(zipPath: string, targetDir: string): Promise<void> {
const attempts: Array<[string, string[]]> = [
['unzip', ['-qo', zipPath, '-d', targetDir]],
// bsdtar ships with macOS and Windows 10+ and reads zip archives.
['tar', ['-xf', zipPath, '-C', targetDir]],
];
let lastError = '';
for (const [command, args] of attempts) {
const result = await runCommand(command, args);
if (result.ok) return;
lastError = result.error;
}
throw new Error(`Could not extract the anime bridge bundle. ${lastError}`);
}
function runCommand(
command: string,
args: string[],
): Promise<{ ok: true } | { ok: false; error: string }> {
return new Promise((resolve) => {
const child = spawn(command, args, { stdio: ['ignore', 'ignore', 'pipe'] });
let stderr = '';
child.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
child.once('error', (error) => resolve({ ok: false, error: `${command}: ${error.message}` }));
child.once('exit', (code) => {
if (code === 0) resolve({ ok: true });
else resolve({ ok: false, error: `${command} exited ${code}. ${stderr.trim()}` });
});
});
}
async function downloadWithProgress(
response: Response,
onProgress: ((fraction: number) => void) | undefined,
): Promise<Uint8Array> {
const declared = Number(response.headers.get('content-length') ?? '0');
const reader = response.body?.getReader();
if (!reader) return new Uint8Array(await response.arrayBuffer());
const chunks: Uint8Array[] = [];
let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
chunks.push(value);
received += value.length;
if (declared > 0) onProgress?.(Math.min(1, received / declared));
}
}
const merged = new Uint8Array(received);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.length;
}
return merged;
}
/**
* Return the bundle's java + jar paths, downloading the release first if the
* install directory does not already hold a usable copy.
*/
export async function ensureBridgeBinaries(options: EnsureBridgeOptions): Promise<BundleBinaries> {
const existing = await findBundleBinaries(options.installDir);
if (existing) return existing;
const platform = options.platform ?? process.platform;
const arch = options.arch ?? process.arch;
const fetchImpl = options.fetchImpl ?? fetch;
const assetName = resolveBundleAssetName(platform, arch);
if (assetName === null) {
throw new Error(
`No anime bridge build is published for ${platform}/${arch}. ` +
'Supported: macOS (arm64, x64), Linux (x64), Windows (x64).',
);
}
options.onProgress?.({ stage: 'locating', progress: null });
const releasesResponse = await fetchImpl(BUNDLE_RELEASES_URL, {
headers: { Accept: 'application/vnd.github+json' },
});
if (!releasesResponse.ok) {
throw new Error(`Could not list anime bridge releases (${releasesResponse.status}).`);
}
const asset = selectBundleAsset(await releasesResponse.json(), assetName);
if (asset === null) {
throw new Error(`No published anime bridge release contains ${assetName}.`);
}
options.onProgress?.({ stage: 'downloading', progress: 0 });
const downloadResponse = await fetchImpl(asset.downloadUrl);
if (!downloadResponse.ok) {
throw new Error(`Downloading the anime bridge failed (${downloadResponse.status}).`);
}
const bytes = await downloadWithProgress(downloadResponse, (fraction) =>
options.onProgress?.({ stage: 'downloading', progress: fraction }),
);
options.onProgress?.({ stage: 'verifying', progress: null });
const verification = verifyPinnedBundle(assetName, bytes);
if (!verification.ok) throw new Error(verification.reason);
options.onProgress?.({ stage: 'extracting', progress: null });
await mkdir(options.installDir, { recursive: true });
const zipPath = path.join(options.installDir, assetName);
await writeFile(zipPath, bytes);
try {
await extractZip(zipPath, options.installDir);
} finally {
await rm(zipPath, { force: true });
}
const binaries = await findBundleBinaries(options.installDir);
if (!binaries) {
throw new Error('The anime bridge bundle unpacked without a java runtime or server jar.');
}
// Some extractors drop the executable bit; restore it rather than failing at spawn.
if (platform !== 'win32') {
await chmod(binaries.javaPath, 0o755).catch(() => undefined);
}
return binaries;
}
@@ -0,0 +1,75 @@
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import type { AnimeBrowserRuntime } from './anime-browser-runtime';
import type { AnimeBrowserPlayRequest } from '../../types/anime-browser';
export interface AnimeBrowserIpcDeps {
// Structurally typed so tests can pass a fake without importing Electron.
ipcMain: {
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown;
};
runtime: AnimeBrowserRuntime;
}
/**
* Bridge the renderer to the anime runtime. Page arguments arrive as `unknown`
* from the renderer, so they are coerced here rather than trusted.
*/
export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void {
const channels = IPC_CHANNELS.request;
const { runtime } = deps;
const handle = (
channel: string,
listener: (event: unknown, ...args: unknown[]) => unknown,
): void => {
deps.ipcMain.handle(channel, listener);
};
handle(channels.animeBrowserGetSnapshot, () => runtime.getSnapshot());
handle(channels.animeBrowserEnsureBridge, () => runtime.ensureBridge());
handle(channels.animeBrowserSelectSource, (_event, sourceId) =>
runtime.selectSource(String(sourceId)),
);
handle(channels.animeBrowserSearch, (_event, query, page) =>
runtime.search(String(query ?? ''), toPage(page)),
);
handle(channels.animeBrowserGetPopular, (_event, page) => runtime.getPopular(toPage(page)));
handle(channels.animeBrowserGetDetails, (_event, animeUrl, sourceId) =>
runtime.getDetails(String(animeUrl), toOptionalId(sourceId)),
);
handle(channels.animeBrowserGetEpisodes, (_event, animeUrl, sourceId) =>
runtime.getEpisodes(String(animeUrl), toOptionalId(sourceId)),
);
handle(channels.animeBrowserListAvailableExtensions, () => runtime.listAvailableExtensions());
handle(channels.animeBrowserInstallExtension, (_event, pkg) =>
runtime.installExtension(String(pkg)),
);
handle(channels.animeBrowserRemoveExtension, (_event, pkg) =>
runtime.removeExtension(String(pkg)),
);
handle(channels.animeBrowserRescanExtensions, () => runtime.rescanExtensions());
handle(channels.animeBrowserAddRepo, (_event, url) => runtime.addRepo(String(url)));
handle(channels.animeBrowserRemoveRepo, (_event, url) => runtime.removeRepo(String(url)));
handle(channels.animeBrowserPlayEpisode, (_event, request) =>
runtime.playEpisode(request as AnimeBrowserPlayRequest),
);
handle(channels.animeBrowserGetPreferences, (_event, sourceId) =>
runtime.getPreferences(String(sourceId)),
);
handle(channels.animeBrowserSetPreference, (_event, sourceId, key, value) =>
runtime.setPreference(String(sourceId), String(key), value as string | string[] | boolean),
);
}
/**
* A source id the renderer may omit. Absent means "use the current selection",
* so an empty value must stay undefined rather than becoming the string "".
*/
function toOptionalId(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
/** Bridge pages are 1-based; anything unusable falls back to the first page. */
function toPage(value: unknown): number {
const page = Number(value);
return Number.isFinite(page) && page >= 1 ? Math.floor(page) : 1;
}
+529
View File
@@ -0,0 +1,529 @@
import { AnimeBridgeClient } from '../../anime-bridge/bridge-client';
import { resolveStream } from '../../anime-bridge/headers';
import { parseAnimeStatus, resolveBridgeMediaUrl } from '../../anime-bridge/media-url';
import {
buildPlaybackCommands,
buildTrackCommands,
selectPreferredStream,
} from '../../anime-bridge/mpv-playback';
import {
listExtensionSources,
readInstalledExtensions,
toBridgeSource,
toInstalledExtensionViews,
type ExtensionSource,
type InstalledExtension,
} from '../../anime-bridge/extension-store';
import { interleave, mapSourcesConcurrently } from '../../anime-bridge/multi-source-search';
import { startSidecar, type SidecarHandle } from '../../anime-bridge/sidecar-process';
import {
fetchRepoCatalogue,
isValidRepoUrl,
type RepoExtension,
} from '../../anime-bridge/extension-repo';
import {
installExtension,
removeExtension as removeExtensionFile,
} from '../../anime-bridge/extension-installer';
import { PreferenceStore } from '../../anime-bridge/preference-store';
import { applyPreferenceValue, parsePreferences } from '../../anime-bridge/preferences';
import type { SourcePreferenceView } from '../../anime-bridge/preferences';
import type { BundleBinaries } from '../../anime-bridge/sidecar-bundle';
import type { InstallProgress } from './anime-bridge-installer';
import { ALL_SOURCES_ID } from '../../types/anime-browser';
import type {
AnimeBrowserBridgeState,
AnimeBrowserDetails,
AnimeBrowserEntry,
AnimeBrowserEpisode,
AnimeBrowserPlayRequest,
AnimeBrowserPlayResult,
AnimeBrowserSearchResult,
AnimeBrowserSearchUpdate,
AnimeBrowserSnapshot,
AvailableExtensionsResult,
ExtensionLoadFailure,
} from '../../types/anime-browser';
import type { BridgeAnimePage } from '../../anime-bridge/types';
export interface AnimeBrowserRuntimeDeps {
/** Where user-supplied Aniyomi extension APKs live. Read lazily so config edits apply. */
extensionsDir: () => string;
/** Configured repository index URLs. Empty unless the user added one. */
repos: () => string[];
/** Persists the repository list. Config stays the source of truth. */
setRepos: (repos: string[]) => void;
/** JSON file holding each source's saved preference values. */
preferencesFile: string;
ensureBinaries: (onProgress: (progress: InstallProgress) => void) => Promise<BundleBinaries>;
/** Sends mpv an IPC command; same transport the Jellyfin path uses. */
sendMpvCommand: (command: Array<string | number>) => void;
/** Brings mpv up if it is not already connected. Resolves false on failure. */
ensureMpvConnected: () => Promise<boolean>;
showMpvOsd?: (message: string) => void;
showVisibleOverlay?: () => void;
/** Lets tests drive the pause between `loadfile` and the track commands. */
wait?: (ms: number) => Promise<void>;
onBridgeState: (state: AnimeBrowserBridgeState) => void;
/**
* Streams per-source progress while a search invoke is pending. Optional so
* a host that has no window to push to can leave it out.
*/
onSearchUpdate?: (update: AnimeBrowserSearchUpdate) => void;
preferredQuality?: () => string | undefined;
log: (message: string) => void;
}
const IDLE_STATE: AnimeBrowserBridgeState = { stage: 'idle', progress: null, message: null };
/** How long to let `loadfile` settle before adding external tracks. */
const TRACK_ATTACH_DELAY_MS = 300;
export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
let bridgeState: AnimeBrowserBridgeState = IDLE_STATE;
let sidecar: SidecarHandle | null = null;
let starting: Promise<SidecarHandle> | null = null;
let extensions: InstalledExtension[] = [];
let sources: ExtensionSource[] = [];
let selectedSourceId: string | null = null;
let loadFailures: ExtensionLoadFailure[] = [];
// Monotonic; identifies the newest browse so stale ones stop emitting.
let searchToken = 0;
const preferenceStore = new PreferenceStore(deps.preferencesFile);
const wait =
deps.wait ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
function setState(state: AnimeBrowserBridgeState): void {
bridgeState = state;
deps.onBridgeState(state);
}
async function sourceFor(sourceId: string) {
const source = sources.find((candidate) => candidate.id === sourceId);
if (!source) throw new Error('That source is no longer installed. Rescan and try again.');
const extension = extensions.find((candidate) => candidate.file === source.file);
if (!extension) throw new Error(`Extension file missing for ${source.name}.`);
// Saved values ride along on every call; the extension is stateless per request.
const saved = await preferenceStore.get(source.id);
return { ...toBridgeSource(extension, source.id), preferences: saved };
}
function requireBridge(): { client: AnimeBridgeClient; baseUrl: string } {
if (!sidecar) throw new Error('The anime bridge is not running yet.');
return { client: sidecar.client, baseUrl: sidecar.baseUrl };
}
async function startBridge(): Promise<SidecarHandle> {
const binaries = await deps.ensureBinaries((progress) =>
setState({ stage: progress.stage, progress: progress.progress, message: null }),
);
setState({ stage: 'starting', progress: null, message: null });
const handle = await startSidecar({
binaries,
onLog: (line) => deps.log(`[anime-bridge] ${line}`),
});
sidecar = handle;
await scanExtensions(handle);
return handle;
}
/**
* Re-read the extensions directory and ask the bridge what each APK provides.
* Called on start and after any install or removal.
*/
async function scanExtensions(handle: SidecarHandle): Promise<void> {
const directory = deps.extensionsDir();
loadFailures = [];
extensions = await readInstalledExtensions(directory);
sources = await listExtensionSources(handle.client, extensions, (extension, error) => {
const message = describeError(error);
loadFailures.push({ pkg: extension.fallbackName, error: message });
deps.log(`[anime-bridge] extension ${extension.fallbackName} failed to load: ${message}`);
});
// Keep the current selection if it survived the rescan. "All sources"
// survives as long as anything is installed.
const keptAll = selectedSourceId === ALL_SOURCES_ID && sources.length > 0;
if (!keptAll && !sources.some((source) => source.id === selectedSourceId)) {
selectedSourceId = sources[0]?.id ?? null;
}
setState({
stage: 'ready',
progress: null,
message:
sources.length === 0
? `No anime extensions installed. Add a repository or put .apk files in ${directory}.`
: null,
});
}
async function ensureBridge(): Promise<AnimeBrowserBridgeState> {
if (sidecar) return bridgeState;
// Collapse concurrent callers onto one start; the UI calls this eagerly.
if (!starting) {
starting = startBridge().catch((error: unknown) => {
setState({ stage: 'failed', progress: null, message: describeError(error) });
starting = null;
throw error;
});
}
try {
await starting;
} catch {
return bridgeState;
}
return bridgeState;
}
async function installExtensionFrom(extension: RepoExtension): Promise<void> {
await installExtension({ extensionsDir: deps.extensionsDir(), extension });
if (sidecar) await scanExtensions(sidecar);
}
function toEntry(
baseUrl: string,
anime: { url?: string; title?: string; thumbnail_url?: string },
source: ExtensionSource,
) {
return {
url: anime.url ?? '',
title: anime.title ?? 'Untitled',
thumbnailUrl: anime.thumbnail_url
? resolveBridgeMediaUrl(baseUrl, anime.thumbnail_url)
: null,
sourceId: source.id,
sourceName: source.name,
} satisfies AnimeBrowserEntry;
}
/** The one source a per-anime call must run against. */
function requireSource(sourceId: string | null): ExtensionSource {
const source = sources.find((candidate) => candidate.id === sourceId);
if (!source) {
throw new Error(
sourceId === ALL_SOURCES_ID ? 'Pick a single source for this.' : 'Select a source first.',
);
}
return source;
}
/**
* Run a listing call against the selected source, or against every installed
* source when "all sources" is selected.
*
* With one source a failure rejects, as it always has. Across all of them a
* failure is reported alongside the sources that did answer, so one broken
* extension cannot blank the grid.
*/
async function browse(
page: number,
fetchPage: (
source: Awaited<ReturnType<typeof sourceFor>>,
page: number,
) => Promise<BridgeAnimePage>,
): Promise<AnimeBrowserSearchResult> {
const { baseUrl } = requireBridge();
const targets =
selectedSourceId === ALL_SOURCES_ID ? sources : [requireSource(selectedSourceId)];
if (targets.length === 0) throw new Error('No sources are installed.');
// Each source's answer is pushed the moment it lands, so a fast source is
// on screen while a slow one is still resolving. Guarded by the token: a
// superseded search stops emitting, and its remaining sources run out
// quietly.
const token = ++searchToken;
const emit = (update: AnimeBrowserSearchUpdate): void => {
if (token === searchToken) deps.onSearchUpdate?.(update);
};
emit({ kind: 'start', token, sourceCount: targets.length });
const { results, failures } = await mapSourcesConcurrently(targets, async (source) => {
try {
const response = await fetchPage(await sourceFor(source.id), page);
const entries = (response.animes ?? []).map((anime) => toEntry(baseUrl, anime, source));
emit({ kind: 'result', token, sourceId: source.id, sourceName: source.name, entries });
return { entries, hasNextPage: response.hasNextPage === true };
} catch (error) {
emit({
kind: 'failure',
token,
failure: { sourceId: source.id, sourceName: source.name, error: describeError(error) },
});
throw error;
}
});
emit({ kind: 'done', token });
// A single-source browse has no other source to fall back on, so surface
// the error the way a direct call would.
if (targets.length === 1 && failures[0]) throw new Error(failures[0].error);
return {
entries: interleave(results.map((result) => result.entries)),
hasNextPage: results.some((result) => result.hasNextPage),
failures,
};
}
return {
getSnapshot(): AnimeBrowserSnapshot {
return {
bridge: bridgeState,
sources: sources.map((source) => ({
id: source.id,
name: source.name,
lang: source.lang,
pkg: source.pkg,
})),
selectedSourceId,
loadFailures,
installed: toInstalledExtensionViews(extensions, sources, loadFailures),
extensionsDir: deps.extensionsDir(),
repos: deps.repos(),
};
},
ensureBridge,
/**
* Extensions available from the configured repositories, annotated with
* what is installed. Returns nothing when no repository is configured —
* SubMiner never supplies one.
*/
async listAvailableExtensions(): Promise<AvailableExtensionsResult> {
const repos = deps.repos();
if (repos.length === 0) return { extensions: [], failures: [] };
const catalogue = await fetchRepoCatalogue(repos);
const installedPkgs = new Set(extensions.map((extension) => extension.fallbackName));
return {
extensions: catalogue.extensions.map((extension) => ({
pkg: extension.pkg,
name: extension.name,
lang: extension.lang,
version: extension.version,
nsfw: extension.nsfw,
repoUrl: extension.repoUrl,
sourceNames: extension.sourceNames,
installed: installedPkgs.has(extension.pkg),
})),
failures: catalogue.failures,
};
},
/** Download an extension by package name, then rescan. */
async installExtension(pkg: string): Promise<void> {
const repos = deps.repos();
if (repos.length === 0) throw new Error('No extension repository is configured.');
const catalogue = await fetchRepoCatalogue(repos);
const match = catalogue.extensions.find((candidate) => candidate.pkg === pkg);
if (!match) throw new Error(`${pkg} is not offered by any configured repository.`);
await installExtensionFrom(match);
},
/** Remove an installed extension, then rescan. */
async removeExtension(pkg: string): Promise<void> {
await removeExtensionFile(deps.extensionsDir(), pkg);
await preferenceStore.clear(pkg).catch(() => undefined);
if (sidecar) await scanExtensions(sidecar);
},
/**
* Add a repository index URL. Rejected unless it is an https index URL, so
* a typo surfaces immediately instead of failing later at fetch time.
*/
addRepo(url: string): void {
const trimmed = url.trim();
if (!isValidRepoUrl(trimmed)) {
throw new Error('A repository URL must be https and point at a .json index file.');
}
const repos = deps.repos();
if (repos.includes(trimmed)) return;
deps.setRepos([...repos, trimmed]);
},
removeRepo(url: string): void {
deps.setRepos(deps.repos().filter((candidate) => candidate !== url));
},
/** Re-read the extensions directory without restarting the bridge. */
async rescanExtensions(): Promise<void> {
if (sidecar) await scanExtensions(sidecar);
},
selectSource(sourceId: string): void {
if (sourceId === ALL_SOURCES_ID && sources.length > 0) {
selectedSourceId = ALL_SOURCES_ID;
return;
}
if (sources.some((source) => source.id === sourceId)) selectedSourceId = sourceId;
},
/**
* The extension's settings schema, merged with anything saved locally. The
* extension is the source of truth for structure; saved values only supply
* what it has no memory of between requests.
*/
async getPreferences(sourceId: string): Promise<SourcePreferenceView[]> {
const { client } = requireBridge();
const source = requireSource(sourceId);
const schema = await client.getSourcePreferences(await sourceFor(source.id));
return parsePreferences(schema);
},
/**
* Persist one preference and hand the whole array back to the extension so
* it can react (Jellyfin logs in and populates its library list here).
*/
async setPreference(
sourceId: string,
key: string,
value: string | string[] | boolean,
): Promise<SourcePreferenceView[]> {
const { client } = requireBridge();
const source = await sourceFor(sourceId);
// Start from the extension's own schema so saved values never go stale
// against an updated extension.
const current =
source.preferences && source.preferences.length > 0
? source.preferences
: await client.getSourcePreferences(source);
const updated = applyPreferenceValue(current, key, value);
await preferenceStore.set(sourceId, updated);
const refreshed = await client.setSourcePreference({ ...source, preferences: updated }, key);
if (refreshed.length > 0) await preferenceStore.set(sourceId, refreshed);
return parsePreferences(refreshed.length > 0 ? refreshed : updated);
},
async search(query: string, page = 1): Promise<AnimeBrowserSearchResult> {
const { client } = requireBridge();
return browse(page, (source, requestedPage) =>
client.searchAnime(source, query, requestedPage),
);
},
async getPopular(page = 1): Promise<AnimeBrowserSearchResult> {
const { client } = requireBridge();
return browse(page, (source, requestedPage) => client.getPopularAnime(source, requestedPage));
},
async getDetails(animeUrl: string, sourceId?: string): Promise<AnimeBrowserDetails> {
const { client, baseUrl } = requireBridge();
const source = requireSource(sourceId ?? selectedSourceId);
const details = await client.getAnimeDetails(await sourceFor(source.id), animeUrl);
return {
...toEntry(baseUrl, { ...details, url: details.url ?? animeUrl }, source),
description: details.description ?? null,
author: details.author ?? null,
genres: details.genres ?? [],
status: parseAnimeStatus(details.status),
};
},
async getEpisodes(animeUrl: string, sourceId?: string): Promise<AnimeBrowserEpisode[]> {
const { client } = requireBridge();
const source = requireSource(sourceId ?? selectedSourceId);
const episodes = await client.getEpisodeList(await sourceFor(source.id), animeUrl);
return episodes.map((episode) => ({
url: episode.url ?? '',
name: episode.name ?? 'Episode',
number: typeof episode.episode_number === 'number' ? episode.episode_number : null,
uploadedAt:
typeof episode.date_upload === 'number' && episode.date_upload > 0
? episode.date_upload
: null,
scanlator: episode.scanlator ?? null,
}));
},
async playEpisode(request: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> {
try {
const { client, baseUrl } = requireBridge();
const videos = await client.getVideoList(
await sourceFor(request.sourceId),
request.episodeUrl,
);
const streams = videos
.map((video) => resolveStream(video))
.filter((stream): stream is NonNullable<typeof stream> => stream !== null)
// External tracks come off the same loopback proxy as the video, so
// they need the same rebase onto the port the bridge really uses.
.map((stream) => ({
...stream,
url: resolveBridgeMediaUrl(baseUrl, stream.url),
audios: stream.audios.map((track) => ({
...track,
url: resolveBridgeMediaUrl(baseUrl, track.url),
})),
subtitles: stream.subtitles.map((track) => ({
...track,
url: resolveBridgeMediaUrl(baseUrl, track.url),
})),
}));
const stream = selectPreferredStream(streams, deps.preferredQuality?.());
if (!stream) {
return { ok: false, error: 'That source returned no playable video.', quality: null };
}
if (!(await deps.ensureMpvConnected())) {
return {
ok: false,
error: 'mpv is not running and could not be started.',
quality: null,
};
}
const title = `${request.animeTitle}${request.episodeName}`;
for (const command of buildPlaybackCommands({ stream, title })) {
deps.sendMpvCommand(command);
}
const trackCommands = buildTrackCommands(stream);
if (trackCommands.length > 0) {
deps.log(
`[anime-browser] ${stream.audios.length} external audio, ` +
`${stream.subtitles.length} external subtitle track(s)`,
);
// mpv attaches added tracks to the file that is loading, so give the
// loadfile a moment to take effect first. Same pause the Jellyfin
// subtitle preload uses.
await wait(TRACK_ATTACH_DELAY_MS);
for (const command of trackCommands) {
deps.sendMpvCommand(command);
}
}
deps.showVisibleOverlay?.();
deps.showMpvOsd?.(title);
return { ok: true, error: null, quality: stream.quality || null };
} catch (error) {
deps.log(`[anime-browser] playback failed: ${String(error)}`);
return { ok: false, error: describeError(error), quality: null };
}
},
async dispose(): Promise<void> {
const handle = sidecar;
sidecar = null;
starting = null;
setState(IDLE_STATE);
await handle?.stop();
},
};
}
export type AnimeBrowserRuntime = ReturnType<typeof createAnimeBrowserRuntime>;
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -75,6 +75,7 @@ test('build cli command context deps maps handlers and values', () => {
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('config-settings'),
openSyncUiWindow: () => {},
openAnimeBrowserWindow: () => {},
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
printHelp: () => calls.push('help'),
@@ -49,6 +49,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openAnimeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -109,6 +110,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
openAnimeBrowserWindow: deps.openAnimeBrowserWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
printHelp: deps.printHelp,
@@ -77,6 +77,7 @@ test('cli command context factory composes main deps and context handlers', () =
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openAnimeBrowserWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
@@ -106,6 +106,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
openYomitanSettings: () => calls.push('open-yomitan'),
openConfigSettingsWindow: () => calls.push('open-config-settings'),
openSyncUiWindow: () => {},
openAnimeBrowserWindow: () => {},
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),
printHelp: () => calls.push('help'),
@@ -66,6 +66,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openAnimeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -146,6 +147,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
openYomitanSettings: () => deps.openYomitanSettings(),
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
openSyncUiWindow: () => deps.openSyncUiWindow(),
openAnimeBrowserWindow: () => deps.openAnimeBrowserWindow(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
printHelp: () => deps.printHelp(),
@@ -59,6 +59,7 @@ function createDeps() {
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openAnimeBrowserWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
+2
View File
@@ -54,6 +54,7 @@ export type CliCommandContextFactoryDeps = {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openAnimeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -136,6 +137,7 @@ export function createCliCommandContext(
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
openAnimeBrowserWindow: deps.openAnimeBrowserWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
printHelp: deps.printHelp,
@@ -53,6 +53,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openAnimeBrowserWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
@@ -130,6 +130,9 @@ export type JellyfinRuntimeComposerOptions = ComposerInputs<{
export type JellyfinRuntimeComposerResult = ComposerOutputs<{
getResolvedJellyfinConfig: ReturnType<typeof createGetResolvedJellyfinConfigHandler>;
getJellyfinClientInfo: ReturnType<typeof createGetJellyfinClientInfoHandler>;
ensureMpvConnectedForPlayback: ReturnType<
typeof createEnsureMpvConnectedForJellyfinPlaybackHandler
>;
reportJellyfinRemoteProgress: ReturnType<
typeof composeJellyfinRemoteHandlers
>['reportJellyfinRemoteProgress'];
@@ -297,6 +300,9 @@ export function composeJellyfinRuntimeHandlers(
return {
getResolvedJellyfinConfig,
getJellyfinClientInfo,
// Shared so other playback sources (the anime browser) reuse the same
// auto-launch in-flight guard instead of racing a second mpv launch.
ensureMpvConnectedForPlayback: ensureMpvConnectedForJellyfinPlayback,
reportJellyfinRemoteProgress,
reportJellyfinRemoteStopped,
handleJellyfinRemotePlay,
@@ -32,6 +32,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
yomitan: false,
settings: false,
syncWindow: false,
animeBrowser: false,
setup: false,
show: false,
hide: false,
+16
View File
@@ -99,3 +99,19 @@ export function createCreateSyncUiWindowHandler<TWindow>(deps: {
backgroundColor: '#24273a',
});
}
export function createCreateAnimeBrowserWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
preloadPath: string;
}) {
return createSetupWindowHandler(deps, {
// Wider than the other surfaces: this one is a cover-art grid.
width: 1180,
height: 820,
title: 'SubMiner Anime',
show: false,
resizable: true,
preloadPath: deps.preloadPath,
backgroundColor: '#24273a',
});
}
@@ -73,6 +73,7 @@ test('build tray template handler wires actions and init guards', () => {
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
openSyncUiWindow: () => calls.push('sync-ui'),
openAnimeBrowserWindow: () => calls.push('anime-browser'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
@@ -128,6 +129,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => {
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
openSyncUiWindow: () => calls.push('configuration'),
openAnimeBrowserWindow: () => calls.push('anime-browser'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => false,
+5
View File
@@ -48,6 +48,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
openAnimeBrowser: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -68,6 +69,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openAnimeBrowserWindow: () => void;
exportLogs: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
@@ -105,6 +107,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openConfigSettings: () => {
deps.openConfigSettingsWindow();
},
openAnimeBrowser: () => {
deps.openAnimeBrowserWindow();
},
openSyncUi: () => {
deps.openSyncUiWindow();
},
+2
View File
@@ -33,6 +33,7 @@ test('tray main deps builders return mapped handlers', () => {
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
openSyncUiWindow: () => calls.push('sync-ui'),
openAnimeBrowserWindow: () => {},
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
@@ -59,6 +60,7 @@ test('tray main deps builders return mapped handlers', () => {
openYomitanSettings: () => calls.push('open-yomitan'),
openConfigSettings: () => calls.push('open-configuration'),
openSyncUi: () => {},
openAnimeBrowser: () => {},
exportLogs: () => calls.push('open-export-logs'),
openJellyfinSetup: () => calls.push('open-jellyfin'),
showJellyfinDiscovery: true,
+3
View File
@@ -38,6 +38,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
openAnimeBrowser: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -58,6 +59,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openAnimeBrowserWindow: () => void;
exportLogs: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
@@ -82,6 +84,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
openAnimeBrowserWindow: deps.openAnimeBrowserWindow,
exportLogs: deps.exportLogs,
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
isJellyfinConfigured: deps.isJellyfinConfigured,
@@ -33,6 +33,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openAnimeBrowserWindow: () => {},
exportLogs: () => {},
openJellyfinSetupWindow: () => {},
isJellyfinConfigured: () => false,
+14 -6
View File
@@ -39,6 +39,7 @@ test('tray menu template contains expected entries and handlers', () => {
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettings: () => calls.push('configuration'),
openSyncUi: () => calls.push('sync-ui'),
openAnimeBrowser: () => calls.push('anime-browser'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetup: () => calls.push('jellyfin'),
showJellyfinDiscovery: true,
@@ -49,7 +50,7 @@ test('tray menu template contains expected entries and handlers', () => {
quitApp: () => calls.push('quit'),
});
assert.equal(template.length, 14);
assert.equal(template.length, 15);
assert.equal(
template.some((entry) => entry.label === 'Open Runtime Options'),
false,
@@ -70,17 +71,20 @@ test('tray menu template contains expected entries and handlers', () => {
assert.equal(template[5]!.label, 'Open SubMiner Settings');
assert.equal(template[6]!.label, 'Sync Stats && History');
template[6]!.click?.();
assert.equal(template[7]!.label, 'Export Logs');
assert.equal(template[7]!.label, 'Browse Anime');
template[7]!.click?.();
assert.equal(template[11]!.label, 'Check for Updates');
template[11]!.click?.();
template[12]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[13]!.click?.();
assert.equal(template[8]!.label, 'Export Logs');
template[8]!.click?.();
assert.equal(template[12]!.label, 'Check for Updates');
template[12]!.click?.();
template[13]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[14]!.click?.();
assert.deepEqual(calls, [
'jellyfin-discovery:true',
'help',
'texthooker',
'sync-ui',
'anime-browser',
'export-logs',
'updates',
'separator',
@@ -100,6 +104,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
openAnimeBrowser: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false,
@@ -129,6 +134,7 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
openAnimeBrowser: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false,
@@ -156,6 +162,7 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
openAnimeBrowser: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: true,
@@ -184,6 +191,7 @@ test('tray menu template renders a visible linux discovery check mark when activ
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
openSyncUi: () => undefined,
openAnimeBrowser: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: true,
+5
View File
@@ -41,6 +41,7 @@ export type TrayMenuActionHandlers = {
openYomitanSettings: () => void;
openConfigSettings: () => void;
openSyncUi: () => void;
openAnimeBrowser: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
@@ -108,6 +109,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
label: 'Sync Stats && History',
click: handlers.openSyncUi,
},
{
label: 'Browse Anime',
click: handlers.openAnimeBrowser,
},
{
label: 'Export Logs',
click: handlers.exportLogs,
+2
View File
@@ -153,6 +153,7 @@ export interface AppState {
firstRunSetupWindow: BrowserWindow | null;
configSettingsWindow: BrowserWindow | null;
syncUiWindow: BrowserWindow | null;
animeBrowserWindow: BrowserWindow | null;
yomitanParserReadyPromise: Promise<void> | null;
yomitanParserInitPromise: Promise<boolean> | null;
mpvClient: MpvIpcClient | null;
@@ -240,6 +241,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
firstRunSetupWindow: null,
configSettingsWindow: null,
syncUiWindow: null,
animeBrowserWindow: null,
yomitanParserReadyPromise: null,
yomitanParserInitPromise: null,
mpvClient: null,
+66
View File
@@ -0,0 +1,66 @@
import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS } from './shared/ipc/contracts';
import type {
AnimeBrowserAPI,
AnimeBrowserBridgeState,
AnimeBrowserDetails,
AnimeBrowserEpisode,
AnimeBrowserPlayRequest,
AnimeBrowserPlayResult,
AnimeBrowserSearchResult,
AnimeBrowserSearchUpdate,
AnimeBrowserSnapshot,
AvailableExtensionsResult,
SourcePreferenceView,
} from './types/anime-browser';
const request = IPC_CHANNELS.request;
const animeBrowserAPI: AnimeBrowserAPI = {
getSnapshot: (): Promise<AnimeBrowserSnapshot> =>
ipcRenderer.invoke(request.animeBrowserGetSnapshot),
ensureBridge: (): Promise<AnimeBrowserBridgeState> =>
ipcRenderer.invoke(request.animeBrowserEnsureBridge),
selectSource: (sourceId: string): Promise<void> =>
ipcRenderer.invoke(request.animeBrowserSelectSource, sourceId),
search: (query: string, page?: number): Promise<AnimeBrowserSearchResult> =>
ipcRenderer.invoke(request.animeBrowserSearch, query, page),
getPopular: (page?: number): Promise<AnimeBrowserSearchResult> =>
ipcRenderer.invoke(request.animeBrowserGetPopular, page),
getDetails: (animeUrl: string, sourceId?: string): Promise<AnimeBrowserDetails> =>
ipcRenderer.invoke(request.animeBrowserGetDetails, animeUrl, sourceId),
getEpisodes: (animeUrl: string, sourceId?: string): Promise<AnimeBrowserEpisode[]> =>
ipcRenderer.invoke(request.animeBrowserGetEpisodes, animeUrl, sourceId),
playEpisode: (playRequest: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> =>
ipcRenderer.invoke(request.animeBrowserPlayEpisode, playRequest),
getPreferences: (sourceId: string): Promise<SourcePreferenceView[]> =>
ipcRenderer.invoke(request.animeBrowserGetPreferences, sourceId),
setPreference: (
sourceId: string,
key: string,
value: string | string[] | boolean,
): Promise<SourcePreferenceView[]> =>
ipcRenderer.invoke(request.animeBrowserSetPreference, sourceId, key, value),
listAvailableExtensions: (): Promise<AvailableExtensionsResult> =>
ipcRenderer.invoke(request.animeBrowserListAvailableExtensions),
installExtension: (pkg: string): Promise<void> =>
ipcRenderer.invoke(request.animeBrowserInstallExtension, pkg),
removeExtension: (pkg: string): Promise<void> =>
ipcRenderer.invoke(request.animeBrowserRemoveExtension, pkg),
rescanExtensions: (): Promise<void> => ipcRenderer.invoke(request.animeBrowserRescanExtensions),
addRepo: (url: string): Promise<void> => ipcRenderer.invoke(request.animeBrowserAddRepo, url),
removeRepo: (url: string): Promise<void> =>
ipcRenderer.invoke(request.animeBrowserRemoveRepo, url),
onBridgeState: (listener: (state: AnimeBrowserBridgeState) => void): (() => void) => {
const handler = (_event: unknown, state: AnimeBrowserBridgeState): void => listener(state);
ipcRenderer.on(IPC_CHANNELS.event.animeBrowserBridgeState, handler);
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserBridgeState, handler);
},
onSearchUpdate: (listener: (update: AnimeBrowserSearchUpdate) => void): (() => void) => {
const handler = (_event: unknown, update: AnimeBrowserSearchUpdate): void => listener(update);
ipcRenderer.on(IPC_CHANNELS.event.animeBrowserSearchUpdate, handler);
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserSearchUpdate, handler);
},
};
contextBridge.exposeInMainWorld('animeBrowserAPI', animeBrowserAPI);
+18
View File
@@ -123,6 +123,22 @@ export const IPC_CHANNELS = {
syncUiDeleteSnapshot: 'sync-ui:delete-snapshot',
syncUiRevealSnapshot: 'sync-ui:reveal-snapshot',
syncUiPickSnapshotFile: 'sync-ui:pick-snapshot-file',
animeBrowserGetSnapshot: 'anime-browser:get-snapshot',
animeBrowserEnsureBridge: 'anime-browser:ensure-bridge',
animeBrowserSelectSource: 'anime-browser:select-source',
animeBrowserSearch: 'anime-browser:search',
animeBrowserGetPopular: 'anime-browser:get-popular',
animeBrowserGetDetails: 'anime-browser:get-details',
animeBrowserGetEpisodes: 'anime-browser:get-episodes',
animeBrowserPlayEpisode: 'anime-browser:play-episode',
animeBrowserGetPreferences: 'anime-browser:get-preferences',
animeBrowserSetPreference: 'anime-browser:set-preference',
animeBrowserListAvailableExtensions: 'anime-browser:list-available-extensions',
animeBrowserInstallExtension: 'anime-browser:install-extension',
animeBrowserRemoveExtension: 'anime-browser:remove-extension',
animeBrowserRescanExtensions: 'anime-browser:rescan-extensions',
animeBrowserAddRepo: 'anime-browser:add-repo',
animeBrowserRemoveRepo: 'anime-browser:remove-repo',
},
event: {
subtitleSet: 'subtitle:set',
@@ -156,6 +172,8 @@ export const IPC_CHANNELS = {
notificationHistoryToggle: 'notification-history:toggle',
syncUiProgress: 'sync-ui:progress',
syncUiStateChanged: 'sync-ui:state-changed',
animeBrowserBridgeState: 'anime-browser:bridge-state',
animeBrowserSearchUpdate: 'anime-browser:search-update',
},
} as const;
+204
View File
@@ -0,0 +1,204 @@
import type { AnimeStatus } from '../anime-bridge/media-url';
import type { SourcePreferenceView } from '../anime-bridge/preferences';
/**
* Stands in for a source id when every installed source should answer. Not a
* real source: `getPreferences` and the per-anime calls still need a single one.
*/
export const ALL_SOURCES_ID = '__all__';
/** An installed anime extension the browser can search. */
export interface AnimeBrowserSource {
id: string;
name: string;
lang: string;
/** Package name, e.g. `eu.kanade.tachiyomi.animeextension.all.jellyfin`. */
pkg: string;
}
export interface AnimeBrowserEntry {
/** Source-relative url; the handle for every later call. */
url: string;
title: string;
thumbnailUrl: string | null;
/**
* Which source produced this entry. Carried on the entry rather than read
* from the current selection, so an all-sources result stays usable.
*/
sourceId: string;
sourceName: string;
}
export interface AnimeBrowserDetails extends AnimeBrowserEntry {
description: string | null;
author: string | null;
genres: string[];
status: AnimeStatus;
}
export interface AnimeBrowserEpisode {
url: string;
name: string;
/** Extension-reported episode number; may be fractional for specials. */
number: number | null;
/** Epoch milliseconds, or null when the source reports no date. */
uploadedAt: number | null;
scanlator: string | null;
}
/** One source that errored while the others answered. */
export interface SourceSearchFailure {
sourceId: string;
sourceName: string;
error: string;
}
export interface AnimeBrowserSearchResult {
entries: AnimeBrowserEntry[];
hasNextPage: boolean;
/**
* Sources that failed during an all-sources search. A single-source search
* rejects instead, so this is empty there.
*/
failures: SourceSearchFailure[];
}
/**
* Incremental progress of one search, pushed while the search invoke is still
* pending so a fast source is visible before a slow one answers.
*
* `token` orders searches: the renderer keeps the highest `start` token it has
* seen and drops events from any other search, so a stale search that is still
* resolving cannot paint over the one the user just typed.
*/
export type AnimeBrowserSearchUpdate =
| { kind: 'start'; token: number; sourceCount: number }
| {
kind: 'result';
token: number;
sourceId: string;
sourceName: string;
entries: AnimeBrowserEntry[];
}
| { kind: 'failure'; token: number; failure: SourceSearchFailure }
| { kind: 'done'; token: number };
/** Progress of the one long-running operation: bringing the bridge up. */
export type AnimeBrowserBridgeStage =
| 'idle'
| 'locating'
| 'downloading'
| 'verifying'
| 'extracting'
| 'starting'
| 'ready'
| 'failed';
export interface AnimeBrowserBridgeState {
stage: AnimeBrowserBridgeStage;
/** 0-1 while downloading, otherwise null. */
progress: number | null;
message: string | null;
}
/** An extension APK that failed to load, surfaced instead of silently vanishing. */
export interface ExtensionLoadFailure {
/** Package (file) name of the APK that failed. */
pkg: string;
error: string;
}
/** An extension offered by a configured repository. */
export interface AvailableExtension {
pkg: string;
name: string;
lang: string;
version: string;
nsfw: boolean;
repoUrl: string;
sourceNames: string[];
installed: boolean;
}
export interface RepoFailure {
repoUrl: string;
error: string;
}
export interface AvailableExtensionsResult {
extensions: AvailableExtension[];
failures: RepoFailure[];
}
/**
* An extension present in the extensions directory. Listed from disk rather
* than from a repository, so an APK dropped in by hand or one whose
* repository has since been removed can still be seen and removed.
*/
export interface InstalledExtensionView {
pkg: string;
/** The sources it provides, or the file name when it provided none. */
name: string;
/** Languages its sources cover, deduplicated. */
langs: string[];
/** How many sources it provides; 0 when it failed to load. */
sourceCount: number;
/** Why it failed to load, or null when it loaded. */
error: string | null;
}
export interface AnimeBrowserSnapshot {
bridge: AnimeBrowserBridgeState;
sources: AnimeBrowserSource[];
selectedSourceId: string | null;
loadFailures: ExtensionLoadFailure[];
/** Every extension on disk, whether or not it loaded. */
installed: InstalledExtensionView[];
/** Where APKs are read from, shown so the user knows where to drop files. */
extensionsDir: string;
/** Configured repository index URLs. Empty until the user adds one. */
repos: string[];
}
export interface AnimeBrowserPlayRequest {
sourceId: string;
animeUrl: string;
animeTitle: string;
episodeUrl: string;
episodeName: string;
}
export interface AnimeBrowserPlayResult {
ok: boolean;
/** Populated when ok is false, phrased for display. */
error: string | null;
quality: string | null;
}
export interface AnimeBrowserAPI {
getSnapshot: () => Promise<AnimeBrowserSnapshot>;
ensureBridge: () => Promise<AnimeBrowserBridgeState>;
selectSource: (sourceId: string) => Promise<void>;
search: (query: string, page?: number) => Promise<AnimeBrowserSearchResult>;
getPopular: (page?: number) => Promise<AnimeBrowserSearchResult>;
/** `sourceId` is required after an all-sources search; pass the entry's own. */
getDetails: (animeUrl: string, sourceId?: string) => Promise<AnimeBrowserDetails>;
getEpisodes: (animeUrl: string, sourceId?: string) => Promise<AnimeBrowserEpisode[]>;
playEpisode: (request: AnimeBrowserPlayRequest) => Promise<AnimeBrowserPlayResult>;
getPreferences: (sourceId: string) => Promise<SourcePreferenceView[]>;
setPreference: (
sourceId: string,
key: string,
value: string | string[] | boolean,
) => Promise<SourcePreferenceView[]>;
listAvailableExtensions: () => Promise<AvailableExtensionsResult>;
installExtension: (pkg: string) => Promise<void>;
removeExtension: (pkg: string) => Promise<void>;
rescanExtensions: () => Promise<void>;
addRepo: (url: string) => Promise<void>;
removeRepo: (url: string) => Promise<void>;
onBridgeState: (listener: (state: AnimeBrowserBridgeState) => void) => () => void;
onSearchUpdate: (listener: (update: AnimeBrowserSearchUpdate) => void) => () => void;
}
export type { SourcePreferenceView } from '../anime-bridge/preferences';
+7
View File
@@ -12,6 +12,7 @@ import type {
ImmersionTrackingRetentionPreset,
TsukihimeConfig,
JellyfinConfig,
AnimeConfig,
JimakuConfig,
JimakuLanguagePreference,
StatsConfig,
@@ -153,6 +154,7 @@ export interface Config {
subtitleStyle?: SubtitleStyleConfig;
subtitleSidebar?: SubtitleSidebarConfig;
auto_start_overlay?: boolean;
anime?: AnimeConfig;
jimaku?: JimakuConfig;
/** @deprecated Use tsukihime. */
animetosho?: TsukihimeConfig;
@@ -313,6 +315,11 @@ export interface ResolvedConfig {
};
subtitleSidebar: ResolvedSubtitleSidebarConfig;
auto_start_overlay: boolean;
anime: AnimeConfig & {
extensionsDir: string;
repos: string[];
preferredQuality: string;
};
jimaku: JimakuConfig & {
apiBaseUrl: string;
languagePreference: JimakuLanguagePreference;
+18
View File
@@ -42,6 +42,24 @@ export interface YoutubePickerResolveResult {
message: string;
}
export interface AnimeConfig {
/**
* Directory holding Aniyomi extension `.apk` files. Defaults to
* `<userData>/anime-extensions` when unset.
*/
extensionsDir?: string;
/**
* Extension repository index URLs (any https `.json` index, such as
* `https://.../index.min.json`).
*
* Ships empty and stays empty unless the user adds one. SubMiner performs no
* repository discovery and bundles no sources.
*/
repos?: string[];
/** Preferred stream label, matched as a substring, e.g. "1080". */
preferredQuality?: string;
}
export interface JimakuConfig {
apiKey?: string;
apiKeyCommand?: string;