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