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

- Add `subminer anime` / `--anime` and a tray entry to open a browser that searches installed Aniyomi extension sources, shows cover art and episodes, and plays into mpv with overlay/mining attached
- Add an Extensions tab to add repos and install/update/remove sources, and per-source settings for sources needing config
- Support searching all sources at once with streaming, per-source results and status
- Prefer Japanese audio/subtitle tracks from the source and keep the primary subtitle slot reserved for Japanese
- Fix window/tray/Dock handling so the browser and mpv can be switched between without quitting the app or losing the Dock icon
- Add anime.repos, anime.extensionsDir, anime.preferredQuality config keys (no bundled repos or discovery)
This commit is contained in:
2026-07-31 17:16:49 -07:00
parent b204d4dd6e
commit e64ff1a0ee
117 changed files with 7565 additions and 529 deletions
+68
View File
@@ -0,0 +1,68 @@
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import path from 'node:path';
import type { BridgePreference } from './types';
/**
* Persists each source's preference array verbatim, keyed by bridge source id.
*
* Extensions keep credentials in here (the Jellyfin source stores a password),
* so the file is written with owner-only permissions.
*/
export class PreferenceStore {
private readonly file: string;
private cache: Record<string, BridgePreference[]> | null = null;
constructor(file: string) {
this.file = file;
}
private async load(): Promise<Record<string, BridgePreference[]>> {
if (this.cache !== null) return this.cache;
try {
const parsed = JSON.parse(await readFile(this.file, 'utf8')) as unknown;
this.cache =
parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, BridgePreference[]>)
: {};
} catch {
// Missing or corrupt file starts empty rather than blocking the browser.
this.cache = {};
}
return this.cache;
}
async get(sourceId: string): Promise<BridgePreference[]> {
const all = await this.load();
return all[sourceId] ?? [];
}
async set(sourceId: string, preferences: BridgePreference[]): Promise<void> {
const all = await this.load();
all[sourceId] = preferences;
await this.persist(all);
}
/**
* Drop every saved value whose key starts with `prefix`.
*
* Removing an extension should not leave its credentials on disk, and a
* source id is not knowable once the APK is gone — so callers pass the
* package name and this clears anything recorded under it.
*/
async clear(prefix: string): Promise<void> {
const all = await this.load();
let changed = false;
for (const key of Object.keys(all)) {
if (key === prefix || key.startsWith(`${prefix}:`)) {
delete all[key];
changed = true;
}
}
if (changed) await this.persist(all);
}
private async persist(all: Record<string, BridgePreference[]>): Promise<void> {
await mkdir(path.dirname(this.file), { recursive: true });
await writeFile(this.file, JSON.stringify(all, null, 2), { mode: 0o600 });
}
}