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

- Add `subminer anime` / `--anime` and a tray entry to open a browser that searches installed Aniyomi extension sources, shows cover art and episodes, and plays into mpv with overlay/mining attached
- Add an Extensions tab to add repos and install/update/remove sources, and per-source settings for sources needing config
- Support searching all sources at once with streaming, per-source results and status
- Prefer Japanese audio/subtitle tracks from the source and keep the primary subtitle slot reserved for Japanese
- Fix window/tray/Dock handling so the browser and mpv can be switched between without quitting the app or losing the Dock icon
- Add anime.repos, anime.extensionsDir, anime.preferredQuality config keys (no bundled repos or discovery)
This commit is contained in:
2026-07-31 17:16:49 -07:00
parent 5b8848518a
commit c5140b0f7b
117 changed files with 7563 additions and 527 deletions
+46
View File
@@ -3,6 +3,7 @@ import * as path from 'node:path';
import { MPV_LAUNCH_MODE_VALUES, parseMpvLaunchMode } from '../../shared/mpv-launch-mode';
import { ResolveContext } from './context';
import { asBoolean, asNumber, asString, isObject } from './shared';
import { isValidRepoUrl } from '../../anime-bridge/extension-repo';
function normalizeExternalProfilePath(value: string): string {
const trimmed = value.trim();
@@ -343,6 +344,51 @@ export function applyIntegrationConfig(context: ResolveContext): void {
warn('mpv', src.mpv, resolved.mpv, 'Expected object.');
}
if (isObject(src.anime)) {
const extensionsDir = asString(src.anime.extensionsDir);
if (extensionsDir !== undefined) {
resolved.anime.extensionsDir = normalizeExternalProfilePath(extensionsDir);
} else if (src.anime.extensionsDir !== undefined) {
warn(
'anime.extensionsDir',
src.anime.extensionsDir,
resolved.anime.extensionsDir,
'Expected string.',
);
}
const preferredQuality = asString(src.anime.preferredQuality);
if (preferredQuality !== undefined) {
resolved.anime.preferredQuality = preferredQuality.trim();
} else if (src.anime.preferredQuality !== undefined) {
warn(
'anime.preferredQuality',
src.anime.preferredQuality,
resolved.anime.preferredQuality,
'Expected string.',
);
}
if (Array.isArray(src.anime.repos)) {
const repos: string[] = [];
for (const entry of src.anime.repos) {
const url = asString(entry)?.trim();
// Rejecting here rather than at fetch time keeps a bad entry from
// silently doing nothing, and blocks plain http downgrades.
if (url !== undefined && isValidRepoUrl(url)) {
if (!repos.includes(url)) repos.push(url);
} else {
warn('anime.repos[]', entry, null, 'Expected an https URL to a .json index file.');
}
}
resolved.anime.repos = repos;
} else if (src.anime.repos !== undefined) {
warn('anime.repos', src.anime.repos, resolved.anime.repos, 'Expected array of strings.');
}
} else if (src.anime !== undefined) {
warn('anime', src.anime, resolved.anime, 'Expected object.');
}
if (isObject(src.jellyfin)) {
const enabled = asBoolean(src.jellyfin.enabled);
if (enabled !== undefined) {