diff --git a/CHANGELOG.md b/CHANGELOG.md
index a32fa520..42db87fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## Unreleased
+
+### Fixed
+
+- Anime Browser Window Switching: Opening the anime browser now shows a tray icon on every platform and, on macOS, puts the app in the Cmd+Tab switcher (which requires the Dock icon; the two are inseparable on macOS), so you can switch between it and mpv. Previously the subtitle overlay's fullscreen support hid the whole app from the Dock and Cmd+Tab, leaving no way to reach the window. The Dock icon is released again when the window closes during playback.
+- Anime Browser Playback Session: Launching a video from the anime browser now starts a regular SubMiner session (tray icon plus the on-demand overlay runtime), and in `subminer anime` standalone mode, closing the browser window during playback no longer quits the app and kills the stream. The window can be reopened from the tray while mpv keeps playing.
+
## v0.19.3 (2026-08-13)
### Added
@@ -23,7 +30,6 @@
- Added per-stage debug timings (`scanMs`, `mecabMs`, `frequencyMs`, `annotateMs`) to the subtitle tokenization pipeline log.
-
## v0.19.2 (2026-08-04)
### Changed
@@ -54,6 +60,7 @@
## v0.19.0 (2026-07-29)
### Added
+
- Anki Maturity Highlighting: Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Tier thresholds and colors are configurable, with a runtime toggle and an updated help legend.
- Post-Playback Menu: After a watch-history episode ends, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit. The pre-playback series menu now offers the previous episode too.
- Delete Library Entries: The stats Library detail view can now delete an entire title in one step (episodes, sessions, subtitle lines, rollups, cover art, and vocabulary counts). Delete progress is now shown app-wide via a progress bar and status toast instead of disappearing when you switch tabs.
@@ -61,9 +68,11 @@
- TsukiHime Subtitle Downloads: Added subtitle downloads for the current video via TsukiHime, loading Japanese as the primary track and your configured secondary language directly into mpv.
### Changed
+
- Clipboard-Video Shortcut: The "append clipboard video to queue" shortcut is now configurable.
### Fixed
+
- AniList Season Resolution: Season 2+ files now resolve to the correct AniList entry instead of silently falling back to season 1 (which mismatched character dictionaries and watch progress). Manual overrides now stay scoped per season, fix both the dictionary and progress tracking together, and also correct per-season cover art.
- Subtitle Annotation Accuracy: Fixed several annotation edge cases, including inconsistent POS exclusions on merged quote-particle tokens, dropped annotations on supplementary-plane kanji, katakana punctuation wrongly treated as noise, and certain kanji vocabulary losing N+1 highlighting eligibility.
- AnkiConnect Proxy Port Conflict: Video startup no longer crashes when another process already holds the configured AnkiConnect proxy port; a notification now explains how to resolve it.
@@ -79,6 +88,7 @@
Internal changes
### Internal
+
- Added a golden-file regression test corpus for the tokenizer/annotation pipeline, plus scripts to record new fixtures and diff against stock Yomitan.
- Consolidated renderer modal state handling into a descriptor registry.
- Consolidated CI quality checks (PR, stable, and prerelease) into one reusable workflow with mpv plugin tests and dependency audits.
@@ -90,10 +100,12 @@
## v0.18.0 (2026-07-10)
### Added
+
- Sentence Audio Normalization: Generated sentence audio is now normalized to -23 LUFS by default, and clips mined from playback mirror mpv's software volume curve with a limiter to prevent clipping. Both behaviors are configurable independently.
- Watch History Command: Added `subminer -H` / `--history` to browse watch history, replay or continue episodes, or pick one via fzf or rofi, with cover art shown in the rofi picker.
### Changed
+
- Fzf Preview Layout: Moved fzf previews below launcher menus, giving long titles and metadata more room.
- Known-Word Highlighting: Now compares subtitle and Anki-card readings, preventing false matches between homographs and unrelated words that share a reading, while still supporting matching across kana and kanji spellings.
- Annotation Filtering: Standalone suffix tokens (e.g. さん, れる) are now excluded from JLPT/frequency/N+1 highlighting by default, matching how particles and interjections are treated; configurable via the pos2 exclusion setting.
@@ -101,6 +113,7 @@
- Stats Trend Charts: Overhauled with persisted title visibility, per-chart title limits, "top" and "most recent" ranking modes, an option to show or hide empty days, calendar-aligned periods, and value-sorted tooltips.
### Fixed
+
- Background Stats Server: `subminer app` background launches now auto-start the stats server when enabled, and skip startup if one is already running.
- Character Name Highlighting: Character dictionaries now split unspaced native names more reliably, and portraits, highlights, and hover lookup survive punctuation, unmatched text, and competing dictionary matches without incorrectly splitting longer words.
- Highlighting Coverage: Frequency/JLPT highlighting and vocabulary stats now include content adverbs (e.g. 確かに, やはり) and kanji nouns MeCab tags as non-independent (e.g. 日, 点, 以外), while still suppressing interjections, pronouns, and grammar fragments; lexicalized kana expressions like かといって keep their annotations.
@@ -114,6 +127,7 @@
Internal changes
### Internal
+
- Test lanes moved to `scripts/test-lanes.ts` with per-directory discovery and isolated per-file timeouts; CI now covers previously orphaned stats, scripts, plugin process-retry, and runtime-compat suites, plus a new stats lane in the change-verification workflow.
@@ -121,15 +135,18 @@
## v0.17.2 (2026-06-28)
### Fixed
+
- YouTube Background Cache: Fixed Windows YouTube background media cache startup for YouTube URLs opened directly in mpv, including resolved stream URLs when mpv still exposes the original YouTube playlist entry, so queued Anki media updates can append audio and images after the cache finishes.
- YouTube Subtitle Picker: Manual subtitle picker requests now show an immediate configured notification while SubMiner probes tracks and opens the modal. Subtitle download progress is replaced with a transient success notification after tracks load.
## v0.17.1 (2026-06-27)
### Added
+
- YouTube Media Cache Mode: Adds `youtube.mediaCache.mode` with `direct` and `background` options. Background mode uses a yt-dlp cache download when direct stream extraction is unreliable — creates a text-only card immediately, queues media updates for mined notes, and fills audio/image fields once the download finishes. Progress is announced via overlay/OSD notifications. Downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`). Switching back to direct mode cancels any in-flight background download.
### Fixed
+
- Log Export: Fixed log filenames to use the local date so exports around UTC midnight include the current day's logs rather than stale prior-day files. Expanded export redaction to mask IPs, emails, auth and cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL parameters.
- YouTube Card Media: Improved media generation reliability by sending safer ffmpeg options for resolved streams and skipping stale stream maps (including cached YouTube files). Hardened background cache downloads with IPv4 and extractor retry flags; failed downloads now notify the user and clear queued media updates instead of leaving them silently pending. Stale background cache files are cleaned on startup and before each new download.
@@ -210,6 +227,7 @@
Internal changes
### Internal
+
- **Build**: `make deps` now initializes git submodules before installing dependencies on a fresh source checkout.
- **Release Tooling**: Release notes now credit contributors and first-time authors resolved from changelog fragments via git and the GitHub API.
- **Changelog Guidance**: PR fragment guidance updated to preserve separate-outcome fragments while directing contributors to consolidate same-PR follow-up notes before adding churn.
@@ -219,9 +237,11 @@
## v0.15.2 (2026-06-02)
### Changed
+
- Yomitan: Updated the bundled Yomitan build to the latest vendored revision.
### Fixed
+
- Anki - Animated AVIF: Clip timing no longer starts or ends early; word-audio lead-in and clip duration are now aligned to frame boundaries.
- Overlay (Hyprland): Fixed fullscreen overlay alignment - modal, stats, and sidebar content no longer shift below the mpv window.
- Overlay (macOS): Subtitle bars are now interactive immediately after autoplay starts with "wait for overlay to be ready" enabled, without requiring a manual click.
diff --git a/changes/anime-browser.md b/changes/anime-browser.md
new file mode 100644
index 00000000..b360a9dd
--- /dev/null
+++ b/changes/anime-browser.md
@@ -0,0 +1,15 @@
+type: added
+area: anime
+
+- Added an anime browser window that searches Aniyomi extension sources, shows cover art and episode lists, and plays an episode in mpv so the overlay and mining tools attach as usual.
+- Added `subminer anime` and the `--anime` flag to open the browser, plus a "Browse Anime" tray entry.
+- Anime extensions are read from `/anime-extensions`; drop Aniyomi `.apk` files there to add sources.
+- Added a source settings tab so extensions that need configuration (server address, credentials, quality) can be set up from the browser; values persist per source.
+- Added an Extensions tab for adding repository URLs and installing, updating, or removing extensions in place; extensions that fail to load are listed with the reason.
+- Browse, Extensions, and Source settings are tabs, so each one gets the full window instead of sharing it with the search results.
+- Repository URLs only need to be an https URL to a `.json` index; the file name is not restricted to `index.min.json`.
+- The source picker offers "All sources", which searches every installed source at once. Results stream in as each source answers — a fast source is on screen while a slow one is still resolving, with per-source progress in the status bar. Results are tagged with the source they came from, and a source that fails is named in the status bar instead of blanking the grid.
+- The Extensions tab opens with an Installed section listing every extension on disk with Remove — including ones added by hand or whose repository has since been removed — and Update where a configured repository still carries it.
+- Added `anime.repos`, `anime.extensionsDir`, and `anime.preferredQuality` config keys. SubMiner ships no extension repositories and performs no discovery.
+- Anime playback targets Japanese audio: dub-labelled entries are skipped when the source offers an alternative, `alang` prefers Japanese, and the source's own audio and subtitle tracks are loaded into mpv (Japanese selected) instead of being discarded, so all of them can be switched from mpv's track menu.
+- The primary subtitle slot stays reserved for Japanese: a source that only has, say, English subtitles gets them added with a normalized language tag (`English` → `en`) but not selected, so the regular `secondarySub` auto-load can route them to the secondary slot instead.
diff --git a/config.example.jsonc b/config.example.jsonc
index 1408eab7..f1971c4b 100644
--- a/config.example.jsonc
+++ b/config.example.jsonc
@@ -611,6 +611,18 @@
} // Lapis kiku setting.
}, // Automatic Anki updates and media generation options.
+ // ==========================================
+ // Anime Browser
+ // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
+ // add a repository index URL here (or drop .apk files in the extensions directory) to have any.
+ // Hot-reload: anime changes apply the next time the anime browser opens.
+ // ==========================================
+ "anime": {
+ "extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses /anime-extensions.
+ "repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
+ "preferredQuality": "" // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
+ }, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
+
// ==========================================
// Jimaku
// Jimaku API configuration and defaults.
diff --git a/docs-site/.vitepress/config.ts b/docs-site/.vitepress/config.ts
index 42641f32..17007463 100644
--- a/docs-site/.vitepress/config.ts
+++ b/docs-site/.vitepress/config.ts
@@ -326,6 +326,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
{ text: 'Anki', link: '/anki-integration' },
{ text: 'Jellyfin', link: '/jellyfin-integration' },
{ text: 'YouTube', link: '/youtube-integration' },
+ { text: 'Anime Browser', link: '/anime-browser' },
{ text: 'Jimaku', link: '/jimaku-integration' },
{ text: 'TsukiHime', link: '/tsukihime-integration' },
{ text: 'AniList', link: '/anilist-integration' },
diff --git a/docs-site/.vitepress/theme/mermaid-modal.css b/docs-site/.vitepress/theme/mermaid-modal.css
index d54e8d19..225dda05 100644
--- a/docs-site/.vitepress/theme/mermaid-modal.css
+++ b/docs-site/.vitepress/theme/mermaid-modal.css
@@ -45,7 +45,9 @@
border: 1px solid var(--vp-c-border);
border-radius: 0;
background: var(--vp-c-bg);
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), 0 24px 64px rgba(0, 0, 0, 0.2);
+ box-shadow:
+ 0 8px 32px rgba(0, 0, 0, 0.3),
+ 0 24px 64px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
@@ -62,7 +64,9 @@
font-family: var(--tui-font-mono);
font-size: 12px;
cursor: pointer;
- transition: border-color 180ms ease, color 180ms ease;
+ transition:
+ border-color 180ms ease,
+ color 180ms ease;
}
.mermaid-modal__close:hover {
diff --git a/docs-site/.vitepress/theme/status-line.test.ts b/docs-site/.vitepress/theme/status-line.test.ts
index 61317fd4..ab095282 100644
--- a/docs-site/.vitepress/theme/status-line.test.ts
+++ b/docs-site/.vitepress/theme/status-line.test.ts
@@ -10,7 +10,5 @@ test('status line file path formats version archive home without trailing slash'
});
test('status line file path keeps normal docs routes as markdown files', () => {
- expect(formatStatusLineFilePath('/v/0.12.0/configuration')).toBe(
- 'v/0.12.0/configuration.md',
- );
+ expect(formatStatusLineFilePath('/v/0.12.0/configuration')).toBe('v/0.12.0/configuration.md');
});
diff --git a/docs-site/.vitepress/theme/tui-theme.css b/docs-site/.vitepress/theme/tui-theme.css
index e9026ff3..f0dcf96c 100644
--- a/docs-site/.vitepress/theme/tui-theme.css
+++ b/docs-site/.vitepress/theme/tui-theme.css
@@ -22,17 +22,8 @@
:root {
--tui-font-mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace;
--tui-font-body:
- 'Manrope Default',
- 'M PLUS 1',
- 'Manrope',
- 'Noto Sans CJK JP',
- 'Noto Sans JP',
- 'Hiragino Kaku Gothic ProN',
- 'Meiryo',
- 'Yu Gothic',
- 'Hiragino Sans',
- system-ui,
- sans-serif;
+ 'Manrope Default', 'M PLUS 1', 'Manrope', 'Noto Sans CJK JP', 'Noto Sans JP',
+ 'Hiragino Kaku Gothic ProN', 'Meiryo', 'Yu Gothic', 'Hiragino Sans', system-ui, sans-serif;
--tui-transition: 180ms ease;
/* Theme-specific values — overridden in .dark below */
@@ -96,8 +87,11 @@ button,
.VPFeature,
.VPNavBarMenuLink,
.VPSidebarItem .text {
- transition: color var(--tui-transition), background var(--tui-transition),
- border-color var(--tui-transition), opacity var(--tui-transition);
+ transition:
+ color var(--tui-transition),
+ background var(--tui-transition),
+ border-color var(--tui-transition),
+ opacity var(--tui-transition);
}
/* === Nav bar === */
@@ -218,8 +212,7 @@ button,
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
color: var(--vp-c-brand-1);
- font-family: var(--tui-font-mono), 'M PLUS 1', 'Noto Sans CJK JP', 'Noto Sans JP',
- monospace;
+ font-family: var(--tui-font-mono), 'M PLUS 1', 'Noto Sans CJK JP', 'Noto Sans JP', monospace;
font-variant-ligatures: none;
}
@@ -228,8 +221,7 @@ button,
border-radius: 0;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-alt) !important;
- font-family: var(--tui-font-mono), 'M PLUS 1', 'Noto Sans CJK JP', 'Noto Sans JP',
- monospace;
+ font-family: var(--tui-font-mono), 'M PLUS 1', 'Noto Sans CJK JP', 'Noto Sans JP', monospace;
font-variant-ligatures: none;
}
@@ -273,7 +265,9 @@ button,
.vp-doc a {
text-decoration: none;
border-bottom: 1px solid var(--tui-link-underline);
- transition: border-color var(--tui-transition), color var(--tui-transition);
+ transition:
+ border-color var(--tui-transition),
+ color var(--tui-transition);
}
.vp-doc a:hover {
@@ -312,20 +306,45 @@ button,
border-left-width: 1px;
}
-.vp-doc .custom-block.tip { border-color: var(--vp-c-brand-1); }
-.vp-doc .custom-block.tip::before { content: '-- tip'; color: var(--vp-c-brand-1); }
+.vp-doc .custom-block.tip {
+ border-color: var(--vp-c-brand-1);
+}
+.vp-doc .custom-block.tip::before {
+ content: '-- tip';
+ color: var(--vp-c-brand-1);
+}
-.vp-doc .custom-block.info { border-color: var(--vp-c-brand-2); }
-.vp-doc .custom-block.info::before { content: '-- info'; color: var(--vp-c-brand-2); }
+.vp-doc .custom-block.info {
+ border-color: var(--vp-c-brand-2);
+}
+.vp-doc .custom-block.info::before {
+ content: '-- info';
+ color: var(--vp-c-brand-2);
+}
-.vp-doc .custom-block.warning { border-color: var(--vp-c-warning-1); }
-.vp-doc .custom-block.warning::before { content: '-- warning'; color: var(--vp-c-warning-1); }
+.vp-doc .custom-block.warning {
+ border-color: var(--vp-c-warning-1);
+}
+.vp-doc .custom-block.warning::before {
+ content: '-- warning';
+ color: var(--vp-c-warning-1);
+}
-.vp-doc .custom-block.danger { border-color: var(--vp-c-danger-1); }
-.vp-doc .custom-block.danger::before { content: '-- danger'; color: var(--vp-c-danger-1); }
+.vp-doc .custom-block.danger {
+ border-color: var(--vp-c-danger-1);
+}
+.vp-doc .custom-block.danger::before {
+ content: '-- danger';
+ color: var(--vp-c-danger-1);
+}
-.vp-doc .custom-block.details { border-color: var(--vp-c-divider); }
-.vp-doc .custom-block.details::before { content: '-- details'; color: var(--vp-c-text-2); }
+.vp-doc .custom-block.details {
+ border-color: var(--vp-c-divider);
+}
+.vp-doc .custom-block.details::before {
+ content: '-- details';
+ color: var(--vp-c-text-2);
+}
.vp-doc .custom-block .custom-block-title {
font-family: var(--tui-font-mono);
@@ -413,11 +432,15 @@ button,
}
@keyframes tui-blink {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0; }
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0;
+ }
}
-
/* === Statusline === */
.tui-statusline {
position: fixed;
@@ -457,7 +480,7 @@ button,
margin-left: -12px;
}
-.tui-statusline__mode[data-mode="HOME"] {
+.tui-statusline__mode[data-mode='HOME'] {
background: var(--vp-c-brand-2);
}
@@ -561,7 +584,9 @@ body {
.VPFeatures .VPFeature {
border-radius: 8px !important;
border: 1px solid var(--vp-c-divider) !important;
- transition: border-color var(--tui-transition), background var(--tui-transition),
+ transition:
+ border-color var(--tui-transition),
+ background var(--tui-transition),
transform var(--tui-transition);
position: relative;
overflow: hidden;
@@ -672,11 +697,7 @@ body {
transform: translateX(-50%);
width: 600px;
height: 400px;
- background: radial-gradient(
- ellipse at center,
- var(--tui-hero-glow) 0%,
- transparent 70%
- );
+ background: radial-gradient(ellipse at center, var(--tui-hero-glow) 0%, transparent 70%);
pointer-events: none;
z-index: -1;
}
diff --git a/docs-site/anime-browser.md b/docs-site/anime-browser.md
new file mode 100644
index 00000000..fbffd5fd
--- /dev/null
+++ b/docs-site/anime-browser.md
@@ -0,0 +1,182 @@
+# Anime Browser
+
+Search anime sources, pick an episode, and play it in mpv with SubMiner's overlay
+and mining tools attached — the same way a local file or a Jellyfin stream works.
+
+Open it with `subminer anime`, with `SubMiner.AppImage --anime`, or from
+**Browse Anime** in the tray menu. The window stays open while you watch, so you
+can queue the next episode without reopening it.
+
+While the window is open, SubMiner shows a tray icon and — on macOS — appears
+in the Cmd+Tab switcher and the Dock (macOS ties the two together), so you can
+flip between the browser and mpv. SubMiner normally hides itself from the Dock
+because the subtitle overlay needs that to float above fullscreen video; it
+hides again when the window closes during playback.
+
+Launching an episode starts a full SubMiner playback session, the same as
+playing a local file: the overlay and mining tools attach, and the tray icon
+stays available. In standalone `subminer anime` mode, closing the window while
+a video is playing leaves playback running — reopen the browser from the tray
+(**Browse Anime**). The app only exits with the window when nothing is playing.
+
+## How it works
+
+SubMiner does not implement any anime source itself. It runs **Aniyomi extension
+APKs** through a bundled JVM sidecar ([M-Extension-Server][mes]), asks the
+selected extension to resolve an episode, and hands the resulting URL to mpv.
+
+```
+extension APK → bridge (JVM) → { url, headers } → mpv → SubMiner overlay
+```
+
+Because the extension resolves the stream, whichever sources you install decide
+what is available. SubMiner only hosts them.
+
+## Installing extensions
+
+**SubMiner ships no extension repositories and bundles no sources.** There is no
+default repository, no suggested list, and no discovery. Until you add one, the
+browser has nothing to search — that is deliberate, and it is what keeps SubMiner
+a neutral host rather than a distributor.
+
+There are two ways to add extensions.
+
+### From a repository
+
+The window has three tabs — **Browse**, **Extensions**, and **Source settings** —
+and each one fills the window, so a long extension list is not squeezed in above
+the search results.
+
+Open the **Extensions** tab, paste a repository index URL, and choose
+**Add repository**. The URL must be `https` and point at a `.json` index file —
+`index.min.json` is the common Aniyomi name, but repositories are free to publish
+under another one (for example `video.min.json`). Anything else is rejected
+immediately rather than failing later. Everything before the file name is treated
+as the repository root, so `.apk` and icon URLs are resolved relative to it.
+
+Extensions your repositories offer but you do not have appear under
+**Available**, each with **Install**. Repositories are stored in config under
+`anime.repos`, so you can also manage them there and keep them in a dotfile.
+
+### Managing what is installed
+
+The Extensions tab opens with an **Installed** section listing everything in the
+extensions directory, with the sources each one provides and a **Remove**
+button. It is built from the directory rather than from a repository, so an
+extension you dropped in by hand — or one whose repository you have since
+removed — is still listed and still removable.
+
+**Update** appears next to an extension a configured repository still carries;
+it downloads the current version over the existing APK.
+
+### From a file
+
+Drop Aniyomi `.apk` files into the extensions directory, shown at the top of the
+Extensions tab. It defaults to `/anime-extensions` — on macOS,
+`~/Library/Application Support/SubMiner/anime-extensions` — and can be moved with
+`anime.extensionsDir`.
+
+A single APK may provide several sources; each appears separately in the
+**Source** picker. Extensions that fail to load are listed in the Installed
+section with the reason, so a broken APK is visible rather than silently
+missing.
+
+An extension that fails to load is skipped rather than blocking the others, so
+one bad APK will not hide the rest.
+
+## Searching every source at once
+
+With more than one source installed, the **Source** picker gains an
+**All sources** entry. Searching with it selected runs the query against every
+installed source at once, and each source's results appear the moment that
+source answers — a fast source is on screen while a slow one is still
+resolving. The status bar counts sources as they finish
+(`Searching… 3/5 sources · 42 results`).
+
+Each cover is labelled with the source it came from, and opening one always
+queries that source, whatever the picker says afterwards.
+
+A source that errors is named in the status bar and the rest still show their
+results; one extension that needs a login cannot blank the grid. If every
+source fails, the first error is shown in full.
+
+Typing a new search while one is still running simply starts over: results
+from the superseded search are discarded, even if its sources answer late.
+
+Source settings belong to a single extension, so the **Source settings** tab
+asks you to pick one while **All sources** is selected.
+
+## Settings
+
+| Key | Purpose |
+| ------------------------ | -------------------------------------------------------------------- |
+| `anime.repos` | Repository index URLs. Empty by default. |
+| `anime.extensionsDir` | Where APKs are read from. Empty uses `/anime-extensions`. |
+| `anime.preferredQuality` | Preferred stream label, matched as a substring (for example `1080`). |
+
+## Source settings
+
+Most extensions need configuration before they return anything — a server
+address and credentials, a preferred quality, a language filter. Open the
+**Source settings** tab to edit them. Changes save as you make them and
+persist across restarts in `/anime-source-preferences.json`.
+
+Each save is handed back to the extension, so it can react: the Jellyfin source
+logs in when the address and password land, then fills in its media-library
+picker. Password-like fields are masked. Because that file can hold
+credentials, it is written with owner-only permissions.
+
+## The bridge
+
+The first launch downloads a platform bundle (~130 MB) containing the server and
+a matching Java runtime, so no system JDK is required. It is verified against a
+pinned SHA-256 before running, unpacked into `/anime-bridge`, and
+reused after that. Progress appears in the banner at the top of the window.
+
+The bridge stays running while the window is open. Resolved video URLs point at
+its own loopback proxy so the extension's cookies and headers apply, which means
+those URLs stop working once it exits — the window keeps it alive for the whole
+session.
+
+Two known limits:
+
+- There is no Android WebView, so extensions that need one (typically for
+ Cloudflare challenges) will fail with an error from the source.
+- Bundles are published for macOS (arm64, x64), Linux (x64), and Windows (x64).
+ Other platforms are unsupported.
+
+## Playback
+
+Selecting an episode resolves the best available stream, applies the source's
+required headers as mpv `http-header-fields`, and loads it. The headers are
+readable back off mpv, so Anki card audio and screenshots fetch correctly too.
+
+### Japanese audio, and switching tracks
+
+Sources often return a dub and the original audio as two separate entries — or
+as two audio tracks of one stream — and the dub is frequently listed first.
+SubMiner always aims at the Japanese audio:
+
+- Entries labelled as a dub are skipped as long as another entry exists. This
+ outranks `anime.preferredQuality`: a 1080p dub is the wrong file, not a better
+ one. If every entry is a dub, it still plays.
+- mpv's `alang` is set to `ja,jpn,jp,japanese` before the file loads, so a
+ stream carrying several audio tracks starts on the Japanese one. With no
+ Japanese track, mpv falls back to the first one as usual.
+- Any audio or subtitle tracks the extension supplies separately are added to
+ mpv with `audio-add` / `sub-add`, tagged with their language, and the
+ Japanese one is selected.
+
+The primary subtitle slot is reserved for Japanese — it is what the overlay
+mines. A source that only carries, say, English subtitles does not get them
+promoted to primary; instead the track is added with a normalized language tag
+(`English` → `en`), and the regular [dual-subtitle settings](configuration.md)
+apply: with `secondarySub.autoLoadSecondarySub` enabled and the language listed
+in `secondarySub.secondarySubLanguages`, it is picked up as the secondary
+subtitle, exactly as it would be for a local file.
+
+Every track is added, including the ones that are not selected, so all of them
+appear in mpv's track menu and can be switched by hand while watching
+(`#` cycles audio, `j` cycles subtitles by default).
+
+[mes]: https://github.com/1Selxo/M-Extension-Server
diff --git a/docs-site/aniskip-integration.md b/docs-site/aniskip-integration.md
index fc9bc230..a8115b08 100644
--- a/docs-site/aniskip-integration.md
+++ b/docs-site/aniskip-integration.md
@@ -45,9 +45,9 @@ Results are cached per file for the app session; only definitive "no intro found
You can trigger AniSkip actions from mpv script-messages:
-| Command | Effect |
-| ------- | ------ |
-| `script-message subminer-skip-intro` | Skip to the intro end immediately (same as pressing the key) |
+| Command | Effect |
+| ----------------------------------------- | ----------------------------------------------------------------------- |
+| `script-message subminer-skip-intro` | Skip to the intro end immediately (same as pressing the key) |
| `script-message subminer-aniskip-refresh` | Force a fresh lookup for the current file, discarding any cached result |
These are handled by the SubMiner app over the IPC socket.
diff --git a/docs-site/changelog.md b/docs-site/changelog.md
index 335aa870..7a417713 100644
--- a/docs-site/changelog.md
+++ b/docs-site/changelog.md
@@ -54,6 +54,7 @@
## v0.19.0 (2026-07-29)
**Added**
+
- Anki Maturity Highlighting: Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Tier thresholds and colors are configurable, with a runtime toggle and an updated help legend.
- Post-Playback Menu: After a watch-history episode ends, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit. The pre-playback series menu now offers the previous episode too.
- Delete Library Entries: The stats Library detail view can now delete an entire title in one step (episodes, sessions, subtitle lines, rollups, cover art, and vocabulary counts). Delete progress is now shown app-wide via a progress bar and status toast instead of disappearing when you switch tabs.
@@ -61,9 +62,11 @@
- TsukiHime Subtitle Downloads: Added subtitle downloads for the current video via TsukiHime, loading Japanese as the primary track and your configured secondary language directly into mpv.
**Changed**
+
- Clipboard-Video Shortcut: The "append clipboard video to queue" shortcut is now configurable.
**Fixed**
+
- AniList Season Resolution: Season 2+ files now resolve to the correct AniList entry instead of silently falling back to season 1 (which mismatched character dictionaries and watch progress). Manual overrides now stay scoped per season, fix both the dictionary and progress tracking together, and also correct per-season cover art.
- Subtitle Annotation Accuracy: Fixed several annotation edge cases, including inconsistent POS exclusions on merged quote-particle tokens, dropped annotations on supplementary-plane kanji, katakana punctuation wrongly treated as noise, and certain kanji vocabulary losing N+1 highlighting eligibility.
- AnkiConnect Proxy Port Conflict: Video startup no longer crashes when another process already holds the configured AnkiConnect proxy port; a notification now explains how to resolve it.
@@ -79,6 +82,7 @@
Internal changes
**Internal**
+
- Added a golden-file regression test corpus for the tokenizer/annotation pipeline, plus scripts to record new fixtures and diff against stock Yomitan.
- Consolidated renderer modal state handling into a descriptor registry.
- Consolidated CI quality checks (PR, stable, and prerelease) into one reusable workflow with mpv plugin tests and dependency audits.
@@ -95,10 +99,12 @@
v0.18.0 (2026-07-10)
**Added**
+
- Sentence Audio Normalization: Generated sentence audio is now normalized to -23 LUFS by default, and clips mined from playback mirror mpv's software volume curve with a limiter to prevent clipping. Both behaviors are configurable independently.
- Watch History Command: Added `subminer -H` / `--history` to browse watch history, replay or continue episodes, or pick one via fzf or rofi, with cover art shown in the rofi picker.
**Changed**
+
- Fzf Preview Layout: Moved fzf previews below launcher menus, giving long titles and metadata more room.
- Known-Word Highlighting: Now compares subtitle and Anki-card readings, preventing false matches between homographs and unrelated words that share a reading, while still supporting matching across kana and kanji spellings.
- Annotation Filtering: Standalone suffix tokens (e.g. さん, れる) are now excluded from JLPT/frequency/N+1 highlighting by default, matching how particles and interjections are treated; configurable via the pos2 exclusion setting.
@@ -106,6 +112,7 @@
- Stats Trend Charts: Overhauled with persisted title visibility, per-chart title limits, "top" and "most recent" ranking modes, an option to show or hide empty days, calendar-aligned periods, and value-sorted tooltips.
**Fixed**
+
- Background Stats Server: `subminer app` background launches now auto-start the stats server when enabled, and skip startup if one is already running.
- Character Name Highlighting: Character dictionaries now split unspaced native names more reliably, and portraits, highlights, and hover lookup survive punctuation, unmatched text, and competing dictionary matches without incorrectly splitting longer words.
- Highlighting Coverage: Frequency/JLPT highlighting and vocabulary stats now include content adverbs (e.g. 確かに, やはり) and kanji nouns MeCab tags as non-independent (e.g. 日, 点, 以外), while still suppressing interjections, pronouns, and grammar fragments; lexicalized kana expressions like かといって keep their annotations.
@@ -119,6 +126,7 @@
Internal changes
**Internal**
+
- Test lanes moved to `scripts/test-lanes.ts` with per-directory discovery and isolated per-file timeouts; CI now covers previously orphaned stats, scripts, plugin process-retry, and runtime-compat suites, plus a new stats lane in the change-verification workflow.
@@ -131,15 +139,18 @@
v0.17.2 (2026-06-28)
**Fixed**
+
- YouTube Background Cache: Fixed Windows YouTube background media cache startup for YouTube URLs opened directly in mpv, including resolved stream URLs when mpv still exposes the original YouTube playlist entry, so queued Anki media updates can append audio and images after the cache finishes.
- YouTube Subtitle Picker: Manual subtitle picker requests now show an immediate configured notification while SubMiner probes tracks and opens the modal. Subtitle download progress is replaced with a transient success notification after tracks load.
v0.17.1 (2026-06-27)
**Added**
+
- YouTube Media Cache Mode: Adds `youtube.mediaCache.mode` with `direct` and `background` options. Background mode uses a yt-dlp cache download when direct stream extraction is unreliable — creates a text-only card immediately, queues media updates for mined notes, and fills audio/image fields once the download finishes. Progress is announced via overlay/OSD notifications. Downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`). Switching back to direct mode cancels any in-flight background download.
**Fixed**
+
- Log Export: Fixed log filenames to use the local date so exports around UTC midnight include the current day's logs rather than stale prior-day files. Expanded export redaction to mask IPs, emails, auth and cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL parameters.
- YouTube Card Media: Improved media generation reliability by sending safer ffmpeg options for resolved streams and skipping stale stream maps (including cached YouTube files). Hardened background cache downloads with IPv4 and extractor retry flags; failed downloads now notify the user and clear queued media updates instead of leaving them silently pending. Stale background cache files are cleaned on startup and before each new download.
@@ -225,6 +236,7 @@
Internal changes
**Internal**
+
- **Build**: `make deps` now initializes git submodules before installing dependencies on a fresh source checkout.
- **Release Tooling**: Release notes now credit contributors and first-time authors resolved from changelog fragments via git and the GitHub API.
- **Changelog Guidance**: PR fragment guidance updated to preserve separate-outcome fragments while directing contributors to consolidate same-PR follow-up notes before adding churn.
@@ -239,9 +251,11 @@
v0.15.2 (2026-06-02)
**Changed**
+
- Yomitan: Updated the bundled Yomitan build to the latest vendored revision.
**Fixed**
+
- Anki - Animated AVIF: Clip timing no longer starts or ends early; word-audio lead-in and clip duration are now aligned to frame boundaries.
- Overlay (Hyprland): Fixed fullscreen overlay alignment - modal, stats, and sidebar content no longer shift below the mpv window.
- Overlay (macOS): Subtitle bars are now interactive immediately after autoplay starts with "wait for overlay to be ready" enabled, without requiring a manual click.
diff --git a/docs-site/ipc-contracts.md b/docs-site/ipc-contracts.md
index 44ce3a62..9054dacc 100644
--- a/docs-site/ipc-contracts.md
+++ b/docs-site/ipc-contracts.md
@@ -38,7 +38,7 @@ flowchart TB
## Runtime Sockets
-The renderer↔main bridge above lives *inside* the Electron app. A separate set of OS sockets connects the app to the other runtimes - mpv and the launcher/plugin. These carry no renderer payloads and bypass the contract/validator layer; they are command and property channels between processes.
+The renderer↔main bridge above lives _inside_ the Electron app. A separate set of OS sockets connects the app to the other runtimes - mpv and the launcher/plugin. These carry no renderer payloads and bypass the contract/validator layer; they are command and property channels between processes.
- **mpv IPC socket** (`/tmp/subminer-socket`, or `\\.\pipe\subminer-socket` on Windows): the `MpvIpcClient` in the main process connects here to send JSON commands and subscribe to playback/subtitle properties via `observe_property`. Created by mpv's `--input-ipc-server`.
- **App control socket** (`/tmp/subminer-control--.sock`, or a named pipe on Windows): the launcher and the mpv plugin send CLI-style commands (`--start`, `--show-visible-overlay`, `--texthooker`) to a running app here. It also dedupes a second `subminer` invocation into the existing instance instead of launching twice.
@@ -69,15 +69,15 @@ How these sockets are established during launch is covered in [Playback Startup
## Core Surfaces
-| File | Role |
-| --- | --- |
-| `src/shared/ipc/contracts.ts` | Canonical channel names and payload type contracts. Single source of truth for both processes. |
-| `src/shared/ipc/validators.ts` | Runtime payload parsers and type guards. Every `invoke` payload is validated here before the handler runs. |
-| `src/preload.ts` | Renderer-side bridge. Exposes a typed API surface to the renderer - only approved channels are accessible. |
-| `src/main/ipc-runtime.ts` | Main-process handler registration and routing. Wires validated channels to domain handlers. |
-| `src/core/services/ipc.ts` | Service-level invoke handling. Applies guardrails (validation, error wrapping) before calling domain logic. |
-| `src/core/services/anki-jimaku-ipc.ts` | Integration-specific IPC boundary for Anki and Jimaku operations. |
-| `src/main/cli-runtime.ts` | CLI/runtime command boundary. Handles commands that originate from the launcher or mpv plugin rather than the renderer. |
+| File | Role |
+| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
+| `src/shared/ipc/contracts.ts` | Canonical channel names and payload type contracts. Single source of truth for both processes. |
+| `src/shared/ipc/validators.ts` | Runtime payload parsers and type guards. Every `invoke` payload is validated here before the handler runs. |
+| `src/preload.ts` | Renderer-side bridge. Exposes a typed API surface to the renderer - only approved channels are accessible. |
+| `src/main/ipc-runtime.ts` | Main-process handler registration and routing. Wires validated channels to domain handlers. |
+| `src/core/services/ipc.ts` | Service-level invoke handling. Applies guardrails (validation, error wrapping) before calling domain logic. |
+| `src/core/services/anki-jimaku-ipc.ts` | Integration-specific IPC boundary for Anki and Jimaku operations. |
+| `src/main/cli-runtime.ts` | CLI/runtime command boundary. Handles commands that originate from the launcher or mpv plugin rather than the renderer. |
## Contract Rules
diff --git a/docs-site/jimaku-integration.md b/docs-site/jimaku-integration.md
index de387548..a103cb57 100644
--- a/docs-site/jimaku-integration.md
+++ b/docs-site/jimaku-integration.md
@@ -23,12 +23,12 @@ If no files match the current episode filter, a "Show all files" button lets you
### Modal Keyboard Shortcuts
-| Key | Action |
-| --- | --- |
-| `Enter` (in text field) | Search |
-| `Enter` (in list) | Select entry / download file |
-| `Arrow Up` / `Arrow Down` | Navigate entries or files |
-| `Escape` | Close modal |
+| Key | Action |
+| ------------------------- | ---------------------------- |
+| `Enter` (in text field) | Search |
+| `Enter` (in list) | Select entry / download file |
+| `Arrow Up` / `Arrow Down` | Navigate entries or files |
+| `Escape` | Close modal |
## Configuration
@@ -41,26 +41,26 @@ Add a `jimaku` section to your `config.jsonc`:
"apiKeyCommand": "cat ~/.jimaku_key",
"apiBaseUrl": "https://jimaku.cc",
"languagePreference": "ja",
- "maxEntryResults": 10
- }
+ "maxEntryResults": 10,
+ },
}
```
-| Option | Type | Default | Description |
-| --- | --- | --- | --- |
-| `jimaku.apiKey` | `string` | - | Jimaku API key (plaintext). Mutually exclusive with `apiKeyCommand`. |
-| `jimaku.apiKeyCommand` | `string` | - | Shell command that prints the API key to stdout. Useful for secret managers (e.g., `pass jimaku/api-key`). |
-| `jimaku.apiBaseUrl` | `string` | `"https://jimaku.cc"` | Base URL for the Jimaku API. Only change this if using a mirror or local instance. |
-| `jimaku.languagePreference` | `"ja"` \| `"en"` \| `"none"` | `"ja"` | Sort subtitle files by language tag. `"ja"` pushes Japanese-tagged files to the top; `"en"` does the same for English. `"none"` preserves the API order. |
-| `jimaku.maxEntryResults` | `number` | `10` | Maximum number of anime entries returned per search. |
+| Option | Type | Default | Description |
+| --------------------------- | ---------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `jimaku.apiKey` | `string` | - | Jimaku API key (plaintext). Mutually exclusive with `apiKeyCommand`. |
+| `jimaku.apiKeyCommand` | `string` | - | Shell command that prints the API key to stdout. Useful for secret managers (e.g., `pass jimaku/api-key`). |
+| `jimaku.apiBaseUrl` | `string` | `"https://jimaku.cc"` | Base URL for the Jimaku API. Only change this if using a mirror or local instance. |
+| `jimaku.languagePreference` | `"ja"` \| `"en"` \| `"none"` | `"ja"` | Sort subtitle files by language tag. `"ja"` pushes Japanese-tagged files to the top; `"en"` does the same for English. `"none"` preserves the API order. |
+| `jimaku.maxEntryResults` | `number` | `10` | Maximum number of anime entries returned per search. |
The keyboard shortcut is configured separately under `shortcuts`:
```jsonc
{
"shortcuts": {
- "openJimaku": "Ctrl+Shift+J"
- }
+ "openJimaku": "Ctrl+Shift+J",
+ },
}
```
diff --git a/docs-site/mpv-plugin.md b/docs-site/mpv-plugin.md
index 01112d8c..7be12870 100644
--- a/docs-site/mpv-plugin.md
+++ b/docs-site/mpv-plugin.md
@@ -31,20 +31,20 @@ input-ipc-server=\\.\pipe\subminer-socket
The plugin reads options from `script-opts` with the `subminer-` prefix (for example `--script-opts=subminer-backend=hyprland`). Managed launches inject these automatically from your SubMiner config; the shipped `subminer.conf` is intentionally empty so command-line opts always win.
-| Option | Default | Description |
-| -------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------- |
-| `binary_path` | `""` | Path to the SubMiner binary; empty enables [auto-detection](#binary-auto-detection) |
-| `socket_path` | platform default | mpv IPC socket path (`/tmp/subminer-socket`, or `\\.\pipe\subminer-socket` on Windows) |
-| `texthooker_enabled` | `no` | Start the texthooker server with the overlay |
-| `texthooker_port` | `5174` | Texthooker server port |
-| `backend` | `auto` | Window backend (`auto`, `hyprland`, `sway`, `x11`, `macos`) |
-| `auto_start` | `no` | Start the overlay app on `file-loaded` (managed launches set this from `mpv.autoStartSubMiner`) |
-| `auto_start_visible_overlay` | `no` | Show the visible overlay on auto-start (from `auto_start_overlay` in config) |
-| `overlay_loading_osd` | `no` | Show an OSD loading spinner while the overlay starts |
-| `auto_start_pause_until_ready` | `yes` | Keep mpv paused until the overlay reports tokenization-ready |
-| `auto_start_pause_until_ready_timeout_seconds` | `30` | Timeout before resuming playback anyway |
-| `osd_messages` | `yes` | Show plugin OSD status messages |
-| `log_level` | `info` | Plugin log verbosity |
+| Option | Default | Description |
+| ---------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------- |
+| `binary_path` | `""` | Path to the SubMiner binary; empty enables [auto-detection](#binary-auto-detection) |
+| `socket_path` | platform default | mpv IPC socket path (`/tmp/subminer-socket`, or `\\.\pipe\subminer-socket` on Windows) |
+| `texthooker_enabled` | `no` | Start the texthooker server with the overlay |
+| `texthooker_port` | `5174` | Texthooker server port |
+| `backend` | `auto` | Window backend (`auto`, `hyprland`, `sway`, `x11`, `macos`) |
+| `auto_start` | `no` | Start the overlay app on `file-loaded` (managed launches set this from `mpv.autoStartSubMiner`) |
+| `auto_start_visible_overlay` | `no` | Show the visible overlay on auto-start (from `auto_start_overlay` in config) |
+| `overlay_loading_osd` | `no` | Show an OSD loading spinner while the overlay starts |
+| `auto_start_pause_until_ready` | `yes` | Keep mpv paused until the overlay reports tokenization-ready |
+| `auto_start_pause_until_ready_timeout_seconds` | `30` | Timeout before resuming playback anyway |
+| `osd_messages` | `yes` | Show plugin OSD status messages |
+| `log_level` | `info` | Plugin log verbosity |
## Keybindings
diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc
index 1408eab7..f1971c4b 100644
--- a/docs-site/public/config.example.jsonc
+++ b/docs-site/public/config.example.jsonc
@@ -611,6 +611,18 @@
} // Lapis kiku setting.
}, // Automatic Anki updates and media generation options.
+ // ==========================================
+ // Anime Browser
+ // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
+ // add a repository index URL here (or drop .apk files in the extensions directory) to have any.
+ // Hot-reload: anime changes apply the next time the anime browser opens.
+ // ==========================================
+ "anime": {
+ "extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses /anime-extensions.
+ "repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
+ "preferredQuality": "" // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
+ }, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
+
// ==========================================
// Jimaku
// Jimaku API configuration and defaults.
diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md
index 645cd144..0599313e 100644
--- a/docs-site/shortcuts.md
+++ b/docs-site/shortcuts.md
@@ -12,10 +12,10 @@ All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindi
## App-Wide Shortcuts
-| Shortcut | Action | Scope | Configurable |
-| ------------- | ---------------------- | -------------------------------------------- | -------------------------------------- |
-| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus | `shortcuts.toggleVisibleOverlayGlobal` |
-| `Alt+Shift+Y` | Open Yomitan settings | OS-global (registered with the OS) | Fixed (not configurable) |
+| Shortcut | Action | Scope | Configurable |
+| ------------- | ---------------------- | ---------------------------------------- | -------------------------------------- |
+| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus | `shortcuts.toggleVisibleOverlayGlobal` |
+| `Alt+Shift+Y` | Open Yomitan settings | OS-global (registered with the OS) | Fixed (not configurable) |
::: tip
`Alt+Shift+O` is dispatched by the overlay window and the mpv plugin, so it works from either surface without OS registration. Only `Alt+Shift+Y` is registered with the OS; if it conflicts with another application, that binding cannot be changed. All `shortcuts.*` keys hot-reload - no restart needed.
@@ -75,21 +75,21 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
## Subtitle & Feature Shortcuts
-| Shortcut | Action | Config key |
-| ------------------ | -------------------------------------------------------- | ------------------------------------------ |
-| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
-| `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` |
-| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
-| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
-| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
-| `Ctrl+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` |
-| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
-| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
-| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
-| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` |
+| Shortcut | Action | Config key |
+| ------------------ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
+| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
+| `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` |
+| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
+| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
+| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
+| `Ctrl+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` |
+| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
+| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
+| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
+| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` |
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` (overlay) / `shortcuts.toggleSubtitleSidebar` (mpv session binding) |
-| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
-| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` |
+| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
+| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` |
`shortcuts.openAnimetosho` remains accepted as a deprecated alias for `shortcuts.openTsukihime`. The current name takes precedence when both are configured.
diff --git a/docs-site/subtitle-sidebar.md b/docs-site/subtitle-sidebar.md
index e5203085..d342e103 100644
--- a/docs-site/subtitle-sidebar.md
+++ b/docs-site/subtitle-sidebar.md
@@ -62,23 +62,23 @@ Styling lives under the `css` object, using CSS property names and CSS custom pr
| `pauseVideoOnHover` | boolean | `true` | Pause playback while hovering the cue list |
| `autoScroll` | boolean | `true` | Keep the active cue in view during playback |
-| `css` property | Default | Description |
-| ------------------------------------------- | --------------------------- | ---------------------------- |
-| `font-family` | `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP` | Cue text font family |
-| `color` | `#cad3f5` | Default cue text color |
-| `background-color` | `rgba(73, 77, 100, 0.9)` | Sidebar shell background color |
-| `font-size` | `16px` | Base cue font size |
-| `opacity` | `0.95` | Sidebar opacity between `0` and `1` |
-| `--subtitle-sidebar-max-width` | `420px` | Maximum sidebar width |
-| `--subtitle-sidebar-timestamp-color` | `#a5adcb` | Cue timestamp color |
-| `--subtitle-sidebar-active-line-color` | `#f5bde6` | Active cue text color |
-| `--subtitle-sidebar-active-background-color`| `rgba(138, 173, 244, 0.22)` | Active cue background color |
-| `--subtitle-sidebar-hover-background-color` | `rgba(54, 58, 79, 0.84)` | Hovered cue background color |
+| `css` property | Default | Description |
+| -------------------------------------------- | --------------------------------------------------------------- | ----------------------------------- |
+| `font-family` | `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP` | Cue text font family |
+| `color` | `#cad3f5` | Default cue text color |
+| `background-color` | `rgba(73, 77, 100, 0.9)` | Sidebar shell background color |
+| `font-size` | `16px` | Base cue font size |
+| `opacity` | `0.95` | Sidebar opacity between `0` and `1` |
+| `--subtitle-sidebar-max-width` | `420px` | Maximum sidebar width |
+| `--subtitle-sidebar-timestamp-color` | `#a5adcb` | Cue timestamp color |
+| `--subtitle-sidebar-active-line-color` | `#f5bde6` | Active cue text color |
+| `--subtitle-sidebar-active-background-color` | `rgba(138, 173, 244, 0.22)` | Active cue background color |
+| `--subtitle-sidebar-hover-background-color` | `rgba(54, 58, 79, 0.84)` | Hovered cue background color |
## Keyboard Shortcut
-| Key | Action | Config key |
-| --- | ----------------------- | ------------------------------ |
-| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
+| Key | Action | Config key |
+| --- | ----------------------- | --------------------------- |
+| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
The toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source. See [Keyboard Shortcuts](/shortcuts) for the full shortcut reference.
diff --git a/docs-site/usage.md b/docs-site/usage.md
index ad695fa4..c5356b4c 100644
--- a/docs-site/usage.md
+++ b/docs-site/usage.md
@@ -70,6 +70,7 @@ subminer https://youtu.be/... # Play a YouTube URL
subminer stats # Open the immersion stats dashboard
subminer doctor # Check dependencies, config, and the mpv socket
subminer settings # Open the SubMiner settings window
+subminer anime # Open the anime browser window
subminer app --setup # Re-open first-run setup
subminer -u # Check for updates
```
@@ -135,6 +136,7 @@ SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
SubMiner.AppImage --yomitan # Open Yomitan settings
SubMiner.AppImage --settings # Open the SubMiner settings window
SubMiner.AppImage --jellyfin # Open the Jellyfin setup window
+SubMiner.AppImage --anime # Open the anime browser window
SubMiner.AppImage --dictionary # Generate a character dictionary ZIP
SubMiner.AppImage --start --dev # Enable app/dev mode
SubMiner.AppImage --start --log-level debug # Verbose logging without dev mode
diff --git a/docs-site/websocket-texthooker-api.md b/docs-site/websocket-texthooker-api.md
index 9756bf3e..01e768be 100644
--- a/docs-site/websocket-texthooker-api.md
+++ b/docs-site/websocket-texthooker-api.md
@@ -2,7 +2,7 @@
**Who this page is for:** developers and tinkerers who want to consume SubMiner's live subtitle stream from their own tools - a browser tab, an automation script, or another mpv plugin. If you just want subtitles in a browser tab for Yomitan, skip to [Texthooker Integration Guide](#texthooker-integration-guide); the rest is reference for building custom clients.
-A *texthooker* is a page/tool that receives the text currently on screen so a dictionary extension (like Yomitan) can look words up. SubMiner ships its own texthooker UI and also broadcasts subtitle text over local WebSockets that any client can connect to.
+A _texthooker_ is a page/tool that receives the text currently on screen so a dictionary extension (like Yomitan) can look words up. SubMiner ships its own texthooker UI and also broadcasts subtitle text over local WebSockets that any client can connect to.
SubMiner exposes a small set of local integration surfaces for browser tools, automation helpers, and mpv-driven workflows:
@@ -15,12 +15,12 @@ This page documents those integration points and shows how to build custom consu
## Quick Reference
-| Surface | Default | Purpose |
-| --- | --- | --- |
-| `websocket` | `ws://127.0.0.1:6677` | Basic subtitle broadcast stream |
-| `annotationWebsocket` | `ws://127.0.0.1:6678` | Structured stream with token metadata |
-| `texthooker` | `http://127.0.0.1:5174` | Local texthooker UI with injected websocket config |
-| mpv plugin | `script-message subminer-*` | Start/stop/toggle/status automation inside mpv |
+| Surface | Default | Purpose |
+| --------------------- | --------------------------- | -------------------------------------------------- |
+| `websocket` | `ws://127.0.0.1:6677` | Basic subtitle broadcast stream |
+| `annotationWebsocket` | `ws://127.0.0.1:6678` | Structured stream with token metadata |
+| `texthooker` | `http://127.0.0.1:5174` | Local texthooker UI with injected websocket config |
+| mpv plugin | `script-message subminer-*` | Start/stop/toggle/status automation inside mpv |
## Enable and Configure the Services
@@ -30,16 +30,16 @@ SubMiner's integration ports are configured in `config.jsonc`. All three service
{
"websocket": {
"enabled": "auto",
- "port": 6677
+ "port": 6677,
},
"annotationWebsocket": {
"enabled": true,
- "port": 6678
+ "port": 6678,
},
"texthooker": {
"launchAtStartup": true,
- "openBrowser": false
- }
+ "openBrowser": false,
+ },
}
```
@@ -79,12 +79,12 @@ When a client connects, SubMiner immediately sends the latest subtitle payload i
#### Field reference
-| Field | Type | Notes |
-| --- | --- | --- |
-| `version` | number | Current websocket payload version. Today this is `1`. |
-| `text` | string | Raw subtitle text. |
+| Field | Type | Notes |
+| ---------- | ------ | ---------------------------------------------------------------------------------------------- |
+| `version` | number | Current websocket payload version. Today this is `1`. |
+| `text` | string | Raw subtitle text. |
| `sentence` | string | Plain subtitle text with line breaks represented as ` `. No annotation spans or attributes. |
-| `tokens` | array | Always empty on the basic subtitle websocket. |
+| `tokens` | array | Always empty on the basic subtitle websocket. |
### 2. Annotation WebSocket
@@ -129,22 +129,22 @@ On a tokenization cache miss, this stream first sends the cue as plain text with
Each annotation token may include:
-| Token field | Type | Notes |
-| --- | --- | --- |
-| `surface` | string | Display text for the token |
-| `reading` | string | Kana reading when available |
-| `headword` | string | Dictionary headword when available |
-| `startPos` / `endPos` | number | Character offsets in the subtitle text |
-| `partOfSpeech` | string | SubMiner token POS label |
-| `isMerged` | boolean | Whether this token represents merged content |
-| `isKnown` | boolean | Marked known by SubMiner's known-word logic |
-| `isNPlusOneTarget` | boolean | True when the token is the sentence's N+1 target |
-| `isNameMatch` | boolean | True for prioritized character-name matches |
-| `frequencyRank` | number | Frequency rank when available |
-| `jlptLevel` | string | JLPT level when available |
-| `className` | string | CSS-ready class list derived from token state |
-| `frequencyRankLabel` | string or `null` | Preformatted rank label for UIs |
-| `jlptLevelLabel` | string or `null` | Preformatted JLPT label for UIs |
+| Token field | Type | Notes |
+| --------------------- | ---------------- | ------------------------------------------------ |
+| `surface` | string | Display text for the token |
+| `reading` | string | Kana reading when available |
+| `headword` | string | Dictionary headword when available |
+| `startPos` / `endPos` | number | Character offsets in the subtitle text |
+| `partOfSpeech` | string | SubMiner token POS label |
+| `isMerged` | boolean | Whether this token represents merged content |
+| `isKnown` | boolean | Marked known by SubMiner's known-word logic |
+| `isNPlusOneTarget` | boolean | True when the token is the sentence's N+1 target |
+| `isNameMatch` | boolean | True for prioritized character-name matches |
+| `frequencyRank` | number | Frequency rank when available |
+| `jlptLevel` | string | JLPT level when available |
+| `className` | string | CSS-ready class list derived from token state |
+| `frequencyRankLabel` | string or `null` | Preformatted rank label for UIs |
+| `jlptLevelLabel` | string or `null` | Preformatted JLPT label for UIs |
### 3. HTML markup conventions
diff --git a/docs-site/youtube-integration.md b/docs-site/youtube-integration.md
index ac1dbdb9..71753d90 100644
--- a/docs-site/youtube-integration.md
+++ b/docs-site/youtube-integration.md
@@ -110,8 +110,8 @@ Background cache downloads are capped at 720p by default (`youtube.mediaCache.ma
}
```
-| Option | Type | Description |
-| --------------------- | ---------- | ------------------------------------------------------------------------------------- |
+| Option | Type | Description |
+| --------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `primarySubLanguages` | `string[]` | Languages that count as a satisfactory primary subtitle (default `["ja", "jpn"]`). Used by the "primary subtitle missing" notification and by managed local/playlist subtitle selection. |
YouTube auto-selection itself always picks a Japanese track first (manual over auto), then falls back to any manual track — `primarySubLanguages` does not change which YouTube track is auto-picked.
@@ -130,11 +130,11 @@ YouTube secondary selection is fixed: SubMiner always tries an English track (ma
}
```
-| Option | Type | Description |
-| ----------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Option | Type | Description |
+| ----------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track for local/Jellyfin sidecar files. Default is empty (`[]`). Not used for YouTube. |
| `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary sidecar track for local files (default: `false`). Not used for YouTube. |
-| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
+| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
These settings come from `config.jsonc` (or built-in defaults); there are no CLI flags or environment variables for subtitle language selection.
diff --git a/docs/knowledge-base/quality.md b/docs/knowledge-base/quality.md
index df7d4ffe..9fa5c1ab 100644
--- a/docs/knowledge-base/quality.md
+++ b/docs/knowledge-base/quality.md
@@ -11,27 +11,27 @@ Grades are directional, not ceremonial. The point is to keep gaps visible.
## Product / Runtime Domains
-| Area | Grade | Notes |
-| --- | --- | --- |
-| Desktop runtime composition | B | strong modularization; still easy for `main` wiring drift to reappear |
-| Launcher CLI | B | focused surface; generated/stale artifact hazards need constant guarding |
-| mpv plugin | B | modular, but Lua/runtime coupling still specialized |
-| Overlay renderer | B | improved modularity; interaction complexity remains |
-| Config system | A- | clear defaults/definitions split and good validation surface |
-| Immersion / AniList / Jellyfin surfaces | B- | growing product scope; ownership spans multiple services |
-| Internal docs system | B | new structure in place; needs habitual maintenance |
-| Public docs site | B | strong user docs; must stay separate from internal KB |
+| Area | Grade | Notes |
+| --------------------------------------- | ----- | ------------------------------------------------------------------------ |
+| Desktop runtime composition | B | strong modularization; still easy for `main` wiring drift to reappear |
+| Launcher CLI | B | focused surface; generated/stale artifact hazards need constant guarding |
+| mpv plugin | B | modular, but Lua/runtime coupling still specialized |
+| Overlay renderer | B | improved modularity; interaction complexity remains |
+| Config system | A- | clear defaults/definitions split and good validation surface |
+| Immersion / AniList / Jellyfin surfaces | B- | growing product scope; ownership spans multiple services |
+| Internal docs system | B | new structure in place; needs habitual maintenance |
+| Public docs site | B | strong user docs; must stay separate from internal KB |
## Architectural Layers
-| Layer | Grade | Notes |
-| --- | --- | --- |
-| `src/main.ts` composition root | B | direction good; still needs vigilance against logic creep |
-| `src/main/` runtime adapters | B | mostly clear; can accumulate wiring debt |
-| `src/core/services/` | B+ | good extraction pattern; some domains remain broad |
-| `src/renderer/` | B | cleaner than before; UI/runtime behavior still dense |
-| `launcher/` | B | clear command boundaries |
-| `docs/` internal KB | B | structure exists; enforcement now guards core rules |
+| Layer | Grade | Notes |
+| ------------------------------ | ----- | --------------------------------------------------------- |
+| `src/main.ts` composition root | B | direction good; still needs vigilance against logic creep |
+| `src/main/` runtime adapters | B | mostly clear; can accumulate wiring debt |
+| `src/core/services/` | B+ | good extraction pattern; some domains remain broad |
+| `src/renderer/` | B | cleaner than before; UI/runtime behavior still dense |
+| `launcher/` | B | clear command boundaries |
+| `docs/` internal KB | B | structure exists; enforcement now guards core rules |
## Current Gaps
diff --git a/launcher/commands/app-command.ts b/launcher/commands/app-command.ts
index a4bfdba2..43d17646 100644
--- a/launcher/commands/app-command.ts
+++ b/launcher/commands/app-command.ts
@@ -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;
diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts
index 4ab0b7be..39ee4657 100644
--- a/launcher/commands/command-modules.test.ts
+++ b/launcher/commands/command-modules.test.ts
@@ -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'),
});
diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts
index ffae2d8a..e040db69 100644
--- a/launcher/commands/playback-command.test.ts
+++ b/launcher/commands/playback-command.test.ts
@@ -63,6 +63,7 @@ function createContext(): LauncherCommandContext {
logsExport: false,
version: false,
settings: false,
+ animeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
diff --git a/launcher/config/args-normalizer.test.ts b/launcher/config/args-normalizer.test.ts
index 64799d34..57083dd3 100644
--- a/launcher/config/args-normalizer.test.ts
+++ b/launcher/config/args-normalizer.test.ts
@@ -120,6 +120,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
logLevel: 'warn',
},
settingsInvocation: null,
+ animeInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
@@ -174,6 +175,7 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
settingsInvocation: {
logLevel: undefined,
},
+ animeInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
@@ -221,6 +223,7 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
action: undefined,
},
settingsInvocation: null,
+ animeInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
@@ -266,6 +269,7 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
jellyfinInvocation: null,
configInvocation: null,
settingsInvocation: null,
+ animeInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts
index a1568cd5..2afad051 100644
--- a/launcher/config/args-normalizer.ts
+++ b/launcher/config/args-normalizer.ts
@@ -170,6 +170,7 @@ export function createDefaultArgs(
version: false,
update: false,
settings: false,
+ animeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
@@ -355,6 +356,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);
diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts
index e531123d..4b19a9da 100644
--- a/launcher/config/cli-parser-builder.ts
+++ b/launcher/config/cli-parser-builder.ts
@@ -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;
@@ -115,6 +116,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
'doctor',
'config',
'settings',
+ 'anime',
'mpv',
'logs',
'dictionary',
@@ -168,6 +170,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;
@@ -456,6 +459,16 @@ export function parseCliPrograms(
};
});
+ commandProgram
+ .command('anime')
+ .description('Open the anime browser window')
+ .option('--log-level ', 'Log level')
+ .action((options: Record) => {
+ animeInvocation = {
+ logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
+ };
+ });
+
commandProgram
.command('mpv')
.description('MPV helpers')
@@ -510,6 +523,7 @@ export function parseCliPrograms(
jellyfinInvocation,
configInvocation,
settingsInvocation,
+ animeInvocation,
mpvInvocation,
appInvocation,
dictionaryTriggered,
diff --git a/launcher/jellyfin.test.ts b/launcher/jellyfin.test.ts
index 9054e767..c3190000 100644
--- a/launcher/jellyfin.test.ts
+++ b/launcher/jellyfin.test.ts
@@ -57,6 +57,7 @@ function createArgs(): Args {
logsExport: false,
version: false,
settings: false,
+ animeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts
index 57c3a3e9..4c2f8599 100644
--- a/launcher/mpv.test.ts
+++ b/launcher/mpv.test.ts
@@ -627,6 +627,7 @@ function makeArgs(overrides: Partial = {}): Args {
logsExport: false,
version: false,
settings: false,
+ animeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
diff --git a/launcher/types.ts b/launcher/types.ts
index bfc82ea0..8507d2d4 100644
--- a/launcher/types.ts
+++ b/launcher/types.ts
@@ -152,6 +152,7 @@ export interface Args {
version: boolean;
update?: boolean;
settings: boolean;
+ animeBrowser: boolean;
configPath: boolean;
configShow: boolean;
mpvIdle: boolean;
diff --git a/package.json b/package.json
index 1385b1ad..6ec0ea91 100644
--- a/package.json
+++ b/package.json
@@ -21,10 +21,11 @@
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --banner='#!/usr/bin/env bun' --outfile=dist/launcher/subminer",
"build:stats": "cd stats && bun run build",
"dev:stats": "cd stats && bun run dev",
- "build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets",
+ "build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:animeui && bun run build:launcher && bun run build:assets",
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
"build:settings": "esbuild src/settings/settings.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/settings/settings.js --sourcemap",
"build:syncui": "esbuild src/syncui/syncui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/syncui/syncui.js --sourcemap && esbuild src/preload-syncui.ts --bundle --platform=node --format=cjs --target=node20 --external:electron --outfile=dist/preload-syncui.js --sourcemap",
+ "build:animeui": "esbuild src/animeui/animeui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/animeui/animeui.js --sourcemap && esbuild src/preload-animeui.ts --bundle --platform=node --format=cjs --target=node20 --external:electron --outfile=dist/preload-animeui.js --sourcemap",
"changelog:build": "bun run scripts/build-changelog.ts build-release",
"changelog:check": "bun run scripts/build-changelog.ts check",
"changelog:docs": "bun run scripts/build-changelog.ts docs",
diff --git a/scripts/preload-bundles.test.ts b/scripts/preload-bundles.test.ts
index 95fd30a5..bc51d771 100644
--- a/scripts/preload-bundles.test.ts
+++ b/scripts/preload-bundles.test.ts
@@ -3,14 +3,33 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
-test('build:syncui bundles the sandboxed preload and keeps Electron external', () => {
+function buildScript(name: string): string {
const packageJson = JSON.parse(
fs.readFileSync(path.join(import.meta.dir, '..', 'package.json'), 'utf8'),
) as { scripts: Record };
- const command = packageJson.scripts['build:syncui'] ?? '';
+ return packageJson.scripts[name] ?? '';
+}
+
+test('build:syncui bundles the sandboxed preload and keeps Electron external', () => {
+ const command = buildScript('build:syncui');
assert.match(command, /src\/preload-syncui\.ts/);
assert.match(command, /--bundle/);
assert.match(command, /--external:electron/);
assert.match(command, /--outfile=dist\/preload-syncui\.js/);
});
+
+test('build:animeui bundles the sandboxed preload and keeps Electron external', () => {
+ const command = buildScript('build:animeui');
+
+ // The preload imports IPC_CHANNELS, so it must be bundled rather than
+ // emitted by plain tsc with a relative runtime require.
+ assert.match(command, /src\/preload-animeui\.ts/);
+ assert.match(command, /--bundle/);
+ assert.match(command, /--external:electron/);
+ assert.match(command, /--outfile=dist\/preload-animeui\.js/);
+});
+
+test('build:animeui runs as part of the top-level build', () => {
+ assert.match(buildScript('build'), /bun run build:animeui/);
+});
diff --git a/scripts/prepare-build-assets.mjs b/scripts/prepare-build-assets.mjs
index fad72b3c..0457d026 100644
--- a/scripts/prepare-build-assets.mjs
+++ b/scripts/prepare-build-assets.mjs
@@ -11,6 +11,8 @@ const settingsSourceDir = path.join(repoRoot, 'src', 'settings');
const settingsOutputDir = path.join(repoRoot, 'dist', 'settings');
const syncUiSourceDir = path.join(repoRoot, 'src', 'syncui');
const syncUiOutputDir = path.join(repoRoot, 'dist', 'syncui');
+const animeUiSourceDir = path.join(repoRoot, 'src', 'animeui');
+const animeUiOutputDir = path.join(repoRoot, 'dist', 'animeui');
const scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts');
const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift');
const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos');
@@ -47,6 +49,10 @@ function copySyncUiAssets() {
copyAssets(syncUiSourceDir, syncUiOutputDir, 'syncui');
}
+function copyAnimeUiAssets() {
+ copyAssets(animeUiSourceDir, animeUiOutputDir, 'animeui');
+}
+
function fallbackToMacosSource() {
copyFile(macosHelperSourcePath, macosHelperSourceCopyPath);
process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`);
@@ -90,6 +96,7 @@ function main() {
copyRendererAssets();
copySettingsAssets();
copySyncUiAssets();
+ copyAnimeUiAssets();
buildMacosHelper();
}
diff --git a/src/anime-bridge/bridge-client.test.ts b/src/anime-bridge/bridge-client.test.ts
new file mode 100644
index 00000000..62a75c6f
--- /dev/null
+++ b/src/anime-bridge/bridge-client.test.ts
@@ -0,0 +1,148 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { AnimeBridgeClient, BridgeExtensionError } from './bridge-client';
+import { BRIDGE_CONTEXT_KEY } from './types';
+
+const EXTENSION_ID = 'a'.repeat(64);
+const source = { apkBase64: 'QVBLLUJZVEVT', sourceId: 'source-1' };
+
+interface Recorded {
+ url: string;
+ body: Record;
+}
+
+function stubFetch(responder: (call: Recorded, index: number) => Response): {
+ fetchImpl: typeof fetch;
+ calls: Recorded[];
+} {
+ const calls: Recorded[] = [];
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const call: Recorded = {
+ url: String(input),
+ body: init?.body ? (JSON.parse(String(init.body)) as Record) : {},
+ };
+ calls.push(call);
+ return responder(call, calls.length - 1);
+ }) as typeof fetch;
+ return { fetchImpl, calls };
+}
+
+function jsonResponse(body: unknown, extensionId?: string): Response {
+ const headers = new Headers({ 'Content-Type': 'application/json' });
+ if (extensionId) headers.set('x-mangatan-extension-id', extensionId);
+ return new Response(JSON.stringify(body), { status: 200, headers });
+}
+
+test('isReady requires every capability the client depends on', async () => {
+ const ready = new AnimeBridgeClient({
+ baseUrl: 'http://127.0.0.1:9',
+ fetchImpl: stubFetch(() =>
+ jsonResponse({ mangatanMihonBridge: 1, sourceFactory: true, preferenceCallbacks: true }),
+ ).fetchImpl,
+ });
+ assert.equal(await ready.isReady(), true);
+
+ const partial = new AnimeBridgeClient({
+ baseUrl: 'http://127.0.0.1:9',
+ fetchImpl: stubFetch(() => jsonResponse({ mangatanMihonBridge: 1, sourceFactory: true }))
+ .fetchImpl,
+ });
+ assert.equal(await partial.isReady(), false);
+});
+
+test('isReady reports false instead of throwing when the bridge is down', async () => {
+ const client = new AnimeBridgeClient({
+ baseUrl: 'http://127.0.0.1:9',
+ fetchImpl: (async () => {
+ throw new Error('ECONNREFUSED');
+ }) as typeof fetch,
+ });
+ assert.equal(await client.isReady(), false);
+});
+
+test('getVideoList posts the APK and episode url with a bridge context preference', async () => {
+ const { fetchImpl, calls } = stubFetch(() => jsonResponse([{ videoUrl: 'http://x/video/t' }]));
+ const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9/', fetchImpl });
+
+ const videos = await client.getVideoList(source, 'https://origin.example/ep/1');
+
+ assert.equal(calls[0]?.url, 'http://127.0.0.1:9/dalvik');
+ assert.equal(calls[0]?.body.method, 'getVideoList');
+ assert.deepEqual(calls[0]?.body.episodeData, { url: 'https://origin.example/ep/1' });
+ assert.equal(calls[0]?.body.data, source.apkBase64);
+ assert.deepEqual(calls[0]?.body.preferences, [{ key: BRIDGE_CONTEXT_KEY, sourceId: 'source-1' }]);
+ assert.equal(videos.length, 1);
+});
+
+test('a cached extension id replaces the APK upload on later calls', async () => {
+ const { fetchImpl, calls } = stubFetch(() => jsonResponse([], EXTENSION_ID));
+ const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
+
+ await client.getVideoList(source, 'https://origin.example/ep/1');
+ await client.getVideoList(source, 'https://origin.example/ep/2');
+
+ assert.equal(calls[0]?.body.data, source.apkBase64);
+ assert.equal(calls[0]?.body.extensionId, undefined);
+ assert.equal(calls[1]?.body.data, undefined);
+ assert.equal(calls[1]?.body.extensionId, EXTENSION_ID);
+});
+
+test('a 409 re-uploads the APK once and succeeds', async () => {
+ const { fetchImpl, calls } = stubFetch((call, index) => {
+ if (index === 0) return jsonResponse([], EXTENSION_ID);
+ // Cache evicted: reject the id-only call, accept the re-upload.
+ if (call.body.extensionId !== undefined) return new Response('', { status: 409 });
+ return jsonResponse([{ videoUrl: 'http://x/video/t' }], EXTENSION_ID);
+ });
+ const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
+
+ await client.getVideoList(source, 'https://origin.example/ep/1');
+ const videos = await client.getVideoList(source, 'https://origin.example/ep/2');
+
+ assert.equal(calls.length, 3);
+ assert.equal(calls[1]?.body.extensionId, EXTENSION_ID);
+ assert.equal(calls[2]?.body.data, source.apkBase64);
+ assert.equal(videos.length, 1);
+});
+
+test('an error body on a 200 response raises BridgeExtensionError with the code', async () => {
+ const { fetchImpl } = stubFetch(() => jsonResponse({ error: 'Cloudflare challenge', code: 403 }));
+ const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
+
+ await assert.rejects(
+ () => client.getVideoList(source, 'https://origin.example/ep/1'),
+ (error: unknown) => {
+ assert.ok(error instanceof BridgeExtensionError);
+ assert.equal(error.code, 403);
+ assert.match(error.message, /Cloudflare challenge/);
+ return true;
+ },
+ );
+});
+
+test('searchAnime sends a 1-based page and returns the page payload', async () => {
+ const { fetchImpl, calls } = stubFetch(() =>
+ jsonResponse({ animes: [{ title: 'Example' }], hasNextPage: true }),
+ );
+ const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
+
+ const page = await client.searchAnime(source, 'example');
+
+ assert.equal(calls[0]?.body.method, 'getSearchAnime');
+ assert.equal(calls[0]?.body.page, 1);
+ assert.equal(calls[0]?.body.search, 'example');
+ assert.deepEqual(calls[0]?.body.filterList, []);
+ assert.equal(page.hasNextPage, true);
+ assert.equal(page.animes?.length, 1);
+});
+
+test('getEpisodeList wraps the anime url in animeData', async () => {
+ const { fetchImpl, calls } = stubFetch(() => jsonResponse([{ name: 'Episode 1', url: '/ep/1' }]));
+ const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
+
+ const episodes = await client.getEpisodeList(source, 'https://origin.example/anime/1');
+
+ assert.equal(calls[0]?.body.method, 'getEpisodeList');
+ assert.deepEqual(calls[0]?.body.animeData, { url: 'https://origin.example/anime/1' });
+ assert.equal(episodes[0]?.name, 'Episode 1');
+});
diff --git a/src/anime-bridge/bridge-client.ts b/src/anime-bridge/bridge-client.ts
new file mode 100644
index 00000000..a45988dc
--- /dev/null
+++ b/src/anime-bridge/bridge-client.ts
@@ -0,0 +1,214 @@
+import { BRIDGE_CONTEXT_KEY } from './types';
+import type {
+ BridgeAnime,
+ BridgeAnimePage,
+ BridgeCapabilities,
+ BridgeEpisode,
+ BridgePreference,
+ BridgeSourceDescriptor,
+ BridgeVideo,
+} from './types';
+
+const EXTENSION_ID_HEADER = 'x-mangatan-extension-id';
+const EXTENSION_ID_PATTERN = /^[0-9a-f]{64}$/;
+
+export interface BridgeSource {
+ /** Base64-encoded extension APK. */
+ apkBase64: string;
+ /** Selects one source inside a multi-source (SourceFactory) APK. */
+ sourceId?: string;
+ preferences?: BridgePreference[];
+}
+
+export interface BridgeClientOptions {
+ /** Loopback base URL of the running bridge, e.g. `http://127.0.0.1:53112`. */
+ baseUrl: string;
+ fetchImpl?: typeof fetch;
+}
+
+/** The bridge reports extension failures as HTTP 200 with an error body. */
+export class BridgeExtensionError extends Error {
+ readonly code?: number;
+ constructor(message: string, code?: number) {
+ super(message);
+ this.name = 'BridgeExtensionError';
+ this.code = code;
+ }
+}
+
+/**
+ * Client for the M-Extension-Server `/dalvik` RPC endpoint.
+ *
+ * The server caches uploaded APKs and returns a content hash, letting
+ * subsequent calls send that id instead of re-uploading megabytes of base64.
+ * A 409 means the cache was evicted, so the APK is resent once.
+ */
+export class AnimeBridgeClient {
+ private readonly baseUrl: string;
+ private readonly fetchImpl: typeof fetch;
+ private readonly extensionIds = new Map();
+
+ constructor(options: BridgeClientOptions) {
+ this.baseUrl = options.baseUrl.replace(/\/+$/, '');
+ this.fetchImpl = options.fetchImpl ?? fetch;
+ }
+
+ async getCapabilities(): Promise {
+ const response = await this.fetchImpl(`${this.baseUrl}/capabilities`);
+ if (!response.ok) {
+ throw new Error(`Anime bridge capabilities check failed (${response.status}).`);
+ }
+ return (await response.json()) as BridgeCapabilities;
+ }
+
+ /** True once the bridge is up and reports the features this client needs. */
+ async isReady(): Promise {
+ try {
+ const capabilities = await this.getCapabilities();
+ return (
+ capabilities.mangatanMihonBridge === 1 &&
+ capabilities.sourceFactory === true &&
+ capabilities.preferenceCallbacks === true
+ );
+ } catch {
+ return false;
+ }
+ }
+
+ async searchAnime(
+ source: BridgeSource,
+ query: string,
+ page = 1,
+ filterList: unknown[] = [],
+ ): Promise {
+ return this.call(source, 'getSearchAnime', {
+ page,
+ search: query,
+ filterList,
+ });
+ }
+
+ /**
+ * List the sources an extension APK provides. A single APK may expose many
+ * (a SourceFactory), so this is how a package becomes selectable entries.
+ */
+ async listAnimeSources(source: BridgeSource): Promise {
+ return this.call(source, 'sourcesAnime', {});
+ }
+
+ /** The extension's own settings schema, with current values. */
+ async getSourcePreferences(source: BridgeSource): Promise {
+ return this.call(source, 'preferencesAnime', {});
+ }
+
+ /**
+ * Commit a preference change. The whole array is sent back with the edited
+ * entry, and `changedPreferenceKey` tells the extension which one moved so it
+ * can react (the Jellyfin source logs in when the address or password lands).
+ * Returns the extension's refreshed schema.
+ */
+ async setSourcePreference(
+ source: BridgeSource,
+ changedPreferenceKey: string,
+ ): Promise {
+ return this.call(source, 'setPreferenceAnime', {}, changedPreferenceKey);
+ }
+
+ /** Full metadata for one anime: description, cover art, genres, status. */
+ async getAnimeDetails(source: BridgeSource, animeUrl: string): Promise {
+ return this.call(source, 'getDetailsAnime', {
+ animeData: { url: animeUrl },
+ });
+ }
+
+ async getPopularAnime(source: BridgeSource, page = 1): Promise {
+ return this.call(source, 'getPopularAnime', { page });
+ }
+
+ async getEpisodeList(source: BridgeSource, animeUrl: string): Promise {
+ return this.call(source, 'getEpisodeList', {
+ animeData: { url: animeUrl },
+ });
+ }
+
+ async getVideoList(source: BridgeSource, episodeUrl: string): Promise {
+ return this.call(source, 'getVideoList', {
+ episodeData: { url: episodeUrl },
+ });
+ }
+
+ private buildPreferences(
+ source: BridgeSource,
+ changedPreferenceKey?: string,
+ ): BridgePreference[] {
+ const context: BridgePreference = { key: BRIDGE_CONTEXT_KEY };
+ if (source.sourceId !== undefined) context.sourceId = source.sourceId;
+ if (changedPreferenceKey !== undefined) context.changedPreferenceKey = changedPreferenceKey;
+ return [...(source.preferences ?? []), context];
+ }
+
+ private async call(
+ source: BridgeSource,
+ method: string,
+ extras: Record,
+ changedPreferenceKey?: string,
+ ): Promise {
+ const cacheKey = source.sourceId ?? source.apkBase64;
+ const cachedId = this.extensionIds.get(cacheKey);
+
+ let response = await this.post(method, extras, source, cachedId, changedPreferenceKey);
+ if (response.status === 409 && cachedId !== undefined) {
+ // Server evicted the cached APK; upload it again.
+ this.extensionIds.delete(cacheKey);
+ response = await this.post(method, extras, source, undefined, changedPreferenceKey);
+ }
+
+ if (!response.ok) {
+ throw new Error(`Anime bridge ${method} failed (${response.status}).`);
+ }
+
+ const returnedId = response.headers.get(EXTENSION_ID_HEADER)?.trim();
+ if (returnedId && EXTENSION_ID_PATTERN.test(returnedId)) {
+ this.extensionIds.set(cacheKey, returnedId);
+ }
+
+ const body = (await response.json()) as T;
+ assertNoExtensionError(body, method);
+ return body;
+ }
+
+ private post(
+ method: string,
+ extras: Record,
+ source: BridgeSource,
+ extensionId: string | undefined,
+ changedPreferenceKey?: string,
+ ): Promise {
+ const payload: Record = {
+ method,
+ ...extras,
+ preferences: this.buildPreferences(source, changedPreferenceKey),
+ ...(extensionId === undefined ? { data: source.apkBase64 } : { extensionId }),
+ };
+
+ return this.fetchImpl(`${this.baseUrl}/dalvik`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8',
+ Accept: 'application/json',
+ },
+ body: JSON.stringify(payload),
+ });
+ }
+}
+
+function assertNoExtensionError(body: unknown, method: string): void {
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) return;
+ const error = (body as { error?: unknown }).error;
+ if (typeof error !== 'string') return;
+ const code = (body as { code?: unknown }).code;
+ throw new BridgeExtensionError(
+ `Anime bridge ${method} failed: ${error}`,
+ typeof code === 'number' ? code : undefined,
+ );
+}
diff --git a/src/anime-bridge/extension-installer.test.ts b/src/anime-bridge/extension-installer.test.ts
new file mode 100644
index 00000000..25f7ae7a
--- /dev/null
+++ b/src/anime-bridge/extension-installer.test.ts
@@ -0,0 +1,149 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { installExtension, looksLikeApk, removeExtension } from './extension-installer';
+import type { RepoExtension } from './extension-repo';
+
+const PKG = 'eu.kanade.tachiyomi.animeextension.all.example';
+
+function apkBytes(payload = 'APK-BODY'): Uint8Array {
+ // APKs are zip archives, so they start with the PK local-file-header magic.
+ return new Uint8Array([0x50, 0x4b, 0x03, 0x04, ...new TextEncoder().encode(payload)]);
+}
+
+function repoExtension(overrides: Partial = {}): RepoExtension {
+ return {
+ pkg: PKG,
+ name: 'Example Source',
+ lang: 'all',
+ version: '1.2.3',
+ versionCode: 12,
+ nsfw: false,
+ apkUrl: 'https://repo.example/anime/apk/example.apk',
+ iconUrl: 'https://repo.example/anime/icon/example.png',
+ repoUrl: 'https://repo.example/anime/index.min.json',
+ sourceNames: ['Example'],
+ ...overrides,
+ };
+}
+
+function respondWith(bytes: Uint8Array, headers: Record = {}): typeof fetch {
+ // Uint8Array is a valid Response body at runtime; the DOM lib types disagree.
+ const body = bytes as unknown as BodyInit;
+ return (async () => new Response(body, { status: 200, headers })) as typeof fetch;
+}
+
+test('looksLikeApk accepts the zip magic and rejects anything else', () => {
+ assert.equal(looksLikeApk(apkBytes()), true);
+ assert.equal(looksLikeApk(new TextEncoder().encode('')), false);
+ assert.equal(looksLikeApk(new Uint8Array([])), false);
+});
+
+test('installExtension writes the apk named after its package', async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
+ const target = await installExtension({
+ extensionsDir: dir,
+ extension: repoExtension(),
+ fetchImpl: respondWith(apkBytes()),
+ });
+
+ assert.equal(target, path.join(dir, `${PKG}.apk`));
+ assert.match((await readFile(target)).toString(), /APK-BODY/);
+});
+
+test('installing again replaces the previous version in place', async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
+ await installExtension({
+ extensionsDir: dir,
+ extension: repoExtension(),
+ fetchImpl: respondWith(apkBytes('OLD')),
+ });
+ await installExtension({
+ extensionsDir: dir,
+ extension: repoExtension({ version: '2.0.0', versionCode: 20 }),
+ fetchImpl: respondWith(apkBytes('NEW')),
+ });
+
+ const contents = (await readFile(path.join(dir, `${PKG}.apk`))).toString();
+ assert.match(contents, /NEW/);
+ assert.doesNotMatch(contents, /OLD/);
+});
+
+test('the extensions directory is created when missing', async () => {
+ const root = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
+ const nested = path.join(root, 'does', 'not', 'exist');
+ await installExtension({
+ extensionsDir: nested,
+ extension: repoExtension(),
+ fetchImpl: respondWith(apkBytes()),
+ });
+ assert.equal(existsSync(path.join(nested, `${PKG}.apk`)), true);
+});
+
+test('a non-ok response is reported with the extension name', async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
+ const fetchImpl = (async () => new Response('', { status: 404 })) as typeof fetch;
+
+ await assert.rejects(
+ () => installExtension({ extensionsDir: dir, extension: repoExtension(), fetchImpl }),
+ /Example Source.*404/,
+ );
+});
+
+test('a response that is not an apk is rejected rather than written', async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
+ // A misconfigured repo commonly serves an HTML error page instead.
+ const fetchImpl = respondWith(new TextEncoder().encode('404'));
+
+ await assert.rejects(
+ () => installExtension({ extensionsDir: dir, extension: repoExtension(), fetchImpl }),
+ /did not download as an APK/,
+ );
+ assert.equal(existsSync(path.join(dir, `${PKG}.apk`)), false);
+});
+
+test('an oversized download is refused by the declared length', async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
+ const fetchImpl = respondWith(apkBytes(), { 'content-length': '999999999' });
+
+ await assert.rejects(
+ () =>
+ installExtension({
+ extensionsDir: dir,
+ extension: repoExtension(),
+ fetchImpl,
+ maxBytes: 1024,
+ }),
+ /larger than the 1024 byte limit/,
+ );
+});
+
+test('an oversized download is refused even when the length header lies', async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
+ const fetchImpl = respondWith(apkBytes('x'.repeat(4096)), { 'content-length': '10' });
+
+ await assert.rejects(
+ () =>
+ installExtension({
+ extensionsDir: dir,
+ extension: repoExtension(),
+ fetchImpl,
+ maxBytes: 1024,
+ }),
+ /larger than the 1024 byte limit/,
+ );
+ assert.equal(existsSync(path.join(dir, `${PKG}.apk`)), false);
+});
+
+test('removeExtension deletes the file and tolerates a missing one', async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
+ const file = path.join(dir, `${PKG}.apk`);
+ await writeFile(file, 'x');
+
+ await removeExtension(dir, PKG);
+ assert.equal(existsSync(file), false);
+ await removeExtension(dir, PKG);
+});
diff --git a/src/anime-bridge/extension-installer.ts b/src/anime-bridge/extension-installer.ts
new file mode 100644
index 00000000..05060036
--- /dev/null
+++ b/src/anime-bridge/extension-installer.ts
@@ -0,0 +1,66 @@
+import { mkdir, rm, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { extensionFileName, type RepoExtension } from './extension-repo';
+
+/**
+ * Downloads extension APKs into the extensions directory.
+ *
+ * Only URLs that came from a repository index the user configured are ever
+ * fetched; nothing here discovers or suggests sources.
+ */
+
+export interface InstallExtensionOptions {
+ extensionsDir: string;
+ extension: RepoExtension;
+ fetchImpl?: typeof fetch;
+ /** Guards against a mistyped repo serving something enormous. */
+ maxBytes?: number;
+}
+
+/** APKs are a few MB; anything far past that is not an extension. */
+const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
+
+const APK_MAGIC = [0x50, 0x4b, 0x03, 0x04]; // "PK\x03\x04" — APKs are zip archives.
+
+export function looksLikeApk(bytes: Uint8Array): boolean {
+ return APK_MAGIC.every((byte, index) => bytes[index] === byte);
+}
+
+/**
+ * Download one extension into `extensionsDir`, replacing any previous version.
+ *
+ * The file is named after the package so an update overwrites in place rather
+ * than leaving two versions for the bridge to load.
+ */
+export async function installExtension(options: InstallExtensionOptions): Promise {
+ const fetchImpl = options.fetchImpl ?? fetch;
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
+
+ const response = await fetchImpl(options.extension.apkUrl);
+ if (!response.ok) {
+ throw new Error(`Downloading ${options.extension.name} failed (${response.status}).`);
+ }
+
+ const declared = Number(response.headers.get('content-length') ?? '0');
+ if (declared > maxBytes) {
+ throw new Error(`${options.extension.name} is larger than the ${maxBytes} byte limit.`);
+ }
+
+ const bytes = new Uint8Array(await response.arrayBuffer());
+ if (bytes.byteLength > maxBytes) {
+ throw new Error(`${options.extension.name} is larger than the ${maxBytes} byte limit.`);
+ }
+ if (!looksLikeApk(bytes)) {
+ throw new Error(`${options.extension.name} did not download as an APK.`);
+ }
+
+ await mkdir(options.extensionsDir, { recursive: true });
+ const target = path.join(options.extensionsDir, extensionFileName(options.extension.pkg));
+ await writeFile(target, bytes);
+ return target;
+}
+
+/** Delete an installed extension. Missing files are treated as already gone. */
+export async function removeExtension(extensionsDir: string, pkg: string): Promise {
+ await rm(path.join(extensionsDir, extensionFileName(pkg)), { force: true });
+}
diff --git a/src/anime-bridge/extension-repo.test.ts b/src/anime-bridge/extension-repo.test.ts
new file mode 100644
index 00000000..a645f76a
--- /dev/null
+++ b/src/anime-bridge/extension-repo.test.ts
@@ -0,0 +1,177 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ extensionFileName,
+ fetchRepoCatalogue,
+ fetchRepoIndex,
+ isValidRepoUrl,
+ parseRepoIndex,
+ repoBaseUrl,
+} from './extension-repo';
+
+const INDEX = 'https://repo.example/anime/index.min.json';
+
+function animeEntry(overrides: Record = {}): Record {
+ return {
+ name: 'Aniyomi: Example Source',
+ pkg: 'eu.kanade.tachiyomi.animeextension.all.example',
+ apk: 'example-v1.2.3.apk',
+ lang: 'all',
+ code: 12,
+ version: '1.2.3',
+ nsfw: 0,
+ sources: [{ name: 'Example', lang: 'en' }],
+ ...overrides,
+ };
+}
+
+test('any https url naming a json index is accepted', () => {
+ assert.equal(isValidRepoUrl(INDEX), true);
+ assert.equal(isValidRepoUrl(' ' + INDEX + ' '), true);
+ // Repos are free to name the index; index.min.json is only a convention.
+ assert.equal(isValidRepoUrl('https://repo.example/anime/index.json'), true);
+ assert.equal(
+ isValidRepoUrl('https://manatan-community.github.io/extensions/video.min.json'),
+ true,
+ );
+ // Plain http would let a network attacker swap the APK list.
+ assert.equal(isValidRepoUrl('http://repo.example/anime/index.min.json'), false);
+ assert.equal(isValidRepoUrl('https://repo.example/anime/'), false);
+ assert.equal(isValidRepoUrl('https://repo.example/index.min.json.txt'), false);
+ assert.equal(isValidRepoUrl('https://repo.example'), false);
+ assert.equal(isValidRepoUrl(''), false);
+});
+
+test('repoBaseUrl strips the index file name', () => {
+ assert.equal(repoBaseUrl(INDEX), 'https://repo.example/anime');
+ assert.equal(
+ repoBaseUrl('https://manatan-community.github.io/extensions/video.min.json'),
+ 'https://manatan-community.github.io/extensions',
+ );
+});
+
+test('parseRepoIndex builds apk and icon urls from the repo root', () => {
+ const [extension] = parseRepoIndex(INDEX, [animeEntry()]);
+
+ assert.equal(extension?.pkg, 'eu.kanade.tachiyomi.animeextension.all.example');
+ assert.equal(extension?.apkUrl, 'https://repo.example/anime/apk/example-v1.2.3.apk');
+ assert.equal(
+ extension?.iconUrl,
+ 'https://repo.example/anime/icon/eu.kanade.tachiyomi.animeextension.all.example.png',
+ );
+ assert.equal(extension?.repoUrl, INDEX);
+ assert.equal(extension?.versionCode, 12);
+ assert.deepEqual(extension?.sourceNames, ['Example']);
+});
+
+test('the Aniyomi name prefix is stripped', () => {
+ const [extension] = parseRepoIndex(INDEX, [animeEntry()]);
+ assert.equal(extension?.name, 'Example Source');
+});
+
+test('manga packages are excluded', () => {
+ const entries = [animeEntry(), animeEntry({ pkg: 'eu.kanade.tachiyomi.extension.en.somemanga' })];
+ const parsed = parseRepoIndex(INDEX, entries);
+ assert.equal(parsed.length, 1);
+ assert.match(parsed[0]!.pkg, /animeextension/);
+});
+
+test('malformed entries are skipped rather than failing the repo', () => {
+ const parsed = parseRepoIndex(INDEX, [
+ null,
+ 'nonsense',
+ animeEntry({ apk: undefined }),
+ animeEntry({ pkg: undefined }),
+ animeEntry(),
+ ]);
+ assert.equal(parsed.length, 1);
+});
+
+test('parseRepoIndex tolerates a non-array payload', () => {
+ assert.deepEqual(parseRepoIndex(INDEX, { message: 'Not Found' }), []);
+ assert.deepEqual(parseRepoIndex(INDEX, null), []);
+});
+
+test('missing optional fields fall back to safe defaults', () => {
+ const [extension] = parseRepoIndex(INDEX, [
+ { pkg: 'eu.kanade.tachiyomi.animeextension.all.bare', apk: 'bare.apk' },
+ ]);
+ assert.equal(extension?.name, 'eu.kanade.tachiyomi.animeextension.all.bare');
+ assert.equal(extension?.lang, 'all');
+ assert.equal(extension?.versionCode, 0);
+ assert.equal(extension?.nsfw, false);
+ assert.deepEqual(extension?.sourceNames, []);
+});
+
+test('nsfw is read from the numeric flag', () => {
+ assert.equal(parseRepoIndex(INDEX, [animeEntry({ nsfw: 1 })])[0]?.nsfw, true);
+ assert.equal(parseRepoIndex(INDEX, [animeEntry({ nsfw: 0 })])[0]?.nsfw, false);
+});
+
+test('fetchRepoIndex rejects an invalid url before making a request', async () => {
+ let called = false;
+ const fetchImpl = (async () => {
+ called = true;
+ return new Response('[]');
+ }) as typeof fetch;
+
+ await assert.rejects(() => fetchRepoIndex('http://insecure/index.min.json', { fetchImpl }));
+ assert.equal(called, false);
+});
+
+test('fetchRepoIndex surfaces a non-ok response', async () => {
+ const fetchImpl = (async () => new Response('', { status: 404 })) as typeof fetch;
+ await assert.rejects(() => fetchRepoIndex(INDEX, { fetchImpl }), /404/);
+});
+
+test('fetchRepoCatalogue merges repos and keeps the highest version code', async () => {
+ const second = 'https://other.example/anime/index.min.json';
+ const fetchImpl = (async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url === INDEX) {
+ return new Response(JSON.stringify([animeEntry({ code: 12, version: '1.2.3' })]));
+ }
+ return new Response(JSON.stringify([animeEntry({ code: 20, version: '2.0.0' })]));
+ }) as typeof fetch;
+
+ const catalogue = await fetchRepoCatalogue([INDEX, second], { fetchImpl });
+
+ assert.equal(catalogue.extensions.length, 1);
+ assert.equal(catalogue.extensions[0]?.versionCode, 20);
+ assert.equal(catalogue.extensions[0]?.repoUrl, second);
+ assert.deepEqual(catalogue.failures, []);
+});
+
+test('one failing repo does not hide the others', async () => {
+ const broken = 'https://broken.example/anime/index.min.json';
+ const fetchImpl = (async (input: RequestInfo | URL) => {
+ if (String(input) === broken) throw new Error('ENOTFOUND');
+ return new Response(JSON.stringify([animeEntry()]));
+ }) as typeof fetch;
+
+ const catalogue = await fetchRepoCatalogue([broken, INDEX], { fetchImpl });
+
+ assert.equal(catalogue.extensions.length, 1);
+ assert.equal(catalogue.failures.length, 1);
+ assert.equal(catalogue.failures[0]?.repoUrl, broken);
+ assert.match(catalogue.failures[0]?.error ?? '', /ENOTFOUND/);
+});
+
+test('an empty repo list yields an empty catalogue without any request', async () => {
+ let called = false;
+ const fetchImpl = (async () => {
+ called = true;
+ return new Response('[]');
+ }) as typeof fetch;
+
+ const catalogue = await fetchRepoCatalogue([], { fetchImpl });
+ assert.deepEqual(catalogue, { extensions: [], failures: [] });
+ assert.equal(called, false);
+});
+
+test('extensions are stored under their package name so updates replace in place', () => {
+ assert.equal(
+ extensionFileName('eu.kanade.tachiyomi.animeextension.all.example'),
+ 'eu.kanade.tachiyomi.animeextension.all.example.apk',
+ );
+});
diff --git a/src/anime-bridge/extension-repo.ts b/src/anime-bridge/extension-repo.ts
new file mode 100644
index 00000000..5639bfb2
--- /dev/null
+++ b/src/anime-bridge/extension-repo.ts
@@ -0,0 +1,183 @@
+/**
+ * Client for Aniyomi-format extension repositories.
+ *
+ * SubMiner ships no repositories and performs no discovery. A repository only
+ * exists once the user adds its index URL, and only extensions from those
+ * repositories are ever listed or downloaded.
+ */
+
+/** Aniyomi extension packages carry this prefix; manga packages are ignored. */
+const ANIME_PACKAGE_PREFIX = 'eu.kanade.tachiyomi.animeextension';
+
+/**
+ * Repos are identified by their index URL. The file name is not fixed:
+ * `index.min.json` is the Aniyomi convention, but repositories publish under
+ * other names too (e.g. `video.min.json`), so only https and a `.json` file
+ * name are required.
+ */
+const INDEX_URL_PATTERN = /^https:\/\/[^\s/]+(?:\/[^\s/]*)*\/[^\s/]+\.json$/;
+
+export interface RepoExtension {
+ /** Package name, the stable identity of an extension across versions. */
+ pkg: string;
+ name: string;
+ lang: string;
+ version: string;
+ /** Monotonic version code; the comparison basis for updates. */
+ versionCode: number;
+ nsfw: boolean;
+ apkUrl: string;
+ iconUrl: string;
+ /** Index URL of the repo this came from. */
+ repoUrl: string;
+ /** Source names the package provides, when the index declares them. */
+ sourceNames: string[];
+}
+
+/** `true` when `url` is a usable Aniyomi index URL. */
+export function isValidRepoUrl(url: string): boolean {
+ return INDEX_URL_PATTERN.test(url.trim());
+}
+
+/** Strip the index file name to get the repo root. */
+export function repoBaseUrl(indexUrl: string): string {
+ return indexUrl.trim().replace(/\/[^/]*$/, '');
+}
+
+interface RawEntry {
+ name?: unknown;
+ pkg?: unknown;
+ apk?: unknown;
+ lang?: unknown;
+ code?: unknown;
+ version?: unknown;
+ nsfw?: unknown;
+ sources?: unknown;
+}
+
+function readSourceNames(sources: unknown): string[] {
+ if (!Array.isArray(sources)) return [];
+ return sources
+ .map((source) =>
+ source !== null && typeof source === 'object'
+ ? (source as { name?: unknown }).name
+ : undefined,
+ )
+ .filter((name): name is string => typeof name === 'string' && name.length > 0);
+}
+
+/**
+ * Parse an index payload into anime extensions.
+ *
+ * Entries that are malformed, or that are manga rather than anime packages,
+ * are skipped rather than failing the whole repo.
+ */
+export function parseRepoIndex(indexUrl: string, payload: unknown): RepoExtension[] {
+ if (!Array.isArray(payload)) return [];
+ const base = repoBaseUrl(indexUrl);
+
+ const extensions: RepoExtension[] = [];
+ for (const raw of payload as RawEntry[]) {
+ if (raw === null || typeof raw !== 'object') continue;
+ const pkg = typeof raw.pkg === 'string' ? raw.pkg : '';
+ const apk = typeof raw.apk === 'string' ? raw.apk : '';
+ if (!pkg.startsWith(ANIME_PACKAGE_PREFIX) || apk.length === 0) continue;
+
+ const versionCode = Number(raw.code);
+ extensions.push({
+ pkg,
+ // Repo entries are prefixed "Aniyomi: "; the app supplies its own context.
+ name: (typeof raw.name === 'string' ? raw.name : pkg).replace(/^Aniyomi:\s*/, ''),
+ lang: typeof raw.lang === 'string' ? raw.lang : 'all',
+ version: typeof raw.version === 'string' ? raw.version : '0',
+ versionCode: Number.isFinite(versionCode) ? versionCode : 0,
+ nsfw: Number(raw.nsfw) === 1,
+ apkUrl: `${base}/apk/${apk}`,
+ iconUrl: `${base}/icon/${pkg}.png`,
+ repoUrl: indexUrl,
+ sourceNames: readSourceNames(raw.sources),
+ });
+ }
+ return extensions;
+}
+
+export interface FetchRepoOptions {
+ fetchImpl?: typeof fetch;
+ signal?: AbortSignal;
+}
+
+/** Fetch and parse one repository index. */
+export async function fetchRepoIndex(
+ indexUrl: string,
+ options: FetchRepoOptions = {},
+): Promise {
+ if (!isValidRepoUrl(indexUrl)) {
+ throw new Error(`Not a valid repository index URL: ${indexUrl}`);
+ }
+ const fetchImpl = options.fetchImpl ?? fetch;
+ const response = await fetchImpl(indexUrl.trim(), {
+ headers: { Accept: 'application/json' },
+ ...(options.signal ? { signal: options.signal } : {}),
+ });
+ if (!response.ok) {
+ throw new Error(`Repository returned ${response.status} for ${indexUrl}`);
+ }
+ return parseRepoIndex(indexUrl, await response.json());
+}
+
+export interface RepoFetchFailure {
+ repoUrl: string;
+ error: string;
+}
+
+export interface RepoCatalogue {
+ extensions: RepoExtension[];
+ failures: RepoFetchFailure[];
+}
+
+/**
+ * Fetch every configured repository.
+ *
+ * When two repos publish the same package, the higher version code wins, so a
+ * user's preferred repo ordering does not silently pin an older build.
+ */
+export async function fetchRepoCatalogue(
+ indexUrls: string[],
+ options: FetchRepoOptions = {},
+): Promise {
+ const failures: RepoFetchFailure[] = [];
+ const byPackage = new Map();
+
+ const results = await Promise.all(
+ indexUrls.map(async (indexUrl) => {
+ try {
+ return { indexUrl, extensions: await fetchRepoIndex(indexUrl, options) };
+ } catch (error) {
+ failures.push({
+ repoUrl: indexUrl,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return { indexUrl, extensions: [] as RepoExtension[] };
+ }
+ }),
+ );
+
+ for (const { extensions } of results) {
+ for (const extension of extensions) {
+ const existing = byPackage.get(extension.pkg);
+ if (!existing || extension.versionCode > existing.versionCode) {
+ byPackage.set(extension.pkg, extension);
+ }
+ }
+ }
+
+ return {
+ extensions: [...byPackage.values()].sort((a, b) => a.name.localeCompare(b.name)),
+ failures,
+ };
+}
+
+/** File name an extension is stored under, so updates replace in place. */
+export function extensionFileName(pkg: string): string {
+ return `${pkg}.apk`;
+}
diff --git a/src/anime-bridge/extension-store.test.ts b/src/anime-bridge/extension-store.test.ts
new file mode 100644
index 00000000..36dd424c
--- /dev/null
+++ b/src/anime-bridge/extension-store.test.ts
@@ -0,0 +1,146 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import {
+ listExtensionSources,
+ readInstalledExtensions,
+ toBridgeSource,
+ toInstalledExtensionViews,
+ type ExtensionSource,
+ type InstalledExtension,
+} from './extension-store';
+import type { AnimeBridgeClient } from './bridge-client';
+
+async function makeExtensionDir(files: Record): Promise {
+ const dir = await mkdtemp(path.join(tmpdir(), 'subminer-ext-'));
+ for (const [name, contents] of Object.entries(files)) {
+ await writeFile(path.join(dir, name), contents);
+ }
+ return dir;
+}
+
+function fakeClient(
+ impl: (source: { apkBase64: string }) => Promise,
+): AnimeBridgeClient {
+ return { listAnimeSources: impl } as unknown as AnimeBridgeClient;
+}
+
+test('readInstalledExtensions reads apks and base64-encodes them', async () => {
+ const dir = await makeExtensionDir({ 'my-source.apk': 'APK-BYTES' });
+ const extensions = await readInstalledExtensions(dir);
+
+ assert.equal(extensions.length, 1);
+ assert.equal(extensions[0]?.fallbackName, 'my-source');
+ assert.equal(Buffer.from(extensions[0]!.apkBase64, 'base64').toString(), 'APK-BYTES');
+});
+
+test('readInstalledExtensions ignores non-apk files and subdirectories', async () => {
+ const dir = await makeExtensionDir({ 'a.apk': 'A', 'notes.txt': 'x', 'b.APK': 'B' });
+ await mkdir(path.join(dir, 'nested.apk'), { recursive: true });
+
+ const names = (await readInstalledExtensions(dir)).map((e) => e.fallbackName);
+ // Sorted, case-insensitive extension match, directories excluded.
+ assert.deepEqual(names, ['a', 'b']);
+});
+
+test('readInstalledExtensions returns empty for a missing directory', async () => {
+ assert.deepEqual(await readInstalledExtensions('/nonexistent/subminer/extensions'), []);
+});
+
+test('toBridgeSource includes sourceId only when selecting inside a factory apk', () => {
+ const extension: InstalledExtension = {
+ file: '/x/a.apk',
+ fallbackName: 'a',
+ apkBase64: 'QQ==',
+ };
+ assert.deepEqual(toBridgeSource(extension), { apkBase64: 'QQ==' });
+ assert.deepEqual(toBridgeSource(extension, 'src-1'), {
+ apkBase64: 'QQ==',
+ sourceId: 'src-1',
+ });
+});
+
+test('listExtensionSources flattens every source a factory apk provides', async () => {
+ const extensions: InstalledExtension[] = [
+ { file: '/x/multi.apk', fallbackName: 'multi', apkBase64: 'QQ==' },
+ ];
+ const client = fakeClient(async () => [
+ { id: 101, name: 'Source One', lang: 'en' },
+ { id: '102', name: 'Source Two', lang: 'ja' },
+ ]);
+
+ const sources = await listExtensionSources(client, extensions);
+
+ assert.equal(sources.length, 2);
+ // Numeric ids are normalized to strings so they can key UI state.
+ assert.equal(sources[0]?.id, '101');
+ assert.equal(sources[0]?.name, 'Source One');
+ assert.equal(sources[1]?.lang, 'ja');
+});
+
+test('listExtensionSources falls back to the file name and a default language', async () => {
+ const extensions: InstalledExtension[] = [
+ { file: '/x/my-ext.apk', fallbackName: 'my-ext', apkBase64: 'QQ==' },
+ ];
+ const client = fakeClient(async () => [{ id: '1', name: ' ' }]);
+
+ const sources = await listExtensionSources(client, extensions);
+ assert.equal(sources[0]?.name, 'my-ext');
+ assert.equal(sources[0]?.lang, 'all');
+});
+
+test('listExtensionSources drops descriptors with no usable id', async () => {
+ const client = fakeClient(async () => [{ name: 'No Id' }, { id: '', name: 'Empty' }]);
+ const sources = await listExtensionSources(client, [
+ { file: '/x/a.apk', fallbackName: 'a', apkBase64: 'QQ==' },
+ ]);
+ assert.deepEqual(sources, []);
+});
+
+test('toInstalledExtensionViews names an extension after the sources it provides', () => {
+ const extensions: InstalledExtension[] = [
+ { file: '/x/multi.apk', fallbackName: 'multi', apkBase64: 'QQ==' },
+ ];
+ const sources: ExtensionSource[] = [
+ { id: '1', name: 'One', lang: 'en', pkg: 'multi', file: '/x/multi.apk' },
+ { id: '2', name: 'Two', lang: 'ja', pkg: 'multi', file: '/x/multi.apk' },
+ ];
+
+ assert.deepEqual(toInstalledExtensionViews(extensions, sources, []), [
+ { pkg: 'multi', name: 'One, Two', langs: ['en', 'ja'], sourceCount: 2, error: null },
+ ]);
+});
+
+test('toInstalledExtensionViews lists an extension that loaded nothing, with its reason', () => {
+ const extensions: InstalledExtension[] = [
+ { file: '/x/broken.apk', fallbackName: 'broken', apkBase64: 'QQ==' },
+ ];
+
+ // A broken APK is still installed, so it must stay listed and removable.
+ assert.deepEqual(
+ toInstalledExtensionViews(extensions, [], [{ pkg: 'broken', error: 'dex2jar failed' }]),
+ [{ pkg: 'broken', name: 'broken', langs: [], sourceCount: 0, error: 'dex2jar failed' }],
+ );
+});
+
+test('one broken extension does not hide the working ones', async () => {
+ const extensions: InstalledExtension[] = [
+ { file: '/x/broken.apk', fallbackName: 'broken', apkBase64: 'QQ==' },
+ { file: '/x/good.apk', fallbackName: 'good', apkBase64: 'Qg==' },
+ ];
+ const failures: string[] = [];
+ const client = fakeClient(async (source) => {
+ if (source.apkBase64 === 'QQ==') throw new Error('dex2jar failed');
+ return [{ id: '7', name: 'Good Source', lang: 'en' }];
+ });
+
+ const sources = await listExtensionSources(client, extensions, (extension) => {
+ failures.push(extension.fallbackName);
+ });
+
+ assert.deepEqual(failures, ['broken']);
+ assert.equal(sources.length, 1);
+ assert.equal(sources[0]?.name, 'Good Source');
+});
diff --git a/src/anime-bridge/extension-store.ts b/src/anime-bridge/extension-store.ts
new file mode 100644
index 00000000..e1fcc7f0
--- /dev/null
+++ b/src/anime-bridge/extension-store.ts
@@ -0,0 +1,117 @@
+import { readdir, readFile } from 'node:fs/promises';
+import path from 'node:path';
+import type { AnimeBridgeClient } from './bridge-client';
+import type { BridgeSource } from './bridge-client';
+import type { ExtensionLoadFailure, InstalledExtensionView } from '../types/anime-browser';
+
+/**
+ * Anime extensions are Aniyomi APKs the user supplies. They are read from a
+ * directory rather than fetched from a hardcoded catalogue, so which sources
+ * exist is entirely the user's choice.
+ */
+
+export interface InstalledExtension {
+ /** Absolute path to the .apk. */
+ file: string;
+ /** File name without extension, used when the bridge reports no name. */
+ fallbackName: string;
+ apkBase64: string;
+}
+
+export interface ExtensionSource {
+ /** Stable id: the bridge source id, which selects it inside a factory APK. */
+ id: string;
+ name: string;
+ lang: string;
+ pkg: string;
+ file: string;
+}
+
+/** Read every .apk in `directory`. A missing directory yields no extensions. */
+export async function readInstalledExtensions(directory: string): Promise {
+ let entries;
+ try {
+ entries = await readdir(directory, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+
+ const extensions: InstalledExtension[] = [];
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.apk')) continue;
+ const file = path.join(directory, entry.name);
+ const bytes = await readFile(file);
+ extensions.push({
+ file,
+ fallbackName: entry.name.replace(/\.apk$/i, ''),
+ apkBase64: bytes.toString('base64'),
+ });
+ }
+ return extensions;
+}
+
+/**
+ * Describe what is on disk, for the installed list in the Extensions tab.
+ *
+ * Built from the directory rather than from a repository catalogue: an APK
+ * dropped in by hand, or one whose repository the user has since removed, is
+ * still installed and must stay removable.
+ */
+export function toInstalledExtensionViews(
+ extensions: InstalledExtension[],
+ sources: ExtensionSource[],
+ loadFailures: ExtensionLoadFailure[],
+): InstalledExtensionView[] {
+ return extensions.map((extension) => {
+ const provided = sources.filter((source) => source.file === extension.file);
+ const names = [...new Set(provided.map((source) => source.name))];
+ return {
+ pkg: extension.fallbackName,
+ name: names.length > 0 ? names.join(', ') : extension.fallbackName,
+ langs: [...new Set(provided.map((source) => source.lang))],
+ sourceCount: provided.length,
+ error: loadFailures.find((failure) => failure.pkg === extension.fallbackName)?.error ?? null,
+ };
+ });
+}
+
+/** The bridge payload for a specific source inside an extension. */
+export function toBridgeSource(extension: InstalledExtension, sourceId?: string): BridgeSource {
+ return { apkBase64: extension.apkBase64, ...(sourceId ? { sourceId } : {}) };
+}
+
+/**
+ * Ask the bridge which sources each extension provides.
+ *
+ * An extension that fails to load is skipped rather than aborting the scan, so
+ * one broken APK cannot hide every working one. Failures are reported through
+ * `onError` for surfacing in the UI.
+ */
+export async function listExtensionSources(
+ client: AnimeBridgeClient,
+ extensions: InstalledExtension[],
+ onError?: (extension: InstalledExtension, error: unknown) => void,
+): Promise {
+ const sources: ExtensionSource[] = [];
+
+ for (const extension of extensions) {
+ try {
+ const descriptors = await client.listAnimeSources(toBridgeSource(extension));
+ for (const descriptor of descriptors) {
+ const id = descriptor.id === undefined ? null : String(descriptor.id);
+ if (id === null || id.length === 0) continue;
+ sources.push({
+ id,
+ name: descriptor.name?.trim() || extension.fallbackName,
+ lang: descriptor.lang ?? 'all',
+ pkg: extension.fallbackName,
+ file: extension.file,
+ });
+ }
+ } catch (error) {
+ onError?.(extension, error);
+ }
+ }
+
+ return sources;
+}
diff --git a/src/anime-bridge/headers.test.ts b/src/anime-bridge/headers.test.ts
new file mode 100644
index 00000000..8528d3ff
--- /dev/null
+++ b/src/anime-bridge/headers.test.ts
@@ -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, '');
+});
diff --git a/src/anime-bridge/headers.ts b/src/anime-bridge/headers.ts
new file mode 100644
index 00000000..480b98e1
--- /dev/null
+++ b/src/anime-bridge/headers.ts
@@ -0,0 +1,54 @@
+import type { BridgeVideo, OkHttpHeaders, ResolvedStream } from './types';
+
+/**
+ * Flatten OkHttp's alternating `[name, value, name, value]` array into a map.
+ * A trailing name with no value is dropped rather than mapped to undefined.
+ */
+export function parseOkHttpHeaders(headers: OkHttpHeaders | undefined): Record {
+ const flat = headers?.['namesAndValues$okhttp'];
+ if (!Array.isArray(flat)) return {};
+
+ const parsed: Record = {};
+ for (let i = 0; i + 1 < flat.length; i += 2) {
+ const name = flat[i];
+ const value = flat[i + 1];
+ if (typeof name === 'string' && typeof value === 'string') parsed[name] = value;
+ }
+ return parsed;
+}
+
+/**
+ * Render headers as mpv's `--http-header-fields` string list. mpv splits
+ * entries on commas, so commas inside a value must be escaped.
+ */
+export function toMpvHeaderFields(headers: Record): string {
+ return Object.entries(headers)
+ .map(([name, value]) => `${name}: ${value.replace(/,/g, '\\,')}`)
+ .join(',');
+}
+
+function normalizeTracks(
+ tracks: Array<{ url?: string; lang?: string }> | undefined,
+): Array<{ url: string; lang: string }> {
+ if (!Array.isArray(tracks)) return [];
+ return tracks
+ .filter((track): track is { url: string; lang?: string } => typeof track.url === 'string')
+ .map((track) => ({ url: track.url, lang: track.lang ?? '' }));
+}
+
+/**
+ * Normalize a bridge video into a playable stream. Returns null when the
+ * extension produced no `videoUrl`, which happens for entries it failed to
+ * resolve.
+ */
+export function resolveStream(video: BridgeVideo): ResolvedStream | null {
+ if (typeof video.videoUrl !== 'string' || video.videoUrl.length === 0) return null;
+
+ return {
+ url: video.videoUrl,
+ quality: video.quality ?? '',
+ headers: parseOkHttpHeaders(video.headers),
+ subtitles: normalizeTracks(video.subtitleTracks),
+ audios: normalizeTracks(video.audioTracks),
+ };
+}
diff --git a/src/anime-bridge/media-url.test.ts b/src/anime-bridge/media-url.test.ts
new file mode 100644
index 00000000..48e286ad
--- /dev/null
+++ b/src/anime-bridge/media-url.test.ts
@@ -0,0 +1,69 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { parseAnimeStatus, resolveBridgeMediaUrl } from './media-url';
+
+const BRIDGE = 'http://127.0.0.1:56037';
+
+test('a loopback proxy url is rebased onto the live bridge port', () => {
+ assert.equal(
+ resolveBridgeMediaUrl(BRIDGE, 'http://127.0.0.1:8080/image/cover-uuid'),
+ 'http://127.0.0.1:56037/image/cover-uuid',
+ );
+ assert.equal(
+ resolveBridgeMediaUrl(BRIDGE, 'http://localhost:8080/video/master-token'),
+ 'http://127.0.0.1:56037/video/master-token',
+ );
+});
+
+test('query strings and fragments survive rebasing', () => {
+ assert.equal(
+ resolveBridgeMediaUrl(BRIDGE, 'http://127.0.0.1:8080/video/token?quality=1080#t=30'),
+ 'http://127.0.0.1:56037/video/token?quality=1080#t=30',
+ );
+});
+
+test('ipv6 loopback is recognised', () => {
+ assert.equal(
+ resolveBridgeMediaUrl(BRIDGE, 'http://[::1]:8080/image/cover'),
+ 'http://127.0.0.1:56037/image/cover',
+ );
+});
+
+test('remote urls are left untouched', () => {
+ const remote = 'https://cdn.example.com/covers/1.jpg';
+ assert.equal(resolveBridgeMediaUrl(BRIDGE, remote), remote);
+});
+
+test('loopback urls outside the proxy routes are left untouched', () => {
+ // Only /image and /video are proxy routes; /capabilities is the server's own API.
+ const other = 'http://127.0.0.1:8080/capabilities';
+ assert.equal(resolveBridgeMediaUrl(BRIDGE, other), other);
+});
+
+test('a base url without a scheme is assumed to be http', () => {
+ assert.equal(
+ resolveBridgeMediaUrl('127.0.0.1:56037', 'http://127.0.0.1:8080/image/cover'),
+ 'http://127.0.0.1:56037/image/cover',
+ );
+});
+
+test('unparseable input is returned unchanged rather than throwing', () => {
+ assert.equal(resolveBridgeMediaUrl(BRIDGE, 'not a url'), 'not a url');
+ assert.equal(
+ resolveBridgeMediaUrl('', 'http://127.0.0.1:8080/image/c'),
+ 'http://127.0.0.1:8080/image/c',
+ );
+ assert.equal(resolveBridgeMediaUrl(BRIDGE, ''), '');
+});
+
+test('parseAnimeStatus maps the SAnime constants', () => {
+ assert.equal(parseAnimeStatus(1), 'ongoing');
+ assert.equal(parseAnimeStatus(2), 'completed');
+ assert.equal(parseAnimeStatus(4), 'publishing-finished');
+ assert.equal(parseAnimeStatus(5), 'cancelled');
+ assert.equal(parseAnimeStatus(6), 'on-hiatus');
+ assert.equal(parseAnimeStatus(0), 'unknown');
+ assert.equal(parseAnimeStatus(undefined), 'unknown');
+ // 3 is unused in the SAnime constants.
+ assert.equal(parseAnimeStatus(3), 'unknown');
+});
diff --git a/src/anime-bridge/media-url.ts b/src/anime-bridge/media-url.ts
new file mode 100644
index 00000000..bf6f1846
--- /dev/null
+++ b/src/anime-bridge/media-url.ts
@@ -0,0 +1,74 @@
+/**
+ * The bridge returns cover art and video URLs pointing at its own loopback
+ * media proxy, but the origin it embeds is not always the port we actually
+ * started it on. Rebase those onto the live bridge origin, and leave any
+ * genuinely remote URL untouched.
+ */
+
+const PROXY_ROUTES = new Set(['image', 'video']);
+const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
+
+function isLoopbackProxyUrl(candidate: URL): boolean {
+ if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:') return false;
+ const host = candidate.hostname.toLowerCase();
+ if (!LOOPBACK_HOSTS.has(host)) return false;
+ const route = candidate.pathname.split('/').filter(Boolean)[0];
+ return route !== undefined && PROXY_ROUTES.has(route);
+}
+
+/**
+ * Rewrite a bridge media URL onto `bridgeBaseUrl`, preserving path and query.
+ * Returns the input unchanged when it is not a loopback proxy URL, or when
+ * either URL cannot be parsed.
+ */
+export function resolveBridgeMediaUrl(bridgeBaseUrl: string, mediaUrl: string): string {
+ let media: URL;
+ try {
+ media = new URL(mediaUrl);
+ } catch {
+ return mediaUrl;
+ }
+ if (!isLoopbackProxyUrl(media)) return mediaUrl;
+
+ const normalizedBase = bridgeBaseUrl.includes('://') ? bridgeBaseUrl : `http://${bridgeBaseUrl}`;
+ let base: URL;
+ try {
+ base = new URL(normalizedBase);
+ } catch {
+ return mediaUrl;
+ }
+ if (base.protocol !== 'http:' && base.protocol !== 'https:') return mediaUrl;
+ if (base.hostname.length === 0) return mediaUrl;
+
+ const rebased = new URL(base.origin);
+ rebased.pathname = media.pathname;
+ rebased.search = media.search;
+ rebased.hash = media.hash;
+ return rebased.toString();
+}
+
+/** Aniyomi's SAnime status constants. */
+export type AnimeStatus =
+ | 'unknown'
+ | 'ongoing'
+ | 'completed'
+ | 'publishing-finished'
+ | 'cancelled'
+ | 'on-hiatus';
+
+export function parseAnimeStatus(status: number | undefined): AnimeStatus {
+ switch (status) {
+ case 1:
+ return 'ongoing';
+ case 2:
+ return 'completed';
+ case 4:
+ return 'publishing-finished';
+ case 5:
+ return 'cancelled';
+ case 6:
+ return 'on-hiatus';
+ default:
+ return 'unknown';
+ }
+}
diff --git a/src/anime-bridge/mpv-playback.test.ts b/src/anime-bridge/mpv-playback.test.ts
new file mode 100644
index 00000000..51cc8352
--- /dev/null
+++ b/src/anime-bridge/mpv-playback.test.ts
@@ -0,0 +1,220 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ buildLoadfileOptions,
+ buildPlaybackCommands,
+ buildTrackCommands,
+ normalizeLangTag,
+ selectPreferredStream,
+} from './mpv-playback';
+import type { ResolvedStream } from './types';
+
+function stream(overrides: Partial = {}): ResolvedStream {
+ return {
+ url: 'http://127.0.0.1:8080/video/token',
+ quality: '1080p',
+ headers: {},
+ subtitles: [],
+ audios: [],
+ ...overrides,
+ };
+}
+
+test('loadfile options keep one visible track and never scan the filesystem', () => {
+ const options = buildLoadfileOptions({ stream: stream() });
+ for (const expected of [
+ 'sub-auto=no',
+ 'secondary-sid=no',
+ 'secondary-sub-visibility=no',
+ 'sub-visibility=yes',
+ ]) {
+ assert.ok(options.split(',').includes(expected), `missing ${expected}`);
+ }
+ // The source's own subtitles are the only ones this path gets, so they must
+ // not be suppressed the way the Jellyfin path suppresses them.
+ assert.ok(!options.split(',').includes('sid=no'));
+ // Comma-separated values would split the option list, so the language
+ // preferences ride as properties instead.
+ assert.ok(!options.includes('alang'));
+ assert.ok(!options.includes('slang'));
+});
+
+test('headers are percent-escaped so their commas do not split the option list', () => {
+ const headers = { Referer: 'https://a.test/', 'User-Agent': 'X' };
+ const options = buildLoadfileOptions({ stream: stream({ headers }) });
+
+ // Verified against mpv 0.41: the unescaped form yields an empty header list.
+ const fields = 'Referer: https://a.test/,User-Agent: X';
+ assert.ok(options.includes(`http-header-fields=%${fields.length}%${fields}`));
+});
+
+test('the escape length counts the full header string including separators', () => {
+ const options = buildLoadfileOptions({
+ stream: stream({ headers: { Cookie: 'a=1, b=2' } }),
+ });
+ // The comma inside the value is backslash-escaped first, so the length grows.
+ const fields = 'Cookie: a=1\\, b=2';
+ assert.ok(options.includes(`%${fields.length}%${fields}`));
+});
+
+test('no header option is emitted when the stream carries no headers', () => {
+ const options = buildLoadfileOptions({ stream: stream() });
+ assert.ok(!options.includes('http-header-fields'));
+});
+
+test('a positive start position is appended, zero is omitted', () => {
+ assert.ok(buildLoadfileOptions({ stream: stream(), startSeconds: 42 }).includes('start=42'));
+ assert.ok(!buildLoadfileOptions({ stream: stream(), startSeconds: 0 }).includes('start='));
+ assert.ok(!buildLoadfileOptions({ stream: stream() }).includes('start='));
+});
+
+test('playback commands set the language preference before loading the file', () => {
+ const commands = buildPlaybackCommands({ stream: stream(), title: 'Example - 01' });
+
+ assert.deepEqual(commands[0], ['script-message', 'subminer-managed-subtitles-loading']);
+ // Japanese first, so a multi-audio stream never starts on the dub. slang is
+ // Japanese-only: an English track belongs in the secondary slot, which the
+ // secondarySub auto-load fills by language tag.
+ assert.deepEqual(commands[1], ['set_property', 'alang', 'ja,jpn,jp,japanese']);
+ assert.deepEqual(commands[2], ['set_property', 'slang', 'ja,jpn,jp,japanese']);
+ assert.equal(commands[3]?.[0], 'loadfile');
+ assert.equal(commands[3]?.[1], 'http://127.0.0.1:8080/video/token');
+ assert.equal(commands[3]?.[2], 'replace');
+ assert.equal(commands[3]?.[3], -1);
+ assert.deepEqual(commands[4], ['set_property', 'force-media-title', 'Example - 01']);
+});
+
+test('force-media-title is skipped when there is no title', () => {
+ assert.equal(buildPlaybackCommands({ stream: stream() }).length, 4);
+ assert.equal(buildPlaybackCommands({ stream: stream(), title: '' }).length, 4);
+});
+
+test('external audio tracks are added, with the Japanese one selected', () => {
+ const commands = buildTrackCommands(
+ stream({
+ audios: [
+ { url: 'http://host/en.m4a', lang: 'en' },
+ { url: 'http://host/ja.m4a', lang: 'ja' },
+ ],
+ }),
+ );
+
+ assert.deepEqual(commands, [
+ ['audio-add', 'http://host/en.m4a', 'auto', 'en', 'en'],
+ ['audio-add', 'http://host/ja.m4a', 'select', 'ja', 'ja'],
+ ]);
+});
+
+test('external audio is left unselected when none of it is Japanese', () => {
+ // alang already picked a track off the container; do not override it.
+ const commands = buildTrackCommands(
+ stream({ audios: [{ url: 'http://host/en.m4a', lang: 'eng' }] }),
+ );
+
+ assert.deepEqual(commands, [['audio-add', 'http://host/en.m4a', 'auto', 'eng', 'en']]);
+});
+
+test('only a Japanese subtitle track is selected as primary', () => {
+ const japanese = buildTrackCommands(
+ stream({
+ subtitles: [
+ { url: 'http://host/en.vtt', lang: 'English' },
+ { url: 'http://host/ja.vtt', lang: 'Japanese' },
+ ],
+ }),
+ );
+ assert.deepEqual(japanese[1], ['sub-add', 'http://host/ja.vtt', 'select', 'Japanese', 'ja']);
+ assert.equal(japanese[0]?.[2], 'auto');
+
+ // English is the user's *secondary* language; it must not take the primary
+ // slot. It rides in unselected, tagged so the secondarySub auto-load can
+ // route it to secondary-sid.
+ const englishOnly = buildTrackCommands(
+ stream({ subtitles: [{ url: 'http://host/en.vtt', lang: 'English' }] }),
+ );
+ assert.deepEqual(englishOnly, [['sub-add', 'http://host/en.vtt', 'auto', 'English', 'en']]);
+});
+
+test('language labels normalize to the tags users configure', () => {
+ assert.equal(normalizeLangTag('English'), 'en');
+ assert.equal(normalizeLangTag('eng'), 'en');
+ assert.equal(normalizeLangTag('en-US'), 'en');
+ assert.equal(normalizeLangTag('Japanese'), 'ja');
+ assert.equal(normalizeLangTag('jpn'), 'ja');
+ assert.equal(normalizeLangTag('Português'), 'pt');
+ // Unknown labels pass through untouched rather than being guessed at.
+ assert.equal(normalizeLangTag('Klingon'), 'Klingon');
+ assert.equal(normalizeLangTag(''), '');
+});
+
+test('unlabelled tracks still get a usable menu title, duplicates are dropped', () => {
+ const commands = buildTrackCommands(
+ stream({
+ subtitles: [
+ { url: 'http://host/a.vtt', lang: '' },
+ { url: 'http://host/a.vtt', lang: '' },
+ { url: 'http://host/b.vtt', lang: '' },
+ ],
+ }),
+ );
+
+ assert.deepEqual(commands, [
+ ['sub-add', 'http://host/a.vtt', 'auto', 'Subtitle 1', ''],
+ ['sub-add', 'http://host/b.vtt', 'auto', 'Subtitle 2', ''],
+ ]);
+});
+
+test('a stream with no external tracks emits no track commands', () => {
+ assert.deepEqual(buildTrackCommands(stream()), []);
+});
+
+test('selectPreferredStream skips dub entries in favour of the original audio', () => {
+ const streams = [
+ stream({ quality: '1080p (Dub)' }),
+ stream({ quality: '720p (Sub)' }),
+ stream({ quality: '480p (Dub)' }),
+ ];
+
+ // Language beats the quality hint: a 1080p dub is the wrong file, not a
+ // better one.
+ assert.equal(selectPreferredStream(streams)?.quality, '720p (Sub)');
+ assert.equal(selectPreferredStream(streams, '1080')?.quality, '720p (Sub)');
+});
+
+test('selectPreferredStream prefers an entry carrying a Japanese audio track', () => {
+ const streams = [
+ stream({ quality: '1080p' }),
+ stream({ quality: '720p', audios: [{ url: 'http://host/ja.m4a', lang: 'ja' }] }),
+ ];
+
+ assert.equal(selectPreferredStream(streams)?.quality, '720p');
+});
+
+test('an all-dub list still plays rather than failing', () => {
+ const streams = [stream({ quality: '1080p Dub' }), stream({ quality: '720p Dub' })];
+
+ assert.equal(selectPreferredStream(streams)?.quality, '1080p Dub');
+ assert.equal(selectPreferredStream(streams, '720')?.quality, '720p Dub');
+});
+
+test('selectPreferredStream honours a quality hint, else takes the first', () => {
+ const streams = [stream({ quality: '360p' }), stream({ quality: '1080p' })];
+
+ assert.equal(selectPreferredStream(streams, '1080')?.quality, '1080p');
+ assert.equal(selectPreferredStream(streams, '1080P')?.quality, '1080p');
+ // Extensions label streams with the host name, so the hint matches a substring.
+ const decorated = [
+ stream({ quality: 'Doodstream - 360p' }),
+ stream({ quality: 'Vidhide - 720p' }),
+ ];
+ assert.equal(selectPreferredStream(decorated, '720')?.quality, 'Vidhide - 720p');
+ // Extensions pre-sort by their own preference, so the first entry wins.
+ assert.equal(selectPreferredStream(streams)?.quality, '360p');
+ // A hint that matches nothing falls back rather than failing.
+ assert.equal(selectPreferredStream(streams, '4k')?.quality, '360p');
+});
+
+test('selectPreferredStream returns null for an empty list', () => {
+ assert.equal(selectPreferredStream([]), null);
+ assert.equal(selectPreferredStream([], '1080p'), null);
+});
diff --git a/src/anime-bridge/mpv-playback.ts b/src/anime-bridge/mpv-playback.ts
new file mode 100644
index 00000000..3fa05e81
--- /dev/null
+++ b/src/anime-bridge/mpv-playback.ts
@@ -0,0 +1,234 @@
+import { toMpvHeaderFields } from './headers';
+import type { ResolvedStream } from './types';
+
+/**
+ * Japanese first, always, and for subtitles Japanese *only*: the primary slot
+ * belongs to the language being mined, and an English track belongs in the
+ * secondary slot, where the `secondarySub` machinery puts it by language tag.
+ * For audio, mpv falls back to the first track when nothing matches, so an
+ * English-only release still plays.
+ */
+export const JAPANESE_LANGUAGE_PREFERENCE = 'ja,jpn,jp,japanese';
+
+/**
+ * mpv must not scan the filesystem for sidecar subtitles when the "file" is a
+ * network stream, and the secondary slot stays empty so the overlay only ever
+ * reads one track. Everything else is left to normal track selection, driven
+ * by the language preferences above.
+ *
+ * `alang`/`slang` are set as properties instead of file-local options: their
+ * values are comma-separated lists, and a comma inside a `loadfile` option
+ * value splits the option list.
+ */
+const BASE_LOADFILE_OPTIONS = [
+ 'sub-auto=no',
+ 'secondary-sid=no',
+ 'secondary-sub-visibility=no',
+ 'sub-visibility=yes',
+];
+
+/** Matches a language tag or a label such as "Japanese (Sub)" or "[JPN]". */
+const JAPANESE_PATTERN = /(^|[^a-z])(ja|jp|jpn|japanese|日本語)([^a-z]|$)/i;
+/** Extensions label dub entries in the quality string, e.g. "1080p (Dub)". */
+const DUB_PATTERN = /(^|[^a-z])(dub|dubbed|dublado|latino|castellano)([^a-z]|$)/i;
+/** The counterpart label for original-audio entries, e.g. "SUB - 1080p". */
+const SUBBED_PATTERN = /(^|[^a-z])(sub|subbed|softsub|hardsub|subtitulado|raw)([^a-z]|$)/i;
+
+export type MpvCommand = Array;
+
+export interface BuildPlaybackOptions {
+ stream: ResolvedStream;
+ /** Shown as the mpv window/OSD title. */
+ title?: string;
+ /** Resume position in seconds. */
+ startSeconds?: number;
+}
+
+export function isJapaneseTag(value: string): boolean {
+ return JAPANESE_PATTERN.test(value);
+}
+
+/**
+ * Build the mpv `loadfile` option string for a stream.
+ *
+ * Headers ride as `file-local-options/http-header-fields` so they apply to this
+ * file only, and so SubMiner's Anki media path can read them back off mpv when
+ * generating card audio and screenshots. Tracks added later with `sub-add` /
+ * `audio-add` inherit them too, which is how external tracks on an
+ * authenticated host stay reachable.
+ */
+export function buildLoadfileOptions(options: BuildPlaybackOptions): string {
+ const parts = [...BASE_LOADFILE_OPTIONS];
+
+ const headerFields = toMpvHeaderFields(options.stream.headers);
+ if (headerFields.length > 0) {
+ // Escape the mpv option-list separators so a header never splits the list.
+ parts.push(`http-header-fields=${escapeOptionValue(headerFields)}`);
+ }
+
+ if (options.startSeconds !== undefined && options.startSeconds > 0) {
+ parts.push(`start=${options.startSeconds}`);
+ }
+
+ return parts.join(',');
+}
+
+/**
+ * mpv splits `loadfile` options on commas and `=`-separates keys, so a value
+ * containing either must be quoted. Percent-encoding is mpv's own escape for
+ * embedded separators in option values.
+ */
+function escapeOptionValue(value: string): string {
+ return `%${value.length}%${value}`;
+}
+
+/**
+ * Ordered mpv commands that start playback of a resolved stream.
+ *
+ * The plugin is told subtitles are being managed before the file loads, so the
+ * overlay does not flash the source's own tracks during the swap.
+ */
+export function buildPlaybackCommands(options: BuildPlaybackOptions): MpvCommand[] {
+ const commands: MpvCommand[] = [
+ ['script-message', 'subminer-managed-subtitles-loading'],
+ ['set_property', 'alang', JAPANESE_LANGUAGE_PREFERENCE],
+ ['set_property', 'slang', JAPANESE_LANGUAGE_PREFERENCE],
+ ['loadfile', options.stream.url, 'replace', -1, buildLoadfileOptions(options)],
+ ];
+
+ if (options.title !== undefined && options.title.length > 0) {
+ commands.push(['set_property', 'force-media-title', options.title]);
+ }
+
+ return commands;
+}
+
+/**
+ * Commands that attach the extension's external audio and subtitle tracks.
+ *
+ * These must be sent *after* the file is loading, so they are separate from
+ * {@link buildPlaybackCommands}. Every track is added — even the ones we do not
+ * select — so they show up in mpv's track menu and can be switched by hand.
+ */
+export function buildTrackCommands(stream: ResolvedStream): MpvCommand[] {
+ return [
+ ...buildAddTrackCommands('audio-add', stream.audios, 'Audio'),
+ ...buildAddTrackCommands('sub-add', stream.subtitles, 'Subtitle'),
+ ];
+}
+
+/**
+ * Only a Japanese track is ever selected outright — the primary slot is for
+ * the mining language. A non-Japanese track is added unselected: for audio,
+ * `alang`'s pick off the container stands; for subtitles, the `secondarySub`
+ * auto-load matches the track's language tag against the user's configured
+ * secondary languages and routes it to `secondary-sid` instead.
+ */
+function buildAddTrackCommands(
+ command: 'audio-add' | 'sub-add',
+ tracks: Array<{ url: string; lang: string }>,
+ kind: 'Audio' | 'Subtitle',
+): MpvCommand[] {
+ const unique = dedupeByUrl(tracks);
+ const selected = unique.findIndex((track) => isJapaneseTag(track.lang));
+
+ return unique.map((track, index) => [
+ command,
+ track.url,
+ index === selected ? 'select' : 'auto',
+ track.lang || `${kind} ${index + 1}`,
+ normalizeLangTag(track.lang),
+ ]);
+}
+
+/** Extension language labels mapped to the tags users put in config. */
+const LANG_TAG_BY_LABEL: Record = {
+ japanese: 'ja',
+ 日本語: 'ja',
+ english: 'en',
+ eng: 'en',
+ spanish: 'es',
+ español: 'es',
+ portuguese: 'pt',
+ português: 'pt',
+ french: 'fr',
+ français: 'fr',
+ german: 'de',
+ deutsch: 'de',
+ italian: 'it',
+ italiano: 'it',
+ indonesian: 'id',
+ arabic: 'ar',
+ russian: 'ru',
+ korean: 'ko',
+ chinese: 'zh',
+ thai: 'th',
+ vietnamese: 'vi',
+};
+
+/**
+ * mpv's `lang` field is what SubMiner's secondary-subtitle auto-load compares
+ * against `secondarySub.secondarySubLanguages`, so a label like "English" must
+ * become the tag a user would actually configure. Unknown labels pass through;
+ * matching is best-effort, and the raw label stays visible as the track title.
+ */
+export function normalizeLangTag(lang: string): string {
+ const trimmed = lang.trim();
+ if (isJapaneseTag(trimmed)) return 'ja';
+ const mapped = LANG_TAG_BY_LABEL[trimmed.toLowerCase()];
+ if (mapped !== undefined) return mapped;
+ if (/^[A-Za-z]{2,3}([-_][A-Za-z0-9]+)?$/.test(trimmed)) {
+ return trimmed.split(/[-_]/, 1)[0]?.toLowerCase() ?? trimmed.toLowerCase();
+ }
+ return trimmed;
+}
+
+function dedupeByUrl(
+ tracks: Array<{ url: string; lang: string }>,
+): Array<{ url: string; lang: string }> {
+ const seen = new Set();
+ return tracks.filter((track) => {
+ if (track.url.length === 0 || seen.has(track.url)) return false;
+ seen.add(track.url);
+ return true;
+ });
+}
+
+/**
+ * Rank a stream by how likely it is to carry Japanese audio.
+ *
+ * Sources commonly return the dub and the original as separate entries rather
+ * than as two audio tracks of one entry, so the choice of *entry* is the first
+ * place a dub can slip in.
+ */
+function scoreStream(stream: ResolvedStream): number {
+ if (stream.audios.some((audio) => isJapaneseTag(audio.lang))) return 2;
+ const label = stream.quality;
+ if (isJapaneseTag(label) || SUBBED_PATTERN.test(label)) return 1;
+ if (DUB_PATTERN.test(label)) return -1;
+ return 0;
+}
+
+/**
+ * Pick the best stream from an extension's video list.
+ *
+ * Japanese audio outranks the quality hint — a 1080p dub is the wrong file, not
+ * a better one. Within the surviving entries the hint decides, and otherwise
+ * the extension's own ordering does.
+ */
+export function selectPreferredStream(
+ streams: ResolvedStream[],
+ preferredQuality?: string,
+): ResolvedStream | null {
+ if (streams.length === 0) return null;
+
+ const best = Math.max(...streams.map(scoreStream));
+ const candidates = streams.filter((stream) => scoreStream(stream) === best);
+
+ if (preferredQuality !== undefined && preferredQuality.length > 0) {
+ const needle = preferredQuality.toLowerCase();
+ const match = candidates.find((stream) => stream.quality.toLowerCase().includes(needle));
+ if (match) return match;
+ }
+ return candidates[0] ?? null;
+}
diff --git a/src/anime-bridge/multi-source-search.test.ts b/src/anime-bridge/multi-source-search.test.ts
new file mode 100644
index 00000000..6dadadfd
--- /dev/null
+++ b/src/anime-bridge/multi-source-search.test.ts
@@ -0,0 +1,71 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { interleave, mapSourcesConcurrently } from './multi-source-search';
+
+const source = (id: string) => ({ id, name: `Source ${id}` });
+
+test('mapSourcesConcurrently returns results in source order, not completion order', async () => {
+ const sources = [source('a'), source('b'), source('c')];
+ const delays: Record = { a: 20, b: 0, c: 10 };
+
+ const { results, failures } = await mapSourcesConcurrently(sources, async (target) => {
+ await new Promise((resolve) => setTimeout(resolve, delays[target.id]));
+ return target.id;
+ });
+
+ assert.deepEqual(results, ['a', 'b', 'c']);
+ assert.deepEqual(failures, []);
+});
+
+test('a failing source is reported without losing the others', async () => {
+ const sources = [source('a'), source('b'), source('c')];
+
+ const { results, failures } = await mapSourcesConcurrently(sources, async (target) => {
+ if (target.id === 'b') throw new Error('login required');
+ return target.id;
+ });
+
+ assert.deepEqual(results, ['a', 'c']);
+ assert.deepEqual(failures, [{ sourceId: 'b', sourceName: 'Source b', error: 'login required' }]);
+});
+
+test('mapSourcesConcurrently never runs more than the concurrency limit at once', async () => {
+ const sources = ['a', 'b', 'c', 'd', 'e'].map(source);
+ let running = 0;
+ let peak = 0;
+
+ await mapSourcesConcurrently(
+ sources,
+ async () => {
+ running += 1;
+ peak = Math.max(peak, running);
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ running -= 1;
+ },
+ 2,
+ );
+
+ assert.equal(peak, 2);
+});
+
+test('mapSourcesConcurrently handles an empty source list', async () => {
+ const { results, failures } = await mapSourcesConcurrently([], async () => 'x');
+ assert.deepEqual(results, []);
+ assert.deepEqual(failures, []);
+});
+
+test('interleave takes one from each source before taking a second', () => {
+ assert.deepEqual(interleave([['a1', 'a2', 'a3'], ['b1'], ['c1', 'c2']]), [
+ 'a1',
+ 'b1',
+ 'c1',
+ 'a2',
+ 'c2',
+ 'a3',
+ ]);
+});
+
+test('interleave ignores empty groups', () => {
+ assert.deepEqual(interleave([[], ['b1', 'b2'], []]), ['b1', 'b2']);
+ assert.deepEqual(interleave([]), []);
+});
diff --git a/src/anime-bridge/multi-source-search.ts b/src/anime-bridge/multi-source-search.ts
new file mode 100644
index 00000000..2c915953
--- /dev/null
+++ b/src/anime-bridge/multi-source-search.ts
@@ -0,0 +1,85 @@
+import type { SourceSearchFailure } from '../types/anime-browser';
+
+/**
+ * Running one query against every installed source at once.
+ *
+ * Each source is a separate extension behind the same single-threaded bridge,
+ * so the fan-out is bounded rather than unleashed: a dozen extensions all
+ * uploading and searching at once starves the ones the user is waiting on.
+ */
+
+/** Enough to hide the latency of a slow source without queueing the bridge. */
+const DEFAULT_CONCURRENCY = 4;
+
+export interface SourceTarget {
+ id: string;
+ name: string;
+}
+
+export interface FanOutResult {
+ /** One entry per source that succeeded, in source order. */
+ results: T[];
+ /** One entry per source that threw, in source order. */
+ failures: SourceSearchFailure[];
+}
+
+/**
+ * Run `task` against every source, at most `concurrency` at a time.
+ *
+ * A source that throws becomes a failure instead of rejecting the whole call —
+ * one misconfigured extension must not hide every other source's results.
+ */
+export async function mapSourcesConcurrently(
+ sources: S[],
+ task: (source: S) => Promise,
+ concurrency: number = DEFAULT_CONCURRENCY,
+): Promise> {
+ // Slots keep the output in source order regardless of completion order, so
+ // the same query lays out the same way twice.
+ const results: Array<{ value: T } | null> = sources.map(() => null);
+ const failures: Array = sources.map(() => null);
+ let next = 0;
+
+ const worker = async (): Promise => {
+ for (;;) {
+ const index = next;
+ next += 1;
+ const source = sources[index];
+ if (!source) return;
+ try {
+ results[index] = { value: await task(source) };
+ } catch (error) {
+ failures[index] = {
+ sourceId: source.id,
+ sourceName: source.name,
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+ }
+ };
+
+ const workers = Math.max(1, Math.min(concurrency, sources.length));
+ await Promise.all(Array.from({ length: workers }, () => worker()));
+
+ return {
+ results: results
+ .filter((slot): slot is { value: T } => slot !== null)
+ .map((slot) => slot.value),
+ failures: failures.filter((slot): slot is SourceSearchFailure => slot !== null),
+ };
+}
+
+/**
+ * Round-robin merge, so the grid opens with one hit from each source rather
+ * than the whole of the first source before the second one starts.
+ */
+export function interleave(groups: T[][]): T[] {
+ const merged: T[] = [];
+ const longest = groups.reduce((max, group) => Math.max(max, group.length), 0);
+ for (let index = 0; index < longest; index += 1) {
+ for (const group of groups) {
+ if (index < group.length) merged.push(group[index] as T);
+ }
+ }
+ return merged;
+}
diff --git a/src/anime-bridge/preference-store.ts b/src/anime-bridge/preference-store.ts
new file mode 100644
index 00000000..30ca4d93
--- /dev/null
+++ b/src/anime-bridge/preference-store.ts
@@ -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 | null = null;
+
+ constructor(file: string) {
+ this.file = file;
+ }
+
+ private async load(): Promise> {
+ 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)
+ : {};
+ } catch {
+ // Missing or corrupt file starts empty rather than blocking the browser.
+ this.cache = {};
+ }
+ return this.cache;
+ }
+
+ async get(sourceId: string): Promise {
+ const all = await this.load();
+ return all[sourceId] ?? [];
+ }
+
+ async set(sourceId: string, preferences: BridgePreference[]): Promise {
+ 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 {
+ 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): Promise {
+ await mkdir(path.dirname(this.file), { recursive: true });
+ await writeFile(this.file, JSON.stringify(all, null, 2), { mode: 0o600 });
+ }
+}
diff --git a/src/anime-bridge/preferences.test.ts b/src/anime-bridge/preferences.test.ts
new file mode 100644
index 00000000..f62367a0
--- /dev/null
+++ b/src/anime-bridge/preferences.test.ts
@@ -0,0 +1,143 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ applyPreferenceValue,
+ isSecretPreference,
+ parsePreferences,
+ type SourcePreferenceView,
+} from './preferences';
+import type { BridgePreference } from './types';
+
+// Shapes taken from the real Jellyfin extension's preferencesAnime response.
+const RAW: BridgePreference[] = [
+ {
+ key: 'host_url',
+ editTextPreference: {
+ title: 'Address',
+ summary: 'The server address',
+ value: '',
+ text: '',
+ },
+ },
+ {
+ key: 'password',
+ editTextPreference: { title: 'Password', summary: 'The user account password', value: '' },
+ },
+ {
+ key: 'pref_quality',
+ listPreference: {
+ title: 'Preferred quality',
+ summary: 'Preferred quality.',
+ valueIndex: 0,
+ entries: ['Source', '20 Mbps'],
+ entryValues: ['source', '20000000'],
+ },
+ },
+ {
+ key: 'pref_episode_details_key',
+ multiSelectListPreference: {
+ title: 'Additional details for episodes',
+ values: [],
+ entries: ['Overview', 'Runtime'],
+ entryValues: ['overview', 'runtime'],
+ },
+ },
+ {
+ key: 'pref_trust_cert',
+ switchPreferenceCompat: { title: 'Trust certificate', value: false },
+ },
+];
+
+function view(views: SourcePreferenceView[], key: string): SourcePreferenceView {
+ const found = views.find((candidate) => candidate.key === key);
+ assert.ok(found, `missing preference ${key}`);
+ return found;
+}
+
+test('parsePreferences flattens each widget type', () => {
+ const views = parsePreferences(RAW);
+ assert.equal(views.length, 5);
+
+ assert.equal(view(views, 'host_url').kind, 'text');
+ assert.equal(view(views, 'host_url').title, 'Address');
+ assert.equal(view(views, 'host_url').value, '');
+
+ const quality = view(views, 'pref_quality');
+ assert.equal(quality.kind, 'list');
+ // valueIndex 0 resolves through entryValues, not entries.
+ assert.equal(quality.value, 'source');
+ assert.deepEqual(quality.entries, ['Source', '20 Mbps']);
+
+ assert.deepEqual(view(views, 'pref_episode_details_key').value, []);
+ assert.equal(view(views, 'pref_trust_cert').value, false);
+});
+
+test('parsePreferences skips the bridge context entry and unknown widgets', () => {
+ const views = parsePreferences([
+ { key: '__mangatan_bridge_context__', sourceId: '1' },
+ { key: 'mystery', someFutureWidget: { title: 'X' } },
+ ...RAW.slice(0, 1),
+ ]);
+ assert.deepEqual(
+ views.map((v) => v.key),
+ ['host_url'],
+ );
+});
+
+test('a list preference with no selection reads as empty', () => {
+ const views = parsePreferences([
+ {
+ key: 'library_pref',
+ listPreference: {
+ title: 'Select media library',
+ valueIndex: -1,
+ entries: [],
+ entryValues: [],
+ },
+ },
+ ]);
+ assert.equal(view(views, 'library_pref').value, '');
+});
+
+test('applyPreferenceValue writes text into both value and text', () => {
+ const updated = applyPreferenceValue(RAW, 'host_url', 'https://media.example');
+ const body = updated.find((e) => e.key === 'host_url')!.editTextPreference as Record<
+ string,
+ unknown
+ >;
+ assert.equal(body.value, 'https://media.example');
+ assert.equal(body.text, 'https://media.example');
+ // Other entries are untouched.
+ assert.equal(parsePreferences(updated).length, RAW.length);
+});
+
+test('applyPreferenceValue moves a list preference by entry value', () => {
+ const updated = applyPreferenceValue(RAW, 'pref_quality', '20000000');
+ const body = updated.find((e) => e.key === 'pref_quality')!.listPreference as Record<
+ string,
+ unknown
+ >;
+ assert.equal(body.valueIndex, 1);
+ assert.equal(parsePreferences(updated).find((v) => v.key === 'pref_quality')?.value, '20000000');
+});
+
+test('applyPreferenceValue handles multi-select and switch widgets', () => {
+ const multi = applyPreferenceValue(RAW, 'pref_episode_details_key', ['overview']);
+ assert.deepEqual(
+ parsePreferences(multi).find((v) => v.key === 'pref_episode_details_key')?.value,
+ ['overview'],
+ );
+
+ const toggled = applyPreferenceValue(RAW, 'pref_trust_cert', true);
+ assert.equal(parsePreferences(toggled).find((v) => v.key === 'pref_trust_cert')?.value, true);
+});
+
+test('applyPreferenceValue leaves unknown keys alone', () => {
+ assert.deepEqual(applyPreferenceValue(RAW, 'not-a-key', 'x'), RAW);
+});
+
+test('secrets are recognised by key or title', () => {
+ const views = parsePreferences(RAW);
+ assert.equal(isSecretPreference(view(views, 'password')), true);
+ assert.equal(isSecretPreference(view(views, 'host_url')), false);
+});
diff --git a/src/anime-bridge/preferences.ts b/src/anime-bridge/preferences.ts
new file mode 100644
index 00000000..444e7492
--- /dev/null
+++ b/src/anime-bridge/preferences.ts
@@ -0,0 +1,132 @@
+import type { BridgePreference } from './types';
+
+/**
+ * Extension preferences arrive as Android preference objects, one wrapper key
+ * per widget type. They are stored and sent back verbatim so the extension sees
+ * exactly the shape it produced; only the value field is edited.
+ */
+
+export type PreferenceKind = 'text' | 'list' | 'multi' | 'switch';
+
+/** A preference flattened for rendering. */
+export interface SourcePreferenceView {
+ key: string;
+ kind: PreferenceKind;
+ title: string;
+ summary: string | null;
+ /** Current value: string for text/list, string[] for multi, boolean for switch. */
+ value: string | string[] | boolean;
+ /** Display labels, parallel to entryValues. Empty for text/switch. */
+ entries: string[];
+ entryValues: string[];
+}
+
+const WIDGETS = {
+ editTextPreference: 'text',
+ listPreference: 'list',
+ multiSelectListPreference: 'multi',
+ switchPreferenceCompat: 'switch',
+ checkBoxPreference: 'switch',
+} as const satisfies Record;
+
+type WidgetName = keyof typeof WIDGETS;
+
+function widgetOf(
+ entry: BridgePreference,
+): { name: WidgetName; body: Record } | null {
+ for (const name of Object.keys(WIDGETS) as WidgetName[]) {
+ const body = entry[name];
+ if (body !== null && typeof body === 'object') {
+ return { name, body: body as Record };
+ }
+ }
+ return null;
+}
+
+function stringList(value: unknown): string[] {
+ return Array.isArray(value)
+ ? value.filter((item): item is string => typeof item === 'string')
+ : [];
+}
+
+/** Flatten bridge preference entries for display. Unknown widgets are skipped. */
+export function parsePreferences(raw: BridgePreference[]): SourcePreferenceView[] {
+ const views: SourcePreferenceView[] = [];
+
+ for (const entry of raw) {
+ if (typeof entry.key !== 'string' || entry.key.startsWith('__')) continue;
+ const widget = widgetOf(entry);
+ if (!widget) continue;
+
+ const { body } = widget;
+ const kind = WIDGETS[widget.name];
+ const entries = stringList(body.entries);
+ const entryValues = stringList(body.entryValues);
+
+ let value: string | string[] | boolean;
+ if (kind === 'multi') {
+ value = stringList(body.values);
+ } else if (kind === 'switch') {
+ value = body.value === true;
+ } else if (kind === 'list') {
+ const index = typeof body.valueIndex === 'number' ? body.valueIndex : -1;
+ // valueIndex is -1 when the extension has no selection yet.
+ value = index >= 0 && index < entryValues.length ? entryValues[index]! : '';
+ } else {
+ value = typeof body.value === 'string' ? body.value : '';
+ }
+
+ views.push({
+ key: entry.key,
+ kind,
+ title: typeof body.title === 'string' ? body.title : entry.key,
+ summary: typeof body.summary === 'string' ? body.summary : null,
+ value,
+ entries,
+ entryValues,
+ });
+ }
+
+ return views;
+}
+
+/**
+ * Return a copy of `raw` with one preference's value replaced, in whichever
+ * fields that widget type reads. Unknown keys are returned unchanged.
+ */
+export function applyPreferenceValue(
+ raw: BridgePreference[],
+ key: string,
+ value: string | string[] | boolean,
+): BridgePreference[] {
+ return raw.map((entry) => {
+ if (entry.key !== key) return entry;
+ const widget = widgetOf(entry);
+ if (!widget) return entry;
+
+ const body = { ...widget.body };
+ const kind = WIDGETS[widget.name];
+
+ if (kind === 'multi') {
+ body.values = Array.isArray(value) ? value : [];
+ } else if (kind === 'switch') {
+ body.value = value === true;
+ } else if (kind === 'list') {
+ const entryValues = stringList(body.entryValues);
+ const index = entryValues.indexOf(String(value));
+ body.valueIndex = index;
+ if (index >= 0) body.value = entryValues[index];
+ } else {
+ // editTextPreference carries the same string in both value and text.
+ body.value = String(value);
+ body.text = String(value);
+ }
+
+ return { ...entry, [widget.name]: body };
+ });
+}
+
+/** True when the preference should be masked in the UI and in logs. */
+export function isSecretPreference(view: SourcePreferenceView): boolean {
+ return /password|token|api[-_ ]?key|secret/i.test(`${view.key} ${view.title}`);
+}
diff --git a/src/anime-bridge/sidecar-bundle.test.ts b/src/anime-bridge/sidecar-bundle.test.ts
new file mode 100644
index 00000000..265d0ecd
--- /dev/null
+++ b/src/anime-bridge/sidecar-bundle.test.ts
@@ -0,0 +1,117 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import {
+ findBundleBinaries,
+ PINNED_BUNDLE_SHA256,
+ PINNED_BUNDLE_TAG,
+ resolveBundleAssetName,
+ selectBundleAsset,
+ sha256,
+ verifyPinnedBundle,
+} from './sidecar-bundle';
+
+test('resolveBundleAssetName maps supported platform/arch pairs', () => {
+ assert.equal(resolveBundleAssetName('darwin', 'arm64'), 'macOS-arm64-bundle.zip');
+ assert.equal(resolveBundleAssetName('darwin', 'x64'), 'macOS-x64-bundle.zip');
+ assert.equal(resolveBundleAssetName('linux', 'x64'), 'linux-x64-bundle.zip');
+ assert.equal(resolveBundleAssetName('win32', 'x64'), 'windows-x64-bundle.zip');
+});
+
+test('resolveBundleAssetName returns null for unpublished combinations', () => {
+ assert.equal(resolveBundleAssetName('linux', 'arm64'), null);
+ assert.equal(resolveBundleAssetName('win32', 'arm64'), null);
+ assert.equal(resolveBundleAssetName('freebsd', 'x64'), null);
+});
+
+test('selectBundleAsset skips releases without a matching asset', () => {
+ const releases = [
+ // The iOS runtime release carries no desktop bundle.
+ { tag_name: 'ios-runtime-v7', assets: [{ name: 'MExtensionServer-ios.jar' }] },
+ {
+ tag_name: 'v1.0.6.0',
+ assets: [
+ {
+ name: 'macOS-arm64-bundle.zip',
+ browser_download_url: 'https://example.test/macOS-arm64-bundle.zip',
+ size: 133_058_560,
+ },
+ ],
+ },
+ ];
+
+ const asset = selectBundleAsset(releases, 'macOS-arm64-bundle.zip');
+ assert.equal(asset?.tagName, 'v1.0.6.0');
+ assert.equal(asset?.downloadUrl, 'https://example.test/macOS-arm64-bundle.zip');
+ assert.equal(asset?.sizeBytes, 133_058_560);
+});
+
+test('selectBundleAsset returns null when nothing matches', () => {
+ assert.equal(selectBundleAsset([{ tag_name: 'v1', assets: [] }], 'linux-x64-bundle.zip'), null);
+ assert.equal(selectBundleAsset([], 'linux-x64-bundle.zip'), null);
+ assert.equal(selectBundleAsset({ message: 'rate limited' }, 'linux-x64-bundle.zip'), null);
+});
+
+test('findBundleBinaries locates the nested jre and jar', async () => {
+ const root = await mkdtemp(path.join(tmpdir(), 'subminer-bundle-'));
+ await mkdir(path.join(root, 'jre', 'jre', 'bin'), { recursive: true });
+ await writeFile(path.join(root, 'jre', 'jre', 'bin', 'java'), '');
+ await writeFile(path.join(root, 'MExtensionServer-1.0.6.0.jar'), '');
+
+ const binaries = await findBundleBinaries(root);
+ assert.equal(binaries?.javaPath, path.join(root, 'jre', 'jre', 'bin', 'java'));
+ assert.equal(binaries?.jarPath, path.join(root, 'MExtensionServer-1.0.6.0.jar'));
+});
+
+test('findBundleBinaries prefers the shallowest java when a nested copy exists', async () => {
+ const root = await mkdtemp(path.join(tmpdir(), 'subminer-bundle-'));
+ await mkdir(path.join(root, 'bin'), { recursive: true });
+ await mkdir(path.join(root, 'bin', 'nested', 'bin'), { recursive: true });
+ await writeFile(path.join(root, 'bin', 'java'), '');
+ await writeFile(path.join(root, 'bin', 'nested', 'bin', 'java'), '');
+ await writeFile(path.join(root, 'MExtensionServer.jar'), '');
+
+ const binaries = await findBundleBinaries(root);
+ assert.equal(binaries?.javaPath, path.join(root, 'bin', 'java'));
+});
+
+test('findBundleBinaries returns null when the bundle is incomplete', async () => {
+ const root = await mkdtemp(path.join(tmpdir(), 'subminer-bundle-'));
+ await writeFile(path.join(root, 'MExtensionServer.jar'), '');
+ assert.equal(await findBundleBinaries(root), null);
+});
+
+test('sha256 produces lowercase hex digests matching known vectors', () => {
+ const encode = (value: string) => new TextEncoder().encode(value);
+ assert.equal(
+ sha256(encode('')),
+ 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
+ );
+ assert.equal(
+ sha256(encode('subminer')),
+ 'f3b7fdb2037add4cd8f122c090a727243b46b1b9d8a6c379f71573e2df120885',
+ );
+});
+
+test('verifyPinnedBundle accepts a matching hash and rejects a mismatch', () => {
+ const asset = 'macOS-arm64-bundle.zip';
+ const wrong = verifyPinnedBundle(asset, new TextEncoder().encode('not the bundle'));
+ assert.equal(wrong.ok, false);
+ assert.match((wrong as { reason: string }).reason, /Checksum mismatch/);
+});
+
+test('verifyPinnedBundle refuses an asset that has no pin', () => {
+ const result = verifyPinnedBundle('linux-x64-bundle.zip', new Uint8Array([1, 2, 3]));
+ assert.equal(result.ok, false);
+ assert.match((result as { reason: string }).reason, /No pinned checksum/);
+});
+
+test('the pinned tag and macOS arm64 hash are the verified release', () => {
+ assert.equal(PINNED_BUNDLE_TAG, 'v1.0.6.0');
+ assert.equal(
+ PINNED_BUNDLE_SHA256['macOS-arm64-bundle.zip'],
+ '5f4fb03abfe88bc46ddf5f4d8221156ee2d66b9cbad7c4bc3ade4baf3a4266e6',
+ );
+});
diff --git a/src/anime-bridge/sidecar-bundle.ts b/src/anime-bridge/sidecar-bundle.ts
new file mode 100644
index 00000000..56cbf900
--- /dev/null
+++ b/src/anime-bridge/sidecar-bundle.ts
@@ -0,0 +1,150 @@
+import { createHash } from 'node:crypto';
+import { readdir, stat } from 'node:fs/promises';
+import path from 'node:path';
+
+/**
+ * Locates the M-Extension-Server release bundle for the host platform. Each
+ * bundle ships a matching JRE alongside the server JAR, so no system JDK is
+ * required.
+ */
+
+export const BUNDLE_RELEASES_URL =
+ 'https://api.github.com/repos/1Selxo/M-Extension-Server/releases?page=1&per_page=10';
+
+/**
+ * The bridge release this integration was verified against.
+ *
+ * Upstream publishes no checksums for the desktop bundles, so we pin a tag and
+ * a hash we computed ourselves rather than tracking "latest". Bumping this
+ * means downloading the new asset, verifying it starts and reports the
+ * capabilities in `AnimeBridgeClient.isReady`, then updating both fields.
+ */
+export const PINNED_BUNDLE_TAG = 'v1.0.6.0';
+
+/** SHA-256 of each pinned asset, keyed by release asset name. */
+export const PINNED_BUNDLE_SHA256: Readonly> = {
+ 'macOS-arm64-bundle.zip': '5f4fb03abfe88bc46ddf5f4d8221156ee2d66b9cbad7c4bc3ade4baf3a4266e6',
+};
+
+/**
+ * Check a downloaded asset against its pin. Assets we have not verified
+ * ourselves are rejected rather than trusted, so an unpinned platform fails
+ * loudly instead of silently running an unchecked binary.
+ */
+export function verifyPinnedBundle(
+ assetName: string,
+ bytes: Uint8Array,
+): { ok: true } | { ok: false; reason: string } {
+ const expected = PINNED_BUNDLE_SHA256[assetName];
+ if (expected === undefined) {
+ return { ok: false, reason: `No pinned checksum for ${assetName}; refusing to run it.` };
+ }
+ const actual = sha256(bytes);
+ if (actual !== expected) {
+ return {
+ ok: false,
+ reason: `Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}.`,
+ };
+ }
+ return { ok: true };
+}
+
+/** Release asset name for a platform/arch pair, or null when unsupported. */
+export function resolveBundleAssetName(platform: string, arch: string): string | null {
+ if (platform === 'linux') return arch === 'x64' ? 'linux-x64-bundle.zip' : null;
+ if (platform === 'win32') return arch === 'x64' ? 'windows-x64-bundle.zip' : null;
+ if (platform === 'darwin') {
+ if (arch === 'arm64') return 'macOS-arm64-bundle.zip';
+ if (arch === 'x64') return 'macOS-x64-bundle.zip';
+ }
+ return null;
+}
+
+export interface BundleBinaries {
+ javaPath: string;
+ jarPath: string;
+}
+
+async function walk(dir: string, depth: number, onFile: (file: string) => void): Promise {
+ if (depth < 0) return;
+ let entries;
+ try {
+ entries = await readdir(dir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) await walk(full, depth - 1, onFile);
+ else onFile(full);
+ }
+}
+
+/**
+ * Find the bundled `java` executable and `MExtensionServer-*.jar` inside an
+ * extracted bundle. The archive nests them a few levels deep and the exact
+ * layout differs per platform, so this searches rather than assuming a path.
+ */
+export async function findBundleBinaries(rootDir: string): Promise {
+ const javaCandidates: string[] = [];
+ const jarCandidates: string[] = [];
+
+ await walk(rootDir, 6, (file) => {
+ const base = path.basename(file);
+ if (base === 'java' || base === 'java.exe') javaCandidates.push(file);
+ else if (/^MExtensionServer.*\.jar$/.test(base)) jarCandidates.push(file);
+ });
+
+ // Prefer the shallowest match so a nested duplicate never shadows the real one.
+ const byDepth = (a: string, b: string) => a.split(path.sep).length - b.split(path.sep).length;
+ const javaPath = javaCandidates.sort(byDepth)[0];
+ const jarPath = jarCandidates.sort(byDepth)[0];
+ if (!javaPath || !jarPath) return null;
+ return { javaPath, jarPath };
+}
+
+/** Verify a downloaded archive against a pinned SHA-256, as Mangatan does. */
+export function sha256(bytes: Uint8Array): string {
+ return createHash('sha256').update(bytes).digest('hex');
+}
+
+export async function isExecutableFile(file: string): Promise {
+ try {
+ const info = await stat(file);
+ return info.isFile();
+ } catch {
+ return false;
+ }
+}
+
+export interface BundleAsset {
+ tagName: string;
+ assetName: string;
+ downloadUrl: string;
+ sizeBytes: number;
+}
+
+interface GithubRelease {
+ tag_name?: string;
+ assets?: Array<{ name?: string; browser_download_url?: string; size?: number }>;
+}
+
+/**
+ * Pick the newest release carrying an asset for this platform. Releases are
+ * returned newest-first, and some (the iOS runtime) carry no desktop bundle.
+ */
+export function selectBundleAsset(releases: unknown, assetName: string): BundleAsset | null {
+ if (!Array.isArray(releases)) return null;
+ for (const release of releases as GithubRelease[]) {
+ const asset = release.assets?.find((candidate) => candidate.name === assetName);
+ if (asset?.browser_download_url && release.tag_name) {
+ return {
+ tagName: release.tag_name,
+ assetName,
+ downloadUrl: asset.browser_download_url,
+ sizeBytes: asset.size ?? 0,
+ };
+ }
+ }
+ return null;
+}
diff --git a/src/anime-bridge/sidecar-process.ts b/src/anime-bridge/sidecar-process.ts
new file mode 100644
index 00000000..5123d8f9
--- /dev/null
+++ b/src/anime-bridge/sidecar-process.ts
@@ -0,0 +1,104 @@
+import { spawn, type ChildProcess } from 'node:child_process';
+import path from 'node:path';
+import { createServer } from 'node:net';
+import { AnimeBridgeClient } from './bridge-client';
+import type { BundleBinaries } from './sidecar-bundle';
+
+/** Cold start includes JVM boot plus AndroidCompat init; be generous. */
+export const DEFAULT_READY_TIMEOUT_MS = 30_000;
+const READY_POLL_INTERVAL_MS = 500;
+
+/** Ask the OS for a free loopback port, then hand it to the JVM. */
+export async function allocatePort(): Promise {
+ return new Promise((resolve, reject) => {
+ const server = createServer();
+ server.once('error', reject);
+ server.listen(0, '127.0.0.1', () => {
+ const address = server.address();
+ if (address === null || typeof address === 'string') {
+ server.close();
+ reject(new Error('Could not allocate a loopback port for the anime bridge.'));
+ return;
+ }
+ const { port } = address;
+ server.close(() => resolve(port));
+ });
+ });
+}
+
+export interface SidecarHandle {
+ baseUrl: string;
+ port: number;
+ client: AnimeBridgeClient;
+ stop: () => Promise;
+}
+
+export interface StartSidecarOptions {
+ binaries: BundleBinaries;
+ port?: number;
+ readyTimeoutMs?: number;
+ spawnImpl?: typeof spawn;
+ onLog?: (line: string) => void;
+}
+
+/**
+ * Launch the bridge and wait until it reports the capabilities this client
+ * needs. The desktop launch contract is `java -jar MExtensionServer.jar `,
+ * run from the JAR's own directory.
+ */
+export async function startSidecar(options: StartSidecarOptions): Promise {
+ const { binaries } = options;
+ const port = options.port ?? (await allocatePort());
+ const baseUrl = `http://127.0.0.1:${port}`;
+ const spawnProcess = options.spawnImpl ?? spawn;
+
+ const child: ChildProcess = spawnProcess(
+ binaries.javaPath,
+ ['-jar', binaries.jarPath, String(port)],
+ {
+ cwd: path.dirname(binaries.jarPath),
+ stdio: ['ignore', 'pipe', 'pipe'],
+ },
+ );
+
+ const log = options.onLog;
+ if (log) {
+ child.stdout?.on('data', (chunk: Buffer) => log(chunk.toString().trimEnd()));
+ child.stderr?.on('data', (chunk: Buffer) => log(chunk.toString().trimEnd()));
+ }
+
+ let exited: { code: number | null; signal: NodeJS.Signals | null } | null = null;
+ child.once('exit', (code, signal) => {
+ exited = { code, signal };
+ });
+
+ const stop = async (): Promise => {
+ if (exited !== null) return;
+ // Graceful first: the server exposes a shutdown endpoint.
+ try {
+ await fetch(`${baseUrl}/stop`, { signal: AbortSignal.timeout(2000) });
+ } catch {
+ // Falling through to a signal is fine; the endpoint may already be gone.
+ }
+ if (exited === null) child.kill();
+ };
+
+ const client = new AnimeBridgeClient({ baseUrl });
+ const deadline = Date.now() + (options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
+
+ while (Date.now() < deadline) {
+ if (exited !== null) {
+ const { code, signal } = exited as { code: number | null; signal: NodeJS.Signals | null };
+ throw new Error(
+ `Anime bridge exited before becoming ready (code ${code}, signal ${signal}).`,
+ );
+ }
+ if (await client.isReady()) return { baseUrl, port, client, stop };
+ await new Promise((resolve) => setTimeout(resolve, READY_POLL_INTERVAL_MS));
+ }
+
+ await stop();
+ throw new Error(
+ `Anime bridge did not become ready within ${options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS}ms.`,
+ );
+}
diff --git a/src/anime-bridge/types.ts b/src/anime-bridge/types.ts
new file mode 100644
index 00000000..485bc2c0
--- /dev/null
+++ b/src/anime-bridge/types.ts
@@ -0,0 +1,101 @@
+/**
+ * Wire types for the M-Extension-Server bridge, which runs Aniyomi
+ * (`eu.kanade.tachiyomi.animeextension`) APKs on a desktop JVM and exposes
+ * them over loopback HTTP.
+ *
+ * Field names mirror the server's JSON exactly, including Kotlin/OkHttp
+ * internals that leak into the payload (see `OkHttpHeaders`).
+ */
+
+/** Marker key the server uses to select a source inside a SourceFactory APK. */
+export const BRIDGE_CONTEXT_KEY = '__mangatan_bridge_context__';
+
+/** Handshake shape from `GET /capabilities`. */
+export interface BridgeCapabilities {
+ mangatanMihonBridge?: number;
+ sourceFactory?: boolean;
+ preferenceCallbacks?: boolean;
+ youtubeResolver?: boolean;
+}
+
+/**
+ * OkHttp serializes `Headers` as a flat alternating name/value array under an
+ * internal field name. Kept verbatim so parsing stays honest about the source.
+ */
+export interface OkHttpHeaders {
+ namesAndValues$okhttp?: string[];
+}
+
+export interface BridgeTrack {
+ url?: string;
+ lang?: string;
+}
+
+/** One playable stream returned by an extension's `getVideoList`. */
+export interface BridgeVideo {
+ /** Page/embed URL the stream was extracted from. */
+ url?: string;
+ /** Display label, e.g. "1080p". */
+ quality?: string;
+ /**
+ * Playable media URL. Normally a `/video/` proxy URL on the bridge
+ * itself, valid only while that server process is alive.
+ */
+ videoUrl?: string;
+ headers?: OkHttpHeaders;
+ audioTracks?: BridgeTrack[];
+ subtitleTracks?: BridgeTrack[];
+}
+
+export interface BridgeEpisode {
+ name?: string;
+ url?: string;
+ date_upload?: number;
+ scanlator?: string;
+ episode_number?: number;
+}
+
+export interface BridgeAnime {
+ url?: string;
+ title?: string;
+ artist?: string;
+ author?: string;
+ description?: string;
+ genres?: string[];
+ status?: number;
+ thumbnail_url?: string;
+}
+
+/** One source inside an extension APK. A SourceFactory APK yields several. */
+export interface BridgeSourceDescriptor {
+ id?: string | number;
+ name?: string;
+ lang?: string;
+ baseUrl?: string;
+}
+
+export interface BridgeAnimePage {
+ animes?: BridgeAnime[];
+ hasNextPage?: boolean;
+}
+
+/** The server reports failures as HTTP 200 with an error body. */
+export interface BridgeErrorBody {
+ error?: string;
+ code?: number;
+}
+
+/** A source preference entry, passed through to the extension unchanged. */
+export interface BridgePreference {
+ key: string;
+ [field: string]: unknown;
+}
+
+/** Normalized stream, ready to hand to mpv. */
+export interface ResolvedStream {
+ url: string;
+ quality: string;
+ headers: Record;
+ subtitles: Array<{ url: string; lang: string }>;
+ audios: Array<{ url: string; lang: string }>;
+}
diff --git a/src/animeui/animeui.ts b/src/animeui/animeui.ts
new file mode 100644
index 00000000..f5a942e0
--- /dev/null
+++ b/src/animeui/animeui.ts
@@ -0,0 +1,505 @@
+import { describe, el } from './dom';
+import { sourceOptionLabel, summarizeSearch } from './format';
+import { applySearchUpdate, idleSearchProgress, summarizeProgress } from './search-progress';
+import { createExtensionsPanel } from './extensions-panel';
+import { renderPreferences, renderPreferencesUnavailable } from './preferences-fields';
+import { ALL_SOURCES_ID } from '../types/anime-browser';
+import type {
+ AnimeBrowserAPI,
+ AnimeBrowserBridgeState,
+ AnimeBrowserEntry,
+ AnimeBrowserEpisode,
+ AnimeBrowserSource,
+} from '../types/anime-browser';
+
+declare global {
+ interface Window {
+ animeBrowserAPI: AnimeBrowserAPI;
+ }
+}
+
+const api = window.animeBrowserAPI;
+
+const searchForm = el('search-form');
+const searchInput = el('search-input');
+const searchButton = el('search-button');
+const sourceSelect = el('source-select');
+const grid = el('grid');
+const gridEmpty = el('grid-empty');
+const results = el('results');
+const detail = el('detail');
+const detailBack = el('detail-back');
+const detailCover = el('detail-cover');
+const detailTitle = el('detail-title');
+const detailChips = el('detail-chips');
+const detailDescription = el('detail-description');
+const episodes = el('episodes');
+const episodesCount = el('episodes-count');
+const banner = el('bridge-banner');
+const bannerMessage = el('bridge-message');
+const bannerMeter = el('bridge-meter');
+const bannerMeterFill = el('bridge-meter-fill');
+const statusMessage = el('status-message');
+const browseTab = el('tab-browse');
+const extensionsTab = el('tab-extensions');
+const settingsTab = el('tab-settings');
+const layout = el('layout');
+const extensionsPanel = el('extensions');
+const settingsPanel = el('settings');
+const settingsFields = el('settings-fields');
+const settingsTitle = el('settings-title');
+
+/** The anime the detail page is showing, with the source that produced it. */
+let selectedAnime: { url: string; title: string; sourceId: string } | null = null;
+
+/** Where the results grid was scrolled to before the detail page covered it. */
+let resultsScrollTop = 0;
+
+/* ---------- tabs ---------- */
+
+type View = 'browse' | 'extensions' | 'settings';
+
+let currentView: View = 'browse';
+
+/**
+ * Show one view at a time. The panels used to sit above the results, which left
+ * a long extension list scrolling inside a sliver of the window; as tabs each
+ * one gets the whole content region.
+ */
+function setView(view: View): void {
+ currentView = view;
+ layout.classList.toggle('hidden', view !== 'browse');
+ extensionsPanel.classList.toggle('hidden', view !== 'extensions');
+ settingsPanel.classList.toggle('hidden', view !== 'settings');
+ browseTab.setAttribute('aria-selected', String(view === 'browse'));
+ extensionsTab.setAttribute('aria-selected', String(view === 'extensions'));
+ settingsTab.setAttribute('aria-selected', String(view === 'settings'));
+}
+
+function setStatus(message: string, tone: 'info' | 'ok' | 'error' = 'info'): void {
+ statusMessage.textContent = message;
+ statusMessage.parentElement?.setAttribute('data-tone', tone);
+}
+
+const BRIDGE_LABELS: Record = {
+ idle: 'Starting the extension bridge',
+ locating: 'Looking up the extension bridge release',
+ downloading: 'Downloading the extension bridge',
+ verifying: 'Verifying the download',
+ extracting: 'Unpacking the extension bridge',
+ starting: 'Starting the extension bridge',
+ ready: 'Bridge ready',
+ failed: 'Bridge failed to start',
+};
+
+const BUSY_STAGES = new Set([
+ 'idle',
+ 'locating',
+ 'downloading',
+ 'verifying',
+ 'extracting',
+ 'starting',
+]);
+
+function renderBridgeState(state: AnimeBrowserBridgeState): void {
+ const busy = BUSY_STAGES.has(state.stage);
+ banner.dataset.stage = state.stage;
+ banner.dataset.busy = String(busy);
+
+ // Once ready with nothing to report, the banner has nothing to say.
+ const hide = state.stage === 'ready' && state.message === null;
+ banner.classList.toggle('hidden', hide);
+ bannerMessage.textContent = state.message ?? BRIDGE_LABELS[state.stage];
+
+ const showMeter = state.progress !== null;
+ bannerMeter.classList.toggle('hidden', !showMeter);
+ if (state.progress !== null) {
+ bannerMeterFill.style.width = `${Math.round(state.progress * 100)}%`;
+ }
+
+ const ready = state.stage === 'ready';
+ searchInput.disabled = !ready;
+ searchButton.disabled = !ready;
+}
+
+/* ---------- source picker ---------- */
+
+/**
+ * The picker lists every installed source, plus an "All sources" entry that
+ * searches them together. That entry only earns its place with more than one
+ * source installed.
+ */
+function renderSources(sources: AnimeBrowserSource[], selectedId: string | null): void {
+ const options: HTMLOptionElement[] = [];
+
+ if (sources.length > 1) {
+ const all = document.createElement('option');
+ all.value = ALL_SOURCES_ID;
+ all.textContent = `All sources (${sources.length})`;
+ all.selected = selectedId === ALL_SOURCES_ID;
+ options.push(all);
+ }
+
+ for (const source of sources) {
+ const option = document.createElement('option');
+ option.value = source.id;
+ option.textContent = sourceOptionLabel(source);
+ option.selected = source.id === selectedId;
+ options.push(option);
+ }
+
+ sourceSelect.replaceChildren(...options);
+ sourceSelect.disabled = options.length <= 1;
+}
+
+function searchingAllSources(): boolean {
+ return sourceSelect.value === ALL_SOURCES_ID;
+}
+
+/* ---------- results ---------- */
+
+function createCard(entry: AnimeBrowserEntry, showSource: boolean): HTMLButtonElement {
+ const card = document.createElement('button');
+ card.type = 'button';
+ card.className = 'card';
+
+ const art = document.createElement('div');
+ art.className = 'card-art';
+ if (entry.thumbnailUrl) {
+ const img = document.createElement('img');
+ img.src = entry.thumbnailUrl;
+ img.alt = '';
+ img.loading = 'lazy';
+ // A dead cover should fall back to the initial, not a broken-image icon.
+ img.addEventListener('error', () => {
+ img.remove();
+ art.classList.add('is-empty');
+ });
+ art.append(img);
+ } else {
+ art.classList.add('is-empty');
+ }
+ art.dataset.initial = entry.title.trim().charAt(0).toUpperCase() || '?';
+
+ if (showSource) {
+ const badge = document.createElement('span');
+ badge.className = 'card-source';
+ badge.textContent = entry.sourceName;
+ art.append(badge);
+ }
+
+ const title = document.createElement('div');
+ title.className = 'card-title';
+ title.textContent = entry.title;
+
+ card.append(art, title);
+ card.title = showSource ? `${entry.title} — ${entry.sourceName}` : entry.title;
+ card.addEventListener('click', () => {
+ void openDetail(entry);
+ });
+ return card;
+}
+
+function renderEntries(entries: AnimeBrowserEntry[], emptyMessage: string): void {
+ // Which source a cover came from only matters when they are mixed together.
+ const showSource = searchingAllSources();
+ grid.replaceChildren(...entries.map((entry) => createCard(entry, showSource)));
+
+ const empty = entries.length === 0;
+ gridEmpty.classList.toggle('hidden', !empty);
+ gridEmpty.textContent = emptyMessage;
+}
+
+/** Streamed results land at the end of the grid, in arrival order. */
+function appendEntries(entries: AnimeBrowserEntry[]): void {
+ const showSource = searchingAllSources();
+ grid.append(...entries.map((entry) => createCard(entry, showSource)));
+}
+
+function formatEpisodeIndex(episode: AnimeBrowserEpisode, fallbackIndex: number): string {
+ const value = episode.number ?? fallbackIndex;
+ return Number.isInteger(value) ? String(value).padStart(2, '0') : value.toFixed(1);
+}
+
+function renderEpisodes(list: AnimeBrowserEpisode[]): void {
+ episodesCount.textContent = list.length === 0 ? '' : `${list.length}`;
+ episodes.replaceChildren(
+ ...list.map((episode, index) => {
+ const item = document.createElement('li');
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'cue';
+
+ const cueIndex = document.createElement('span');
+ cueIndex.className = 'cue-index';
+ cueIndex.textContent = formatEpisodeIndex(episode, list.length - index);
+
+ const name = document.createElement('span');
+ name.className = 'cue-name';
+ name.textContent = episode.name;
+ if (episode.uploadedAt !== null) {
+ const sub = document.createElement('span');
+ sub.className = 'cue-sub';
+ sub.textContent = new Date(episode.uploadedAt).toISOString().slice(0, 10);
+ name.append(sub);
+ }
+
+ button.append(cueIndex, name);
+ button.addEventListener('click', () => {
+ void playEpisode(button, episode);
+ });
+ item.append(button);
+ return item;
+ }),
+ );
+}
+
+/**
+ * The detail page replaces the results grid rather than squeezing in beside
+ * it. The grid stays in the DOM with its scroll position remembered, so Back
+ * returns to the same results without re-running the search.
+ */
+async function openDetail(entry: AnimeBrowserEntry): Promise {
+ selectedAnime = { url: entry.url, title: entry.title, sourceId: entry.sourceId };
+ resultsScrollTop = results.scrollTop;
+ results.classList.add('hidden');
+ detail.classList.remove('hidden');
+ detail.scrollTop = 0;
+ detailTitle.textContent = entry.title;
+ detailDescription.textContent = 'Loading…';
+ detailChips.replaceChildren();
+ episodes.replaceChildren();
+ episodesCount.textContent = '';
+ detailCover.src = entry.thumbnailUrl ?? '';
+
+ try {
+ // Always ask the entry's own source: after an all-sources search the
+ // picker's selection says nothing about where this cover came from.
+ const [details, episodeList] = await Promise.all([
+ api.getDetails(entry.url, entry.sourceId),
+ api.getEpisodes(entry.url, entry.sourceId),
+ ]);
+
+ detailTitle.textContent = details.title;
+ detailDescription.textContent = details.description ?? 'No description from this source.';
+ if (details.thumbnailUrl) detailCover.src = details.thumbnailUrl;
+
+ const chips: HTMLSpanElement[] = [];
+ const source = document.createElement('span');
+ source.className = 'chip source';
+ source.textContent = entry.sourceName;
+ chips.push(source);
+ if (details.status !== 'unknown') {
+ const status = document.createElement('span');
+ status.className = 'chip status';
+ status.textContent = details.status.replace(/-/g, ' ');
+ chips.push(status);
+ }
+ for (const genre of details.genres.slice(0, 6)) {
+ const chip = document.createElement('span');
+ chip.className = 'chip';
+ chip.textContent = genre;
+ chips.push(chip);
+ }
+ detailChips.replaceChildren(...chips);
+
+ renderEpisodes(episodeList);
+ setStatus(`${details.title} · ${episodeList.length} episodes`);
+ } catch (error) {
+ detailDescription.textContent = '';
+ setStatus(describe(error), 'error');
+ }
+}
+
+function closeDetail(): void {
+ detail.classList.add('hidden');
+ results.classList.remove('hidden');
+ results.scrollTop = resultsScrollTop;
+ selectedAnime = null;
+}
+
+async function playEpisode(button: HTMLButtonElement, episode: AnimeBrowserEpisode): Promise {
+ if (!selectedAnime) return;
+
+ for (const other of episodes.querySelectorAll('.cue')) {
+ other.removeAttribute('data-state');
+ }
+ button.dataset.state = 'loading';
+ setStatus(`Resolving ${episode.name}…`);
+
+ const result = await api.playEpisode({
+ sourceId: selectedAnime.sourceId,
+ animeUrl: selectedAnime.url,
+ animeTitle: selectedAnime.title,
+ episodeUrl: episode.url,
+ episodeName: episode.name,
+ });
+
+ if (result.ok) {
+ button.dataset.state = 'playing';
+ setStatus(
+ result.quality ? `Playing ${episode.name} · ${result.quality}` : `Playing ${episode.name}`,
+ 'ok',
+ );
+ } else {
+ button.removeAttribute('data-state');
+ setStatus(result.error ?? 'Could not play that episode.', 'error');
+ }
+}
+
+/* ---------- streamed search ---------- */
+
+/**
+ * Results are pushed per source while the search call is still pending, so a
+ * fast source is on screen before a slow one answers. The grid is built from
+ * those pushes; the awaited result then settles the final status line (and
+ * backfills the grid if no push ever arrived).
+ */
+let progress = idleSearchProgress();
+
+/** Orders runSearch calls so a slow search cannot finish over a newer one. */
+let searchRequest = 0;
+
+api.onSearchUpdate((update) => {
+ const applied = applySearchUpdate(progress, update);
+ if (!applied) return; // A superseded search; let it run out quietly.
+ progress = applied.progress;
+
+ if (applied.started) {
+ grid.replaceChildren();
+ gridEmpty.classList.add('hidden');
+ return;
+ }
+ if (applied.entries.length > 0) appendEntries(applied.entries);
+ if (!progress.done) {
+ setStatus(summarizeProgress(progress), progress.failures.length > 0 ? 'error' : 'info');
+ }
+});
+
+async function runSearch(query: string): Promise {
+ const request = ++searchRequest;
+ // A new search means new results; leave the detail page for them.
+ if (!detail.classList.contains('hidden')) closeDetail();
+ setStatus(query ? `Searching for “${query}”…` : 'Loading popular…');
+ grid.replaceChildren();
+ gridEmpty.classList.add('hidden');
+
+ try {
+ const result = query ? await api.search(query) : await api.getPopular();
+ // A newer search owns the grid now; this one's result is history.
+ if (request !== searchRequest) return;
+
+ // Every source failing is an error, not an empty result set.
+ if (result.entries.length === 0 && result.failures.length > 0) {
+ const first = result.failures[0];
+ renderEntries([], `${first?.sourceName}: ${first?.error}`);
+ setStatus(summarizeSearch(result), 'error');
+ return;
+ }
+
+ // The stream already filled the grid; only render from the result when no
+ // update arrived (covers a host without streaming wired up).
+ if (grid.childElementCount === 0 || result.entries.length === 0) {
+ renderEntries(
+ result.entries,
+ query ? `Nothing found for “${query}”.` : 'This source returned nothing.',
+ );
+ }
+ setStatus(summarizeSearch(result), result.failures.length > 0 ? 'error' : 'info');
+ } catch (error) {
+ if (request !== searchRequest) return;
+ renderEntries([], describe(error));
+ setStatus(describe(error), 'error');
+ }
+}
+
+/* ---------- source settings ---------- */
+
+async function openSettings(): Promise {
+ const option = sourceSelect.selectedOptions[0];
+ settingsTitle.textContent = option ? `${option.textContent} settings` : 'Source settings';
+ settingsFields.replaceChildren();
+ setView('settings');
+
+ // Settings belong to one extension, so there is nothing coherent to show for
+ // "All sources".
+ if (searchingAllSources()) {
+ renderPreferencesUnavailable(
+ settingsFields,
+ 'Pick a single source in the Source picker to edit its settings.',
+ );
+ return;
+ }
+
+ const sourceId = sourceSelect.value;
+ try {
+ renderPreferences(settingsFields, await api.getPreferences(sourceId), (key, value) =>
+ api.setPreference(sourceId, key, value),
+ );
+ } catch (error) {
+ setStatus(describe(error), 'error');
+ setView('browse');
+ }
+}
+
+/* ---------- wiring ---------- */
+
+async function refreshSources(): Promise {
+ const snapshot = await api.getSnapshot();
+ renderSources(snapshot.sources, snapshot.selectedSourceId);
+}
+
+const extensions = createExtensionsPanel({ api, setStatus, onSourcesChanged: refreshSources });
+
+browseTab.addEventListener('click', () => setView('browse'));
+
+settingsTab.addEventListener('click', () => void openSettings());
+
+extensionsTab.addEventListener('click', () => {
+ setView('extensions');
+ void extensions.refresh();
+});
+
+searchForm.addEventListener('submit', (event) => {
+ event.preventDefault();
+ // Searching is a browse action, whichever tab it was typed from.
+ setView('browse');
+ void runSearch(searchInput.value.trim());
+});
+
+sourceSelect.addEventListener('change', () => {
+ void (async () => {
+ await api.selectSource(sourceSelect.value);
+ // Settings belong to the source, so reload them rather than showing stale fields.
+ if (currentView === 'settings') await openSettings();
+ await runSearch(searchInput.value.trim());
+ })();
+});
+
+detailBack.addEventListener('click', closeDetail);
+
+document.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape' && !detail.classList.contains('hidden')) {
+ closeDetail();
+ }
+});
+
+api.onBridgeState(renderBridgeState);
+
+void (async () => {
+ renderBridgeState({ stage: 'idle', progress: null, message: null });
+ const state = await api.ensureBridge();
+ renderBridgeState(state);
+
+ const snapshot = await api.getSnapshot();
+ renderSources(snapshot.sources, snapshot.selectedSourceId);
+
+ if (state.stage === 'ready' && snapshot.sources.length > 0) {
+ searchInput.focus();
+ await runSearch('');
+ } else if (state.stage === 'ready') {
+ setStatus(state.message ?? 'No extensions installed.', 'error');
+ } else {
+ setStatus(state.message ?? 'The extension bridge is not available.', 'error');
+ }
+})();
diff --git a/src/animeui/dom.ts b/src/animeui/dom.ts
new file mode 100644
index 00000000..7a978505
--- /dev/null
+++ b/src/animeui/dom.ts
@@ -0,0 +1,13 @@
+/** Small helpers shared by the anime browser's panels. */
+
+export function el(id: string): T {
+ const node = document.getElementById(id);
+ if (!node) throw new Error(`Missing element #${id}`);
+ return node as T;
+}
+
+export function describe(error: unknown): string {
+ const message = error instanceof Error ? error.message : String(error);
+ // Electron wraps handler errors; keep only the useful tail.
+ return message.replace(/^Error invoking remote method '[^']+':\s*/, '').replace(/^Error:\s*/, '');
+}
diff --git a/src/animeui/extensions-panel.ts b/src/animeui/extensions-panel.ts
new file mode 100644
index 00000000..afb33bd3
--- /dev/null
+++ b/src/animeui/extensions-panel.ts
@@ -0,0 +1,280 @@
+import { describe, el } from './dom';
+import { describeInstalled } from './format';
+import type {
+ AnimeBrowserAPI,
+ AvailableExtension,
+ InstalledExtensionView,
+} from '../types/anime-browser';
+
+/**
+ * The Extensions tab: what is installed, which repositories feed it, and what
+ * those repositories still offer.
+ *
+ * Installed extensions get their own section at the top, listed from the
+ * extensions directory rather than from a repository catalogue — an APK dropped
+ * in by hand, or one whose repository has since been removed, is still
+ * installed and has to stay removable.
+ */
+
+export interface ExtensionsPanelOptions {
+ api: AnimeBrowserAPI;
+ setStatus: (message: string, tone?: 'info' | 'ok' | 'error') => void;
+ /** Called after an install or removal, so the source picker keeps up. */
+ onSourcesChanged: () => Promise;
+}
+
+interface RowAction {
+ label: string;
+ primary?: boolean;
+ onClick: () => void | Promise;
+}
+
+interface RowOptions {
+ name: string;
+ sub: string;
+ tags?: Array<{ text: string; className: string }>;
+ actions?: RowAction[];
+ isError?: boolean;
+}
+
+function extensionRow(options: RowOptions): HTMLDivElement {
+ const row = document.createElement('div');
+ row.className = options.isError ? 'ext-row is-error' : 'ext-row';
+
+ const main = document.createElement('div');
+ main.className = 'ext-main';
+ const name = document.createElement('div');
+ name.className = 'ext-name';
+ name.textContent = options.name;
+ const sub = document.createElement('div');
+ sub.className = 'ext-sub';
+ sub.textContent = options.sub;
+ main.append(name, sub);
+ row.append(main);
+
+ for (const tag of options.tags ?? []) {
+ const chip = document.createElement('span');
+ chip.className = `ext-tag ${tag.className}`;
+ chip.textContent = tag.text;
+ row.append(chip);
+ }
+
+ for (const action of options.actions ?? []) {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = action.primary ? 'primary-button' : 'ghost-button';
+ button.textContent = action.label;
+ button.addEventListener('click', () => {
+ button.disabled = true;
+ void Promise.resolve(action.onClick()).finally(() => {
+ button.disabled = false;
+ });
+ });
+ row.append(button);
+ }
+
+ return row;
+}
+
+function emptyNote(text: string): HTMLParagraphElement {
+ const empty = document.createElement('p');
+ empty.className = 'ext-empty';
+ empty.textContent = text;
+ return empty;
+}
+
+export function createExtensionsPanel(options: ExtensionsPanelOptions) {
+ const { api, setStatus, onSourcesChanged } = options;
+
+ const extensionsDirLabel = el('extensions-dir');
+ const installedList = el('installed-list');
+ const installedCount = el('installed-count');
+ const availableList = el('extensions-list');
+ const repoInput = el('repo-input');
+ const repoAddButton = el('repo-add');
+ const repoList = el('repo-list');
+
+ async function afterChange(extensionName: string, verb: string): Promise {
+ await refresh();
+ await onSourcesChanged();
+ setStatus(`${extensionName} ${verb}`, 'ok');
+ }
+
+ function renderInstalled(
+ installed: InstalledExtensionView[],
+ offeredPkgs: Set,
+ extensionsDir: string,
+ ): void {
+ installedCount.textContent = installed.length === 0 ? '' : String(installed.length);
+
+ if (installed.length === 0) {
+ installedList.replaceChildren(
+ emptyNote(
+ `Nothing installed yet. Add a repository below, or drop .apk files in ${extensionsDir}.`,
+ ),
+ );
+ return;
+ }
+
+ installedList.replaceChildren(
+ ...installed.map((view) => {
+ const actions: RowAction[] = [];
+ // Only offer an update for an extension a configured repository still
+ // carries; reinstalling overwrites the APK in place.
+ if (offeredPkgs.has(view.pkg)) {
+ actions.push({
+ label: 'Update',
+ onClick: async () => {
+ setStatus(`Updating ${view.name}…`);
+ try {
+ await api.installExtension(view.pkg);
+ await afterChange(view.name, 'updated');
+ } catch (error) {
+ setStatus(describe(error), 'error');
+ }
+ },
+ });
+ }
+ actions.push({
+ label: 'Remove',
+ onClick: async () => {
+ setStatus(`Removing ${view.name}…`);
+ try {
+ await api.removeExtension(view.pkg);
+ await afterChange(view.name, 'removed');
+ } catch (error) {
+ setStatus(describe(error), 'error');
+ }
+ },
+ });
+
+ return extensionRow({
+ name: view.name,
+ sub: view.error ?? describeInstalled(view),
+ isError: view.error !== null,
+ tags: view.error === null ? [] : [{ text: 'failed', className: 'nsfw' }],
+ actions,
+ });
+ }),
+ );
+ }
+
+ function renderRepos(repos: string[]): void {
+ repoList.replaceChildren(
+ ...repos.map((repoUrl) =>
+ extensionRow({
+ name: repoUrl.replace(/^https:\/\//, '').replace(/\/[^/]*\.json$/, ''),
+ sub: repoUrl,
+ actions: [
+ {
+ label: 'Remove',
+ onClick: async () => {
+ await api.removeRepo(repoUrl);
+ await refresh();
+ },
+ },
+ ],
+ }),
+ ),
+ );
+ }
+
+ function renderAvailable(
+ available: AvailableExtension[],
+ repoFailures: Array<{ name: string; error: string }>,
+ hasRepos: boolean,
+ ): void {
+ const rows: HTMLElement[] = repoFailures.map((failure) =>
+ extensionRow({ name: failure.name, sub: failure.error, isError: true }),
+ );
+
+ for (const extension of available) {
+ rows.push(
+ extensionRow({
+ name: extension.name,
+ sub: `${extension.lang} · v${extension.version}`,
+ tags: extension.nsfw ? [{ text: '18+', className: 'nsfw' }] : [],
+ actions: [
+ {
+ label: 'Install',
+ primary: true,
+ onClick: async () => {
+ setStatus(`Installing ${extension.name}…`);
+ try {
+ await api.installExtension(extension.pkg);
+ await afterChange(extension.name, 'installed');
+ } catch (error) {
+ setStatus(describe(error), 'error');
+ }
+ },
+ },
+ ],
+ }),
+ );
+ }
+
+ if (rows.length === 0) {
+ rows.push(
+ emptyNote(
+ hasRepos
+ ? 'Every extension the configured repositories offer is already installed.'
+ : 'No repository configured, so there is nothing to install from.',
+ ),
+ );
+ }
+
+ availableList.replaceChildren(...rows);
+ }
+
+ async function refresh(): Promise {
+ const snapshot = await api.getSnapshot();
+ extensionsDirLabel.textContent = snapshot.extensionsDir;
+ renderRepos(snapshot.repos);
+
+ const repoFailures: Array<{ name: string; error: string }> = [];
+ let available;
+ try {
+ available = await api.listAvailableExtensions();
+ } catch (error) {
+ repoFailures.push({ name: 'Repository error', error: describe(error) });
+ available = { extensions: [], failures: [] };
+ }
+ for (const failure of available.failures) {
+ repoFailures.push({ name: failure.repoUrl, error: failure.error });
+ }
+
+ const offeredPkgs = new Set(available.extensions.map((extension) => extension.pkg));
+ renderInstalled(snapshot.installed, offeredPkgs, snapshot.extensionsDir);
+ renderAvailable(
+ // Installed extensions have their own section; leaving them here too
+ // would list every one of them twice.
+ available.extensions.filter((extension) => !extension.installed),
+ repoFailures,
+ snapshot.repos.length > 0,
+ );
+ }
+
+ repoAddButton.addEventListener('click', () => {
+ void (async () => {
+ const url = repoInput.value.trim();
+ if (url.length === 0) return;
+ try {
+ await api.addRepo(url);
+ repoInput.value = '';
+ setStatus('Repository added');
+ await refresh();
+ } catch (error) {
+ setStatus(describe(error), 'error');
+ }
+ })();
+ });
+
+ repoInput.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ repoAddButton.click();
+ }
+ });
+
+ return { refresh };
+}
diff --git a/src/animeui/format.test.ts b/src/animeui/format.test.ts
new file mode 100644
index 00000000..5d7aa863
--- /dev/null
+++ b/src/animeui/format.test.ts
@@ -0,0 +1,60 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { describeInstalled, sourceOptionLabel, summarizeSearch } from './format';
+import type { AnimeBrowserSearchResult } from '../types/anime-browser';
+
+const result = (
+ entryCount: number,
+ failures: AnimeBrowserSearchResult['failures'] = [],
+): AnimeBrowserSearchResult => ({
+ entries: Array.from({ length: entryCount }, (_unused, index) => ({
+ url: `/a/${index}`,
+ title: `Anime ${index}`,
+ thumbnailUrl: null,
+ sourceId: 's',
+ sourceName: 'Source',
+ })),
+ hasNextPage: false,
+ failures,
+});
+
+test('sourceOptionLabel omits the language for an all-language source', () => {
+ assert.equal(sourceOptionLabel({ id: '1', name: 'Nyaa', lang: 'ja', pkg: 'p' }), 'Nyaa (ja)');
+ assert.equal(sourceOptionLabel({ id: '2', name: 'Jellyfin', lang: 'all', pkg: 'p' }), 'Jellyfin');
+});
+
+test('summarizeSearch counts results and singularizes one', () => {
+ assert.equal(summarizeSearch(result(4)), '4 results');
+ assert.equal(summarizeSearch(result(1)), '1 result');
+ assert.equal(summarizeSearch(result(0)), '0 results');
+});
+
+test('summarizeSearch names the sources that failed alongside the ones that answered', () => {
+ const summary = summarizeSearch(
+ result(6, [
+ { sourceId: 'a', sourceName: 'Alpha', error: 'login required' },
+ { sourceId: 'b', sourceName: 'Beta', error: 'timed out' },
+ ]),
+ );
+ assert.equal(summary, '6 results · 2 unavailable: Alpha, Beta');
+});
+
+test('describeInstalled reports sources and languages when the extension loaded', () => {
+ assert.equal(
+ describeInstalled({
+ pkg: 'multi',
+ name: 'One, Two',
+ langs: ['en', 'ja'],
+ sourceCount: 2,
+ error: null,
+ }),
+ 'multi · 2 sources · en, ja',
+ );
+});
+
+test('describeInstalled falls back to the package alone when nothing loaded', () => {
+ assert.equal(
+ describeInstalled({ pkg: 'broken', name: 'broken', langs: [], sourceCount: 0, error: 'boom' }),
+ 'broken',
+ );
+});
diff --git a/src/animeui/format.ts b/src/animeui/format.ts
new file mode 100644
index 00000000..484e1c7b
--- /dev/null
+++ b/src/animeui/format.ts
@@ -0,0 +1,35 @@
+import type {
+ AnimeBrowserSearchResult,
+ AnimeBrowserSource,
+ InstalledExtensionView,
+} from '../types/anime-browser';
+
+/** Display strings for the anime browser, kept separate from the DOM. */
+
+/** `Nyaa (ja)`, or just the name for a source that serves every language. */
+export function sourceOptionLabel(source: AnimeBrowserSource): string {
+ return source.lang === 'all' ? source.name : `${source.name} (${source.lang})`;
+}
+
+/**
+ * The status line after a search.
+ *
+ * An all-sources search can half-succeed, so the sources that failed are named
+ * rather than folded into a count the user cannot act on.
+ */
+export function summarizeSearch(result: AnimeBrowserSearchResult): string {
+ const count = `${result.entries.length} result${result.entries.length === 1 ? '' : 's'}`;
+ if (result.failures.length === 0) return count;
+ const names = result.failures.map((failure) => failure.sourceName).join(', ');
+ return `${count} · ${result.failures.length} unavailable: ${names}`;
+}
+
+/** `pkg · 3 sources · en, ja`, trimmed to what the extension actually reported. */
+export function describeInstalled(view: InstalledExtensionView): string {
+ const parts = [view.pkg];
+ if (view.sourceCount > 0) {
+ parts.push(`${view.sourceCount} source${view.sourceCount === 1 ? '' : 's'}`);
+ }
+ if (view.langs.length > 0) parts.push(view.langs.join(', '));
+ return parts.join(' · ');
+}
diff --git a/src/animeui/index.html b/src/animeui/index.html
new file mode 100644
index 00000000..235651ab
--- /dev/null
+++ b/src/animeui/index.html
@@ -0,0 +1,158 @@
+
+
+
+
+
+ SubMiner Anime
+
+
+
+
+