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
+66
View File
@@ -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, '');
});