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
+103
View File
@@ -0,0 +1,103 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applySearchUpdate, idleSearchProgress, summarizeProgress } from './search-progress';
import type { AnimeBrowserEntry, SourceSearchFailure } from '../types/anime-browser';
const entry = (title: string): AnimeBrowserEntry => ({
url: `/a/${title}`,
title,
thumbnailUrl: null,
sourceId: 's1',
sourceName: 'Source One',
});
const failure: SourceSearchFailure = {
sourceId: 's2',
sourceName: 'Source Two',
error: 'login required',
};
test('a search accumulates results and failures update by update', () => {
let progress = idleSearchProgress();
let applied = applySearchUpdate(progress, { kind: 'start', token: 1, sourceCount: 3 });
assert.ok(applied);
assert.equal(applied.started, true);
progress = applied.progress;
applied = applySearchUpdate(progress, {
kind: 'result',
token: 1,
sourceId: 's1',
sourceName: 'Source One',
entries: [entry('A'), entry('B')],
});
assert.ok(applied);
assert.deepEqual(
applied.entries.map((item) => item.title),
['A', 'B'],
);
progress = applied.progress;
applied = applySearchUpdate(progress, { kind: 'failure', token: 1, failure });
assert.ok(applied);
progress = applied.progress;
assert.equal(progress.entryCount, 2);
assert.equal(progress.sourcesDone, 2);
assert.deepEqual(progress.failures, [failure]);
assert.equal(progress.done, false);
applied = applySearchUpdate(progress, { kind: 'done', token: 1 });
assert.ok(applied);
assert.equal(applied.progress.done, true);
});
test('updates from a superseded search are dropped entirely', () => {
let progress = idleSearchProgress();
progress = applySearchUpdate(progress, { kind: 'start', token: 1, sourceCount: 2 })!.progress;
progress = applySearchUpdate(progress, { kind: 'start', token: 2, sourceCount: 2 })!.progress;
// The first search's straggler results and completion must not touch token 2.
assert.equal(
applySearchUpdate(progress, {
kind: 'result',
token: 1,
sourceId: 's1',
sourceName: 'Source One',
entries: [entry('stale')],
}),
null,
);
assert.equal(applySearchUpdate(progress, { kind: 'done', token: 1 }), null);
assert.equal(progress.entryCount, 0);
});
test('an older start cannot reset a newer search', () => {
let progress = idleSearchProgress();
progress = applySearchUpdate(progress, { kind: 'start', token: 5, sourceCount: 1 })!.progress;
assert.equal(applySearchUpdate(progress, { kind: 'start', token: 4, sourceCount: 9 }), null);
assert.equal(applySearchUpdate(progress, { kind: 'start', token: 5, sourceCount: 9 }), null);
assert.equal(progress.sourceCount, 1);
});
test('summarizeProgress counts sources and names failures', () => {
let progress = idleSearchProgress();
progress = applySearchUpdate(progress, { kind: 'start', token: 1, sourceCount: 5 })!.progress;
progress = applySearchUpdate(progress, {
kind: 'result',
token: 1,
sourceId: 's1',
sourceName: 'Source One',
entries: [entry('A')],
})!.progress;
assert.equal(summarizeProgress(progress), 'Searching… 1/5 sources · 1 result');
progress = applySearchUpdate(progress, { kind: 'failure', token: 1, failure })!.progress;
assert.equal(
summarizeProgress(progress),
'Searching… 2/5 sources · 1 result · unavailable: Source Two',
);
});