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
+11
View File
@@ -16,6 +16,10 @@ type AppCommandDeps = {
appPath: string,
logLevel: LauncherCommandContext['args']['logLevel'],
) => void;
launchAnimeBrowserDetached: (
appPath: string,
logLevel: LauncherCommandContext['args']['logLevel'],
) => void;
};
const defaultAppCommandDeps: AppCommandDeps = {
@@ -23,6 +27,8 @@ const defaultAppCommandDeps: AppCommandDeps = {
launchSyncUiDetached: (appPath, logLevel) =>
launchAppCommandDetached(appPath, ['--sync-window'], logLevel, 'sync-ui'),
launchAppBackgroundDetached,
launchAnimeBrowserDetached: (appPath, logLevel) =>
launchAppCommandDetached(appPath, ['--anime'], logLevel, 'anime'),
};
export function runAppPassthroughCommand(
@@ -37,6 +43,11 @@ export function runAppPassthroughCommand(
deps.runAppCommandWithInherit(appPath, ['--settings']);
return true;
}
if (args.animeBrowser) {
// Detached: the browser window is long-lived and owns the bridge process.
deps.launchAnimeBrowserDetached(appPath, args.logLevel);
return true;
}
if (args.syncUi) {
deps.launchSyncUiDetached(appPath, args.logLevel);
return true;
@@ -207,6 +207,7 @@ test('app command starts default macOS background app detached from launcher', (
calls.push('attached');
},
launchSyncUiDetached: () => calls.push('sync-ui'),
launchAnimeBrowserDetached: () => {},
launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${appPath}:${logLevel}`);
},
@@ -227,6 +228,7 @@ test('app command starts default Linux background app detached from launcher', (
calls.push('attached');
},
launchSyncUiDetached: () => calls.push('sync-ui'),
launchAnimeBrowserDetached: () => {},
launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${appPath}:${logLevel}`);
},
@@ -248,6 +250,7 @@ test('app command keeps explicit passthrough args attached', () => {
forwarded.push(appArgs);
},
launchSyncUiDetached: () => detached.push('sync-ui'),
launchAnimeBrowserDetached: () => {},
launchAppBackgroundDetached: () => {
detached.push('detached');
},
@@ -266,6 +269,7 @@ test('sync UI command launches the app detached from the terminal', () => {
const handled = runAppPassthroughCommand(context, {
runAppCommandWithInherit: () => calls.push('piped'),
launchSyncUiDetached: (appPath, logLevel) => calls.push(`sync-ui:${appPath}:${logLevel}`),
launchAnimeBrowserDetached: () => calls.push('anime'),
launchAppBackgroundDetached: () => calls.push('detached'),
});
@@ -63,6 +63,7 @@ function createContext(): LauncherCommandContext {
logsExport: false,
version: false,
settings: false,
animeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
+4
View File
@@ -120,6 +120,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
logLevel: 'warn',
},
settingsInvocation: null,
animeInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
@@ -171,6 +172,7 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
settingsInvocation: {
logLevel: undefined,
},
animeInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
@@ -215,6 +217,7 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
action: undefined,
},
settingsInvocation: null,
animeInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
@@ -257,6 +260,7 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
jellyfinInvocation: null,
configInvocation: null,
settingsInvocation: null,
animeInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
+7
View File
@@ -168,6 +168,7 @@ export function createDefaultArgs(
version: false,
update: false,
settings: false,
animeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
@@ -348,6 +349,12 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
);
}
if (invocations.animeInvocation) {
if (invocations.animeInvocation.logLevel) {
parsed.logLevel = parseLogLevel(invocations.animeInvocation.logLevel);
}
parsed.animeBrowser = true;
}
if (invocations.settingsInvocation) {
if (invocations.settingsInvocation.logLevel) {
parsed.logLevel = parseLogLevel(invocations.settingsInvocation.logLevel);
+14
View File
@@ -23,6 +23,7 @@ export interface CliInvocations {
jellyfinInvocation: JellyfinInvocation | null;
configInvocation: CommandActionInvocation | null;
settingsInvocation: CommandActionInvocation | null;
animeInvocation: CommandActionInvocation | null;
mpvInvocation: CommandActionInvocation | null;
appInvocation: { appArgs: string[] } | null;
dictionaryTriggered: boolean;
@@ -102,6 +103,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
'doctor',
'config',
'settings',
'anime',
'mpv',
'logs',
'dictionary',
@@ -155,6 +157,7 @@ export function parseCliPrograms(
let jellyfinInvocation: JellyfinInvocation | null = null;
let configInvocation: CommandActionInvocation | null = null;
let settingsInvocation: CommandActionInvocation | null = null;
let animeInvocation: CommandActionInvocation | null = null;
let mpvInvocation: CommandActionInvocation | null = null;
let appInvocation: { appArgs: string[] } | null = null;
let dictionaryTriggered = false;
@@ -415,6 +418,16 @@ export function parseCliPrograms(
};
});
commandProgram
.command('anime')
.description('Open the anime browser window')
.option('--log-level <level>', 'Log level')
.action((options: Record<string, unknown>) => {
animeInvocation = {
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
};
});
commandProgram
.command('mpv')
.description('MPV helpers')
@@ -469,6 +482,7 @@ export function parseCliPrograms(
jellyfinInvocation,
configInvocation,
settingsInvocation,
animeInvocation,
mpvInvocation,
appInvocation,
dictionaryTriggered,
+1
View File
@@ -57,6 +57,7 @@ function createArgs(): Args {
logsExport: false,
version: false,
settings: false,
animeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
+1
View File
@@ -629,6 +629,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
logsExport: false,
version: false,
settings: false,
animeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
+1
View File
@@ -149,6 +149,7 @@ export interface Args {
version: boolean;
update?: boolean;
settings: boolean;
animeBrowser: boolean;
configPath: boolean;
configShow: boolean;
mpvIdle: boolean;