Compare commits

...

21 Commits

Author SHA1 Message Date
sudacode f5e98dfa9d fix(anime): harden the extension bridge against untrusted repos and hangs
Addresses CodeRabbit review feedback on the Anime Browser:

- reject repository package/apk names that are not plain identifiers, and
  verify the install target resolves inside the extensions directory
- count mpv's %n% option escape in UTF-8 bytes, and escape backslashes in
  header values so a trailing one cannot eat the list separator
- key the bridge extension-id cache by APK content, so an in-place upgrade
  re-uploads instead of running the previous build
- bound every bridge, release-listing, and download request with a timeout
- enforce the APK size limit while streaming rather than after buffering
- read APK bytes on demand instead of holding a base64 copy per extension
  for the lifetime of the browser
- serialize preference mutations and write the file atomically
- handle the sidecar spawn error event, and wait for the child to exit in
  stop() before returning
- report a failed Anime Browser bootstrap instead of showing the starting
  banner forever
- keep the preferences panel's save confirmation and in-flight multi-select
  edits by re-rendering only on a structural schema change
2026-07-31 17:57:01 -07:00
sudacode e64ff1a0ee feat(anime): add anime browser powered by Aniyomi extensions
- Add `subminer anime` / `--anime` and a tray entry to open a browser that searches installed Aniyomi extension sources, shows cover art and episodes, and plays into mpv with overlay/mining attached
- Add an Extensions tab to add repos and install/update/remove sources, and per-source settings for sources needing config
- Support searching all sources at once with streaming, per-source results and status
- Prefer Japanese audio/subtitle tracks from the source and keep the primary subtitle slot reserved for Japanese
- Fix window/tray/Dock handling so the browser and mpv can be switched between without quitting the app or losing the Dock icon
- Add anime.repos, anime.extensionsDir, anime.preferredQuality config keys (no bundled repos or discovery)
2026-07-31 17:21:12 -07:00
sudacode b204d4dd6e feat(anki): add configurable word card type for Kiku/Lapis (#175) 2026-07-31 17:17:29 -07:00
sudacode 89ed675935 fix(overlay): keep Yomitan popup interactive on macOS/Windows (#177) 2026-07-30 19:47:08 -07:00
sudacode 1995200e76 chore(release): prepare v0.19.0 2026-07-29 22:26:03 -07:00
sudacode e876e483d6 chore(release): prepare v0.19.0-beta.5 2026-07-29 02:09:01 -07:00
sudacode 6d2a72e13b feat(stats): add library entry deletion and app-wide delete progress (#174) 2026-07-29 02:00:16 -07:00
sudacode 0d7084c8aa fix(anilist): resolve later seasons via sequel relations, not title guessing (#173) 2026-07-28 22:56:26 -07:00
sudacode 95e0abc7b7 test(immersion): apply runtime SQLite pragmas in query tests
The query test suite opened each temp database with SQLite's defaults
(rollback journal, synchronous=FULL) while the runtime opens them via
applyPragmas (WAL, synchronous=NORMAL). Every statement therefore ran as
its own fsync-ing transaction.

That is unnoticeable on a fast local disk but dominates on CI: the file
took 40.6s there versus 0.5s locally, and the 106-insert case
'getVocabularyStats pages past hidden rows' crossed the 5s per-test
timeout and failed the quality gate.

Open test databases through a helper that applies the same pragmas as
production, so the tests exercise the runtime's actual SQLite
configuration instead of a slower one.
2026-07-28 02:23:16 -07:00
sudacode d9155ceacb docs: update requirements table and Windows install instructions
- Add TsukiHime and AniSkip to feature table; note xz dependency
- Clarify Anki+AnkiConnect is required to mine, not to run
- Rework Windows setup with winget/scoop commands and PATH steps
- Note ffmpeg has no path-override setting, unlike mpv
- Fix screenshot alt text and installation.md anchor link
2026-07-28 02:10:50 -07:00
sudacode 9d0585423c chore(release): prepare v0.19.0-beta.4 2026-07-28 01:09:48 -07:00
sudacode 455cfff90a docs: fix inaccuracies and add link/anchor validation tests
- Generate config.example.jsonc with a Linux socket path instead of Windows, so the example stays reproducible across platforms
- Document TsukiHime config (`tsukihime.*`), maturity-based known-word highlighting keys, and other config surfaces missing from the reference
- Correct secondary-subtitle auto-load defaults and Anki field-matching (case-insensitive) claims
- Expand Windows installation guide with winget/Scoop package tables and manual PATH setup
- Add docs-site/links.test.ts to catch broken internal links and heading-anchor mismatches
- Fix stale cross-references in jimaku-integration.md and demos.md
2026-07-28 00:56:38 -07:00
sudacode 987b325edb Anki maturity-based known-word highlighting (#172) 2026-07-27 23:58:02 -07:00
sudacode 08c6807cb1 fix(launcher): normalize rofi prompt spacing
- Add formatRofiPrompt to trim trailing whitespace and append a single space, keeping the prompt from running into the input field
- Apply it across all rofi -p usages in picker.ts and history-command.ts
- Add tests for formatRofiPrompt edge cases (empty/whitespace-only prompts)
2026-07-26 00:13:54 -07:00
sudacode 18c6410f24 feat(launcher): add previous episode option to history entry menu
- Extract buildHistoryEntryActions to build the series action menu, now including a previous-episode option alongside replay/next/browse/quit
- Update docs and changelog entry to describe the new option
- Add tests covering previous/replay/next ordering and omission when last watched file is missing
2026-07-25 23:35:44 -07:00
sudacode e223cf9b71 fix(release): restore compatible js-yaml override 2026-07-22 23:51:21 -07:00
sudacode c718bb8b34 chore(release): prepare 0.19.0-beta.2 2026-07-22 23:34:08 -07:00
sudacode 177f7e5fd6 docs(readme): remove TsukiHime credit
- Drop the TsukiHime row from the open-source credits table
2026-07-22 23:07:07 -07:00
sudacode 20b50ec4e1 feat(launcher): add post-playback history menu with previous episode (#170) 2026-07-22 23:05:53 -07:00
sudacode deae61b211 refactor: split anki-connect and stats-server resolvers into modules (#169) 2026-07-17 23:05:59 -07:00
sudacode 44959ed282 fix(anki,appimage): handle proxy port conflicts and stray GPU child
- AnkiConnect proxy now detects EADDRINUSE, logs a warning, and surfaces an overlay notification instead of crashing video startup
- Background AppImage bootstrap runs Chromium with in-process-gpu so no GPU child survives app.exit() and outlives the FUSE mount, fixing the remaining DrKonqi "Service Crash" case
- Add changelog fragment for the proxy fix; update the AppImage quit fragment with the GPU child root cause
2026-07-17 23:04:56 -07:00
297 changed files with 20690 additions and 4483 deletions
+57
View File
@@ -1,12 +1,61 @@
# 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.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.
- Cross-Machine Sync: Added SSH-based syncing of stats and watch history between machines, available from the tray ("Sync Stats & History") or `subminer sync`, with saved devices, per-host sync direction, background auto-sync, connection testing, manual snapshots, and support for Windows remotes.
- 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.
- AppImage Quit Crash: Fixed a "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
- Autoplay Pause Timing: Fixed playback resuming a few seconds before subtitle tokenization warmup finished, most noticeable when resuming mid-episode or when a cue starts within the first two seconds.
- Stats Known-Word Count: Fixed stats reporting 0 known words for every session after the known-word cache format changed.
- Stats Library Cover Art: Relinking a title to a different AniList entry now updates its cover in the Library grid immediately instead of leaving a stale, mismatched cover cached.
- Rofi Prompt Spacing: Rofi menu prompts now keep a space before the input field instead of running into the placeholder text.
- Stats Settings & Reliability: Hardened stats settings validation (nested/legacy AnkiConnect config now falls back safely instead of breaking) and stats routes against malformed requests and other edge cases.
- Stats Delete Performance: Deleting sessions, episodes, and library entries is now dramatically faster and no longer stalls playback (e.g. a 12-episode title dropped from about a minute to under a second on a large library); the Vocabulary tab also loads much faster. The first launch after upgrading runs a one-time database migration.
<details>
<summary>Internal changes</summary>
### 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.
- Removed the unused stats IPC transport and unified stats dashboard HTTP types with the backend contract.
- Added a script to verify known-word highlight tiers against live Anki data outside of playback.
</details>
## 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.
@@ -14,6 +63,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.
@@ -27,6 +77,7 @@
<summary>Internal changes</summary>
### 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.
</details>
@@ -34,15 +85,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.
@@ -123,6 +177,7 @@
<summary>Internal changes</summary>
### 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.
@@ -132,9 +187,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.
+43 -16
View File
@@ -66,7 +66,7 @@ Local stats dashboard tracking watch time, vocabulary growth, mining throughput,
Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback.
<div align="center">
<img src="docs-site/public/screenshots/playlist-browser.png" width="800" alt="Stats dashboard showing watch time, cards mined, streaks, and tracking data">
<img src="docs-site/public/screenshots/playlist-browser.png" width="800" alt="Playlist browser modal showing sibling episode files beside the active mpv queue">
</div>
<br>
@@ -90,6 +90,14 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
<td><b>Jimaku</b></td>
<td>Search and download Japanese subtitles</td>
</tr>
<tr>
<td><b>TsukiHime</b></td>
<td>Search and download subtitles extracted from anime releases, with Japanese and secondary-language tabs (<code>Ctrl+Shift+T</code>) — no API key, requires <code>xz</code> on your <code>PATH</code></td>
</tr>
<tr>
<td><b>AniSkip</b></td>
<td>Automatic intro detection with chapter markers and a one-key skip (<code>TAB</code> by default)</td>
</tr>
<tr>
<td><b>alass / ffsubsync</b></td>
<td>Manual subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</td>
@@ -110,18 +118,19 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
## Requirements
Only **mpv** and Anki+AnkiConnect are required. Everything else is optional but enhances the experience.
Only **mpv** is required to run SubMiner. Anki + AnkiConnect are required to mine cards, which is the point of the app, but everything else is optional.
| Dependency | Status | What it does |
| -------------------- | ----------- | ---------------------------------------- |
| mpv | Required | The video player SubMiner overlays on |
| Anki + AnkiConnect | Required | Card creation from the Yomitan popup |
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
| MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
| yt-dlp | Optional | YouTube playback |
| fzf / rofi | Optional | Video picker in the launcher |
| alass / ffsubsync | Optional | Subtitle sync |
| guessit | Optional | Better anime title and episode detection |
| Dependency | Status | What it does |
| -------------------- | ---------------- | -------------------------------------------------------- |
| mpv | Required | The video player SubMiner overlays on |
| Anki + AnkiConnect | Required to mine | Card creation from the Yomitan popup |
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
| MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
| yt-dlp | Optional | YouTube playback |
| xz | Optional | TsukiHime subtitle downloads (not on Windows by default) |
| alass / ffsubsync | Optional | Subtitle sync |
| guessit | Optional | Better anime title and episode detection |
| fzf / rofi | Optional | Video picker in the `subminer` launcher (Linux/macOS) |
<details>
<summary><b>Platform-specific install commands</b></summary>
@@ -138,9 +147,23 @@ sudo pacman -S --needed mpv ffmpeg mecab mecab-ipadic
brew install mpv ffmpeg mecab mecab-ipadic
```
**Windows:** Install [mpv](https://mpv.io/installation/) and [ffmpeg](https://ffmpeg.org/download.html) and ensure both are on `PATH`.
**Windows:**
See the [full requirements list](https://docs.subminer.moe/installation#1-install-requirements) for optional dependencies.
```powershell
winget install shinchiro.mpv
winget install Gyan.FFmpeg
```
Then reopen your terminal and check `mpv --version` and `ffmpeg -version`. winget puts `ffmpeg` on `PATH` automatically; mpv uses a regular installer that may not, so if `mpv` is not found, either add its folder (usually `%LOCALAPPDATA%\Programs\mpv`) to `PATH` or set `mpv.executablePath` during first-run setup.
[Scoop](https://scoop.sh) is the alternative if you want one package manager for everything, since it is the only one that also carries `xz`:
```powershell
scoop bucket add extras
scoop install extras/mpv main/ffmpeg main/yt-dlp main/xz
```
See the [full requirements list](https://docs.subminer.moe/installation#_1-install-requirements) for optional dependencies.
</details>
@@ -166,6 +189,11 @@ paru -S subminer-bin
mkdir -p ~/.local/bin
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
&& chmod +x ~/.local/bin/SubMiner.AppImage
```
The AppImage is all you need. The optional `subminer` command-line launcher runs on [Bun](https://bun.sh), and first-run setup can install both for you. To grab it manually instead, install Bun first, then:
```bash
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
&& chmod +x ~/.local/bin/subminer
```
@@ -213,7 +241,7 @@ On **Windows**, just run `SubMiner.exe` and the setup will open automatically on
subminer video.mkv # launch mpv with SubMiner
subminer /path/to/dir # pick a file with fzf
subminer -R /path/to/dir # pick a file with rofi (Linux only)
subminer -H # browse local watch history (replay / next episode / browse)
subminer -H # browse history, then previous / replay / next / select / quit
```
On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
@@ -238,7 +266,6 @@ SubMiner builds on the work of these open-source projects:
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
| [TsukiHime](https://tsukihime.org) | Release-track subtitle search and downloads (Animetosho successor) |
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
+75 -188
View File
@@ -10,7 +10,7 @@
"@xhayper/discord-rpc": "^1.3.4",
"axios": "^1.18.1",
"commander": "^14.0.3",
"electron-updater": "^6.8.3",
"electron-updater": "^6.8.9",
"hono": "^4.12.28",
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
@@ -21,9 +21,9 @@
"@types/node": "^24.10.0",
"@types/ws": "^8.18.1",
"electron": "42.6.0",
"electron-builder": "26.8.2",
"electron-builder": "26.15.3",
"esbuild": "^0.25.12",
"eslint": "^10.4.0",
"eslint": "^10.8.0",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"undici": "7.28.0",
@@ -35,20 +35,19 @@
},
"overrides": {
"@xmldom/xmldom": "0.8.13",
"app-builder-lib": "26.8.2",
"electron-builder-squirrel-windows": "26.8.2",
"app-builder-lib": "26.15.3",
"brace-expansion": "5.0.8",
"electron-builder-squirrel-windows": "26.15.3",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.0",
"lodash": "4.18.0",
"minimatch": "10.2.3",
"minimatch": "10.2.5",
"picomatch": "4.0.4",
"tar": "7.5.16",
"tar": "7.5.21",
"tmp": "0.2.7",
},
"packages": {
"7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="],
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
"@discordjs/rest": ["@discordjs/rest@2.6.1", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.40", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "6.27.0" } }, "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg=="],
@@ -67,7 +66,7 @@
"@electron/osx-sign": ["@electron/osx-sign@1.3.3", "", { "dependencies": { "compare-version": "^0.1.2", "debug": "^4.3.4", "fs-extra": "^10.0.0", "isbinaryfile": "^4.0.8", "minimist": "^1.2.6", "plist": "^3.0.5" }, "bin": { "electron-osx-flat": "bin/electron-osx-flat.js", "electron-osx-sign": "bin/electron-osx-sign.js" } }, "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg=="],
"@electron/rebuild": ["@electron/rebuild@4.0.3", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "detect-libc": "^2.0.1", "got": "^11.7.0", "graceful-fs": "^4.2.11", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^11.2.0", "ora": "^5.1.0", "read-binary-file-arch": "^1.0.6", "semver": "^7.3.5", "tar": "^7.5.6", "yargs": "^17.0.1" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA=="],
"@electron/rebuild": ["@electron/rebuild@4.2.0", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^12.2.0", "read-binary-file-arch": "^1.0.6" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ=="],
"@electron/universal": ["@electron/universal@2.0.3", "", { "dependencies": { "@electron/asar": "^3.3.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "dir-compare": "^4.2.0", "fs-extra": "^11.1.1", "minimatch": "^9.0.3", "plist": "^3.1.0" } }, "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g=="],
@@ -131,13 +130,13 @@
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="],
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
"@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="],
@@ -153,8 +152,6 @@
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
"@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.5.28", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Lc/b8JXO2W2+H+5UXfw7PCHZCim1jlrB0CmLPsjfVmihMluBpdYafFImhjAHxHlWGfuZ32WzjVPUap5fGmkthw=="],
@@ -181,11 +178,15 @@
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
"@npmcli/agent": ["@npmcli/agent@3.0.0", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^10.0.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q=="],
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
"@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="],
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.8.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@peculiar/json-schema": ["@peculiar/json-schema@1.1.12", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w=="],
"@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="],
"@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="],
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
@@ -215,12 +216,8 @@
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
"@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="],
"@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
"@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
@@ -229,7 +226,7 @@
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
"abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
"abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -239,21 +236,15 @@
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"app-builder-bin": ["app-builder-bin@5.0.0-alpha.12", "", {}, "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w=="],
"app-builder-lib": ["app-builder-lib@26.8.2", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.2", "electron-builder-squirrel-windows": "26.8.2" } }, "sha512-z3ptLzJwNl35fyR0wxv4qWOfZuU36VysYHnbs8PDtf8S0QzIl2OWimdDFVmCxYMkIV1k/RT9CeTgcP7oUznFOw=="],
"app-builder-lib": ["app-builder-lib@26.15.3", "", { "dependencies": { "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@noble/hashes": "^2.2.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.15.3", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.2.5", "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "unzipper": "^0.12.3", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.15.3", "electron-builder-squirrel-windows": "26.15.3" } }, "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
"astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="],
"asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
@@ -263,27 +254,27 @@
"at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="],
"aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="],
"axios": ["axios@1.18.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
"brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"builder-util": ["builder-util@26.8.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="],
"builder-util": ["builder-util@26.15.3", "", { "dependencies": { "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw=="],
"builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
"cacache": ["cacache@19.0.1", "", { "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", "tar": "^7.4.3", "unique-filename": "^4.0.0" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="],
"bytestreamjs": ["bytestreamjs@2.0.1", "", {}, "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ=="],
"cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="],
@@ -299,16 +290,8 @@
"ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="],
"cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
"cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
"clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -323,8 +306,6 @@
"core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
"crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="],
"cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
@@ -335,8 +316,6 @@
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="],
"defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
@@ -353,9 +332,7 @@
"discord-api-types": ["discord-api-types@0.38.49", "", {}, "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg=="],
"dmg-builder": ["dmg-builder@26.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-DaWI+p4DOqiFVZFMovdGYammBOyJAiHHFWUTQ0Z7gNc0twfdIN0LvyJ+vFsgZEDR1fjgbpCj690IVtbYIsZObQ=="],
"dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="],
"dmg-builder": ["dmg-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
@@ -363,26 +340,24 @@
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"electron": ["electron@42.6.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-axGNgd+yCTg+vi1VEGrQqAj9WVWkePKwbICSAvMiT2eTaxhij9a/xhBHD6rXV8wrlW9ZfJzE5+xg752ImxrmTw=="],
"electron-builder": ["electron-builder@26.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.2", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-ieiiXPdgH3qrG6lcvy2mtnI5iEmAopmLuVRMSJ5j40weU0tgpNx0OAk9J5X5nnO0j9+KIkxHzwFZVUDk1U3aGw=="],
"electron-builder": ["electron-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.3", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA=="],
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "electron-winstaller": "5.4.0" } }, "sha512-kXhajX6DzdIQcTlctVTKoG1oO39JhWcTG0lH7ZEJ4FzPaKJy7KFNfNJUd5BoEmLjv5GlrRZpEOYnniD+LcwNJA=="],
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "electron-winstaller": "5.4.0" } }, "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA=="],
"electron-publish": ["electron-publish@26.8.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="],
"electron-publish": ["electron-publish@26.15.3", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q=="],
"electron-updater": ["electron-updater@6.8.3", "", { "dependencies": { "builder-util-runtime": "9.5.1", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ=="],
"electron-updater": ["electron-updater@6.8.9", "", { "dependencies": { "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig=="],
"electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
@@ -405,7 +380,7 @@
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@10.4.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ=="],
"eslint": ["eslint@10.8.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ=="],
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
@@ -423,14 +398,14 @@
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
"extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
@@ -445,14 +420,10 @@
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
"fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="],
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -501,12 +472,6 @@
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"iconv-corefoundation": ["iconv-corefoundation@1.1.7", "", { "dependencies": { "cli-truncate": "^2.1.0", "node-addon-api": "^1.6.3" }, "os": "darwin" }, "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
@@ -515,29 +480,23 @@
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="],
"is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
@@ -571,16 +530,12 @@
"lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
"log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="],
"lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="],
"make-fetch-happen": ["make-fetch-happen@14.0.3", "", { "dependencies": { "@npmcli/agent": "^3.0.0", "cacache": "^19.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^4.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "ssri": "^12.0.0" } }, "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ=="],
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -591,26 +546,14 @@
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"minimatch": ["minimatch@10.2.3", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg=="],
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="],
"minipass-fetch": ["minipass-fetch@4.0.1", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", "minizlib": "^3.0.1" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ=="],
"minipass-flush": ["minipass-flush@1.0.5", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw=="],
"minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="],
"minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="],
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
@@ -619,17 +562,15 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"node-abi": ["node-abi@4.28.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g=="],
"node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
"node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="],
"node-gyp": ["node-gyp@11.5.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^14.0.3", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "tar": "^7.4.3", "tinyglobby": "^0.2.12", "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ=="],
"node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="],
"nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="],
"node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="],
"nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="],
"normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="],
@@ -637,36 +578,28 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="],
"p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pkijs": ["pkijs@3.4.0", "", { "dependencies": { "@noble/hashes": "1.4.0", "asn1js": "^3.0.6", "bytestreamjs": "^2.0.1", "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw=="],
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
"postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="],
@@ -675,7 +608,9 @@
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="],
"proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
@@ -689,31 +624,33 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
"pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
"quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="],
"read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="],
"resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="],
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
"restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="],
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"sanitize-filename": ["sanitize-filename@1.6.4", "", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg=="],
@@ -733,39 +670,25 @@
"simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="],
"slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="],
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
"socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
"ssri": ["ssri@12.0.0", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ=="],
"stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tar": ["tar@7.5.16", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w=="],
"tar": ["tar@7.5.21", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA=="],
"temp": ["temp@0.9.4", "", { "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="],
@@ -795,21 +718,17 @@
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="],
"unique-slug": ["unique-slug@5.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"unzipper": ["unzipper@0.12.5", "", { "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", "fs-extra": "11.3.1", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } }, "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
"webcrypto-core": ["webcrypto-core@1.9.2", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q=="],
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
@@ -817,8 +736,6 @@
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
@@ -855,42 +772,26 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@npmcli/agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"@types/cacheable-request/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/fs-extra/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/keyv/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/plist/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/responselike/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/ws/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
"app-builder-lib/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
"builder-util/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"cacache/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -899,35 +800,25 @@
"es-set-tostringtag/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"get-intrinsic/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"node-gyp/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="],
"node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
"pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
"postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
"socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="],
"@types/cacheable-request/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
@@ -935,8 +826,6 @@
"@types/keyv/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@types/plist/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@types/responselike/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@types/ws/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
@@ -947,6 +836,8 @@
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"app-builder-lib/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"builder-util/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
@@ -955,11 +846,7 @@
"electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-sized/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
"app-builder-lib/@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
+15
View File
@@ -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 `<userData>/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.
-5
View File
@@ -1,5 +0,0 @@
type: fixed
area: overlay
- Applied configured primary POS exclusions consistently to merged trailing quote-particle tokens, preserved annotations for supplementary-plane kanji, and stopped treating katakana punctuation as kana-only annotation noise.
- Kept kanji vocabulary tagged `名詞/非自立` eligible for N+1 highlighting, consistent with frequency, JLPT, and vocabulary persistence.
@@ -1,4 +0,0 @@
type: changed
area: shortcuts
- Made the clipboard-video playlist shortcut configurable through `shortcuts.appendClipboardVideoToQueue`.
@@ -1,4 +0,0 @@
type: fixed
area: app
- Fixed "Service Crash" desktop notifications (KDE DrKonqi) after closing a video when running the Linux AppImage: on quit, the AppImage runtime unmounted the FUSE squashfs while Chromium utility children (notably the network service) were still shutting down, killing them with SIGBUS. Background launches (`--background`, used by the mpv plugin and the launcher) now run through a small supervisor that mounts the AppImage via `--appimage-mount`, executes `AppRun` from that mount, and releases the mount only after no process is still executing from it. Set `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1` to restore the old direct launch.
@@ -1,4 +0,0 @@
type: fixed
area: overlay
- Fixed `mpv.pauseUntilOverlayReady` releasing playback seconds before tokenization warmup finished: startup subtitle priming emits the current cue untokenized so the overlay can paint early, and that emission was treated as the autoplay-readiness signal as soon as the overlay window loaded. The autoplay gate now ignores untokenized subtitle payloads while tokenization warmup is pending, so playback resumes only after the first tokenized delivery (or the post-warmup release). Most visible when resuming mid-episode or when a subtitle cue starts within the first two seconds.
-7
View File
@@ -1,7 +0,0 @@
type: internal
area: tokenizer
- Added a golden-file regression corpus for the tokenizer/annotation pipeline: recorded Yomitan backend responses and MeCab tokens replay through the real tokenizeSubtitle pipeline in bun tests without Electron or dictionaries.
- Added `record-tokenizer-fixture:electron` script to capture new fixtures from a live Yomitan/MeCab session, with flags for known words, JLPT levels, and annotation toggles.
- Seeded eleven fixtures covering the #147#156 regression classes (grammar-helper suppression, lexical くれる, kanji non-independent nouns, N+1 targeting, reading collisions, unparsed runs, ordinal/honorific prefixes).
- Added `compare-yomitan-api:electron` script that diffs SubMiner tokenization against a stock Yomitan instance via the yomitan-api bridge (segmentation, readings, headword forms).
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed the macOS Yomitan popup going inert after mining a card: clicking outside the popup no longer passes through to mpv (with the overlay flickering hidden and shown), and scrolling over the popup scrolls its definitions again instead of seeking mpv.
-4
View File
@@ -1,4 +0,0 @@
type: internal
area: overlay
- Consolidated renderer modal state handling into a descriptor registry.
-4
View File
@@ -1,4 +0,0 @@
type: internal
area: release
- Consolidated pull request, stable release, and prerelease quality checks in one reusable workflow, with Lua mpv plugin tests and blocking high-severity dependency audits running in every gate.
-8
View File
@@ -1,8 +0,0 @@
type: added
area: sync
- Added cross-machine immersion sync for stats and watch history over SSH, available as a window (**Sync Stats & History** in the tray menu, or `subminer sync --ui`) and as a command (`subminer sync <host>`, with `--push` / `--pull` for one-way insert-only transfers). The window keeps saved devices with per-host direction, one-click sync with live stage-by-stage progress and separate merge summaries for each machine, connection testing for first-time setup, cancellable runs while the app/stats server/playback is active, and manual database snapshots (create/merge/reveal/delete, stored in `/tmp/subminer-db-snapshots/` by default). Hosts with auto-sync enabled sync in the background on a configurable interval, including during playback, with results reported as overlay notifications; hosts synced from the CLI are remembered in `<config dir>/sync-hosts.json` and appear in the window automatically.
- Merges are an insert-only union keyed on session UUIDs / video keys / series title keys, so re-syncing is idempotent and nothing is double-counted: each side snapshots its database (`VACUUM INTO`) from a consistent WAL point, snapshots are exchanged with `scp`, and each machine merges the other's data transactionally. Lifetime totals and daily/monthly rollups are updated incrementally (history older than the session retention window is preserved), unfinished sessions are excluded until a later sync sees them finalized, and remote-only historical rollups are copied only when they do not conflict with retained local session history. Sync aborts on stats schema version mismatches and refuses to run while the stats daemon or a live mpv session is active (`--force` overrides).
- The sync engine runs only inside the app: the sync window and the `subminer sync` command both delegate to `SubMiner --sync-cli` (headless, works over SSH with no display), so neither machine needs bun or the command-line launcher. A remote machine only needs SubMiner itself, found automatically as the app binary or via the launcher proxy.
- Windows remotes are supported: sync detects the remote shell (POSIX, cmd, or PowerShell) and manages remote temp files through SubMiner itself (`sync --make-temp` / `--remove-temp`) instead of `mktemp` / `rm`, so a Windows machine with the built-in OpenSSH Server works as a sync remote, found in its default Windows install location automatically.
- Added supporting flags: `subminer sync <host> --check` tests the SSH connection and remote launcher availability without syncing, `subminer sync --snapshot <file>` and `--merge <file>` expose the underlying steps for manual transfers, and `subminer sync --json` emits machine-readable NDJSON progress (the protocol the sync window consumes).
-4
View File
@@ -1,4 +0,0 @@
type: added
area: overlay
- Added a TsukiHime integration for downloading primary and secondary subtitles, mirroring the Jimaku flow: `Ctrl+Shift+T` (configurable via `shortcuts.openTsukihime`) opens an in-overlay modal with a Japanese primary tab and a secondary tab that follows `secondarySub.secondarySubLanguages`. It parses the current video filename, searches TsukiHime releases, lists extracted text subtitle tracks filtered by the active tab, then downloads the chosen track, decompresses it (requires the `xz` binary), saves it next to the video with a language suffix (`<video>.en.<ext>`, `.ja` for Japanese tracks, etc.), and loads Japanese into mpv's primary slot or configured secondary tracks into its secondary slot. TsukiHime carries the Animetosho index and mirrors its attachment storage, so older releases stay reachable. No API key is required; also reachable via `subminer --open-tsukihime`, the `__tsukihime-open` keybinding command, and configurable under a new `tsukihime` config section.
-4
View File
@@ -1,4 +0,0 @@
type: internal
area: stats
- Removed the unused stats IPC data transport and unified the stats dashboard's HTTP wire types with the backend contract.
+6
View File
@@ -0,0 +1,6 @@
type: added
area: anki
- Added `ankiConnect.lapisKiku.wordCardKind` (Settings > Mining/Anki > Kiku/Lapis Features > "Word Card Type") to choose which card-type flag SubMiner marks on Kiku/Lapis word cards: `word-and-sentence` (default, unchanged behavior), `click`, `sentence`, `audio`, or `none` to leave the flags alone. Requested by Kiku users who only want `IsClickCard` set.
- Word cards can now be flagged as Kiku click cards (`IsClickCard`), which SubMiner previously never set.
- Setting a card-type flag now clears `IsClickCard` alongside the other card-type flags, so a note can no longer claim two card types at once.
+26 -3
View File
@@ -433,6 +433,12 @@
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
"knownWordMaturityColors": {
"new": "#ee99a0", // Color for known words whose Anki cards are new (never reviewed), when maturity highlighting is enabled.
"learning": "#b7bdf8", // Color for known words whose Anki cards are in (re)learning, when maturity highlighting is enabled.
"young": "#91d7e3", // Color for known words whose Anki cards are in review below the mature threshold, when maturity highlighting is enabled.
"mature": "#a6da95" // Color for known words whose Anki cards are at or above the mature interval threshold, when maturity highlighting is enabled.
}, // Known word maturity colors setting.
"jlptColors": {
"N1": "#ed8796", // N1 setting.
"N2": "#f5a97f", // N2 setting.
@@ -517,7 +523,7 @@
// ==========================================
// AnkiConnect Integration
// Automatic Anki updates and media generation options.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
// Shared AI provider transport settings are read from top-level ai and typically require restart.
// Most other AnkiConnect settings still require restart.
// ==========================================
@@ -569,6 +575,8 @@
}, // Media setting.
"knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"maturityEnabled": false, // Color known-word highlights by Anki card maturity (new, learning, young, mature) instead of a single color. Requires known-word highlighting. Values: true | false
"matureThresholdDays": 21, // Card interval in days at which a known word counts as mature (Anki convention: 21).
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
@@ -597,9 +605,24 @@
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
} // Is kiku setting.
}, // Is kiku setting.
"lapisKiku": {
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none
} // 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 <userData>/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.
@@ -683,7 +706,7 @@
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
"socketPath": "\\\\.\\pipe\\subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
+3 -1
View File
@@ -28,7 +28,7 @@ const outDir = optionalEnv(process.env.SUBMINER_DOCS_OUT_DIR);
const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? process.cwd();
const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.14.0';
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.18.0';
const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST);
const versionLinkOrigin =
optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? 'production';
@@ -306,6 +306,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
{ text: 'Usage', link: '/usage' },
{ text: 'Mining Workflow', link: '/mining-workflow' },
{ text: 'Launcher Script', link: '/launcher-script' },
{ text: 'Feature Demos', link: '/demos' },
],
},
{
@@ -326,6 +327,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' },
+6 -2
View File
@@ -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 {
@@ -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');
});
+59 -38
View File
@@ -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;
}
+12 -10
View File
@@ -39,7 +39,7 @@ SubMiner monitors playback and triggers an AniList progress update when an episo
The update flow:
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename and path. Season folders such as `Season 2` are treated as a strong season signal. SubMiner tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
2. **AniList search** -- The detected title is searched against the AniList GraphQL API. For season 2 and later files, SubMiner searches the season-specific title first, then falls back to the base title. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count.
2. **AniList search** -- The base title (with any `Season N` / `SN` marker stripped) is searched against the AniList GraphQL API, and SubMiner picks the best match by comparing titles (romaji, English, native, synonyms) and filtering by episode count. AniList has no notion of numbered seasons -- sequels are separate entries with their own titles (`Zoku`, `Kan`, `2nd Season`), so searching `<title> Season 3` finds nothing. For season 2 and later, SubMiner instead walks `SEQUEL` relations from the season 1 entry, preferring the TV line, and falls back to ordering the franchise's TV entries by air date when the relation chain is incomplete. If neither locates the season, SubMiner **skips the update** rather than writing progress to the season 1 entry, and tells you to pin the right entry with a [character dictionary override](/character-dictionary#correcting-anilist-matches).
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`, or `COMPLETED` when the watched episode is the final episode of the season (the "already at this progress" skip is bypassed for the final episode so completion still lands).
@@ -69,6 +69,8 @@ SubMiner fetches cover art from AniList for display in the stats dashboard. When
A no-match result is cached for 5 minutes before SubMiner retries, preventing repeated API calls for unrecognized media.
If the automatic match is wrong, use **Change AniList Entry** on a title in the stats Library. Relinking rewrites the cached art for every episode of that title, and both the detail view and the Library grid pick up the new cover right away: the grid refetches after a relink, and cover responses carry an ETag and are revalidated on each request instead of being cached for a day.
## Rate Limiting
All AniList API calls go through a shared rate limiter that enforces a sliding window of 20 requests per minute. The limiter also reads AniList's `X-RateLimit-Remaining` and `Retry-After` response headers and pauses requests when the server signals throttling. This applies to both episode tracking and cover art fetching.
@@ -93,15 +95,15 @@ All AniList API calls go through a shared rate limiter that enforces a sliding w
}
```
| Option | Values | Description |
| ------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ |
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 18760) |
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) |
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one |
| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded |
| Option | Values | Description |
| ------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------ |
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 18760) |
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) |
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one |
| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded |
There is no `characterDictionary.enabled` key: character dictionary sync is enabled by `subtitleStyle.nameMatchEnabled`. See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format.
+182
View File
@@ -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 `<userData>/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 `<userData>/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 `<userData>/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 `<userData>/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
+3 -3
View File
@@ -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.
+19 -4
View File
@@ -289,6 +289,21 @@ Trigger with the mine sentence shortcut (`Ctrl/Cmd+S` by default). The card is c
To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (19) to select how many recent lines to combine.
## Word Card Type (Kiku/Lapis)
Word cards get a card-type flag when SubMiner fills their sentence, whether that comes from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining. By default the flag is `IsWordAndSentenceCard`; pick a different one with `ankiConnect.lapisKiku.wordCardKind`.
```jsonc
"ankiConnect": {
"isKiku": { "enabled": true },
"lapisKiku": {
"wordCardKind": "click" // word-and-sentence (default), click, sentence, audio, none
}
}
```
`click` marks `IsClickCard`, `sentence` marks `IsSentenceCard`, `audio` marks `IsAudioCard`, and `none` leaves the flags untouched for templates that manage them elsewhere. Whichever flag is chosen, the other card-type flags are cleared so the note never claims two card types. The setting is only read when `isKiku` or `isLapis` is enabled, and cards mined with Mine Sentence or Mine Audio keep their own flag.
## Field Grouping (Kiku)
When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types like [Kiku](https://github.com/youyoumu/kiku) that support grouped sentence/audio/image fields.
@@ -313,11 +328,11 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
### What Gets Merged
| Field | Merge behavior |
| -------- | ---------------------------------------- |
| Field | Merge behavior |
| -------- | --------------------------------------------- |
| Sentence | Both cards' sentences kept as grouped entries |
| Audio | Both cards' `[sound:...]` entries kept |
| Image | Both cards' images kept |
| Audio | Both cards' `[sound:...]` entries kept |
| Image | Both cards' images kept |
Identical values from both cards are kept as separate grouped entries; the merge does not deduplicate.
+57 -2
View File
@@ -1,12 +1,59 @@
# Changelog
## v0.18.0 (2026-07-10)
## 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.
- Cross-Machine Sync: Added SSH-based syncing of stats and watch history between machines, available from the tray ("Sync Stats & History") or `subminer sync`, with saved devices, per-host sync direction, background auto-sync, connection testing, manual snapshots, and support for Windows remotes.
- 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.
- AppImage Quit Crash: Fixed a "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
- Autoplay Pause Timing: Fixed playback resuming a few seconds before subtitle tokenization warmup finished, most noticeable when resuming mid-episode or when a cue starts within the first two seconds.
- Stats Known-Word Count: Fixed stats reporting 0 known words for every session after the known-word cache format changed.
- Stats Library Cover Art: Relinking a title to a different AniList entry now updates its cover in the Library grid immediately instead of leaving a stale, mismatched cover cached.
- Rofi Prompt Spacing: Rofi menu prompts now keep a space before the input field instead of running into the placeholder text.
- Stats Settings & Reliability: Hardened stats settings validation (nested/legacy AnkiConnect config now falls back safely instead of breaking) and stats routes against malformed requests and other edge cases.
- Stats Delete Performance: Deleting sessions, episodes, and library entries is now dramatically faster and no longer stalls playback (e.g. a 12-episode title dropped from about a minute to under a second on a large library); the Vocabulary tab also loads much faster. The first launch after upgrading runs a one-time database migration.
<details>
<summary>Internal changes</summary>
**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.
- Removed the unused stats IPC transport and unified stats dashboard HTTP types with the backend contract.
- Added a script to verify known-word highlight tiers against live Anki data outside of playback.
</details>
## Previous Versions
<details>
<summary>v0.18.x</summary>
<h2>v0.18.0 (2026-07-10)</h2>
**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.
@@ -14,6 +61,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.
@@ -27,11 +75,12 @@
<summary>Internal changes</summary>
**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.
</details>
## Previous Versions
</details>
<details>
<summary>v0.17.x</summary>
@@ -39,15 +88,18 @@
<h2>v0.17.2 (2026-06-28)</h2>
**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.
<h2>v0.17.1 (2026-06-27)</h2>
**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.
@@ -133,6 +185,7 @@
<summary>Internal changes</summary>
**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.
@@ -147,9 +200,11 @@
<h2>v0.15.2 (2026-06-02)</h2>
**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.
+3 -1
View File
@@ -223,7 +223,9 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary
subminer app --session-action '{"actionId":"openCharacterDictionaryManager"}'
```
Manual selections are stored in `character-dictionaries/anilist-overrides.json` using a series key derived from the episode's parent directory plus the filename guess. Later episodes in the same directory use the selected AniList ID automatically, while separate season directories can keep separate overrides and character dictionaries. When the override replaces a previous wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
SubMiner stores manual selections in `character-dictionaries/anilist-overrides.json`. The episode's parent directory **and detected season** define the override scope, so later episodes in the same season keep the selected AniList ID even if their filename guesses differ, while a different season never inherits the override -- including when every season sits in one flat folder. When you replace a wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
An override also pins the entry used for [AniList watch progress](/anilist-integration), so correcting a wrong match once fixes both the character dictionary and progress tracking.
## Managing Loaded Entries
+110 -54
View File
@@ -71,6 +71,10 @@ When both files exist, SubMiner prefers `config.jsonc` over `config.json`.
See [config.example.jsonc](/config.example.jsonc) for a comprehensive example with all available options, default values, and detailed comments. Only include the options you want to customize in your config file.
::: warning One value in that file is platform-specific
The example is generated with a fixed Linux/macOS socket path so it stays reproducible, so it shows `"socketPath": "/tmp/subminer-socket"`. On Windows the real default is `\\\\.\\pipe\\subminer-socket`. Leave `mpv.socketPath` out of your config entirely unless you need a custom path, and SubMiner picks the right one for your platform.
:::
Generate a fresh default config from the centralized config registry:
```bash
@@ -145,12 +149,13 @@ The configuration file includes several main sections:
- [**Shared AI Provider**](#shared-ai-provider) - Canonical OpenAI-compatible provider config shared by Anki and YouTube subtitle fixing
- [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media
- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis note types
- [**N+1 Word Highlighting**](#n1-word-highlighting) - Known-word cache and single-target highlighting
- [**N+1 Word Highlighting**](#n-1-word-highlighting) - Known-word cache and single-target highlighting
- [**Field Grouping Modes**](#field-grouping-modes) - Kiku/Lapis duplicate card merging
**External Integrations**
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
- [**TsukiHime**](#tsukihime) - Multi-language subtitle search and download
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
- [**AniList**](#anilist) - Optional post-watch progress updates
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile
@@ -393,29 +398,30 @@ See `config.example.jsonc` for detailed configuration options.
}
```
| Option | Values | Description |
| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
| `primaryVisibleOnYomitanPopup` | boolean | Keep hover-mode primary subtitles visible while the Yomitan popup is open (`true` by default). |
| `nameMatchEnabled` | boolean | Enable character dictionary sync and subtitle token coloring for character-name matches (`false` by default) |
| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`10000` by default) |
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
| Option | Values | Description |
| ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
| `primaryVisibleOnYomitanPopup` | boolean | Keep hover-mode primary subtitles visible while the Yomitan popup is open (`true` by default). |
| `nameMatchEnabled` | boolean | Enable character dictionary sync and subtitle token coloring for character-name matches (`false` by default) |
| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
| `knownWordMaturityColors` | object | Per-tier known-word colors used when `ankiConnect.knownWords.maturityEnabled` is on: `new` (`#ee99a0`), `learning` (`#b7bdf8`), `young` (`#91d7e3`), `mature` (`#a6da95`) |
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`10000` by default) |
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
Subtitle CSS custom properties:
@@ -537,6 +543,8 @@ Display a second subtitle track (e.g., English alongside Japanese) in the overla
See `config.example.jsonc` for detailed configuration options.
Secondary subtitles do **not** auto-load by default. To turn them on for local and Jellyfin playback, set `autoLoadSecondarySub` to `true` and list the language codes you want:
```json
{
"secondarySub": {
@@ -547,11 +555,15 @@ See `config.example.jsonc` for detailed configuration options.
}
```
| Option | Values | Description |
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match |
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
| Option | Values | Description |
| ----------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match. Default is empty (`[]`). |
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load a matching secondary subtitle track for local/Jellyfin sidecar files (default: `false`) |
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
These two settings apply to local and Jellyfin playback only. YouTube secondary selection is fixed to English and ignores them; see [YouTube Integration](/youtube-integration#secondary-subtitle-languages). `defaultMode` still controls how the loaded secondary bar is displayed in every case.
Because the mined-card translation field is filled from the secondary subtitle when one is present, leaving `autoLoadSecondarySub` off means local-file cards fall back to AI translation (when configured) or the original sentence text.
The secondary-subtitle language list also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback.
@@ -862,8 +874,8 @@ When config hot-reload updates shortcut/keybinding/style values, close and reope
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
Current runtime options cover automatic card updates, known-word highlighting,
N+1 annotation, JLPT underlines, frequency highlighting, known-word match mode,
and Kiku field grouping mode.
known-word maturity coloring, N+1 annotation, JLPT underlines, frequency
highlighting, known-word match mode, and Kiku field grouping mode.
Annotation toggles only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
@@ -1030,6 +1042,8 @@ This example is intentionally compact. The option table below documents availabl
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
| `ankiConnect.knownWords.maturityEnabled` | `true`, `false` | Color known words by Anki card maturity (new/learning/young/mature) instead of one color. Requires `knownWords.highlightEnabled` (default: `false`). Tier colors come from `subtitleStyle.knownWordMaturityColors`. |
| `ankiConnect.knownWords.matureThresholdDays` | number | Card interval in days at which a known word counts as mature (default: `21`, matching Anki's own convention) |
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
@@ -1055,6 +1069,9 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
"enabled": true,
"fieldGrouping": "manual",
"deleteDuplicateInAuto": true
},
"lapisKiku": {
"wordCardKind": "word-and-sentence"
}
}
```
@@ -1063,6 +1080,21 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
- Enable `isKiku` to turn on duplicate merge behavior for mined Word/Expression hits.
- When both are enabled, Kiku behavior is applied for grouping while sentence-card model settings are still read from `isLapis`.
- `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes).
- `lapisKiku.wordCardKind` picks the card-type flag set on word cards; see [Word Card Type](#word-card-type). It is read only while `isLapis` or `isKiku` is enabled.
### Word Card Type
When SubMiner fills the sentence on a mined word card - from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining - it marks which card that note should generate. `ankiConnect.lapisKiku.wordCardKind` chooses the flag:
| Value | Flag set |
| ----------------------------- | ----------------------- |
| `word-and-sentence` (default) | `IsWordAndSentenceCard` |
| `click` | `IsClickCard` |
| `sentence` | `IsSentenceCard` |
| `audio` | `IsAudioCard` |
| `none` | none; flags left as-is |
The other card-type flags are cleared so a note never claims two card types at once. Notes are skipped when the note type has no field for the chosen flag, and when the note was already mined as a sentence or audio card. Cards created by Mine Sentence and Mine Audio keep their own flag regardless of this setting.
### N+1 Word Highlighting
@@ -1075,6 +1107,7 @@ Known-word cache policy:
- `subtitleStyle.nPlusOneColor` sets the color for the single target token when exactly one eligible unknown word exists.
- The N+1 minimum sentence-word setting controls the token count required before N+1 highlighting can trigger.
- `subtitleStyle.knownWordColor` sets the known-word highlight color for tokens already in Anki.
- Set `ankiConnect.knownWords.maturityEnabled` to `true` to color known words by Anki card maturity instead, using the four `subtitleStyle.knownWordMaturityColors` tiers. See [Known-Word Maturity Highlighting](/subtitle-annotations#known-word-maturity-highlighting) for how tiers are derived. Changing it or `matureThresholdDays` forces a full cache refresh.
- The known-word deck map accepts an object keyed by deck name.
- Prefer expression/word fields such as `Expression` or `Word`. Avoid reading-only fields unless you intentionally want homophone readings to count as known words.
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
@@ -1113,6 +1146,7 @@ When the manual merge popup opens, SubMiner pauses playback and closes any open
<video controls playsinline preload="metadata" :poster="withBase('/assets/kiku-integration-poster.jpg')" style="width: 100%; max-width: 960px;">
<source :src="withBase('/assets/kiku-integration.webm')" type="video/webm" />
<source :src="withBase('/assets/kiku-integration.mp4')" type="video/mp4" />
Your browser does not support the video tag.
</video>
@@ -1138,6 +1172,28 @@ Configure Jimaku API access and defaults:
Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry delay from the API response.
### TsukiHime
TsukiHime subtitle search works out of the box and needs no account or API key. It does require the `xz` binary on your `PATH`, because TsukiHime serves extracted subtitles xz-compressed.
```json
{
"tsukihime": {
"apiBaseUrl": "https://api.tsukihime.org/v1",
"maxSearchResults": 10
}
}
```
| Option | Values | Description |
| ---------------------------- | ------------ | ----------------------------------------------------------------------------------------------------- |
| `tsukihime.apiBaseUrl` | string (URL) | Base URL of the TsukiHime API (default: `https://api.tsukihime.org/v1`). Only change it for a mirror. |
| `tsukihime.maxSearchResults` | number | Maximum releases returned per search (default: `10`; the API caps this at 100) |
The keyboard shortcut lives under `shortcuts.openTsukihime` (default `Ctrl+Shift+T`; set to `null` to disable). The older `animetosho` section and `shortcuts.openAnimetosho` are still accepted as deprecated aliases, with the current names taking precedence when both are set.
See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, language tabs, and troubleshooting.
### Subtitle Sync
Sync the active subtitle track from the overlay picker using `alass` or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
@@ -1190,17 +1246,17 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
}
```
| Option | Values | Description |
| -------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- |
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 18760) |
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) |
| `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries |
| `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries |
| `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries |
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary settings updates to all Yomitan profiles or only active profile |
| Option | Values | Description |
| -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- |
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 18760) |
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) |
| `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries |
| `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries |
| `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries |
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary settings updates to all Yomitan profiles or only active profile |
When `enabled` is `true` and `accessToken` is empty, SubMiner opens an AniList setup helper window. Keep `enabled` as `false` to disable all AniList setup/update behavior.
@@ -1490,7 +1546,7 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
"executablePath": "",
"launchMode": "normal",
"profile": "",
"socketPath": "\\\\.\\pipe\\subminer-socket",
"socketPath": "/tmp/subminer-socket",
"backend": "auto",
"autoStartSubMiner": true,
"pauseUntilOverlayReady": true,
@@ -1501,18 +1557,18 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
}
```
| Option | Values | Description |
| ------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
| Option | Values | Description |
| ------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (platform-dependent default: `/tmp/subminer-socket`, or `\\\\.\\pipe\\subminer-socket` on Windows) |
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
| `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness, with a 30-second fallback (default: `true`) |
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection, chapter markers, and the skip-intro key (default: `true`) |
| `aniskipButtonKey` | string | mpv key used to skip the detected intro while the skip prompt is visible (default: `"TAB"`) |
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
| `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness, with a 30-second fallback (default: `true`) |
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection, chapter markers, and the skip-intro key (default: `true`) |
| `aniskipButtonKey` | string | mpv key used to skip the detected intro while the skip prompt is visible (default: `"TAB"`) |
If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list.
-3
View File
@@ -20,9 +20,6 @@ Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner aut
</a>
</video>
::: info VIDEO COMING SOON
:::
## Subtitle Download & Sync
Search and download subtitles from Jimaku, then retime them with alass or ffsubsync - all from within SubMiner.
+31
View File
@@ -14,6 +14,10 @@ const ankiIntegrationContents = readFileSync(
'utf8',
);
const configurationContents = readFileSync(new URL('./configuration.md', import.meta.url), 'utf8');
const troubleshootingContents = readFileSync(
new URL('./troubleshooting.md', import.meta.url),
'utf8',
);
function extractReleaseHeadings(content: string, count: number): string[] {
return Array.from(content.matchAll(/^## v[^\n]+$/gm))
@@ -58,6 +62,33 @@ test('docs reflect current launcher and release surfaces', () => {
expect(changelogContents).toContain('v0.5.1 (2026-03-09)');
});
test('docs document config surfaces that are easy to miss when they ship', () => {
// Anki maturity-based known-word highlighting (#172) landed in
// subtitle-annotations.md but was missing from the config reference.
expect(configurationContents).toContain('ankiConnect.knownWords.maturityEnabled');
expect(configurationContents).toContain('ankiConnect.knownWords.matureThresholdDays');
// Every top-level config block should be reachable from the config reference.
expect(configurationContents).toContain('### TsukiHime');
expect(configurationContents).toContain('tsukihime.maxSearchResults');
// xz is a hard runtime dependency of the TsukiHime download path.
expect(installationContents).toContain('xz');
});
test('docs state the real secondary-subtitle and Anki field-matching behavior', () => {
// secondarySub auto-load is off by default; the config example previously
// implied otherwise while youtube-integration.md documented it correctly.
expect(configurationContents).toContain('Secondary subtitles do **not** auto-load by default');
expect(configurationContents).toContain('default: `false`');
// Anki field names are matched case-insensitively (src/anki-integration.ts
// resolveFieldName: exact match first, then a lowercase comparison).
expect(usageContents).not.toContain('exactly (case-sensitive)');
expect(troubleshootingContents).not.toContain('exactly (case-sensitive)');
expect(ankiIntegrationContents).toContain('case-insensitively');
});
test('docs dev server links version navigation to local dev routes', () => {
expect(docsPackageContents).toContain('scripts/build-versioned-docs.ts');
expect(docsPackageContents).toContain(
+2
View File
@@ -57,6 +57,8 @@ Jellyfin stream URLs are normalized to stable item links before stats titles are
When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section.
Open a title and use **Delete Entry** in its header to remove a mistakenly tracked show outright. This deletes every episode of that title along with their sessions, subtitle lines, rollups and cover art, drops the words and kanji that were only seen there, and removes the card from the Library grid. Individual episodes and sessions can still be deleted on their own from the episode list and session rows. Entry deletion is refused while that title is the one currently playing.
![Stats Library](/screenshots/stats-library.png)
#### Trends
+109 -17
View File
@@ -12,20 +12,23 @@ Three steps to get started:
Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional.
| Dependency | Status | What it does |
| -------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| mpv | Required | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
| ffmpeg | Recommended | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
| MeCab + mecab-ipadic | Recommended | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. |
| yt-dlp | Optional | YouTube playback and subtitle extraction. |
| fzf | Optional | Terminal-based video picker in the launcher. |
| rofi | Optional | GUI-based video picker (Linux). |
| chafa | Optional | Thumbnail previews in fzf. |
| ffmpegthumbnailer | Optional | Video thumbnail generation for the picker. |
| guessit | Optional | Better AniSkip title/season/episode parsing. |
| alass | Optional | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. |
| ffsubsync | Optional | Audio-based subtitle sync engine. Disabled without alass or ffsubsync. |
| fuse2 | Linux only | Required to run the AppImage. |
Several entries below exist only for the `subminer` command-line launcher, which is Linux and macOS only. On Windows you launch playback with the **SubMiner mpv** shortcut instead, so you can ignore those rows.
| Dependency | Status | Platforms | What it does |
| -------------------- | ----------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| mpv | Required | All | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
| ffmpeg | Recommended | All | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
| MeCab + mecab-ipadic | Recommended | All | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. |
| yt-dlp | Optional | All | YouTube playback and subtitle extraction. |
| xz | Optional | All | Required for TsukiHime subtitle downloads (subtitles are served xz-compressed). Preinstalled on most Linux distros; not present on Windows by default. |
| guessit | Optional | All | Better AniSkip title/season/episode parsing. |
| alass | Optional | All | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. |
| ffsubsync | Optional | All | Audio-based subtitle sync engine. Disabled without alass or ffsubsync. |
| fzf | Optional | Linux, macOS | Terminal-based video picker in the `subminer` launcher. |
| rofi | Optional | Linux | GUI-based video picker in the `subminer` launcher. |
| chafa | Optional | Linux, macOS | Thumbnail previews in the fzf picker. |
| ffmpegthumbnailer | Optional | Linux, macOS | Video thumbnail generation for the pickers. |
| fuse2 | Required | Linux | Needed to run the AppImage. |
### Linux
@@ -109,9 +112,98 @@ pip install ffsubsync
### Windows
Windows 10 or later. Install [`mpv`](https://mpv.io/installation/) and [`ffmpeg`](https://ffmpeg.org/download.html) and ensure both are on `PATH`. Optionally install [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary.
Windows 10 or later. No compositor tools or window helpers are needed - native window tracking is built in.
No compositor tools or window helpers are needed - native window tracking is built in.
You need **mpv** (required) and **ffmpeg** (strongly recommended, for card audio and screenshots), and both must be on your `PATH`.
::: tip What is PATH?
`PATH` is the list of folders Windows searches when a program asks to run another program by name. SubMiner runs `mpv` and `ffmpeg` by name, so if their folders are not on `PATH`, SubMiner cannot find them even though they are installed. The routes below mostly handle `PATH` for you; the manual route explains how to add a folder yourself.
:::
You can install these with a package manager or by hand. Coverage differs, so pick based on what you need:
| Dependency | winget | Scoop |
| ---------------- | --------------- | ------------- |
| mpv (required) | `shinchiro.mpv` | `extras/mpv` |
| ffmpeg | `Gyan.FFmpeg` | `main/ffmpeg` |
| yt-dlp (YouTube) | `yt-dlp.yt-dlp` | `main/yt-dlp` |
| xz (TsukiHime) | not packaged | `main/xz` |
Use **winget** if you want Microsoft's first-party tool and don't need TsukiHime subtitle downloads. Use **Scoop** if you want one package manager to cover everything, since it is the only one that also packages `xz`.
#### Recommended: winget
[winget](https://learn.microsoft.com/windows/package-manager/winget/) is Microsoft's own package manager and ships with Windows 11 and current Windows 10 (it comes with **App Installer** from the Microsoft Store). In **PowerShell** or **Command Prompt**:
```powershell
winget install shinchiro.mpv
winget install Gyan.FFmpeg
```
Close and reopen your terminal, then check that both are found:
```powershell
mpv --version
ffmpeg -version
```
`ffmpeg` is installed as a portable package, so winget links it into a folder that is already on your `PATH` and it should work right away.
`mpv` uses a regular installer, and depending on the version it may **not** add itself to `PATH`. If `mpv --version` says `not recognized`, you have two easy options:
- Note where it installed (usually `%LOCALAPPDATA%\Programs\mpv`) and add that folder to `PATH` using the manual steps below, or
- Skip `PATH` entirely and set `mpv.executablePath` to the full path of `mpv.exe` during first-run setup.
Once `mpv --version` works, or you have the full path to `mpv.exe` ready, continue to [step 2](#_2-install-subminer).
<details>
<summary><b>Alternative: Scoop (covers every dependency, no admin rights)</b></summary>
[Scoop](https://scoop.sh) installs into your user profile, needs no administrator prompt, and always puts commands on `PATH`. It is the only Windows package manager that carries all of SubMiner's optional dependencies, including `xz`, so it is the best choice if you want a single tool to manage everything.
```powershell
# One-time Scoop setup (skip if you already have it)
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression
# mpv lives in the "extras" bucket; everything else is in "main"
scoop bucket add extras
scoop install extras/mpv main/ffmpeg
# Optional: yt-dlp for YouTube playback, xz for TsukiHime subtitle downloads
scoop install main/yt-dlp main/xz
```
Close and reopen your terminal, then verify with `mpv --version` and `ffmpeg -version`.
</details>
<details>
<summary><b>Manual install (download the zips yourself)</b></summary>
1. Download mpv from [mpv.io/installation](https://mpv.io/installation/) (the Windows builds link) and ffmpeg from [ffmpeg.org/download.html](https://ffmpeg.org/download.html).
2. Unzip each one somewhere permanent, for example `C:\Tools\mpv` and `C:\Tools\ffmpeg`. Note the folder that actually contains `mpv.exe` and the one containing `ffmpeg.exe` (for ffmpeg this is usually a `bin` subfolder).
3. Press `Win`, type **Edit the system environment variables**, and open it. Click **Environment Variables…**, select **Path** under **User variables**, click **Edit…**, then use **New** to add each of those two folders. Confirm with **OK** on every dialog. Microsoft documents this in more detail under [environment variables](https://learn.microsoft.com/windows/deployment/usmt/usmt-recognized-environment-variables).
4. Close and reopen your terminal, since `PATH` changes only apply to newly opened windows. Then check:
```powershell
mpv --version
ffmpeg -version
```
If you see `not recognized as the name of a cmdlet`, the folder you added is not the one holding the `.exe`. Reopen the Path editor and double-check.
::: tip mpv can skip PATH, ffmpeg cannot
If you would rather not edit `PATH` for mpv, set `mpv.executablePath` to the full path of `mpv.exe` during first-run setup instead.
There is no equivalent setting for ffmpeg: SubMiner invokes it by bare name when generating card audio and screenshots, so ffmpeg has to be on `PATH`. Without it, cards are still created but their audio and image fields come out empty. (`subsync.ffmpeg_path` only affects subtitle sync, not card media.)
:::
</details>
**Optional extras:** [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary improves annotation accuracy; it is not in any package manager, so install it from that page. `xz` is needed only for [TsukiHime](/tsukihime-integration) subtitle downloads and is not packaged by winget or Chocolatey, so use `scoop install main/xz` or download [XZ Utils](https://tukaani.org/xz/) and add its folder to `PATH`.
The `subminer` command-line launcher and its picker tools (`fzf`, `rofi`, `chafa`, `ffmpegthumbnailer`) are Linux/macOS only; on Windows you use the **SubMiner mpv** shortcut instead.
## 2. Install SubMiner
@@ -278,7 +370,7 @@ Run the built-in diagnostic to confirm everything is working:
subminer doctor
```
This checks for the app binary, mpv, ffmpeg, config file, and socket path. Fix any failures before continuing.
This checks for the app binary, mpv, ffmpeg, yt-dlp, fzf, rofi, your config file, and the mpv socket path. Only the app binary and mpv are hard failures; the rest are reported as optional. Fix any hard failures before continuing.
## Anki Setup (Recommended)
+10 -10
View File
@@ -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-<uid>-<hash>.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
+18 -18
View File
@@ -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",
},
}
```
@@ -112,5 +112,5 @@ Verify mpv is running and connected via IPC. SubMiner loads the subtitle by issu
## Related
- [Configuration Reference](/configuration#jimaku) - full config options
- [Mining Workflow](/mining-workflow#jimaku-subtitle-search) - how Jimaku fits into the sentence mining loop
- [Mining Workflow](/mining-workflow#related-features) - how Jimaku fits into the sentence mining loop
- [Troubleshooting](/troubleshooting#jimaku) - additional error guidance
+23 -19
View File
@@ -73,9 +73,13 @@ subminer -R -H # rofi history browser
The first menu lists every locally watched series, most recently watched first, using the parsed media title (e.g. the anime title) when available and the directory name otherwise. Selecting a series opens an action menu:
- **Replay last watched** — replays the most recently watched episode
- **Next episode** — plays the episode after the last watched one (continues into the next season directory when the season ends)
- **Browse episodes** — lists the video files in the series directory in episode order, using the same fzf/rofi episode picker as directory browsing; if the series has multiple season directories, a season menu is shown first
- **Previous episode**: plays the episode before the last watched one and continues into the previous season directory when the season starts
- **Replay last watched**: replays the most recently watched episode
- **Next episode**: plays the episode after the last watched one and continues into the next season directory when the season ends
- **Browse episodes**: lists the video files in the series directory in episode order, using the same fzf/rofi episode picker as directory browsing; if the series has multiple season directories, a season menu appears first
- **Quit SubMiner**: closes the history session without starting an episode
After an episode ends or you close mpv, the launcher returns to an action menu for the same series. The menu lists Previous, Rewatch, Next, Select episode, and Quit SubMiner in that order, omitting Previous or Next when no episode exists in that direction. Choosing Previous or Next can move between season directories. After you play another episode, Previous, Rewatch, and Next use it instead of the older database entry. Pressing Escape closes the history session.
Series whose directories are not currently accessible (e.g. an unmounted network share) are hidden from the list. Watch history requires the immersion tracker database (`immersionTracking.dbPath`, default `<config dir>/immersion.sqlite`), which SubMiner populates during playback.
@@ -172,22 +176,22 @@ Use `subminer <subcommand> -h` for command-specific help.
## Options
| Flag | Description |
| --------------------- | --------------------------------------------------------------------------- |
| `-d, --directory` | Video search directory (default: cwd) |
| `-r, --recursive` | Search directories recursively |
| `-R, --rofi` | Use rofi instead of fzf |
| `-H, --history` | Browse local watch history (see [Watch History](#watch-history)) |
| `-v, --version` | Print the launcher's own version (can differ from the installed app binary) |
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
| `--start` | Explicitly start overlay after mpv launches |
| `-S, --start-overlay` | Force the visible overlay on start |
| `-T, --no-texthooker` | Disable texthooker server |
| `-p, --profile` | mpv profile name (no default; omitted unless set) |
| `-a, --args` | Pass additional mpv arguments as a quoted string |
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) |
| `--settings` | Open the SubMiner settings window |
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
| Flag | Description |
| --------------------- | ---------------------------------------------------------------------------- |
| `-d, --directory` | Video search directory (default: cwd) |
| `-r, --recursive` | Search directories recursively |
| `-R, --rofi` | Use rofi instead of fzf |
| `-H, --history` | Browse local watch history (see [Watch History](#watch-history)) |
| `-v, --version` | Print the launcher's own version (can differ from the installed app binary) |
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
| `--start` | Explicitly start overlay after mpv launches |
| `-S, --start-overlay` | Force the visible overlay on start |
| `-T, --no-texthooker` | Disable texthooker server |
| `-p, --profile` | mpv profile name (no default; omitted unless set) |
| `-a, --args` | Pass additional mpv arguments as a quoted string |
| `-b, --backend` | Force window backend (`auto`, `hyprland`, `sway`, `x11`, `macos`, `windows`) |
| `--settings` | Open the SubMiner settings window |
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
App-binary flags such as `--setup`, `--dev`, and `--debug` are not launcher flags - pass them through with `subminer app`, for example `subminer app --setup`.
+132
View File
@@ -0,0 +1,132 @@
import { expect, test } from 'bun:test';
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const docsSiteDir = fileURLToPath(new URL('.', import.meta.url));
// Mirrors VitePress' heading slugifier (vitepress/dist/node, `rControl` + `rSpecial`).
// Note that a *run* of special characters collapses to a single `-`, so
// "KDE Plasma & other" becomes "kde-plasma-other", not "kde-plasma--other".
const rControl = new RegExp('[\\u0000-\\u001f]', 'g');
const rSpecial = /[\s~`!@#$%^&*()\-_+=[\]{}|\\;:"'“”‘’<>,.?/]+/g;
function slugify(heading: string): string {
return heading
.replace(rControl, '')
.replace(rSpecial, '-')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/^(\d)/, '_$1')
.toLowerCase();
}
const EXCLUDED_PAGES = new Set(['README.md']);
const PUBLIC_PREFIXES = ['/assets/', '/screenshots/', '/config.example.jsonc', '/favicon'];
function loadPages(): Map<string, string> {
const pages = new Map<string, string>();
for (const file of readdirSync(docsSiteDir)) {
if (!file.endsWith('.md') || EXCLUDED_PAGES.has(file)) continue;
const route = `/${file.replace(/\.md$/, '')}`;
pages.set(route, readFileSync(`${docsSiteDir}${file}`, 'utf8'));
}
return pages;
}
function anchorsFor(contents: string): Set<string> {
const anchors = new Set<string>();
for (const match of contents.matchAll(/^#{1,6}\s+(.+?)\s*$/gm)) {
let heading = match[1]!;
const explicitId = heading.match(/\{#([^}]+)\}\s*$/);
if (explicitId) {
anchors.add(explicitId[1]!);
heading = heading.replace(/\{#[^}]+\}\s*$/, '');
}
anchors.add(slugify(heading.replace(/`/g, '')));
}
return anchors;
}
function resolveRoute(target: string, fromRoute: string): string {
if (target === '') return fromRoute;
if (target.startsWith('./')) return `/${target.slice(2).replace(/\.md$/, '')}`;
const normalized = target.replace(/\.md$/, '').replace(/\/$/, '');
return normalized === '' ? '/index' : normalized;
}
const pages = loadPages();
const anchors = new Map([...pages].map(([route, body]) => [route, anchorsFor(body)]));
test('every internal docs link resolves to an existing page', () => {
const broken: string[] = [];
for (const [route, body] of pages) {
for (const match of body.matchAll(/\]\((\/[^)\s]*|\.\/[^)\s]*)\)/g)) {
const link = match[1]!;
const target = link.split('#')[0]!;
if (PUBLIC_PREFIXES.some((prefix) => target.startsWith(prefix))) continue;
const resolved = resolveRoute(target, route);
if (resolved !== '/index' && !pages.has(resolved)) {
broken.push(`${route.slice(1)}.md -> ${link}`);
}
}
}
expect(broken).toEqual([]);
});
test('every internal docs anchor matches a real heading slug', () => {
const broken: string[] = [];
for (const [route, body] of pages) {
for (const match of body.matchAll(/\]\((\/[^)\s]*|\.\/[^)\s]*|#[^)\s]*)\)/g)) {
const link = match[1]!;
const hashIndex = link.indexOf('#');
if (hashIndex < 0) continue;
const target = link.slice(0, hashIndex);
const anchor = link.slice(hashIndex + 1);
if (PUBLIC_PREFIXES.some((prefix) => target.startsWith(prefix))) continue;
const resolved = resolveRoute(target, route);
const pageAnchors = anchors.get(resolved);
if (!pageAnchors || pageAnchors.has(anchor)) continue;
broken.push(`${route.slice(1)}.md -> ${link}`);
}
}
expect(broken).toEqual([]);
});
test('slugify matches the VitePress cases these docs actually rely on', () => {
// Regression guards for the anchors that were previously wrong.
expect(slugify('N+1 Word Highlighting')).toBe('n-1-word-highlighting');
expect(slugify('KDE Plasma & other Wayland compositors')).toBe(
'kde-plasma-other-wayland-compositors',
);
expect(slugify('Proxy Mode Setup (Yomitan / Texthooker)')).toBe(
'proxy-mode-setup-yomitan-texthooker',
);
expect(slugify('Kiku/Lapis Integration')).toBe('kiku-lapis-integration');
expect(slugify('Secondary Subtitles')).toBe('secondary-subtitles');
expect(slugify('2. Install SubMiner')).toBe('_2-install-subminer');
});
test('every docs page is reachable from the sidebar', async () => {
const { default: config } = await import('./.vitepress/config');
const sidebar = config.themeConfig?.sidebar as Array<{
items?: Array<{ text: string; link?: string }>;
}>;
const linked = new Set<string>();
for (const group of sidebar) {
for (const item of group.items ?? []) {
if (item.link) linked.add(item.link === '/' ? '/index' : item.link);
}
}
const orphans = [...pages.keys()].filter((route) => !linked.has(route));
expect(orphans).toEqual([]);
});
+1 -1
View File
@@ -183,7 +183,7 @@ If you want to build your own browser client, websocket consumer, or automation
These features support the mining loop but have their own dedicated pages:
- **[Jimaku subtitle search](/jimaku-integration)** - search and download anime subtitle files directly from the overlay (`Ctrl+Shift+J` by default), then load them into mpv.
- **[N+1 word highlighting](/subtitle-annotations#n1-word-highlighting)** - cross-reference your Anki decks to highlight known words, making true N+1 sentences (exactly one unknown word) easy to spot during immersion.
- **[N+1 word highlighting](/subtitle-annotations#n-1-word-highlighting)** - cross-reference your Anki decks to highlight known words, making true N+1 sentences (exactly one unknown word) easy to spot during immersion.
- **[Immersion tracking](/immersion-tracking)** - log watching and mining activity to a local database and view session times, words seen, and cards mined in the built-in stats dashboard.
Next: [Anki Integration](/anki-integration) - field mapping, media generation, and card enrichment configuration.
+14 -14
View File
@@ -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
+1 -1
View File
@@ -8,7 +8,7 @@
"docs:dev": "SUBMINER_DOCS_VERSION_LINK_ORIGIN=local bun run ../scripts/build-versioned-docs.ts && SUBMINER_DOCS_VERSION_LINK_ORIGIN=local SUBMINER_DOCS_VERSION_MANIFEST=\"$(bun run ../scripts/print-docs-version-manifest.ts)\" VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build",
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview --host 0.0.0.0 --port 4173 --strictPort",
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts seo.test.ts .vitepress/theme/status-line.test.ts ../scripts/docs-versioning.test.ts"
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts links.test.ts seo.test.ts .vitepress/theme/status-line.test.ts ../scripts/docs-versioning.test.ts"
},
"dependencies": {
"@catppuccin/vitepress": "^0.1.2",
+26 -3
View File
@@ -433,6 +433,12 @@
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
"knownWordMaturityColors": {
"new": "#ee99a0", // Color for known words whose Anki cards are new (never reviewed), when maturity highlighting is enabled.
"learning": "#b7bdf8", // Color for known words whose Anki cards are in (re)learning, when maturity highlighting is enabled.
"young": "#91d7e3", // Color for known words whose Anki cards are in review below the mature threshold, when maturity highlighting is enabled.
"mature": "#a6da95" // Color for known words whose Anki cards are at or above the mature interval threshold, when maturity highlighting is enabled.
}, // Known word maturity colors setting.
"jlptColors": {
"N1": "#ed8796", // N1 setting.
"N2": "#f5a97f", // N2 setting.
@@ -517,7 +523,7 @@
// ==========================================
// AnkiConnect Integration
// Automatic Anki updates and media generation options.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
// Shared AI provider transport settings are read from top-level ai and typically require restart.
// Most other AnkiConnect settings still require restart.
// ==========================================
@@ -569,6 +575,8 @@
}, // Media setting.
"knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"maturityEnabled": false, // Color known-word highlights by Anki card maturity (new, learning, young, mature) instead of a single color. Requires known-word highlighting. Values: true | false
"matureThresholdDays": 21, // Card interval in days at which a known word counts as mature (Anki convention: 21).
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
@@ -597,9 +605,24 @@
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
} // Is kiku setting.
}, // Is kiku setting.
"lapisKiku": {
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none
} // 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 <userData>/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.
@@ -683,7 +706,7 @@
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
"socketPath": "\\\\.\\pipe\\subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
+3 -1
View File
@@ -251,7 +251,9 @@ test('dev docs version links use local targets for version route testing', async
delete process.env.SUBMINER_DOCS_CHANNEL;
delete process.env.SUBMINER_DOCS_BASE;
delete process.env.SUBMINER_DOCS_VERSION;
delete process.env.SUBMINER_DOCS_LATEST_STABLE;
// Set explicitly (like the sibling version-nav tests) so this assertion stays
// pinned to the manifest under test instead of the config's fallback constant.
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
latestStable: 'v0.14.0',
+18 -18
View File
@@ -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.
+40 -1
View File
@@ -43,6 +43,44 @@ Prefer expression/word fields for `ankiConnect.knownWords.decks`. Reading-only f
Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large.
:::
## Known-Word Maturity Highlighting
Instead of one color for every known word, maturity highlighting tints each known token by the review state of its Anki cards (like asbplayer), giving an at-a-glance sense of how much of a line is solidly learned.
**How it works:**
1. During the known-word cache refresh, SubMiner classifies each note with Anki search filters (`prop:ivl`, `is:learn`) - no extra card data is downloaded.
2. Each note gets the tier of its **most mature** card: `mature` (in review, interval ≥ threshold), `young` (in review, interval below the threshold), `learning` (in the learning or relearning queue), or `new` (never studied). The buckets are disjoint, matching Anki's own card counts: a lapsed card in relearning counts as `learning`, not `young`, even though its interval is ≥ 1 day. A note with a mature card plus a relearning card still shows `mature`.
3. A word matched by several notes takes the most mature tier among them, with the same reading-aware matching as regular known-word highlighting.
4. Known tokens render in the tier color instead of `subtitleStyle.knownWordColor`; if tier data is missing for a match, the token falls back to the single known-word color.
**Key settings:**
| Option | Default | Description |
| ------------------------------------------------ | --------- | --------------------------------------------------------------------- |
| `ankiConnect.knownWords.maturityEnabled` | `false` | Color known words by card maturity (requires known-word highlighting) |
| `ankiConnect.knownWords.matureThresholdDays` | `21` | Card interval in days at which a word counts as mature |
| `subtitleStyle.knownWordMaturityColors.new` | `#ee99a0` | Tier color for never-reviewed cards |
| `subtitleStyle.knownWordMaturityColors.learning` | `#b7bdf8` | Tier color for cards in the learning/relearning queue |
| `subtitleStyle.knownWordMaturityColors.young` | `#91d7e3` | Tier color for young review cards |
| `subtitleStyle.knownWordMaturityColors.mature` | `#a6da95` | Tier color for mature cards |
Changing `maturityEnabled` or the threshold triggers a full known-word cache refresh so tiers are refetched, as does upgrading to a build that revises the tier rules.
How often the `learning` color appears depends on your deck preset: with no relearning steps configured, a lapsed card returns straight to review and shows `young` instead.
While maturity highlighting is on, the session help color legend replaces its single "Known words" swatch with one row per tier (new, learning, young, mature).
**Checking the colors you actually see:**
Tiers are only as fresh as the last known-word cache refresh (`ankiConnect.knownWords.refreshMinutes`), so a card that crosses the mature threshold mid-day keeps its old color until the next refresh. To check a whole episode offline, run the verifier against its subtitle file:
```sh
bun run verify-known-word-highlights:electron -- --input /path/to/episode.ja.srt --audit
```
It tokenizes every cue through the real Yomitan/MeCab pipeline with your live known-word cache, prints each line in your configured tier colors, and summarizes the tier counts. `--audit` re-derives each highlighted tier from live Anki card data (`notesInfo` + `cardsInfo` intervals) and lists any token whose color disagrees, with the note ids and intervals behind it. Electron locks the Yomitan profile, so quit SubMiner first or pass `--profile-copy` to run against a scratch copy. Other useful flags: `--refresh` (refresh the cache first), `--limit <n>`, `--quiet`, `--json`.
## Character-Name Highlighting
Character-name matches are built from the active merged SubMiner character dictionary, which auto-syncs character data from AniList for your recently-watched titles. When the current AniList media ID is known, SubMiner ignores loaded entries from other titles for subtitle name matching and inline portraits. Matching names are highlighted in subtitles and become available for hover-driven Yomitan character profiles - portraits, roles, voice actors, and biographical detail.
@@ -131,6 +169,7 @@ All colors are customizable via the `subtitleStyle.jlptColors` object.
These annotation layers can be toggled at runtime via the runtime options palette (`Ctrl/Cmd+Shift+O`) without restarting:
- `ankiConnect.knownWords.highlightEnabled` (`On` / `Off`)
- `ankiConnect.knownWords.maturityEnabled` (`On` / `Off`)
- `ankiConnect.knownWords.matchMode`
- `ankiConnect.nPlusOne.enabled` (`On` / `Off`)
- `subtitleStyle.enableJlpt` (`On` / `Off`)
@@ -146,6 +185,6 @@ When multiple annotations apply to the same token, the visual priority is:
1. **Character-name match** (highest) - dictionary-driven character-name token styling; it clears the token's N+1, frequency, and JLPT annotations
2. **N+1 target** - the single unknown word in an N+1 sentence
3. **Known-word color** - already-learned token tint
3. **Known-word color** - already-learned token tint (per-tier maturity colors when `maturityEnabled` is on)
4. **Frequency highlight** - common-word coloring (not applied when a higher layer already matched)
5. **JLPT underline** - level-based underline (stacks with N+1/known/frequency since it uses underline rather than text color, but not with a character-name match)
+15 -15
View File
@@ -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.
+104 -87
View File
@@ -16,90 +16,6 @@ SubMiner retries the connection automatically with increasing delays (200 ms, 50
If the overlay never appears at all, see [Playback Startup Flow](./architecture#playback-startup-flow) for how a managed launch starts mpv and brings up the overlay.
## Logging and App Mode
- Default log output is `warn`.
- Use `--log-level` for more/less output.
- Use `--dev`/`--debug` only to force app/dev mode (for example to get dev behavior from the overlay/app); they do not change log verbosity.
- You can combine both, for example `SubMiner.AppImage --start --dev --log-level debug`, when you need maximum diagnostics.
## Performance and Resource Impact
### At a glance
- Baseline: `SubMiner --start` is usually lightweight for normal playback.
- Common spikes come from:
- first subtitle parse/tokenization bursts
- media generation (`ffmpeg` audio/image and AVIF paths)
- media sync and subtitle tooling (`alass`, `ffsubsync`)
- `ankiConnect` enrichment (plus polling overhead when proxy mode is disabled)
### If playback feels sluggish
1. Reduce overlay workload:
- set secondary subtitles hidden:
- `secondarySub.defaultMode: "hidden"`
- disable optional enrichment:
- `subtitleStyle.enableJlpt: false`
- `subtitleStyle.frequencyDictionary.enabled: false`
2. Reduce rendering pressure:
- lower `subtitleStyle.css["font-size"]`
- keep overlay complexity minimal during heavy CPU periods
3. Reduce media overhead:
- keep `ankiConnect.media.imageType` set to `static` (avoid animated AVIF unless needed)
- lower `ankiConnect.media.imageQuality`
- reduce `ankiConnect.media.maxMediaDuration`
4. Lower integration cost:
- disable AI translation when not needed (`ankiConnect.ai.enabled: false`)
- if needed, run immersion telemetry with lower duration expectations (`immersionTracking.enabled: false` for constrained sessions)
- favor the default lightweight YouTube subtitle startup settings on low-resource systems
### Practical low-impact profile
```json
{
"subtitleStyle": {
"css": {
"font-size": "30px"
},
"enableJlpt": false,
"frequencyDictionary": {
"enabled": false
}
},
"secondarySub": {
"defaultMode": "hidden"
},
"ankiConnect": {
"media": {
"imageType": "static",
"imageQuality": 80,
"maxMediaDuration": 12
},
"ai": {
"enabled": false
}
},
"immersionTracking": {
"enabled": false
}
}
```
### If usage is still high
- Confirm only one SubMiner instance is running.
- Check whether bottlenecks are `ffmpeg`, `yt-dlp`, or sync tooling in system monitor.
- Keep the default `warn` level for normal use; raise to `info` or `debug` only for targeted diagnosis.
- Reproduce once with `SubMiner.AppImage --start --log-level debug` and open DevTools (`y` then `d`) if freezes recur.
**"Failed to parse MPV message"**
Logged when a malformed JSON line arrives from the mpv socket. Usually harmless - SubMiner skips the bad line and continues. If it happens constantly, check that nothing else is writing to the same socket path.
@@ -148,7 +64,7 @@ SubMiner retries with exponential backoff (up to 5 s) and suppresses repeated er
**Cards are created but fields are empty**
Field names in your config must match your Anki note type exactly (case-sensitive). Check `ankiConnect.fields` - for example, if your note type uses `SentenceAudio` but your config says `Audio`, the field will not be populated.
Field names in your config must name a field that exists on your Anki note type. Matching is case-insensitive (`sentenceaudio` finds `SentenceAudio`), but the spelling must otherwise match, and unknown fields are skipped silently. Check `ankiConnect.fields` - for example, if your note type uses `SentenceAudio` but your config says `Audio`, the field will not be populated.
See [Anki Integration](/anki-integration) for the full field mapping reference.
@@ -316,17 +232,115 @@ If subtitle sync fails (the error message is prefixed with the engine name):
- Try running the sync tool manually to see detailed error output.
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
## TsukiHime
**"xz binary not found"**
TsukiHime serves extracted subtitles xz-compressed, so SubMiner shells out to `xz` to decompress them. Install it:
- **Arch Linux**: `sudo pacman -S xz`
- **Ubuntu/Debian**: `sudo apt install xz-utils`
- **Fedora**: `sudo dnf install xz`
- **macOS**: `brew install xz`
- **Windows**: neither winget nor Chocolatey packages `xz`. Use `scoop install main/xz`, or download XZ Utils from [tukaani.org/xz](https://tukaani.org/xz/) and add the folder containing `xz.exe` to your `PATH`. Restart SubMiner afterwards.
Most Linux distributions ship it already. See [TsukiHime Integration](/tsukihime-integration#troubleshooting) for the other TsukiHime error messages.
## Jimaku
**"Jimaku request failed" or HTTP 429**
The Jimaku API has rate limits. If you see 429 errors, wait for the retry duration shown in the OSD message and try again. If you have a Jimaku API key, set it in `jimaku.apiKey` or `jimaku.apiKeyCommand` to get higher rate limits.
## Logging and App Mode
- Default log output is `warn`.
- Use `--log-level` for more/less output.
- Use `--dev`/`--debug` only to force app/dev mode (for example to get dev behavior from the overlay/app); they do not change log verbosity.
- You can combine both, for example `SubMiner.AppImage --start --dev --log-level debug`, when you need maximum diagnostics.
## Performance and Resource Impact
### At a glance
- Baseline: `SubMiner --start` is usually lightweight for normal playback.
- Common spikes come from:
- first subtitle parse/tokenization bursts
- media generation (`ffmpeg` audio/image and AVIF paths)
- media sync and subtitle tooling (`alass`, `ffsubsync`)
- `ankiConnect` enrichment (plus polling overhead when proxy mode is disabled)
### If playback feels sluggish
1. Reduce overlay workload:
- set secondary subtitles hidden:
- `secondarySub.defaultMode: "hidden"`
- disable optional enrichment:
- `subtitleStyle.enableJlpt: false`
- `subtitleStyle.frequencyDictionary.enabled: false`
2. Reduce rendering pressure:
- lower `subtitleStyle.css["font-size"]`
- keep overlay complexity minimal during heavy CPU periods
3. Reduce media overhead:
- keep `ankiConnect.media.imageType` set to `static` (avoid animated AVIF unless needed)
- lower `ankiConnect.media.imageQuality`
- reduce `ankiConnect.media.maxMediaDuration`
4. Lower integration cost:
- disable AI translation when not needed (`ankiConnect.ai.enabled: false`)
- if needed, run immersion telemetry with lower duration expectations (`immersionTracking.enabled: false` for constrained sessions)
- favor the default lightweight YouTube subtitle startup settings on low-resource systems
### Practical low-impact profile
```json
{
"subtitleStyle": {
"css": {
"font-size": "30px"
},
"enableJlpt": false,
"frequencyDictionary": {
"enabled": false
}
},
"secondarySub": {
"defaultMode": "hidden"
},
"ankiConnect": {
"media": {
"imageType": "static",
"imageQuality": 80,
"maxMediaDuration": 12
},
"ai": {
"enabled": false
}
},
"immersionTracking": {
"enabled": false
}
}
```
### If usage is still high
- Confirm only one SubMiner instance is running.
- Check whether bottlenecks are `ffmpeg`, `yt-dlp`, or sync tooling in system monitor.
- Keep the default `warn` level for normal use; raise to `info` or `debug` only for targeted diagnosis.
- Reproduce once with `SubMiner.AppImage --start --log-level debug` and open DevTools (`y` then `d`) if freezes recur.
## Platform-Specific
### Linux
- **Wayland (Hyprland/Sway only)**: Native Wayland support is limited to Hyprland and Sway. Window tracking uses compositor-specific commands (`hyprctl` / `swaymsg`). If these are not on `PATH`, tracking will fail silently. Other Wayland compositors (KDE Plasma, GNOME, …) are not supported natively - both mpv and SubMiner must run under X11 or Xwayland instead. On those sessions SubMiner forces XWayland automatically for itself and for every mpv it launches (see [KDE Plasma & other Wayland compositors](#kde-plasma--other-wayland-compositors)).
- **Wayland (Hyprland/Sway only)**: Native Wayland support is limited to Hyprland and Sway. Window tracking uses compositor-specific commands (`hyprctl` / `swaymsg`). If these are not on `PATH`, tracking will fail silently. Other Wayland compositors (KDE Plasma, GNOME, …) are not supported natively - both mpv and SubMiner must run under X11 or Xwayland instead. On those sessions SubMiner forces XWayland automatically for itself and for every mpv it launches (see [KDE Plasma & other Wayland compositors](#kde-plasma-other-wayland-compositors)).
- **X11 / Xwayland**: Requires `xdotool`, `xprop`, and `xwininfo`. If missing, the overlay cannot track the mpv window position. This is the required backend for any Wayland compositor other than Hyprland or Sway - both mpv and SubMiner must be running under X11/Xwayland for window tracking _and_ for the overlay to stay above mpv (Wayland forbids clients from controlling window stacking). SubMiner uses a managed X11 overlay while mpv is windowed, switches to an override-redirect X11 overlay while tracked mpv is fullscreen, and hides/releases that overlay when another X11/Xwayland app takes focus. The visible overlay stays hidden until SubMiner has tracked mpv geometry, so startup should not create a display-sized fallback overlay while tokenization warms up.
- **Tray icon missing**: SubMiner creates an Electron tray icon in `--background` mode, but Linux trays require a StatusNotifier/AppIndicator host. Hyprland does not provide one by itself; enable a tray in Waybar, Hyprpanel, or another panel. If Electron cannot register the tray, SubMiner logs a warning that mentions the missing tray host.
- **Mouse passthrough**: On Linux X11/Xwayland, SubMiner uses `xdotool` to poll the cursor and only enables overlay input while the cursor is over subtitle or popup regions. Outside those regions, pointer input passes through to mpv. Native Wayland compositors other than Hyprland/Sway cannot provide the stacking control SubMiner needs.
@@ -428,8 +442,11 @@ Feature-specific issues are covered in each feature's own page:
- [Character Dictionary](/character-dictionary) - AniList character name matching and inline portraits
- [Jellyfin Integration](/jellyfin-integration) - remote playback and library connection
- [Jimaku Integration](/jimaku-integration) - subtitle fetching and API rate limits
- [TsukiHime Integration](/tsukihime-integration) - multi-language subtitle download and `xz` decompression
- [YouTube Integration](/youtube-integration) - subtitle generation and playback
- [Immersion Tracking](/immersion-tracking) - telemetry and session logging
- [Immersion Tracking](/immersion-tracking) - telemetry, session logging, and the stats dashboard
- [Launcher Script](/launcher-script) - `subminer` commands, pickers, watch history, and cross-machine sync
- [MPV Plugin](/mpv-plugin) - in-player chords, script-opts, and binary auto-detection
- [WebSocket / Texthooker API](/websocket-texthooker-api) - external texthooker clients
- [Subtitle Annotations](/subtitle-annotations) - N+1, frequency, JLPT, and name-match layers
- [Subtitle Sidebar](/subtitle-sidebar) - sidebar navigation and behavior
+98 -117
View File
@@ -33,7 +33,7 @@ If you want sentence, audio, and screenshot fields on your Anki cards, add this
}
```
Field names must match your Anki note type exactly (case-sensitive). See [Anki Integration](/anki-integration) for the full reference.
Field names must match a field on your Anki note type. Matching is case-insensitive (an exact match wins, then a lowercase comparison), but the spelling must otherwise match. See [Anki Integration](/anki-integration) for the full reference.
:::
## How It Works
@@ -56,126 +56,92 @@ From there, subtitles render as interactive, hoverable word spans and you mine c
The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. On Linux, normal `subminer` playback auto-installs the launcher-managed runtime plugin copy from the bundled app if that managed copy is missing, so no separate plugin install is needed for standard launcher usage. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
## Live Config Reload
While SubMiner is running, it watches your active config file and applies safe updates automatically.
Live-updated settings include:
- `subtitleStyle`
- `keybindings`
- `shortcuts`
- `secondarySub.defaultMode`
- `subtitleSidebar`
- `notifications`
- `logging`
- `jimaku`, `subsync`
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`
- `stats.toggleKey`, `stats.markWatchedKey`
- `youtube.primarySubLanguages`
- most `ankiConnect.*` settings (including `ankiConnect.ai`)
Invalid config edits are rejected; SubMiner keeps the previous valid runtime config and shows an error notification.
For restart-required sections, SubMiner shows a restart-needed notification.
## Commands
On Windows, replace `SubMiner.AppImage` with `SubMiner.exe` in the direct packaged-app examples below.
These are the commands you will actually use day to day. The full inventory of subcommands and flags lives in [Launcher Script](/launcher-script#subcommands).
```bash
# Browse and play videos
subminer # Current directory (uses fzf)
subminer -R # Use rofi instead of fzf
subminer -d ~/Videos # Specific directory
subminer -r -d ~/Anime # Recursive search
subminer video.mkv # Play specific file (overlay auto-starts)
subminer --start video.mkv # Explicit overlay start (use when mpv.autoStartSubMiner is false in config)
subminer -S video.mkv # Also force the visible overlay on start (--start-overlay)
subminer video.mkv # Play a specific file
subminer # Browse the current directory (fzf picker)
subminer -R # Browse with the rofi picker instead
subminer -d ~/Anime -r # Browse a specific directory, recursively
subminer -H # Browse watch history, then replay/next/previous
subminer https://youtu.be/... # Play a YouTube URL
subminer ytsearch:"jp news" # Play first YouTube search result
subminer -H # Browse watch history (replay/continue episodes, fzf or rofi picker)
subminer app --setup # Open first-run setup popup
subminer --version # Print the launcher's version
subminer -v # Same as above
subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging
subminer --log-level warn video.mkv # Set logging level explicitly
subminer --args '--fs=opengl-hq --ytdl-format=bestvideo*+bestaudio/best' video.mkv # Pass extra mpv args
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
```
# Options
subminer -T video.mkv # Disable texthooker server
subminer -b x11 video.mkv # Force X11 backend
subminer video.mkv # No mpv profile passed by default
On **Windows** there is no `subminer` launcher. Use the **SubMiner mpv** shortcut for playback (see [Windows mpv Shortcut](#windows-mpv-shortcut)), and run `SubMiner.exe` directly for everything else.
Two flags are worth knowing early:
- `-a/--args` passes extra arguments straight to mpv, for example `subminer --args "--ao=alsa --volume=80" video.mkv`.
- `--log-level debug` turns on verbose logging when something is not working.
<details>
<summary><b>Less common launcher commands</b></summary>
```bash
subminer --start video.mkv # Explicit overlay start (when mpv.autoStartSubMiner is false)
subminer -S video.mkv # Also force the visible overlay on start
subminer -T video.mkv # Disable the texthooker server
subminer -b x11 video.mkv # Force a window backend
subminer -p gpu-hq video.mkv # Use a specific mpv profile
subminer jellyfin # Open Jellyfin setup window (subcommand form)
subminer jellyfin -l --server http://127.0.0.1:8096 --username me --password 'secret'
subminer jellyfin --logout # Clear stored Jellyfin token/session data
subminer jellyfin -p # Interactive Jellyfin library/item picker + playback
subminer jellyfin -d # Jellyfin cast-discovery mode (background tray app)
subminer app --stop # Stop background app (including Jellyfin cast broadcast)
subminer doctor # Dependency + config + socket diagnostics
subminer logs -e # Export a sanitized log ZIP and print its path
subminer config path # Print active config path
subminer config show # Print active config contents
subminer mpv socket # Print active mpv socket path
subminer mpv status # Exit 0 if socket is ready, else exit 1
subminer mpv idle # Launch detached idle mpv with SubMiner defaults
subminer sync media-box # Sync stats/watch history with an SSH host
subminer sync media-box --push # Merge this machine's stats into the host only
subminer sync media-box --pull # Merge the host's stats into this machine only
subminer sync media-box --check # Verify SSH and remote SubMiner without syncing
subminer sync media-box --json # Emit machine-readable NDJSON progress
subminer sync --ui # Open the Sync Stats & History window
subminer sync --snapshot ~/subminer-snapshot.sqlite # Write a local DB snapshot
subminer sync --merge ~/subminer-snapshot.sqlite # Merge a snapshot into the local DB
subminer sync --make-temp # Create an internal sync temp directory
subminer sync --remove-temp /tmp/subminer-sync-123 # Remove an internal sync temp directory
subminer dictionary /path/to/file-or-directory # Generate character dictionary ZIP from target (manual Yomitan import)
subminer dictionary --candidates /path/to/file.mkv
subminer dictionary --select 21355 /path/to/file.mkv
subminer texthooker # Launch texthooker-only mode
subminer texthooker -o # Launch texthooker and open it in your browser
subminer stats # Start the local stats server (see Immersion Tracking)
subminer ytsearch:"jp news" # Play the first YouTube search result
subminer texthooker # Texthooker-only mode (-o also opens the browser)
subminer stats -b # Start/reuse the background stats daemon
subminer stats -s # Stop the background stats daemon
subminer app --anilist-setup # Pass args directly to SubMiner binary (example: AniList login flow)
subminer stats cleanup # Backfill vocabulary metadata, prune stale rows
subminer stats rebuild # Rebuild rollup data
subminer doctor --refresh-known-words # Refresh the known-word cache
subminer logs -e # Export a sanitized log ZIP and print its path
subminer config path # Print the active config path
subminer config show # Print the active config contents
subminer mpv socket # Print the active mpv socket path
subminer mpv status # Exit 0 if the socket is ready, else exit 1
subminer mpv idle # Launch a detached idle mpv with SubMiner defaults
subminer app --stop # Stop the background app
subminer --version # Print the launcher's version
```
# Direct packaged app control
SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs)
SubMiner.AppImage --start --texthooker # Start overlay with texthooker
SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window)
SubMiner.AppImage --texthooker --open-browser # Launch texthooker and open browser
SubMiner.AppImage --setup # Open first-run setup popup
Jellyfin, cross-machine sync, and character-dictionary commands have their own sections: [Jellyfin](/jellyfin-integration), [Sync Between Machines](/launcher-script#sync-between-machines), and [Character Dictionary](/character-dictionary).
</details>
<details>
<summary><b>Direct packaged-app flags (advanced)</b></summary>
These call the app binary directly rather than going through the launcher. On Windows, replace `SubMiner.AppImage` with `SubMiner.exe`.
```bash
SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs)
SubMiner.AppImage --start --texthooker # Start overlay with texthooker
SubMiner.AppImage --texthooker # Texthooker only (no overlay window)
SubMiner.AppImage --setup # Open first-run setup
SubMiner.AppImage --stop # Stop overlay
SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility
SubMiner.AppImage --show-visible-overlay # Force show visible overlay
SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay
SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle primary subtitle bar visibility
SubMiner.AppImage --toggle-subtitle-sidebar # Toggle the subtitle sidebar
SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
SubMiner.AppImage --start --dev # Enable app/dev mode only
SubMiner.AppImage --start --debug # Alias for --dev
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
SubMiner.AppImage --playback-feedback "your feedback" # Route playback feedback through the configured feedback surface
SubMiner.AppImage --start --toggle # Start mpv IPC + toggle visibility
SubMiner.AppImage --show-visible-overlay # Force show the visible overlay
SubMiner.AppImage --hide-visible-overlay # Force hide the visible overlay
SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle the primary subtitle bar
SubMiner.AppImage --toggle-subtitle-sidebar # Toggle the subtitle sidebar
SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
SubMiner.AppImage --yomitan # Open Yomitan settings
SubMiner.AppImage --settings # Open SubMiner settings window
SubMiner.AppImage --jellyfin # Open Jellyfin setup window
SubMiner.AppImage --jellyfin-login --jellyfin-server http://127.0.0.1:8096 --jellyfin-username me --jellyfin-password 'secret'
SubMiner.AppImage --jellyfin-logout # Clear stored Jellyfin token/session data
SubMiner.AppImage --jellyfin-libraries
SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search anime --jellyfin-limit 20
SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID --jellyfin-audio-stream-index 1 --jellyfin-subtitle-stream-index 2 # Requires connected mpv IPC (--start)
SubMiner.AppImage --jellyfin-remote-announce # Force cast-target capability announce + visibility check
SubMiner.AppImage --sync-cli --help # Show the packaged app's headless sync help
SubMiner.AppImage --sync-cli sync media-box # Run the sync engine directly in headless mode
SubMiner.AppImage --dictionary # Generate character dictionary ZIP for current anime
SubMiner.AppImage --dictionary-candidates # List AniList candidates for current character dictionary series
SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin correct AniList media for series
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
SubMiner.AppImage --help # Show all options
```
`--check` performs connection and version checks without changing data. `--json` emits the NDJSON event protocol used by the sync window. `--ui` opens that window in a detached app process and returns the shell immediately; closing a standalone-launched Sync window exits that app instance. `--make-temp` and `--remove-temp` are internal remote-transfer helpers and should normally be left to SubMiner. The packaged app's `--sync-cli` flag selects its headless sync-compatible entrypoint; the `subminer sync` launcher command proxies to it automatically.
The remaining flags are internal or scripting-only surfaces: the `--jellyfin-*` family (login, library listing, item playback, cast announce), `--sync-cli` (the app's headless sync entrypoint that `subminer sync` proxies to), `--dictionary-candidates` / `--dictionary-select`, and `--playback-feedback <text>`. Run `SubMiner.AppImage --help` for the complete list. The previous `--open-animetosho` flag is still accepted as a deprecated alias for `--open-tsukihime`.
The previous `--open-animetosho` flag remains accepted as a deprecated alias for `--open-tsukihime`.
</details>
The tray menu includes `Export Logs`, which creates the same sanitized local-date log ZIP as `subminer logs -e` and shows the archive path when complete. Export sanitization masks common PII and secrets, including home-directory usernames, IP addresses, emails, auth/cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL query strings. The exported copy is sanitized; source log files remain unredacted on disk.
@@ -217,18 +183,11 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
### Launcher Subcommands
- `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
- `subminer doctor`: health checks for core dependencies and runtime paths.
- `subminer settings`: open the SubMiner settings window (also `subminer --settings`).
- `subminer logs -e`: export a sanitized ZIP of today's local-date logs, or the most recent logs when no current-day log exists. The exported copy masks common PII and secrets; on-disk logs are unchanged.
- `subminer config`: config file helpers (`path`, `show`).
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
- `subminer sync <host>`: sync immersion stats and watch history with another machine over SSH. The host is the SSH destination (`user@host` or an SSH config alias). Use `--push` to merge only this machine's data into the host, or `--pull` to merge only the host's data into this machine; both remain insert-only and do not make either database an exact mirror. Remote launcher checks include standard SubMiner and Bun paths even when SSH omits them from `PATH`. Use `--snapshot <file>` to write a consistent local stats DB snapshot, `--merge <file>` to merge a snapshot into the local stats DB, and `--force` to skip the running stats/mpv safety check. Advanced options: `--db <file>` overrides the local stats DB path, and `--remote-cmd <cmd>` overrides the `subminer` command used on the remote host.
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
- Use `subminer dictionary --candidates <path>` and `subminer dictionary --select <id> <path>` to correct AniList character-dictionary matches for a whole series.
- `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`). A _texthooker_ is a web page that displays the current subtitle line as selectable text, so browser-based dictionary extensions and other tools can read along with playback.
- `subminer app` / `subminer bin`: direct passthrough to the SubMiner binary/AppImage.
- Subcommand help pages are available (for example `subminer jellyfin -h`).
The launcher groups related work under subcommands: `jellyfin` (aliased `jf`), `stats`, `sync`, `dictionary` (aliased `dict`), `texthooker`, `doctor`, `settings`, `config`, `mpv`, `logs`, and `app` (aliased `bin`) for passing arguments straight to the SubMiner binary.
Every subcommand has its own help page, for example `subminer jellyfin -h`. See [Launcher Script - Subcommands](/launcher-script#subcommands) for the full table, and [Sync Between Machines](/launcher-script#sync-between-machines) for the SSH stats/history sync.
A _texthooker_ is a web page that displays the current subtitle line as selectable text, so browser-based dictionary extensions and other tools can read along with playback.
### First-Run Setup
@@ -325,6 +284,28 @@ Notes:
For local video files, SubMiner uses the same config-driven language priorities to auto-select the primary and secondary subtitle tracks from internal and external subtitle sources.
## Live Config Reload
While SubMiner is running, it watches your active config file and applies safe updates automatically.
Live-updated settings include:
- `subtitleStyle`
- `keybindings`
- `shortcuts`
- `secondarySub.defaultMode`
- `subtitleSidebar`
- `notifications`
- `logging`
- `jimaku`, `subsync`
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`
- `stats.toggleKey`, `stats.markWatchedKey`
- `youtube.primarySubLanguages`
- most `ankiConnect.*` settings (including `ankiConnect.ai`)
Invalid config edits are rejected; SubMiner keeps the previous valid runtime config and shows an error notification.
For restart-required sections, SubMiner shows a restart-needed notification.
## Controller Support
SubMiner supports gamepad/controller input for couch-friendly usage via the Chrome Gamepad API. Controller input drives the overlay while keyboard-only mode is enabled.
+33 -33
View File
@@ -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 `<br>`. 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
@@ -127,22 +127,22 @@ In practice, if you are building a new client, prefer `annotationWebsocket` unle
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
@@ -377,4 +377,4 @@ ws.on('message', async (raw) => {
- [Mining Workflow - Texthooker](/mining-workflow#texthooker)
- [MPV Plugin](/mpv-plugin)
- [Launcher Script](/launcher-script)
- [Anki Integration](/anki-integration#proxy-mode-setup-yomitan--texthooker)
- [Anki Integration](/anki-integration#proxy-mode-setup-yomitan-texthooker)
+6 -6
View File
@@ -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.
@@ -153,6 +153,6 @@ These settings come from `config.jsonc` (or built-in defaults); there are no CLI
- [Usage --- YouTube Playback](/usage#youtube-playback)
- [Configuration --- YouTube Playback Settings](/configuration#youtube-playback-settings)
- [Configuration --- Secondary Subtitle](/configuration#secondary-subtitle)
- [Configuration --- Secondary Subtitles](/configuration#secondary-subtitles)
- [Keyboard Shortcuts](/shortcuts)
- [Jellyfin Integration](/jellyfin-integration)
@@ -64,15 +64,17 @@ External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out o
A cue parser extracts both timing and text content from subtitle files for prefetching.
**Parsed cue structure:**
```typescript
interface SubtitleCue {
startTime: number; // seconds
endTime: number; // seconds
text: string; // raw subtitle text
startTime: number; // seconds
endTime: number; // seconds
text: string; // raw subtitle text
}
```
**Supported formats:**
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
- ASS: Parse `[Events]` section, extract `Dialogue:` lines, split on the first 9 commas only (ASS v4+ has 10 fields; the last field is Text which can itself contain commas). Strip ASS override tags (`{\...}`) from the text before storing.
ASS text fields contain inline override tags like `{\b1}`, `{\an8}`, `{\fad(200,300)}`. The cue parser strips these during extraction so the tokenizer receives clean text.
@@ -153,6 +155,7 @@ tokens (already have frequencyRank values from parser-level applyFrequencyRanks)
### Dependency Analysis
All annotations either depend on MeCab POS data or benefit from running after it:
- **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately.
- **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data.
- **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data.
@@ -169,18 +172,14 @@ function annotateTokens(tokens, deps, options): MergedToken[] {
// Single pass: known word + frequency filtering + JLPT computed together
const annotated = tokens.map((token) => {
const isKnown = nPlusOneEnabled
? token.isKnown || computeIsKnown(token, deps)
: false;
const isKnown = nPlusOneEnabled ? token.isKnown || computeIsKnown(token, deps) : false;
// Filter frequency rank using POS exclusions (rank values already set at parser level)
const frequencyRank = frequencyEnabled
? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions)
: undefined;
const jlptLevel = jlptEnabled
? computeJlptLevel(token, deps.getJlptLevel)
: undefined;
const jlptLevel = jlptEnabled ? computeJlptLevel(token, deps.getJlptLevel) : undefined;
return { ...token, isKnown, frequencyRank, jlptLevel };
});
@@ -221,6 +220,7 @@ Replace `document.createElement('span')` calls in the renderer with `templateSpa
### Current Behavior
In `renderWithTokens` (`subtitle-render.ts`), each render cycle:
1. Clears DOM with `innerHTML = ''`
2. Creates a `DocumentFragment`
3. Calls `document.createElement('span')` for each token (~10-15 per subtitle)
@@ -256,27 +256,30 @@ Full recycling (collecting old nodes, clearing attributes, reusing them) require
## Combined Impact Summary
| Scenario | Before | After | Improvement |
|----------|--------|-------|-------------|
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
| Scenario | Before | After | Improvement |
| --------------------------------- | ---------- | ---------- | ----------- |
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
---
## Files Summary
### New Files
- `src/core/services/subtitle-prefetch.ts`
- `src/core/services/subtitle-cue-parser.ts`
### Modified Files
- `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`)
- `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass)
- `src/renderer/subtitle-render.ts` (template cloneNode)
- `src/main.ts` (wire up prefetch service)
### Test Files
- New tests for subtitle cue parser (SRT, VTT, ASS formats)
- New tests for subtitle prefetch service (priority window, seek, pause/resume)
- Updated tests for annotation stage (same behavior, new implementation)
+7 -3
View File
@@ -3,7 +3,7 @@
# Domain Ownership
Status: active
Last verified: 2026-05-23
Last verified: 2026-07-15
Owner: Kyle Yasuda
Read when: you need to find the owner module for a behavior or test surface
@@ -16,7 +16,9 @@ Read when: you need to find the owner module for a behavior or test surface
## Product / Integration Domains
- Config system: `src/config/`
- Config system: `src/config/`; Anki resolution is composed by
`src/config/resolve/anki-connect.ts` from focused resolvers in
`src/config/resolve/anki-connect/`
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
- MPV runtime and protocol: `src/core/services/mpv*.ts`
- Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/`
@@ -26,7 +28,9 @@ Read when: you need to find the owner module for a behavior or test surface
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
- Window trackers: `src/window-trackers/`
- Stats app: `stats/`
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
in `src/core/services/stats-server/`
- Stats SPA: `stats/`
- Public docs site: `docs-site/`
## Shared Contract Entry Points
+15 -15
View File
@@ -7,21 +7,21 @@ Last verified: 2026-05-23
Owner: Kyle Yasuda
Read when: finding internal docs or checking verification status
| Area | Path | Status | Last verified | Notes |
| --- | --- | --- | --- | --- |
| KB home | `docs/README.md` | active | 2026-05-23 | internal entrypoint |
| Architecture index | `docs/architecture/README.md` | active | 2026-05-23 | top-level runtime map |
| Domain ownership | `docs/architecture/domains.md` | active | 2026-05-23 | runtime and feature ownership |
| Layering rules | `docs/architecture/layering.md` | active | 2026-05-23 | dependency direction and smells |
| Subtitle overlay priming | `docs/architecture/subtitle-overlay-priming.md` | active | 2026-06-01 | visible-overlay subtitle startup flow |
| KB rules | `docs/knowledge-base/README.md` | active | 2026-05-23 | maintenance policy |
| Core beliefs | `docs/knowledge-base/core-beliefs.md` | active | 2026-03-13 | agent-first principles |
| Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
| Workflow index | `docs/workflow/README.md` | active | 2026-05-23 | execution map |
| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
| Agent plugins | `docs/workflow/agent-plugins.md` | active | 2026-05-23 | repo-local agent workflow plugin ownership |
| Verification guide | `docs/workflow/verification.md` | active | 2026-05-23 | maintained verification lanes |
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
| Area | Path | Status | Last verified | Notes |
| ------------------------ | ----------------------------------------------- | ------ | ------------- | ------------------------------------------ |
| KB home | `docs/README.md` | active | 2026-05-23 | internal entrypoint |
| Architecture index | `docs/architecture/README.md` | active | 2026-05-23 | top-level runtime map |
| Domain ownership | `docs/architecture/domains.md` | active | 2026-05-23 | runtime and feature ownership |
| Layering rules | `docs/architecture/layering.md` | active | 2026-05-23 | dependency direction and smells |
| Subtitle overlay priming | `docs/architecture/subtitle-overlay-priming.md` | active | 2026-06-01 | visible-overlay subtitle startup flow |
| KB rules | `docs/knowledge-base/README.md` | active | 2026-05-23 | maintenance policy |
| Core beliefs | `docs/knowledge-base/core-beliefs.md` | active | 2026-03-13 | agent-first principles |
| Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
| Workflow index | `docs/workflow/README.md` | active | 2026-05-23 | execution map |
| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
| Agent plugins | `docs/workflow/agent-plugins.md` | active | 2026-05-23 | repo-local agent workflow plugin ownership |
| Verification guide | `docs/workflow/verification.md` | active | 2026-05-23 | maintained verification lanes |
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
## Update Rules
+18 -18
View File
@@ -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
@@ -15,13 +15,16 @@
## File Structure
**Backend (`src/core/services/immersion-tracker/`):**
- `query-trends.ts` — add `LibrarySummaryRow` type, `buildLibrarySummary` helper, wire into `getTrendsDashboard`, drop `animePerDay` from `TrendsDashboardQueryResult`, delete now-unused `buildPerAnimeFromSessions` and `buildLookupsPerHundredPerAnime`.
- `__tests__/query.test.ts` — update existing `getTrendsDashboard` test (drop `animePerDay` assertion, add `librarySummary` assertion); add new tests for summary-specific behavior (empty window, multi-title, null lookupsPerHundred).
**Backend test fixtures:**
- `src/core/services/__tests__/stats-server.test.ts` — update `TRENDS_DASHBOARD` fixture (remove `animePerDay`, add `librarySummary`), fix `assert.deepEqual` that references `body.animePerDay.watchTime`.
**Frontend (`stats/src/`):**
- `types/stats.ts` — add `LibrarySummaryRow` interface, add `librarySummary` field to `TrendsDashboardData`, remove `animePerDay` field.
- `lib/api-client.test.ts` — update the two inline fetch-mock fixtures (remove `animePerDay`, add `librarySummary`).
- `components/trends/LibrarySummarySection.tsx`**new** file. Owns the header content: leaderboard Recharts chart + sortable HTML table. Takes `{ rows, hiddenTitles }` as props.
@@ -29,6 +32,7 @@
- `components/trends/anime-visibility.ts` — unchanged. The existing helpers operate on `PerAnimeDataPoint[]`; we'll adapt by passing a derived `PerAnimeDataPoint[]` built from `librarySummary` (or add an overload — see Task 7 for the final decision).
**Changelog:**
- `changes/stats-library-summary.md`**new** changelog fragment.
---
@@ -36,6 +40,7 @@
## Task 1: Backend — Add `LibrarySummaryRow` type and empty stub field
**Files:**
- Modify: `src/core/services/immersion-tracker/query-trends.ts`
- [ ] **Step 1: Add the row type and add `librarySummary: []` to the returned object**
@@ -82,6 +87,7 @@ git commit -m "feat(stats): scaffold LibrarySummaryRow type and empty field"
## Task 2: Backend — TDD the `buildLibrarySummary` helper
**Files:**
- Modify: `src/core/services/immersion-tracker/query-trends.ts`
- Modify: `src/core/services/immersion-tracker/__tests__/query.test.ts`
@@ -160,16 +166,7 @@ test('getTrendsDashboard builds librarySummary with per-title aggregates', () =>
lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ?
WHERE session_id = ?
`,
).run(
`${startedAtMs + activeMs}`,
activeMs,
activeMs,
10,
tokens,
cards,
lookups,
sessionId,
);
).run(`${startedAtMs + activeMs}`, activeMs, activeMs, 10, tokens, cards, lookups, sessionId);
}
for (const [day, active, tokens, cards] of [
@@ -289,8 +286,7 @@ function buildLibrarySummary(
cards: acc.cards,
words: acc.words,
lookups: acc.lookups,
lookupsPerHundred:
acc.words > 0 ? +((acc.lookups / acc.words) * 100).toFixed(1) : null,
lookupsPerHundred: acc.words > 0 ? +((acc.lookups / acc.words) * 100).toFixed(1) : null,
firstWatched: acc.firstWatched,
lastWatched: acc.lastWatched,
});
@@ -334,6 +330,7 @@ git commit -m "feat(stats): build per-title librarySummary from daily rollups an
## Task 3: Backend — Add null-lookupsPerHundred and empty-window tests
**Files:**
- Modify: `src/core/services/immersion-tracker/__tests__/query.test.ts`
- [ ] **Step 1: Write a failing test for `lookupsPerHundred: null` when words == 0**
@@ -402,16 +399,7 @@ test('getTrendsDashboard librarySummary returns null lookupsPerHundred when word
lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ?
WHERE session_id = ?
`,
).run(
`${startMs + 20 * 60_000}`,
20 * 60_000,
20 * 60_000,
5,
0,
0,
0,
session.sessionId,
);
).run(`${startMs + 20 * 60_000}`, 20 * 60_000, 20 * 60_000, 5, 0, 0, 0, session.sessionId);
db.prepare(
`
@@ -464,6 +452,7 @@ git commit -m "test(stats): cover librarySummary null-lookups and empty-window c
## Task 4: Backend — Drop `animePerDay` from the response type and clean up dead helpers
**Files:**
- Modify: `src/core/services/immersion-tracker/query-trends.ts`
- Modify: `src/core/services/immersion-tracker/__tests__/query.test.ts`
- Modify: `src/core/services/__tests__/stats-server.test.ts`
@@ -489,61 +478,61 @@ animePerDay: {
In `getTrendsDashboard` (around lines 649-668 and 694-699), keep the internal `animePerDay` construction (it's still used by `animeCumulative`) but do NOT include it in the returned object. Also drop the now-unused `lookups` and `lookupsPerHundred` fields from the internal `animePerDay` object. Replace the block starting with `const animePerDay = {` through the return statement:
```ts
const animePerDay = {
episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId),
watchTime: buildPerAnimeFromDailyRollups(
dailyRollups,
titlesByVideoId,
(rollup) => rollup.totalActiveMin,
),
cards: buildPerAnimeFromDailyRollups(
dailyRollups,
titlesByVideoId,
(rollup) => rollup.totalCards,
),
words: buildPerAnimeFromDailyRollups(
dailyRollups,
titlesByVideoId,
(rollup) => rollup.totalTokensSeen,
),
};
const animePerDay = {
episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId),
watchTime: buildPerAnimeFromDailyRollups(
dailyRollups,
titlesByVideoId,
(rollup) => rollup.totalActiveMin,
),
cards: buildPerAnimeFromDailyRollups(
dailyRollups,
titlesByVideoId,
(rollup) => rollup.totalCards,
),
words: buildPerAnimeFromDailyRollups(
dailyRollups,
titlesByVideoId,
(rollup) => rollup.totalTokensSeen,
),
};
return {
activity,
progress: {
watchTime: accumulatePoints(activity.watchTime),
sessions: accumulatePoints(activity.sessions),
words: accumulatePoints(activity.words),
newWords: accumulatePoints(
useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs),
),
cards: accumulatePoints(activity.cards),
episodes: accumulatePoints(
useMonthlyBuckets
? buildEpisodesPerMonthFromRollups(monthlyRollups)
: buildEpisodesPerDayFromDailyRollups(dailyRollups),
),
lookups: accumulatePoints(
useMonthlyBuckets
? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount)
: buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
),
},
ratios: {
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
},
librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId),
animeCumulative: {
watchTime: buildCumulativePerAnime(animePerDay.watchTime),
episodes: buildCumulativePerAnime(animePerDay.episodes),
cards: buildCumulativePerAnime(animePerDay.cards),
words: buildCumulativePerAnime(animePerDay.words),
},
patterns: {
watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
watchTimeByHour: buildWatchTimeByHour(sessions),
},
};
return {
activity,
progress: {
watchTime: accumulatePoints(activity.watchTime),
sessions: accumulatePoints(activity.sessions),
words: accumulatePoints(activity.words),
newWords: accumulatePoints(
useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs),
),
cards: accumulatePoints(activity.cards),
episodes: accumulatePoints(
useMonthlyBuckets
? buildEpisodesPerMonthFromRollups(monthlyRollups)
: buildEpisodesPerDayFromDailyRollups(dailyRollups),
),
lookups: accumulatePoints(
useMonthlyBuckets
? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount)
: buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
),
},
ratios: {
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
},
librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId),
animeCumulative: {
watchTime: buildCumulativePerAnime(animePerDay.watchTime),
episodes: buildCumulativePerAnime(animePerDay.episodes),
cards: buildCumulativePerAnime(animePerDay.cards),
words: buildCumulativePerAnime(animePerDay.words),
},
patterns: {
watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
watchTimeByHour: buildWatchTimeByHour(sessions),
},
};
```
- [ ] **Step 3: Delete now-unused helpers**
@@ -622,6 +611,7 @@ git commit -m "refactor(stats): drop animePerDay from trends response in favor o
## Task 5: Frontend — Update types and api-client test fixtures
**Files:**
- Modify: `stats/src/types/stats.ts`
- Modify: `stats/src/lib/api-client.test.ts`
@@ -710,6 +700,7 @@ git commit -m "refactor(stats): replace animePerDay type with librarySummary"
## Task 6: Frontend — Create `LibrarySummarySection` skeleton with empty state
**Files:**
- Create: `stats/src/components/trends/LibrarySummarySection.tsx`
- [ ] **Step 1: Create the file with the empty state and props plumbing**
@@ -765,6 +756,7 @@ git commit -m "feat(stats): scaffold LibrarySummarySection with empty state"
## Task 7: Frontend — Add the leaderboard bar chart to `LibrarySummarySection`
**Files:**
- Modify: `stats/src/components/trends/LibrarySummarySection.tsx`
- [ ] **Step 1: Replace the skeleton body with the leaderboard chart**
@@ -772,15 +764,7 @@ git commit -m "feat(stats): scaffold LibrarySummarySection with empty state"
Replace the entire contents of `stats/src/components/trends/LibrarySummarySection.tsx` with:
```tsx
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { LibrarySummaryRow } from '../../types/stats';
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
@@ -821,9 +805,7 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
return (
<>
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
<h3 className="text-xs font-semibold text-ctp-text mb-2">
Top Titles by Watch Time (min)
</h3>
<h3 className="text-xs font-semibold text-ctp-text mb-2">Top Titles by Watch Time (min)</h3>
<ResponsiveContainer width="100%" height={LEADERBOARD_HEIGHT}>
<BarChart
data={leaderboard}
@@ -881,6 +863,7 @@ git commit -m "feat(stats): add top-titles leaderboard chart to LibrarySummarySe
## Task 8: Frontend — Add the sortable table to `LibrarySummarySection`
**Files:**
- Modify: `stats/src/components/trends/LibrarySummarySection.tsx`
- [ ] **Step 1: Add sort state, column definitions, and the table markup**
@@ -889,15 +872,7 @@ Replace the entire file with the version below. The change vs. Task 7: imports `
```tsx
import { useMemo, useState } from 'react';
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { LibrarySummaryRow } from '../../types/stats';
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
import { epochDayToDate, formatDuration, formatNumber } from '../../lib/formatters';
@@ -1023,9 +998,7 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
if (visibleRows.length === 0) {
return (
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
<div className="text-xs text-ctp-overlay2">
No library activity in the selected window.
</div>
<div className="text-xs text-ctp-overlay2">No library activity in the selected window.</div>
</div>
);
}
@@ -1042,9 +1015,7 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
return (
<>
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
<h3 className="text-xs font-semibold text-ctp-text mb-2">
Top Titles by Watch Time (min)
</h3>
<h3 className="text-xs font-semibold text-ctp-text mb-2">Top Titles by Watch Time (min)</h3>
<ResponsiveContainer width="100%" height={LEADERBOARD_HEIGHT}>
<BarChart
data={leaderboard}
@@ -1081,10 +1052,7 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
</div>
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
<h3 className="text-xs font-semibold text-ctp-text mb-2">Per-Title Summary</h3>
<div
className="overflow-auto"
style={{ maxHeight: TABLE_MAX_HEIGHT }}
>
<div className="overflow-auto" style={{ maxHeight: TABLE_MAX_HEIGHT }}>
<table className="w-full text-xs">
<thead className="sticky top-0 bg-ctp-surface0">
<tr className="border-b border-ctp-surface1 text-ctp-subtext0">
@@ -1138,9 +1106,7 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
{formatNumber(row.lookups)}
</td>
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
{row.lookupsPerHundred === null
? '—'
: row.lookupsPerHundred.toFixed(1)}
{row.lookupsPerHundred === null ? '—' : row.lookupsPerHundred.toFixed(1)}
</td>
<td className="px-2 py-2 text-right text-ctp-subtext0 tabular-nums">
{formatDateRange(row.firstWatched, row.lastWatched)}
@@ -1173,6 +1139,7 @@ git commit -m "feat(stats): add sortable per-title table to LibrarySummarySectio
## Task 9: Frontend — Wire `LibrarySummarySection` into `TrendsTab` and remove the per-day block
**Files:**
- Modify: `stats/src/components/trends/TrendsTab.tsx`
- [ ] **Step 1: Delete the per-day filtered locals and imports**
@@ -1192,10 +1159,7 @@ const filteredWatchTimePerAnime = filterHiddenAnimeData(
);
const filteredCardsPerAnime = filterHiddenAnimeData(data.animePerDay.cards, activeHiddenAnime);
const filteredWordsPerAnime = filterHiddenAnimeData(data.animePerDay.words, activeHiddenAnime);
const filteredLookupsPerAnime = filterHiddenAnimeData(
data.animePerDay.lookups,
activeHiddenAnime,
);
const filteredLookupsPerAnime = filterHiddenAnimeData(data.animePerDay.lookups, activeHiddenAnime);
const filteredLookupsPerHundredPerAnime = filterHiddenAnimeData(
data.animePerDay.lookupsPerHundred,
activeHiddenAnime,
@@ -1286,6 +1250,7 @@ git commit -m "feat(stats): replace per-day trends section with library summary"
## Task 10: Add changelog fragment and run the full handoff gate
**Files:**
- Create: `changes/stats-library-summary.md`
- [ ] **Step 1: Check the existing changelog fragment format**
@@ -43,6 +43,7 @@
## Task 1: 365d range — backend type extension
**Files:**
- Modify: `src/core/services/immersion-tracker/query-trends.ts:16` and `src/core/services/immersion-tracker/query-trends.ts:84-88`
- Test: `src/core/services/immersion-tracker/__tests__/query.test.ts`
@@ -101,13 +102,14 @@
## Task 2: 365d range — server route allow-list
**Files:**
- Modify: `src/core/services/stats-server.ts` (search for trends route handler — look for `/api/stats/trends` or `getTrendsDashboard`)
- Test: `src/core/services/__tests__/stats-server.test.ts`
- [ ] **Step 1: Locate the trends route in `stats-server.ts`**
Run: `grep -n 'trends\|TrendRange' src/core/services/stats-server.ts`
Read the surrounding code. If the route delegates straight through to `tracker.getTrendsDashboard(range, groupBy)` without an allow-list, **this entire task is a no-op** — skip ahead to Task 3 and document in the commit message of Task 3 that no server changes were needed. If there *is* an allow-list (e.g. a `validRanges` array), continue.
Read the surrounding code. If the route delegates straight through to `tracker.getTrendsDashboard(range, groupBy)` without an allow-list, **this entire task is a no-op** — skip ahead to Task 3 and document in the commit message of Task 3 that no server changes were needed. If there _is_ an allow-list (e.g. a `validRanges` array), continue.
- [ ] **Step 2: Add a failing test for `range=365d`**
@@ -145,6 +147,7 @@
## Task 3: 365d range — frontend client and selector
**Files:**
- Modify: `stats/src/lib/api-client.ts`
- Modify: `stats/src/lib/api-client.test.ts`
- Modify: `stats/src/hooks/useTrends.ts:5`
@@ -175,10 +178,13 @@
- [ ] **Step 6: Add `365d` to the `DateRangeSelector` segmented control**
In `stats/src/components/trends/DateRangeSelector.tsx:56`, change:
```tsx
options={['7d', '30d', '90d', 'all'] as TimeRange[]}
```
to:
```tsx
options={['7d', '30d', '90d', '365d', 'all'] as TimeRange[]}
```
@@ -206,6 +212,7 @@
## Task 4: Vocabulary Top 50 — collapse word/reading column
**Files:**
- Modify: `stats/src/components/vocabulary/FrequencyRankTable.tsx:110-144`
- Test: create `stats/src/components/vocabulary/FrequencyRankTable.test.tsx` if not present (check first with `ls stats/src/components/vocabulary/`)
@@ -217,6 +224,7 @@
- [ ] **Step 2: Write the failing test**
Create or extend `stats/src/components/vocabulary/FrequencyRankTable.test.tsx` with:
```tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'bun:test';
@@ -271,6 +279,7 @@
Replace the `<th>Reading</th>` header column and the corresponding `<td>` in the body. The new shape:
Header (around line 113-119):
```tsx
<thead>
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
@@ -283,6 +292,7 @@
```
Body row (around line 122-141):
```tsx
<tr
key={w.wordId}
@@ -297,16 +307,10 @@
{(() => {
const reading = fullReading(w.headword, w.reading);
if (!reading || reading === w.headword) return null;
return (
<span className="text-ctp-subtext0 text-xs ml-1.5">
【{reading}】
</span>
);
return <span className="text-ctp-subtext0 text-xs ml-1.5">【{reading}】</span>;
})()}
</td>
<td className="py-1.5 pr-3">
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
</td>
<td className="py-1.5 pr-3">{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}</td>
<td className="py-1.5 text-right font-mono tabular-nums text-ctp-blue text-xs">
{w.frequency}x
</td>
@@ -336,6 +340,7 @@
## Task 5: Episode detail — filter Anki-deleted cards
**Files:**
- Modify: `stats/src/components/anime/EpisodeDetail.tsx:109-147`
- Test: create `stats/src/components/anime/EpisodeDetail.test.tsx` if not present
@@ -410,11 +415,13 @@
Then change the JSX iteration from `cardEvents.map(...)` to `filteredCardEvents.map(...)` (one occurrence around line 113), and after the `</div>` closing the cards-mined section, add:
```tsx
{hiddenCardCount > 0 && (
<div className="px-3 pb-3 -mt-1 text-[10px] text-ctp-overlay2 italic">
{hiddenCardCount} card{hiddenCardCount === 1 ? '' : 's'} hidden (deleted from Anki)
</div>
)}
{
hiddenCardCount > 0 && (
<div className="px-3 pb-3 -mt-1 text-[10px] text-ctp-overlay2 italic">
{hiddenCardCount} card{hiddenCardCount === 1 ? '' : 's'} hidden (deleted from Anki)
</div>
);
}
```
Place that footer immediately before the closing `</div>` of the bordered cards-mined section, so it stays scoped to that block.
@@ -422,12 +429,14 @@
**Important:** the filter only fires once `noteInfos` has been populated. While `noteInfos` is still empty (initial load before the second fetch resolves), every card with noteIds would be filtered out — that's wrong. Guard the filter so that it only runs after the noteInfos fetch has completed. The simplest signal: track `noteInfosLoaded: boolean` next to `noteInfos`, set it `true` in the `.then` callback, and only apply filtering when `noteInfosLoaded || allNoteIds.length === 0`.
Concrete change near line 22:
```tsx
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
const [noteInfosLoaded, setNoteInfosLoaded] = useState(false);
```
Inside the existing `useEffect` (around line 36-46), set the loaded flag:
```tsx
if (allNoteIds.length > 0) {
getStatsClient()
@@ -452,6 +461,7 @@
```
And gate the filter:
```tsx
const filteredCardEvents = noteInfosLoaded
? cardEvents
@@ -496,6 +506,7 @@
## Task 6: Library detail — delete episode action
**Files:**
- Modify: `stats/src/components/library/MediaHeader.tsx`
- Modify: `stats/src/components/library/MediaDetailView.tsx`
- Modify: `stats/src/hooks/useMediaLibrary.ts`
@@ -553,9 +564,7 @@
```tsx
<div className="flex items-start gap-2">
<h2 className="text-lg font-bold text-ctp-text truncate flex-1">
{detail.canonicalTitle}
</h2>
<h2 className="text-lg font-bold text-ctp-text truncate flex-1">{detail.canonicalTitle}</h2>
{onDeleteEpisode && (
<button
type="button"
@@ -718,6 +727,7 @@
## Task 7: Library — collapsible series groups
**Files:**
- Modify: `stats/src/components/library/LibraryTab.tsx`
- Test: create `stats/src/components/library/LibraryTab.test.tsx`
@@ -758,11 +768,13 @@
- [ ] **Step 3: Add collapsible state and toggle to `LibraryTab.tsx`**
Modify imports:
```tsx
import { useState, useMemo, useCallback } from 'react';
```
Inside the component, after the existing `useState` calls:
```tsx
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set());
@@ -780,6 +792,7 @@
```
Actually, the cleanest pattern is **initialize once on first data load via `useEffect`**:
```tsx
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set());
const [hasInitializedCollapsed, setHasInitializedCollapsed] = useState(false);
@@ -814,71 +827,71 @@
Replace the section block (around line 64-115) so the header is a `<button>`:
```tsx
{grouped.map((group) => {
const isCollapsed = collapsedGroups.has(group.key);
const isSingleVideo = group.items.length === 1;
return (
<section
key={group.key}
className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden"
>
<button
type="button"
onClick={() => !isSingleVideo && toggleGroup(group.key)}
aria-expanded={!isCollapsed}
aria-controls={`group-body-${group.key}`}
disabled={isSingleVideo}
className={`w-full flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40 text-left ${
isSingleVideo ? '' : 'hover:bg-ctp-base/60 transition-colors cursor-pointer'
}`}
{
grouped.map((group) => {
const isCollapsed = collapsedGroups.has(group.key);
const isSingleVideo = group.items.length === 1;
return (
<section
key={group.key}
className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden"
>
{!isSingleVideo && (
<span
aria-hidden="true"
className={`text-xs text-ctp-overlay2 transition-transform shrink-0 ${
isCollapsed ? '' : 'rotate-90'
}`}
>
{'\u25B6'}
</span>
<button
type="button"
onClick={() => !isSingleVideo && toggleGroup(group.key)}
aria-expanded={!isCollapsed}
aria-controls={`group-body-${group.key}`}
disabled={isSingleVideo}
className={`w-full flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40 text-left ${
isSingleVideo ? '' : 'hover:bg-ctp-base/60 transition-colors cursor-pointer'
}`}
>
{!isSingleVideo && (
<span
aria-hidden="true"
className={`text-xs text-ctp-overlay2 transition-transform shrink-0 ${
isCollapsed ? '' : 'rotate-90'
}`}
>
{'\u25B6'}
</span>
)}
<CoverImage
videoId={group.items[0]!.videoId}
title={group.title}
src={group.imageUrl}
className="w-16 h-16 rounded-2xl shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold text-ctp-text truncate">{group.title}</h3>
</div>
{group.subtitle ? (
<div className="text-xs text-ctp-overlay1 truncate mt-1">{group.subtitle}</div>
) : null}
<div className="text-xs text-ctp-overlay2 mt-2">
{group.items.length} video{group.items.length !== 1 ? 's' : ''} ·{' '}
{formatDuration(group.totalActiveMs)} · {formatNumber(group.totalCards)} cards
</div>
</div>
</button>
{!isCollapsed && (
<div id={`group-body-${group.key}`} className="p-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{group.items.map((item) => (
<MediaCard
key={item.videoId}
item={item}
onClick={() => setSelectedVideoId(item.videoId)}
/>
))}
</div>
</div>
)}
<CoverImage
videoId={group.items[0]!.videoId}
title={group.title}
src={group.imageUrl}
className="w-16 h-16 rounded-2xl shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold text-ctp-text truncate">
{group.title}
</h3>
</div>
{group.subtitle ? (
<div className="text-xs text-ctp-overlay1 truncate mt-1">{group.subtitle}</div>
) : null}
<div className="text-xs text-ctp-overlay2 mt-2">
{group.items.length} video{group.items.length !== 1 ? 's' : ''} ·{' '}
{formatDuration(group.totalActiveMs)} · {formatNumber(group.totalCards)} cards
</div>
</div>
</button>
{!isCollapsed && (
<div id={`group-body-${group.key}`} className="p-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{group.items.map((item) => (
<MediaCard
key={item.videoId}
item={item}
onClick={() => setSelectedVideoId(item.videoId)}
/>
))}
</div>
</div>
)}
</section>
);
})}
</section>
);
});
}
```
**Watch out:** the previous header had a clickable `<a>` for the channel URL. Wrapping the whole header in a `<button>` makes nested anchors invalid. The simplest fix: drop the channel URL link from inside the header (it's still reachable from the individual `MediaCard`s), or move it to a separate row outside the button. Choose the first — minimum visual disruption.
@@ -906,6 +919,7 @@
## Task 8: Session grouping helper
**Files:**
- Create: `stats/src/lib/session-grouping.ts`
- Create: `stats/src/lib/session-grouping.test.ts`
@@ -1012,7 +1026,9 @@
for (const session of sessions) {
const hasVideoId =
typeof session.videoId === 'number' && Number.isFinite(session.videoId) && session.videoId > 0;
typeof session.videoId === 'number' &&
Number.isFinite(session.videoId) &&
session.videoId > 0;
const key = hasVideoId ? `v-${session.videoId}` : `s-${session.sessionId}`;
const existing = byVideo.get(key);
if (existing) {
@@ -1066,6 +1082,7 @@
## Task 9: Sessions tab — episode rollup UI
**Files:**
- Modify: `stats/src/components/sessions/SessionsTab.tsx`
- Modify: `stats/src/lib/delete-confirm.ts` (add `confirmBucketDelete`)
- Modify: `stats/src/lib/delete-confirm.test.ts`
@@ -1161,114 +1178,120 @@
Skeleton:
```tsx
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => {
const buckets = groupSessionsByVideo(daySessions);
return (
<div key={dayLabel}>
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
{dayLabel}
</h3>
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
</div>
<div className="space-y-2">
{buckets.map((bucket) => {
if (bucket.sessions.length === 1) {
const s = bucket.sessions[0]!;
const detailsId = `session-details-${s.sessionId}`;
{
Array.from(groups.entries()).map(([dayLabel, daySessions]) => {
const buckets = groupSessionsByVideo(daySessions);
return (
<div key={dayLabel}>
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
{dayLabel}
</h3>
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
</div>
<div className="space-y-2">
{buckets.map((bucket) => {
if (bucket.sessions.length === 1) {
const s = bucket.sessions[0]!;
const detailsId = `session-details-${s.sessionId}`;
return (
<div key={bucket.key}>
<SessionRow
session={s}
isExpanded={expandedId === s.sessionId}
detailsId={detailsId}
onToggle={() =>
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
onNavigateToMediaDetail={onNavigateToMediaDetail}
/>
{expandedId === s.sessionId && (
<div id={detailsId}>
<SessionDetail session={s} />
</div>
)}
</div>
);
}
const isOpen = expandedBuckets.has(bucket.key);
return (
<div key={bucket.key}>
<SessionRow
session={s}
isExpanded={expandedId === s.sessionId}
detailsId={detailsId}
onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId)}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
onNavigateToMediaDetail={onNavigateToMediaDetail}
/>
{expandedId === s.sessionId && (
<div id={detailsId}>
<SessionDetail session={s} />
<div
key={bucket.key}
className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/40"
>
<button
type="button"
onClick={() => toggleBucket(bucket.key)}
aria-expanded={isOpen}
className="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-ctp-surface0/70 transition-colors"
>
<span
aria-hidden="true"
className={`text-xs text-ctp-overlay2 transition-transform ${isOpen ? 'rotate-90' : ''}`}
>
{'\u25B6'}
</span>
<div className="min-w-0 flex-1">
<div className="text-sm text-ctp-text truncate">
{bucket.representativeSession.canonicalTitle ?? 'Unknown Episode'}
</div>
<div className="text-xs text-ctp-overlay2">
{bucket.sessions.length} sessions · {formatDuration(bucket.totalActiveMs)} ·{' '}
{bucket.totalCardsMined} cards
</div>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleDeleteBucket(bucket);
}}
className="text-[10px] text-ctp-red/70 hover:text-ctp-red px-1.5 py-0.5 rounded hover:bg-ctp-red/10 transition-colors"
title="Delete all sessions in this group"
>
Delete
</button>
</button>
{isOpen && (
<div className="pl-8 pr-2 pb-2 space-y-2">
{bucket.sessions.map((s) => {
const detailsId = `session-details-${s.sessionId}`;
return (
<div key={s.sessionId}>
<SessionRow
session={s}
isExpanded={expandedId === s.sessionId}
detailsId={detailsId}
onToggle={() =>
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
onNavigateToMediaDetail={onNavigateToMediaDetail}
/>
{expandedId === s.sessionId && (
<div id={detailsId}>
<SessionDetail session={s} />
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
const isOpen = expandedBuckets.has(bucket.key);
return (
<div key={bucket.key} className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/40">
<button
type="button"
onClick={() => toggleBucket(bucket.key)}
aria-expanded={isOpen}
className="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-ctp-surface0/70 transition-colors"
>
<span
aria-hidden="true"
className={`text-xs text-ctp-overlay2 transition-transform ${isOpen ? 'rotate-90' : ''}`}
>
{'\u25B6'}
</span>
<div className="min-w-0 flex-1">
<div className="text-sm text-ctp-text truncate">
{bucket.representativeSession.canonicalTitle ?? 'Unknown Episode'}
</div>
<div className="text-xs text-ctp-overlay2">
{bucket.sessions.length} sessions ·{' '}
{formatDuration(bucket.totalActiveMs)} ·{' '}
{bucket.totalCardsMined} cards
</div>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleDeleteBucket(bucket);
}}
className="text-[10px] text-ctp-red/70 hover:text-ctp-red px-1.5 py-0.5 rounded hover:bg-ctp-red/10 transition-colors"
title="Delete all sessions in this group"
>
Delete
</button>
</button>
{isOpen && (
<div className="pl-8 pr-2 pb-2 space-y-2">
{bucket.sessions.map((s) => {
const detailsId = `session-details-${s.sessionId}`;
return (
<div key={s.sessionId}>
<SessionRow
session={s}
isExpanded={expandedId === s.sessionId}
detailsId={detailsId}
onToggle={() =>
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
onNavigateToMediaDetail={onNavigateToMediaDetail}
/>
{expandedId === s.sessionId && (
<div id={detailsId}>
<SessionDetail session={s} />
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
})}
</div>
</div>
</div>
);
})}
);
});
}
```
**Note on nested buttons:** the bucket header is a `<button>` and contains a "Delete" `<button>`. HTML disallows nested buttons. Switch the outer element to a `<div role="button" tabIndex={0} onClick={...} onKeyDown={...}>` instead, OR put the delete button in a wrapping flex container *outside* the toggle button. Pick the second option — it's accessible without role gymnastics:
**Note on nested buttons:** the bucket header is a `<button>` and contains a "Delete" `<button>`. HTML disallows nested buttons. Switch the outer element to a `<div role="button" tabIndex={0} onClick={...} onKeyDown={...}>` instead, OR put the delete button in a wrapping flex container _outside_ the toggle button. Pick the second option — it's accessible without role gymnastics:
```tsx
<div className="flex items-center">
@@ -1281,7 +1304,7 @@
</div>
```
Use that pattern in the actual implementation. The skeleton above shows the *intent*; the final code must have sibling buttons, not nested ones.
Use that pattern in the actual implementation. The skeleton above shows the _intent_; the final code must have sibling buttons, not nested ones.
Add `handleDeleteBucket`:
@@ -1336,6 +1359,7 @@
## Task 10: Chart clarity pass
**Files:**
- Modify: `stats/src/lib/chart-theme.ts`
- Modify: `stats/src/components/trends/TrendChart.tsx`
- Modify: `stats/src/components/trends/StackedTrendChart.tsx`
@@ -1528,6 +1552,7 @@
## Task 11: Changelog fragment
**Files:**
- Create: `changes/2026-04-09-stats-dashboard-feedback-pass.md`
- [ ] **Step 1: Read the existing changelog format**
@@ -46,16 +46,16 @@ Add to `stats/src/types/stats.ts` and the backend query module:
```ts
type LibrarySummaryRow = {
title: string; // display title — anime series, YouTube video title, etc.
watchTimeMin: number; // sum(total_active_min) across the window
videos: number; // distinct video_id count
sessions: number; // session count from imm_sessions
cards: number; // sum(total_cards)
words: number; // sum(total_tokens_seen)
lookups: number; // sum(lookup_count) from imm_sessions
title: string; // display title — anime series, YouTube video title, etc.
watchTimeMin: number; // sum(total_active_min) across the window
videos: number; // distinct video_id count
sessions: number; // session count from imm_sessions
cards: number; // sum(total_cards)
words: number; // sum(total_tokens_seen)
lookups: number; // sum(lookup_count) from imm_sessions
lookupsPerHundred: number | null; // lookups / words * 100, null when words == 0
firstWatched: number; // min(rollup_day) as epoch day, within the window
lastWatched: number; // max(rollup_day) as epoch day, within the window
firstWatched: number; // min(rollup_day) as epoch day, within the window
lastWatched: number; // max(rollup_day) as epoch day, within the window
};
```
@@ -21,6 +21,7 @@ Out of scope for this pass: English-token ingestion cleanup and Overview stat-ca
## Files touched (inventory)
Dashboard (`stats/src/`):
- `components/library/LibraryTab.tsx` — collapsible groups (item 1).
- `components/library/MediaDetailView.tsx`, `components/library/MediaHeader.tsx` — delete-episode action (item 4).
- `components/sessions/SessionsTab.tsx`, `components/library/MediaSessionList.tsx` — episode rollup (item 2).
@@ -31,6 +32,7 @@ Dashboard (`stats/src/`):
- New file: `stats/src/lib/session-grouping.ts` + `session-grouping.test.ts`.
Backend (`src/core/services/`):
- `immersion-tracker/query-trends.ts` — extend `TrendRange` and `TREND_DAY_LIMITS` (item 3).
- `immersion-tracker/__tests__/query.test.ts` — 365d coverage (item 3).
- `stats-server.ts` — passthrough if range validation lives here (check before editing).
@@ -93,9 +95,10 @@ Within each day, sessions with the same `videoId` collapse into one parent row s
### Implementation
- New helper in `stats/src/lib/session-grouping.ts`:
```ts
export interface SessionBucket {
key: string; // videoId as string, or `s-${sessionId}` for singletons
key: string; // videoId as string, or `s-${sessionId}` for singletons
videoId: number | null;
sessions: SessionSummary[];
totalActiveMs: number;
@@ -104,6 +107,7 @@ Within each day, sessions with the same `videoId` collapse into one parent row s
}
export function groupSessionsByVideo(sessions: SessionSummary[]): SessionBucket[];
```
Sessions missing a `videoId` become singleton buckets.
- `SessionsTab.tsx`: after day grouping, pipe each `daySessions` through `groupSessionsByVideo`. Render each bucket:
@@ -132,11 +136,13 @@ Within each day, sessions with the same `videoId` collapse into one parent row s
### Backend
`src/core/services/immersion-tracker/query-trends.ts`:
- `type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';`
- Add `'365d': 365` to `TREND_DAY_LIMITS`.
- `getTrendDayLimit` picks up the new key automatically because of the `Exclude<TrendRange, 'all'>` generic.
`src/core/services/stats-server.ts`:
- Search for any hardcoded range validation (e.g. allow-list in the trends route handler) and extend it.
### Frontend
@@ -202,11 +208,7 @@ Merge Word + Reading into a single column titled "Word". Reading sits immediatel
```tsx
<td className="py-1.5 pr-3">
<span className="text-ctp-text font-medium">{w.headword}</span>
{reading && (
<span className="text-ctp-subtext0 text-xs ml-1.5">
【{reading}】
</span>
)}
{reading && <span className="text-ctp-subtext0 text-xs ml-1.5">【{reading}】</span>}
</td>
```
where `reading = fullReading(w.headword, w.reading)` and differs from `headword`.
@@ -230,6 +232,7 @@ Merge Word + Reading into a single column titled "Word". Reading sits immediatel
### Target behavior
After `ankiNotesInfo` resolves:
- Drop `noteId`s that are not in the resolved map.
- Drop `cardEvents` whose `noteIds` list was non-empty but is now empty after filtering.
- Card events with a positive `cardsDelta` but no `noteIds` (legacy rollup path) still render as `+N cards` — we have no way to cross-reference them, so leave them alone.
@@ -255,6 +258,7 @@ After `ankiNotesInfo` resolves:
### Current behavior
`TrendChart.tsx`, `StackedTrendChart.tsx`, and `WatchTimeChart.tsx` render Recharts components with:
- No `CartesianGrid` → no horizontal reference lines.
- 9px axis ticks → borderline unreadable.
- Height 120 → cramped.
@@ -270,6 +274,7 @@ All three charts share a theme, have horizontal gridlines, readable ticks, and s
### Implementation
Extend `stats/src/lib/chart-theme.ts` with the additional shared defaults (keeping the existing `CHART_THEME` export intact so current consumers don't break):
```ts
export const CHART_THEME = {
tick: '#a5adcb',
@@ -299,6 +304,7 @@ export const TOOLTIP_CONTENT_STYLE = {
```
Apply to each chart:
- Import `CartesianGrid` from recharts.
- Insert `<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />` inside each chart container.
- `<XAxis tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} />` and equivalent `YAxis`.
+11
View File
@@ -16,6 +16,10 @@ type AppCommandDeps = {
appPath: string,
logLevel: LauncherCommandContext['args']['logLevel'],
) => void;
launchAnimeBrowserDetached: (
appPath: string,
logLevel: LauncherCommandContext['args']['logLevel'],
) => void;
};
const defaultAppCommandDeps: AppCommandDeps = {
@@ -23,6 +27,8 @@ const defaultAppCommandDeps: AppCommandDeps = {
launchSyncUiDetached: (appPath, logLevel) =>
launchAppCommandDetached(appPath, ['--sync-window'], logLevel, 'sync-ui'),
launchAppBackgroundDetached,
launchAnimeBrowserDetached: (appPath, logLevel) =>
launchAppCommandDetached(appPath, ['--anime'], logLevel, 'anime'),
};
export function runAppPassthroughCommand(
@@ -37,6 +43,11 @@ export function runAppPassthroughCommand(
deps.runAppCommandWithInherit(appPath, ['--settings']);
return true;
}
if (args.animeBrowser) {
// Detached: the browser window is long-lived and owns the bridge process.
deps.launchAnimeBrowserDetached(appPath, args.logLevel);
return true;
}
if (args.syncUi) {
deps.launchSyncUiDetached(appPath, args.logLevel);
return true;
@@ -207,6 +207,7 @@ test('app command starts default macOS background app detached from launcher', (
calls.push('attached');
},
launchSyncUiDetached: () => calls.push('sync-ui'),
launchAnimeBrowserDetached: () => {},
launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${appPath}:${logLevel}`);
},
@@ -227,6 +228,7 @@ test('app command starts default Linux background app detached from launcher', (
calls.push('attached');
},
launchSyncUiDetached: () => calls.push('sync-ui'),
launchAnimeBrowserDetached: () => {},
launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${appPath}:${logLevel}`);
},
@@ -248,6 +250,7 @@ test('app command keeps explicit passthrough args attached', () => {
forwarded.push(appArgs);
},
launchSyncUiDetached: () => detached.push('sync-ui'),
launchAnimeBrowserDetached: () => {},
launchAppBackgroundDetached: () => {
detached.push('detached');
},
@@ -266,6 +269,7 @@ test('sync UI command launches the app detached from the terminal', () => {
const handled = runAppPassthroughCommand(context, {
runAppCommandWithInherit: () => calls.push('piped'),
launchSyncUiDetached: (appPath, logLevel) => calls.push(`sync-ui:${appPath}:${logLevel}`),
launchAnimeBrowserDetached: () => calls.push('anime'),
launchAppBackgroundDetached: () => calls.push('detached'),
});
+193 -15
View File
@@ -7,11 +7,13 @@ import {
collectVideos,
findRofiTheme,
formatPickerLaunchError,
formatRofiPrompt,
showFzfMenu,
showRofiMenu,
} from '../picker.js';
import {
findNextEpisode,
findPreviousEpisode,
groupHistoryBySeries,
listSeasonDirs,
materializeCoverArt,
@@ -23,6 +25,167 @@ import {
import type { Args } from '../types.js';
import type { LauncherCommandContext } from './context.js';
export type HistorySessionAction = 'previous' | 'replay' | 'next' | 'browse' | 'quit';
export interface HistoryPlaybackSelection {
entry: HistorySeriesEntry;
videoPath: string;
themePath?: string | null;
entryIcon?: string | null;
}
interface HistorySessionMenuAction {
kind: HistorySessionAction;
label: string;
}
export function buildHistorySessionActions(
justPlayedPath: string,
previousEpisodePath: string | null,
nextEpisodePath: string | null,
): HistorySessionMenuAction[] {
const actions: HistorySessionMenuAction[] = [];
if (previousEpisodePath) {
actions.push({
kind: 'previous',
label: `Previous episode: ${path.basename(previousEpisodePath)}`,
});
}
actions.push({
kind: 'replay',
label: `Rewatch episode: ${path.basename(justPlayedPath)}`,
});
if (nextEpisodePath) {
actions.push({
kind: 'next',
label: `Play next episode: ${path.basename(nextEpisodePath)}`,
});
}
actions.push(
{ kind: 'browse', label: 'Select / browse episode' },
{ kind: 'quit', label: 'Quit SubMiner' },
);
return actions;
}
export function buildHistoryEntryActions(
lastWatchedPath: string | null,
previousEpisodePath: string | null,
nextEpisodePath: string | null,
): HistorySessionMenuAction[] {
const actions: HistorySessionMenuAction[] = [];
if (previousEpisodePath) {
actions.push({
kind: 'previous',
label: `Previous episode: ${path.basename(previousEpisodePath)}`,
});
}
if (lastWatchedPath) {
actions.push({
kind: 'replay',
label: `Replay last watched: ${path.basename(lastWatchedPath)}`,
});
}
if (nextEpisodePath) {
actions.push({ kind: 'next', label: `Next episode: ${path.basename(nextEpisodePath)}` });
}
actions.push(
{ kind: 'browse', label: 'Browse episodes' },
{ kind: 'quit', label: 'Quit SubMiner' },
);
return actions;
}
interface HistoryPlaybackLoopDeps {
play: (videoPath: string) => Promise<void>;
pickPostPlaybackAction: (input: {
entry: HistorySeriesEntry;
justPlayedPath: string;
previousEpisodePath: string | null;
nextEpisodePath: string | null;
}) => Promise<HistorySessionAction | null>;
findPreviousEpisode: (videoPath: string) => string | null;
findNextEpisode: (videoPath: string) => string | null;
browseEpisodes: (entry: HistorySeriesEntry) => Promise<string | null>;
}
export async function runHistoryPlaybackLoop(
initial: HistoryPlaybackSelection,
deps: HistoryPlaybackLoopDeps,
): Promise<void> {
let videoPath = initial.videoPath;
while (true) {
await deps.play(videoPath);
const previousEpisodePath = deps.findPreviousEpisode(videoPath);
const nextEpisodePath = deps.findNextEpisode(videoPath);
const action = await deps.pickPostPlaybackAction({
entry: initial.entry,
justPlayedPath: videoPath,
previousEpisodePath,
nextEpisodePath,
});
switch (action) {
case 'replay':
break;
case 'previous':
if (!previousEpisodePath) return;
videoPath = previousEpisodePath;
break;
case 'next':
if (!nextEpisodePath) return;
videoPath = nextEpisodePath;
break;
case 'browse': {
const browsedPath = await deps.browseEpisodes(initial.entry);
if (!browsedPath) return;
videoPath = browsedPath;
break;
}
case 'quit':
case null:
return;
}
}
}
export async function runHistorySession(
context: LauncherCommandContext,
play: (videoPath: string) => Promise<void>,
): Promise<boolean> {
const initial = await runHistoryCommand(context);
if (!initial) return false;
await runHistoryPlaybackLoop(initial, {
play,
findPreviousEpisode,
findNextEpisode,
browseEpisodes: async (entry) => browseEpisodes(entry, context, initial.themePath ?? null),
pickPostPlaybackAction: async ({
entry,
justPlayedPath,
previousEpisodePath,
nextEpisodePath,
}) => {
const actions = buildHistorySessionActions(
justPlayedPath,
previousEpisodePath,
nextEpisodePath,
);
const actionIdx = pickIndex(
actions.map((action) => action.label),
entry.displayName,
context.args.useRofi,
initial.themePath ?? null,
actions.map(() => initial.entryIcon ?? null),
);
return actionIdx < 0 ? null : actions[actionIdx]!.kind;
},
});
return true;
}
function checkPickerDependencies(args: Args): void {
if (args.useRofi) {
if (!commandExists('rofi')) fail('Missing dependency: rofi');
@@ -37,7 +200,16 @@ function showRofiIndexMenu(
themePath: string | null,
icons: Array<string | null> = [],
): number {
const rofiArgs = ['-dmenu', '-i', '-matching', 'fuzzy', '-format', 'i', '-p', prompt];
const rofiArgs = [
'-dmenu',
'-i',
'-matching',
'fuzzy',
'-format',
'i',
'-p',
formatRofiPrompt(prompt),
];
const hasIcons = icons.some(Boolean);
if (hasIcons) rofiArgs.push('-show-icons');
if (themePath) {
@@ -142,7 +314,7 @@ function browseEpisodes(
if (seasons.length > 1) {
const idx = pickIndex(
seasons.map((season) => season.name),
`${entry.displayName} Season`,
`${entry.displayName}: Season`,
args.useRofi,
themePath,
);
@@ -155,7 +327,9 @@ function browseEpisodes(
return pickEpisodeFromDir(dir, context);
}
export async function runHistoryCommand(context: LauncherCommandContext): Promise<string | null> {
export async function runHistoryCommand(
context: LauncherCommandContext,
): Promise<HistoryPlaybackSelection | null> {
const { args, scriptPath } = context;
checkPickerDependencies(args);
@@ -196,16 +370,14 @@ export async function runHistoryCommand(context: LauncherCommandContext): Promis
const lastPath = path.resolve(entry.lastWatched.sourcePath);
const lastExists = fs.existsSync(lastPath);
const previousEpisode = findPreviousEpisode(lastPath);
const nextEpisode = findNextEpisode(lastPath);
const actions: Array<{ kind: 'replay' | 'next' | 'browse'; label: string }> = [];
if (lastExists) {
actions.push({ kind: 'replay', label: `Replay last watched — ${path.basename(lastPath)}` });
}
if (nextEpisode) {
actions.push({ kind: 'next', label: `Next episode — ${path.basename(nextEpisode)}` });
}
actions.push({ kind: 'browse', label: 'Browse episodes' });
const actions = buildHistoryEntryActions(
lastExists ? lastPath : null,
previousEpisode,
nextEpisode,
);
const entryIcon = seriesIcons[seriesIdx] ?? null;
const actionIdx = pickIndex(
@@ -219,10 +391,16 @@ export async function runHistoryCommand(context: LauncherCommandContext): Promis
switch (actions[actionIdx]!.kind) {
case 'replay':
return lastPath;
return { entry, videoPath: lastPath, themePath, entryIcon };
case 'previous':
return previousEpisode ? { entry, videoPath: previousEpisode, themePath, entryIcon } : null;
case 'next':
return nextEpisode;
case 'browse':
return browseEpisodes(entry, context, themePath);
return nextEpisode ? { entry, videoPath: nextEpisode, themePath, entryIcon } : null;
case 'browse': {
const videoPath = browseEpisodes(entry, context, themePath);
return videoPath ? { entry, videoPath, themePath, entryIcon } : null;
}
case 'quit':
return null;
}
}
+294
View File
@@ -0,0 +1,294 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import {
buildHistoryEntryActions,
buildHistorySessionActions,
runHistoryPlaybackLoop,
} from './history-command.js';
import type { HistorySeriesEntry } from '../history.js';
type HistoryLoop = (
initial: { entry: HistorySeriesEntry; videoPath: string },
deps: {
play: (videoPath: string) => Promise<void>;
pickPostPlaybackAction: (input: {
entry: HistorySeriesEntry;
justPlayedPath: string;
previousEpisodePath: string | null;
nextEpisodePath: string | null;
}) => Promise<'previous' | 'replay' | 'next' | 'browse' | 'quit' | null>;
findPreviousEpisode: (videoPath: string) => string | null;
findNextEpisode: (videoPath: string) => string | null;
browseEpisodes: (entry: HistorySeriesEntry) => Promise<string | null>;
},
) => Promise<void>;
const typedRunHistoryPlaybackLoop: HistoryLoop = runHistoryPlaybackLoop;
function makeEntry(lastWatchedPath: string): HistorySeriesEntry {
return {
seriesRoot: path.dirname(lastWatchedPath),
displayName: 'Test Show',
coverBlobHash: null,
lastWatched: {
videoId: 1,
sourcePath: lastWatchedPath,
parsedTitle: 'Test Show',
parsedSeason: 1,
parsedEpisode: 1,
animeTitle: 'Test Show',
lastWatchedMs: 1,
coverBlobHash: null,
},
};
}
test('history loop plays an initial selection, browsed selection, then quits', async () => {
assert.equal(
typeof runHistoryPlaybackLoop,
'function',
'history playback loop is not implemented',
);
const entry = makeEntry('/shows/test-show/episode-01.mkv');
const played: string[] = [];
const menuEntries: HistorySeriesEntry[] = [];
let menuCount = 0;
await typedRunHistoryPlaybackLoop(
{ entry, videoPath: '/shows/test-show/episode-02.mkv' },
{
play: async (videoPath) => {
played.push(videoPath);
},
pickPostPlaybackAction: async ({ entry: menuEntry }) => {
menuEntries.push(menuEntry);
return menuCount++ === 0 ? 'browse' : 'quit';
},
findPreviousEpisode: () => null,
findNextEpisode: () => null,
browseEpisodes: async (browseEntry) => {
assert.equal(browseEntry, entry);
return '/shows/test-show/episode-04.mkv';
},
},
);
assert.deepEqual(played, ['/shows/test-show/episode-02.mkv', '/shows/test-show/episode-04.mkv']);
assert.deepEqual(menuEntries, [entry, entry]);
});
test('history replay uses the actual just-played path instead of the database row', async () => {
assert.equal(
typeof runHistoryPlaybackLoop,
'function',
'history playback loop is not implemented',
);
const entry = makeEntry('/shows/test-show/stale-episode-01.mkv');
const played: string[] = [];
const menuPaths: string[] = [];
let menuCount = 0;
await typedRunHistoryPlaybackLoop(
{ entry, videoPath: '/shows/test-show/episode-07.mkv' },
{
play: async (videoPath) => {
played.push(videoPath);
},
pickPostPlaybackAction: async ({ justPlayedPath }) => {
menuPaths.push(justPlayedPath);
return menuCount++ === 0 ? 'replay' : 'quit';
},
findPreviousEpisode: () => null,
findNextEpisode: () => '/shows/test-show/episode-08.mkv',
browseEpisodes: async () => null,
},
);
assert.deepEqual(played, ['/shows/test-show/episode-07.mkv', '/shows/test-show/episode-07.mkv']);
assert.deepEqual(menuPaths, [
'/shows/test-show/episode-07.mkv',
'/shows/test-show/episode-07.mkv',
]);
});
test('history next is computed from the actual just-played path', async () => {
assert.equal(
typeof runHistoryPlaybackLoop,
'function',
'history playback loop is not implemented',
);
const entry = makeEntry('/shows/test-show/stale-episode-01.mkv');
const played: string[] = [];
const nextInputs: string[] = [];
let menuCount = 0;
await typedRunHistoryPlaybackLoop(
{ entry, videoPath: '/shows/test-show/episode-07.mkv' },
{
play: async (videoPath) => {
played.push(videoPath);
},
pickPostPlaybackAction: async ({ nextEpisodePath }) => {
if (menuCount++ === 0) {
assert.equal(nextEpisodePath, '/shows/test-show/episode-08.mkv');
return 'next';
}
assert.equal(nextEpisodePath, null);
return 'quit';
},
findPreviousEpisode: () => null,
findNextEpisode: (videoPath) => {
nextInputs.push(videoPath);
return videoPath.endsWith('episode-07.mkv') ? '/shows/test-show/episode-08.mkv' : null;
},
browseEpisodes: async () => null,
},
);
assert.deepEqual(played, ['/shows/test-show/episode-07.mkv', '/shows/test-show/episode-08.mkv']);
assert.deepEqual(nextInputs, [
'/shows/test-show/episode-07.mkv',
'/shows/test-show/episode-08.mkv',
]);
});
test('history show menu offers previous, rewatch, next, browse, and quit in order', () => {
assert.equal(
typeof buildHistorySessionActions,
'function',
'history session actions are not implemented',
);
assert.deepEqual(
buildHistorySessionActions(
'/shows/test-show/episode-07.mkv',
'/shows/test-show/episode-06.mkv',
'/shows/test-show/episode-08.mkv',
),
[
{ kind: 'previous', label: 'Previous episode: episode-06.mkv' },
{ kind: 'replay', label: 'Rewatch episode: episode-07.mkv' },
{ kind: 'next', label: 'Play next episode: episode-08.mkv' },
{ kind: 'browse', label: 'Select / browse episode' },
{ kind: 'quit', label: 'Quit SubMiner' },
],
);
});
test('history show menu omits previous and next when the just-played episode has neither', () => {
assert.equal(
typeof buildHistorySessionActions,
'function',
'history session actions are not implemented',
);
assert.deepEqual(buildHistorySessionActions('/shows/test-show/finale.mkv', null, null), [
{ kind: 'replay', label: 'Rewatch episode: finale.mkv' },
{ kind: 'browse', label: 'Select / browse episode' },
{ kind: 'quit', label: 'Quit SubMiner' },
]);
});
test('history entry menu offers previous, replay, and next before playback starts', () => {
assert.equal(
typeof buildHistoryEntryActions,
'function',
'history entry actions not implemented',
);
assert.deepEqual(
buildHistoryEntryActions(
'/shows/test-show/episode-03.mkv',
'/shows/test-show/episode-02.mkv',
'/shows/test-show/episode-04.mkv',
),
[
{ kind: 'previous', label: 'Previous episode: episode-02.mkv' },
{ kind: 'replay', label: 'Replay last watched: episode-03.mkv' },
{ kind: 'next', label: 'Next episode: episode-04.mkv' },
{ kind: 'browse', label: 'Browse episodes' },
{ kind: 'quit', label: 'Quit SubMiner' },
],
);
});
test('history entry menu omits replay when the last watched file is gone', () => {
assert.deepEqual(buildHistoryEntryActions(null, null, '/shows/test-show/episode-04.mkv'), [
{ kind: 'next', label: 'Next episode: episode-04.mkv' },
{ kind: 'browse', label: 'Browse episodes' },
{ kind: 'quit', label: 'Quit SubMiner' },
]);
});
test('history playback loop selects previous based on the just-played path, then re-derives previous from the new current episode', async () => {
assert.equal(
typeof runHistoryPlaybackLoop,
'function',
'history playback loop is not implemented',
);
const entry = makeEntry('/shows/test-show/stale-episode-09.mkv');
const played: string[] = [];
const previousInputs: string[] = [];
const previousSeenByMenu: Array<string | null> = [];
let menuCount = 0;
await typedRunHistoryPlaybackLoop(
{ entry, videoPath: '/shows/test-show/episode-07.mkv' },
{
play: async (videoPath) => {
played.push(videoPath);
},
pickPostPlaybackAction: async ({ previousEpisodePath }) => {
previousSeenByMenu.push(previousEpisodePath);
return menuCount++ === 0 ? 'previous' : 'quit';
},
findPreviousEpisode: (videoPath) => {
previousInputs.push(videoPath);
if (videoPath.endsWith('episode-07.mkv')) return '/shows/test-show/episode-06.mkv';
if (videoPath.endsWith('episode-06.mkv')) return '/shows/test-show/episode-05.mkv';
return null;
},
findNextEpisode: () => null,
browseEpisodes: async () => null,
},
);
assert.deepEqual(played, ['/shows/test-show/episode-07.mkv', '/shows/test-show/episode-06.mkv']);
assert.deepEqual(previousInputs, [
'/shows/test-show/episode-07.mkv',
'/shows/test-show/episode-06.mkv',
]);
assert.deepEqual(previousSeenByMenu, [
'/shows/test-show/episode-06.mkv',
'/shows/test-show/episode-05.mkv',
]);
});
test('history playback loop stops advancing when previous is chosen with no prior episode', async () => {
assert.equal(
typeof runHistoryPlaybackLoop,
'function',
'history playback loop is not implemented',
);
const entry = makeEntry('/shows/test-show/episode-01.mkv');
const played: string[] = [];
await typedRunHistoryPlaybackLoop(
{ entry, videoPath: '/shows/test-show/episode-01.mkv' },
{
play: async (videoPath) => {
played.push(videoPath);
},
pickPostPlaybackAction: async ({ previousEpisodePath }) => {
assert.equal(previousEpisodePath, null);
return 'previous';
},
findPreviousEpisode: () => null,
findNextEpisode: () => null,
browseEpisodes: async () => null,
},
);
assert.deepEqual(played, ['/shows/test-show/episode-01.mkv']);
});
+16 -1
View File
@@ -5,7 +5,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { LauncherCommandContext } from './context.js';
import { runPlaybackCommandWithDeps } from './playback-command.js';
import { registerCleanup, runPlaybackCommandWithDeps } from './playback-command.js';
import { state } from '../mpv.js';
function createContext(): LauncherCommandContext {
@@ -63,6 +63,7 @@ function createContext(): LauncherCommandContext {
logsExport: false,
version: false,
settings: false,
animeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
@@ -103,6 +104,20 @@ function createContext(): LauncherCommandContext {
};
}
test('playback cleanup signal handlers are registered once across repeated sessions', () => {
assert.equal(typeof registerCleanup, 'function', 'cleanup registration is not exported');
const context = createContext();
const registeredSignals: NodeJS.Signals[] = [];
context.processAdapter.onSignal = (signal) => {
registeredSignals.push(signal);
};
registerCleanup(context);
registerCleanup(context);
assert.deepEqual(registeredSignals, ['SIGINT', 'SIGTERM']);
});
test('youtube playback launches overlay with app-owned youtube flow args', async () => {
const calls: string[] = [];
const context = createContext();
+5 -1
View File
@@ -30,6 +30,7 @@ import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
const SETUP_POLL_INTERVAL_MS = 500;
const cleanupRegisteredProcessAdapters = new WeakSet<LauncherCommandContext['processAdapter']>();
function getLauncherConfigDir(): string {
return getDefaultConfigDir({
@@ -92,8 +93,10 @@ async function chooseTarget(
return { target: selected, kind: 'file' };
}
function registerCleanup(context: LauncherCommandContext): void {
export function registerCleanup(context: LauncherCommandContext): void {
const { args, processAdapter } = context;
if (cleanupRegisteredProcessAdapters.has(processAdapter)) return;
processAdapter.onSignal('SIGINT', () => {
stopOverlay(args);
processAdapter.exit(130);
@@ -102,6 +105,7 @@ function registerCleanup(context: LauncherCommandContext): void {
stopOverlay(args);
processAdapter.exit(143);
});
cleanupRegisteredProcessAdapters.add(processAdapter);
}
async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promise<void> {
+4
View File
@@ -120,6 +120,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
logLevel: 'warn',
},
settingsInvocation: null,
animeInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
@@ -171,6 +172,7 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
settingsInvocation: {
logLevel: undefined,
},
animeInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
@@ -215,6 +217,7 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
action: undefined,
},
settingsInvocation: null,
animeInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
@@ -257,6 +260,7 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
jellyfinInvocation: null,
configInvocation: null,
settingsInvocation: null,
animeInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
+7
View File
@@ -168,6 +168,7 @@ export function createDefaultArgs(
version: false,
update: false,
settings: false,
animeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
@@ -348,6 +349,12 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
);
}
if (invocations.animeInvocation) {
if (invocations.animeInvocation.logLevel) {
parsed.logLevel = parseLogLevel(invocations.animeInvocation.logLevel);
}
parsed.animeBrowser = true;
}
if (invocations.settingsInvocation) {
if (invocations.settingsInvocation.logLevel) {
parsed.logLevel = parseLogLevel(invocations.settingsInvocation.logLevel);
+14
View File
@@ -23,6 +23,7 @@ export interface CliInvocations {
jellyfinInvocation: JellyfinInvocation | null;
configInvocation: CommandActionInvocation | null;
settingsInvocation: CommandActionInvocation | null;
animeInvocation: CommandActionInvocation | null;
mpvInvocation: CommandActionInvocation | null;
appInvocation: { appArgs: string[] } | null;
dictionaryTriggered: boolean;
@@ -102,6 +103,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
'doctor',
'config',
'settings',
'anime',
'mpv',
'logs',
'dictionary',
@@ -155,6 +157,7 @@ export function parseCliPrograms(
let jellyfinInvocation: JellyfinInvocation | null = null;
let configInvocation: CommandActionInvocation | null = null;
let settingsInvocation: CommandActionInvocation | null = null;
let animeInvocation: CommandActionInvocation | null = null;
let mpvInvocation: CommandActionInvocation | null = null;
let appInvocation: { appArgs: string[] } | null = null;
let dictionaryTriggered = false;
@@ -415,6 +418,16 @@ export function parseCliPrograms(
};
});
commandProgram
.command('anime')
.description('Open the anime browser window')
.option('--log-level <level>', 'Log level')
.action((options: Record<string, unknown>) => {
animeInvocation = {
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
};
});
commandProgram
.command('mpv')
.description('MPV helpers')
@@ -469,6 +482,7 @@ export function parseCliPrograms(
jellyfinInvocation,
configInvocation,
settingsInvocation,
animeInvocation,
mpvInvocation,
appInvocation,
dictionaryTriggered,
+43
View File
@@ -130,3 +130,46 @@ export function findNextEpisode(lastPath: string): string | null {
return findFirstEpisodeInNextSeason(resolvedLast, dir);
}
function findLastEpisodeInPreviousSeason(resolvedCurrent: string, dir: string): string | null {
const seriesRoot = resolveSeriesRoot(resolvedCurrent);
if (seriesRoot === dir) return null;
const seasons = listSeasonDirs(seriesRoot);
const currentIdx = seasons.findIndex((season) => path.resolve(season.path) === dir);
const currentSeason = seasonNumberFromDirName(path.basename(dir));
const previousSeasonEntry =
currentIdx >= 0
? seasons[currentIdx - 1]
: seasons
.filter(
(season) =>
currentSeason !== null && season.season !== null && season.season < currentSeason,
)
.at(-1);
if (!previousSeasonEntry) return null;
const previousSeason = sortVideosByEpisode(collectVideos(previousSeasonEntry.path, false));
return previousSeason.at(-1) ?? null;
}
export function findPreviousEpisode(currentPath: string): string | null {
const resolvedCurrent = path.resolve(currentPath);
const dir = path.dirname(resolvedCurrent);
const episodes = sortVideosByEpisode(collectVideos(dir, false));
const idx = episodes.indexOf(resolvedCurrent);
if (idx >= 0) {
if (idx - 1 >= 0) return episodes[idx - 1]!;
} else {
const currentInfo = parseMediaInfo(resolvedCurrent);
if (currentInfo.episode !== null) {
const candidates = episodes.filter((episode) => {
const info = parseMediaInfo(episode);
return info.episode !== null && info.episode < currentInfo.episode!;
});
const candidate = candidates[candidates.length - 1];
if (candidate) return candidate;
}
}
return findLastEpisodeInPreviousSeason(resolvedCurrent, dir);
}
+50
View File
@@ -7,6 +7,7 @@ import { Database } from 'bun:sqlite';
import {
detectImageExtension,
findNextEpisode,
findPreviousEpisode,
groupHistoryBySeries,
isReadonlyWalRetryError,
listSeasonDirs,
@@ -198,6 +199,55 @@ test('findNextEpisode advances seasons when a deleted file was the last episode'
}
});
test('findPreviousEpisode steps back within a season and across seasons', () => {
assert.equal(typeof findPreviousEpisode, 'function', 'findPreviousEpisode is not implemented');
const seriesRoot = createSeriesTree();
try {
const season1 = path.join(seriesRoot, 'Season-1');
const season2 = path.join(seriesRoot, 'Season-2');
assert.equal(
findPreviousEpisode(path.join(season1, 'Show - S01E03.mkv')),
path.join(season1, 'Show - S01E02.mkv'),
);
assert.equal(
findPreviousEpisode(path.join(season1, 'Show - S01E02.mkv')),
path.join(season1, 'Show - S01E01.mkv'),
);
assert.equal(findPreviousEpisode(path.join(season1, 'Show - S01E01.mkv')), null);
assert.equal(
findPreviousEpisode(path.join(season2, 'Show - S02E01.mkv')),
path.join(season1, 'Show - S01E03.mkv'),
);
} finally {
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
}
});
test('findPreviousEpisode falls back to episode numbers when file was removed', () => {
const seriesRoot = createSeriesTree();
try {
const season1 = path.join(seriesRoot, 'Season-1');
const missing = path.join(season1, 'Show - S01E02 - Deleted Cut.mkv');
assert.equal(findPreviousEpisode(missing), path.join(season1, 'Show - S01E01.mkv'));
} finally {
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
}
});
test('findPreviousEpisode falls back to prior season when a deleted file was the first episode', () => {
const seriesRoot = createSeriesTree();
try {
const season1 = path.join(seriesRoot, 'Season-1');
const season2 = path.join(seriesRoot, 'Season-2');
fs.rmSync(path.join(season2, 'Show - S02E01.mkv'));
const missing = path.join(season2, 'Show - S02E01 - Deleted Cut.mkv');
assert.equal(findPreviousEpisode(missing), path.join(season1, 'Show - S01E03.mkv'));
} finally {
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
}
});
const PNG_MAGIC = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
function createHistoryDb(
+1
View File
@@ -57,6 +57,7 @@ function createArgs(): Args {
logsExport: false,
version: false,
settings: false,
animeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
+8 -6
View File
@@ -21,7 +21,7 @@ import { runDictionaryCommand } from './commands/dictionary-command.js';
import { runLogsCommand } from './commands/logs-command.js';
import { runStatsCommand } from './commands/stats-command.js';
import { runJellyfinCommand } from './commands/jellyfin-command.js';
import { runHistoryCommand } from './commands/history-command.js';
import { runHistorySession } from './commands/history-command.js';
import { runSyncCommand } from './commands/sync-command.js';
import { runPlaybackCommand } from './commands/playback-command.js';
import { runUpdateCommand } from './commands/update-command.js';
@@ -149,13 +149,15 @@ async function main(): Promise<void> {
}
if (appContext.args.history) {
const selected = await runHistoryCommand(appContext);
if (!selected) {
const played = await runHistorySession(appContext, async (videoPath) => {
appContext.args.target = videoPath;
appContext.args.targetKind = 'file';
await runPlaybackCommand(appContext);
});
if (!played) {
log('info', args.logLevel, 'No watch history selection made, exiting');
return;
}
appContext.args.target = selected;
appContext.args.targetKind = 'file';
return;
}
await runPlaybackCommand(appContext);
+1
View File
@@ -629,6 +629,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
logsExport: false,
version: false,
settings: false,
animeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
+16 -1
View File
@@ -3,7 +3,22 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { findRofiTheme } from './picker';
import { findRofiTheme, formatRofiPrompt } from './picker';
// ── formatRofiPrompt: spacing between prompt and input field ──────────────────
test('formatRofiPrompt appends a single trailing space', () => {
assert.equal(formatRofiPrompt('Select Video'), 'Select Video ');
});
test('formatRofiPrompt collapses existing trailing whitespace to one space', () => {
assert.equal(formatRofiPrompt('Watch History '), 'Watch History ');
});
test('formatRofiPrompt leaves an empty prompt empty', () => {
assert.equal(formatRofiPrompt(''), '');
assert.equal(formatRofiPrompt(' '), '');
});
// ── findRofiTheme: Linux packaged path discovery ──────────────────────────────
+13 -4
View File
@@ -17,13 +17,22 @@ export function escapeShellSingle(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
/**
* Rofi renders the prompt flush against the input field, so keep exactly one
* trailing space to separate them.
*/
export function formatRofiPrompt(prompt: string): string {
const trimmed = prompt.trimEnd();
return trimmed ? `${trimmed} ` : '';
}
export function showRofiFlatMenu(
items: string[],
prompt: string,
initialQuery = '',
themePath: string | null = null,
): string {
const args = ['-dmenu', '-i', '-matching', 'fuzzy', '-p', prompt];
const args = ['-dmenu', '-i', '-matching', 'fuzzy', '-p', formatRofiPrompt(prompt)];
if (themePath) {
args.push('-theme', themePath);
} else {
@@ -110,7 +119,7 @@ export async function promptOptionalJellyfinSearch(
themePath: string | null = null,
): Promise<string> {
if (useRofi && commandExists('rofi')) {
const rofiArgs = ['-dmenu', '-i', '-p', 'Jellyfin Search (optional)'];
const rofiArgs = ['-dmenu', '-i', '-p', formatRofiPrompt('Jellyfin Search (optional)')];
if (themePath) {
rofiArgs.push('-theme', themePath);
} else {
@@ -157,7 +166,7 @@ function showRofiIconMenu(
themePath: string | null = null,
): number {
if (entries.length === 0) return -1;
const rofiArgs = ['-dmenu', '-i', '-show-icons', '-format', 'i', '-p', prompt];
const rofiArgs = ['-dmenu', '-i', '-show-icons', '-format', 'i', '-p', formatRofiPrompt(prompt)];
if (initialQuery) rofiArgs.push('-filter', initialQuery);
if (themePath) {
rofiArgs.push('-theme', themePath);
@@ -391,7 +400,7 @@ export function showRofiMenu(
'-dmenu',
'-i',
'-p',
'Select Video ',
formatRofiPrompt('Select Video'),
'-show-icons',
'-theme-str',
'configuration { font: "Noto Sans CJK JP Regular 8";}',
+4 -2
View File
@@ -185,6 +185,7 @@ export const IMMERSION_DB_FIXTURE_DDL = `
line_id INTEGER NOT NULL,
word_id INTEGER NOT NULL,
occurrence_count INTEGER NOT NULL,
seen_ms INTEGER,
PRIMARY KEY(line_id, word_id),
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
FOREIGN KEY(word_id) REFERENCES imm_words(id) ON DELETE CASCADE
@@ -193,6 +194,7 @@ export const IMMERSION_DB_FIXTURE_DDL = `
line_id INTEGER NOT NULL,
kanji_id INTEGER NOT NULL,
occurrence_count INTEGER NOT NULL,
seen_ms INTEGER,
PRIMARY KEY(line_id, kanji_id),
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
FOREIGN KEY(kanji_id) REFERENCES imm_kanji(id) ON DELETE CASCADE
@@ -313,8 +315,8 @@ export const IMMERSION_DB_FIXTURE_DDL = `
CREATE INDEX idx_subtitle_lines_session_line ON imm_subtitle_lines(session_id, line_index);
CREATE INDEX idx_subtitle_lines_video_line ON imm_subtitle_lines(video_id, line_index);
CREATE INDEX idx_subtitle_lines_anime_line ON imm_subtitle_lines(anime_id, line_index);
CREATE INDEX idx_word_line_occurrences_word ON imm_word_line_occurrences(word_id, line_id);
CREATE INDEX idx_kanji_line_occurrences_kanji ON imm_kanji_line_occurrences(kanji_id, line_id);
CREATE INDEX idx_word_line_occurrences_word_seen ON imm_word_line_occurrences(word_id, seen_ms, occurrence_count, line_id);
CREATE INDEX idx_kanji_line_occurrences_kanji_seen ON imm_kanji_line_occurrences(kanji_id, seen_ms, occurrence_count, line_id);
CREATE INDEX idx_media_art_cover_blob_hash ON imm_media_art(cover_blob_hash);
CREATE INDEX idx_media_art_anilist_id ON imm_media_art(anilist_id);
CREATE INDEX idx_media_art_cover_url ON imm_media_art(cover_url);
+1
View File
@@ -149,6 +149,7 @@ export interface Args {
version: boolean;
update?: boolean;
settings: boolean;
animeBrowser: boolean;
configPath: boolean;
configShow: boolean;
mpvIdle: boolean;
+14 -9
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.19.0-beta.1",
"version": "0.19.0",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -13,6 +13,7 @@
"get-frequency:electron": "bun run build:yomitan && bun build scripts/get_frequency.ts --format=cjs --target=node --outfile dist/scripts/get_frequency.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/get_frequency.js --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line",
"test-yomitan-parser": "bun run scripts/test-yomitan-parser.ts",
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js",
"verify-known-word-highlights:electron": "bun run build:yomitan && bun build scripts/verify-known-word-highlights.ts --format=cjs --target=node --outfile dist/scripts/verify-known-word-highlights.js --packages=external && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/verify-known-word-highlights.js",
"record-tokenizer-fixture:electron": "bun run build:yomitan && bun build scripts/record-tokenizer-fixture.ts --format=cjs --target=node --outfile dist/scripts/record-tokenizer-fixture.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/record-tokenizer-fixture.js",
"compare-yomitan-api:electron": "bun run build:yomitan && bun build scripts/compare-yomitan-api.ts --format=cjs --target=node --outfile dist/scripts/compare-yomitan-api.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/compare-yomitan-api.js",
"build:yomitan": "bun scripts/build-yomitan.mjs",
@@ -20,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",
@@ -82,13 +84,16 @@
},
"overrides": {
"@xmldom/xmldom": "0.8.13",
"app-builder-lib": "26.8.2",
"electron-builder-squirrel-windows": "26.8.2",
"app-builder-lib": "26.15.3",
"brace-expansion": "5.0.8",
"electron-builder-squirrel-windows": "26.15.3",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.0",
"lodash": "4.18.0",
"minimatch": "10.2.3",
"minimatch": "10.2.5",
"picomatch": "4.0.4",
"tar": "7.5.16",
"tar": "7.5.21",
"tmp": "0.2.7"
},
"keywords": [
@@ -109,7 +114,7 @@
"@xhayper/discord-rpc": "^1.3.4",
"axios": "^1.18.1",
"commander": "^14.0.3",
"electron-updater": "^6.8.3",
"electron-updater": "^6.8.9",
"hono": "^4.12.28",
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
@@ -120,10 +125,10 @@
"@types/node": "^24.10.0",
"@types/ws": "^8.18.1",
"electron": "42.6.0",
"electron-builder": "26.8.2",
"electron-builder": "26.15.3",
"undici": "7.28.0",
"esbuild": "^0.25.12",
"eslint": "^10.4.0",
"eslint": "^10.8.0",
"prettier": "^3.8.1",
"typescript": "^5.9.3"
},
+55 -9
View File
@@ -6,29 +6,75 @@
### Added
- **Sync Stats & History**
- Keep mining stats and watch history in sync across machines over SSH, from a new **Sync Stats & History** window (tray menu) or the `subminer sync <host>` command.
- Syncing merges data safely, so nothing is duplicated even if you sync the same machines repeatedly, and hosts with auto-sync enabled sync in the background on a schedule, reporting results as overlay notifications.
- Manual database snapshots (create, merge, reveal, delete) cover one-off transfers, and Windows machines running the built-in OpenSSH Server can be used as sync remotes too. No setup beyond SSH access is required on the remote side.
- New **Sync Stats & History** window (tray menu) and `subminer sync <host>` command keep mining stats and watch history in sync between machines over SSH, with saved devices, per-host sync direction, and live stage-by-stage progress.
- Merges are safe to repeat: data combines without duplicates, and hosts with auto-sync enabled sync automatically in the background on a schedule, reporting results as overlay notifications.
- Manual snapshot tools (create, merge, reveal, delete) and connection testing cover one-off transfers; Windows machines running the built-in OpenSSH Server work as sync remotes too, with no setup needed beyond SSH access. Power users can script transfers directly with `--push`/`--pull`, `--check`, `--snapshot`/`--merge`, and `--json` flags.
- **TsukiHime Subtitle Downloads**
- Download Japanese and secondary-language subtitles for the current video directly from TsukiHime, mirroring the existing Jimaku flow: `Ctrl+Shift+T` opens an in-overlay search modal with separate tabs for the primary and secondary languages.
- Matching releases are found automatically from the video filename; the chosen subtitle downloads and loads straight into mpv, no API key required.
- Download Japanese and secondary-language subtitles for the current video directly from TsukiHime, mirroring the existing Jimaku flow.
- Press `Ctrl+Shift+T` to search by tabs for the primary and secondary languages; the matching release is found automatically from the video filename and loads straight into mpv, no API key required.
- **Post-Playback History Menu**
- After a watch-history episode ends or mpv closes, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit SubMiner.
- Previous/Next continue across season directories, so you can binge a show without manually browsing folders.
- The menu shown right after picking a series from `subminer -H` now offers the previous episode too, matching the post-playback menu.
- **Known-Word Highlighting by Anki Maturity**
- Subtitle highlights for known words can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Enable it with `ankiConnect.knownWords.maturityEnabled`, or toggle it live during a session.
- The mature-interval threshold and the four tier colors are configurable, and the in-session help legend shows the active tier colors while maturity highlighting is on.
- Tiers follow Anki's own card state: a lapsed card correctly shows as learning rather than young, and a note is treated as mature if any of its cards are mature. Stats and other known-word tools stay accurate with this new data.
- **Stats Library Entry Deletion**
- Added a "Delete Entry" action in the stats Library detail view that removes an entire title in one step: every episode, session, subtitle line, rollup, cover, and vocabulary count derived from it. Previously a mistaken entry had to be cleared episode by episode and still lingered in the Library.
- Delete progress (session, session group, episode, or full entry) now shows app-wide as a progress bar plus a status toast, staying visible across tabs and windows instead of disappearing when you switch away.
- Deletes are dramatically faster on large libraries, and opening the Vocabulary tab no longer stalls; the first launch after upgrading migrates the stats database in place to support this.
### Changed
- **Clipboard-Video Shortcut**: The "append clipboard video to queue" shortcut is now configurable (`shortcuts.appendClipboardVideoToQueue`) instead of fixed.
- **Clipboard-Video Shortcut**
- The "append clipboard video to queue" shortcut is now configurable via `shortcuts.appendClipboardVideoToQueue` instead of being fixed.
### Fixed
- **Word Highlighting Accuracy**: Fixed several cases of incorrect word highlighting and annotations, including inconsistent part-of-speech exclusions on merged quote-particle tokens, missing annotations for rare kanji, katakana punctuation wrongly treated as non-kana noise, and certain kanji vocabulary being skipped for next-level ("N+1") highlighting.
- **Startup Playback Pausing Too Early**: Fixed playback resuming before subtitle processing had finished warming up, which could briefly show untranslated subtitles right after opening a video, most noticeable when resuming mid-episode.
- **Linux AppImage Crash Notification on Quit**: Fixed a spurious "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
- **Word Highlighting Accuracy**
- Fixed several incorrect word highlighting and annotation cases: inconsistent part-of-speech exclusions on merged quote-particle tokens, missing annotations for rare kanji, katakana punctuation wrongly treated as non-kana noise, and certain kanji vocabulary skipped for next-level ("N+1") highlighting.
- **AniList Season Resolution**
- Season 2 and later episodes now resolve to the correct AniList entry by walking sequel relations instead of guessing from the title, so watch progress, the character dictionary, and cover art for later seasons no longer silently fall back to season 1.
- Manual AniList overrides now stay in effect for every episode in the same season (by folder and detected season), and setting an override now fixes both the character dictionary and AniList watch progress together instead of needing separate corrections.
- **Startup Playback Pausing Too Early**
- Fixed playback resuming before subtitle processing finished warming up, which could briefly show untranslated subtitles right after opening a video.
- Most noticeable when resuming mid-episode or when a subtitle cue starts within the first couple of seconds.
- **Linux AppImage Crash Notification on Quit**
- Fixed a spurious "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
- If needed, the mount-keepalive behavior behind this fix can be disabled with `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1`.
- **AnkiConnect Proxy Port Conflict**
- Fixed video playback failing to start when another process already held the configured AnkiConnect proxy port; SubMiner now shows a notification explaining how to resolve the conflict instead of crashing.
- **Stats & Settings Reliability**
- Fixed session stats reporting zero known words after the known-word cache gained maturity tiers.
- Hardened the stats server against malformed requests, stalled AniList lookups, media mismatches during word mining, and missing Yomitan connections.
- AnkiConnect settings validation now preserves valid custom configurations while safely falling back on invalid values instead of failing.
- **Stats Library Cover After Relink**
- Relinking a title to a different AniList entry now updates its cover art in the stats Library grid, not just the detail view, so unrelated titles no longer end up sharing the wrong cover.
- **Rofi Menu Prompt Spacing**
- Rofi menu prompts now keep a space between the prompt label and the input field instead of crowding the search placeholder text.
## What's Changed
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
- refactor(tokenizer): extract subtitle annotation filter into rule table by @ksyasuda in #162
- refactor(tsukihime): swap Animetosho backend for TsukiHime API by @ksyasuda in #165
- refactor: split anki-connect and stats-server resolvers into modules by @ksyasuda in #169
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
- Anki maturity-based known-word highlighting by @ksyasuda in #172
- fix(anilist): resolve later seasons via sequel relations, not title guessing by @ksyasuda in #173
- feat(stats): add library entry deletion and app-wide delete progress by @ksyasuda in #174
## Installation
+21 -2
View File
@@ -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<string, string> };
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/);
});
+7
View File
@@ -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();
}
+593
View File
@@ -0,0 +1,593 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import {
KnownWordCacheManager,
getKnownWordCacheLifecycleConfig,
} from '../src/anki-integration/known-word-cache.js';
import { getMatureIntervalThresholdDays } from '../src/anki-integration/known-word-maturity.js';
import { resolveConfigDir } from '../src/config/path-resolution.js';
import { ConfigService } from '../src/config/service.js';
import { parseSubtitleCues } from '../src/core/services/subtitle-cue-parser.js';
import { createTokenizerDepsRuntime, tokenizeSubtitle } from '../src/core/services/tokenizer.js';
import {
resolveCompleteTokenReading,
resolveKnownWordReadingForMatch,
resolveKnownWordText,
} from '../src/core/services/tokenizer/annotation-stage.js';
import { MecabTokenizer } from '../src/mecab-tokenizer.js';
import type { MergedToken } from '../src/types.js';
import type { KnownWordMaturityTier } from '../src/types/subtitle.js';
import {
createYomitanRuntimeStateWithSearch,
destroyParserWindow,
loadElectronModule,
withTimeout,
type YomitanRuntimeState,
} from './yomitan-script-runtime.js';
interface CliOptions {
input: string;
configDir?: string;
yomitanUserDataPath?: string;
yomitanExtensionPath?: string;
limit: number;
audit: boolean;
refresh: boolean;
json: boolean;
quiet: boolean;
profileCopy: boolean;
}
type TierOrFallback = KnownWordMaturityTier | 'known-no-tier';
interface TokenReport {
cueIndex: number;
startTime: number;
surface: string;
headword: string;
reading: string;
tier: TierOrFallback;
noteIds: number[];
}
interface AuditMismatch extends TokenReport {
liveTier: KnownWordMaturityTier | 'no-notes';
intervals: number[];
}
const TIERS: readonly KnownWordMaturityTier[] = ['new', 'learning', 'young', 'mature'];
const FALLBACK_TIER_COLORS: Record<KnownWordMaturityTier, string> = {
new: '#ee99a0',
learning: '#b7bdf8',
young: '#91d7e3',
mature: '#a6da95',
};
function parseCliArgs(argv: string[]): CliOptions {
const options: CliOptions = {
input: '',
limit: 0,
audit: false,
refresh: false,
json: false,
quiet: false,
profileCopy: false,
};
const rest: string[] = [];
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!;
const takeValue = (flag: string): string => {
const next = argv[i + 1];
if (!next) {
throw new Error(`Missing value for ${flag}`);
}
i += 1;
return next;
};
if (arg === '--help' || arg === '-h') {
process.stdout.write(`${usage()}\n`);
process.exit(0);
} else if (arg === '--input') {
options.input = takeValue(arg);
} else if (arg === '--config-dir') {
options.configDir = takeValue(arg);
} else if (arg === '--yomitan-user-data') {
options.yomitanUserDataPath = takeValue(arg);
} else if (arg === '--yomitan-extension-path') {
options.yomitanExtensionPath = takeValue(arg);
} else if (arg === '--limit') {
options.limit = Math.max(0, Number.parseInt(takeValue(arg), 10) || 0);
} else if (arg === '--audit') {
options.audit = true;
} else if (arg === '--refresh') {
options.refresh = true;
} else if (arg === '--json') {
options.json = true;
} else if (arg === '--quiet') {
options.quiet = true;
} else if (arg === '--profile-copy') {
options.profileCopy = true;
} else if (arg === '--') {
// `bun run <script> -- --flag ...` forwards the separator too.
continue;
} else if (arg.startsWith('--')) {
throw new Error(`Unknown flag: ${arg}`);
} else {
rest.push(arg);
}
}
if (!options.input && rest.length > 0) {
options.input = rest.join(' ');
}
if (!options.input) {
throw new Error(`No subtitle file given.\n${usage()}`);
}
return options;
}
function usage(): string {
return [
'Usage: verify-known-word-highlights <subtitle.srt|.ass> [flags]',
'',
' --limit <n> Only check the first n cues (default: all)',
' --audit Re-derive every highlighted tier from live Anki card data',
' --refresh Force a known-word cache refresh before checking',
' --json Emit a machine-readable report',
' --quiet Skip the per-line colored dump',
' --profile-copy Copy the Yomitan profile to a scratch dir so this can run',
' while SubMiner is open (Electron locks the userData dir)',
' --config-dir <dir> SubMiner config dir (default: auto-detected)',
' --yomitan-user-data <dir> Electron userData dir holding the Yomitan profile',
' --yomitan-extension-path <dir>',
].join('\n');
}
const ANSI_RESET = '\u001b[0m';
function colorize(text: string, hex: string): string {
const normalized = hex.trim().replace(/^#/, '');
const expanded =
normalized.length === 3
? normalized
.split('')
.map((char) => `${char}${char}`)
.join('')
: normalized;
if (!/^[0-9a-fA-F]{6}$/.test(expanded)) {
return text;
}
const r = Number.parseInt(expanded.slice(0, 2), 16);
const g = Number.parseInt(expanded.slice(2, 4), 16);
const b = Number.parseInt(expanded.slice(4, 6), 16);
return `\u001b[38;2;${r};${g};${b}m${text}${ANSI_RESET}`;
}
function formatTimestamp(seconds: number): string {
const total = Math.max(0, Math.floor(seconds));
const mm = String(Math.floor(total / 60)).padStart(2, '0');
const ss = String(total % 60).padStart(2, '0');
return `${mm}:${ss}`;
}
// The user's real config dir is copied into a scratch dir so this read-only
// check can never rewrite config.jsonc (ConfigService migrates on load) or the
// live known-word cache (a --refresh persists tier data).
function createScratchState(configDir: string): { dir: string; cachePath: string } {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-highlight-verify-'));
for (const fileName of ['config.jsonc', 'config.json']) {
const source = path.join(configDir, fileName);
if (fs.existsSync(source)) {
fs.copyFileSync(source, path.join(dir, fileName));
}
}
const cachePath = path.join(dir, 'known-words-cache.json');
const liveCachePath = path.join(configDir, 'known-words-cache.json');
if (fs.existsSync(liveCachePath)) {
fs.copyFileSync(liveCachePath, cachePath);
}
return { dir, cachePath };
}
// Electron locks a userData dir, so the Yomitan profile can't be shared with a
// running SubMiner. Copying the dictionary-bearing parts of the profile lets
// this check run mid-session (IndexedDB alone is often over 1 GB).
const YOMITAN_PROFILE_DIRS = [
'extensions',
'IndexedDB',
'Local Extension Settings',
'Local Storage',
];
function copyYomitanProfile(sourceUserDataPath: string): string {
const target = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-highlight-profile-'));
for (const name of YOMITAN_PROFILE_DIRS) {
const source = path.join(sourceUserDataPath, name);
if (fs.existsSync(source)) {
fs.cpSync(source, path.join(target, name), { recursive: true });
}
}
return target;
}
function readPersistedCacheScope(cachePath: string): string | null {
try {
const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as { scope?: unknown };
return typeof parsed.scope === 'string' ? parsed.scope : null;
} catch {
return null;
}
}
const ANKI_REQUEST_TIMEOUT_MS = 30_000;
function createAnkiClient(url: string) {
const request = async (action: string, params: unknown): Promise<unknown> => {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, version: 6, params }),
signal: AbortSignal.timeout(ANKI_REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`AnkiConnect ${action}: HTTP ${response.status} ${response.statusText}`);
}
const payload = (await response.json()) as { result: unknown; error: string | null };
if (payload.error) {
throw new Error(`AnkiConnect ${action}: ${payload.error}`);
}
return payload.result;
};
return {
request,
findNotes: (query: string) => request('findNotes', { query }),
notesInfo: (noteIds: number[]) => request('notesInfo', { notes: noteIds }),
};
}
function resolveTokenMatch(
token: MergedToken,
cache: KnownWordCacheManager,
matchMode: 'surface' | 'headword',
): { tier: KnownWordMaturityTier | null; noteIds: Set<number> } {
const matchText = resolveKnownWordText(token.surface, token.headword, matchMode);
const matchReading = resolveKnownWordReadingForMatch(token, matchMode);
const primaryTier = matchText ? cache.getKnownWordTier(matchText, matchReading) : null;
if (primaryTier) {
return { tier: primaryTier, noteIds: cache.getKnownWordMatchNoteIds(matchText, matchReading) };
}
const fallbackReading = resolveCompleteTokenReading(token);
if (!fallbackReading || fallbackReading === matchText.trim()) {
return {
tier: null,
noteIds: matchText ? cache.getKnownWordMatchNoteIds(matchText, matchReading) : new Set(),
};
}
const fallbackOptions = { allowReadingOnlyMatch: false } as const;
return {
tier: cache.getKnownWordTier(fallbackReading, undefined, fallbackOptions),
noteIds: cache.getKnownWordMatchNoteIds(fallbackReading, undefined, fallbackOptions),
};
}
// Ground truth straight from card data, independent of the Anki search filters
// the cache refresh uses (prop:ivl / is:learn).
function classifyCardsIntoTier(
cards: Array<{ interval: number; queue: number; type: number }>,
thresholdDays: number,
): KnownWordMaturityTier {
if (cards.some((card) => card.interval >= thresholdDays)) return 'mature';
if (cards.some((card) => card.interval >= 1)) return 'young';
if (
cards.some((card) => card.type === 1 || card.type === 3 || card.queue === 1 || card.queue === 4)
)
return 'learning';
return 'new';
}
async function auditTokens(
reports: TokenReport[],
client: ReturnType<typeof createAnkiClient>,
thresholdDays: number,
): Promise<{ mismatches: AuditMismatch[]; auditedNotes: number }> {
const noteIds = [...new Set(reports.flatMap((report) => report.noteIds))];
const cardIdsByNote = new Map<number, number[]>();
for (let i = 0; i < noteIds.length; i += 500) {
const infos = (await client.notesInfo(noteIds.slice(i, i + 500))) as Array<{
noteId: number;
cards?: number[];
}>;
for (const info of infos) {
cardIdsByNote.set(info.noteId, info.cards ?? []);
}
}
const allCardIds = [...cardIdsByNote.values()].flat();
const cardById = new Map<number, { interval: number; queue: number; type: number }>();
for (let i = 0; i < allCardIds.length; i += 500) {
const infos = (await client.request('cardsInfo', {
cards: allCardIds.slice(i, i + 500),
})) as Array<{ cardId: number; interval: number; queue: number; type: number }>;
for (const info of infos) {
cardById.set(info.cardId, {
interval: info.interval,
queue: info.queue,
type: info.type,
});
}
}
const mismatches: AuditMismatch[] = [];
for (const report of reports) {
const cards = report.noteIds
.flatMap((noteId) => cardIdsByNote.get(noteId) ?? [])
.map((cardId) => cardById.get(cardId))
.filter((card): card is { interval: number; queue: number; type: number } => Boolean(card));
const liveTier = cards.length === 0 ? 'no-notes' : classifyCardsIntoTier(cards, thresholdDays);
if (liveTier !== report.tier) {
mismatches.push({
...report,
liveTier,
intervals: cards.map((card) => card.interval),
});
}
}
return { mismatches, auditedNotes: noteIds.length };
}
async function main(): Promise<void> {
const args = parseCliArgs(process.argv.slice(2));
let electronModule: typeof import('electron') | null = null;
let yomitanState: YomitanRuntimeState | null = null;
let scratchDir: string | null = null;
let profileCopyDir: string | null = null;
try {
const configDir =
args.configDir ??
resolveConfigDir({
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
existsSync: fs.existsSync,
});
const scratch = createScratchState(configDir);
scratchDir = scratch.dir;
const config = new ConfigService(scratch.dir).getConfig();
const ankiConfig = config.ankiConnect;
const matchMode = ankiConfig.knownWords?.matchMode === 'surface' ? 'surface' : 'headword';
const thresholdDays = getMatureIntervalThresholdDays(ankiConfig);
const client = createAnkiClient(ankiConfig.url);
const cacheScopeKey = getKnownWordCacheLifecycleConfig(ankiConfig);
const cache = new KnownWordCacheManager({
client: { findNotes: (query) => client.findNotes(query), notesInfo: client.notesInfo },
getConfig: () => ankiConfig,
knownWordCacheStatePath: scratch.cachePath,
showStatusNotification: () => {},
});
// A cache whose persisted scope key no longer matches the config is
// discarded on load, so every token would come back unknown.
if (!args.refresh && readPersistedCacheScope(scratch.cachePath) !== cacheScopeKey) {
process.stderr.write(
'warning: the persisted known-word cache was built under different settings and will be ' +
'ignored (the app refetches it on its next refresh). Re-run with --refresh to fetch tiers now.\n',
);
}
// startLifecycle loads the persisted cache; the refresh timer it arms is
// cleared before the event loop can run it.
cache.startLifecycle();
cache.stopLifecycle();
if (args.refresh) {
await cache.refresh(true);
}
const cues = parseSubtitleCues(fs.readFileSync(args.input, 'utf-8'), args.input);
const selectedCues = args.limit > 0 ? cues.slice(0, args.limit) : cues;
const mecabTokenizer = new MecabTokenizer();
if (!(await mecabTokenizer.checkAvailability())) {
throw new Error('MeCab is not available; tokenization would not match the overlay.');
}
electronModule = await loadElectronModule();
const userDataPath = args.profileCopy
? copyYomitanProfile(args.yomitanUserDataPath ?? configDir)
: (args.yomitanUserDataPath ?? configDir);
profileCopyDir = args.profileCopy ? userDataPath : null;
if (electronModule?.app && typeof electronModule.app.setPath === 'function') {
electronModule.app.setPath('userData', userDataPath);
}
yomitanState = await createYomitanRuntimeStateWithSearch(
userDataPath,
args.yomitanExtensionPath,
);
if (!yomitanState.available) {
throw new Error(`Yomitan tokenizer unavailable: ${yomitanState.note ?? 'unknown reason'}`);
}
const deps = createTokenizerDepsRuntime({
getYomitanExt: () => yomitanState!.yomitanExt as never,
getYomitanSession: () => yomitanState!.yomitanSession as never,
getYomitanParserWindow: () => yomitanState!.parserWindow as never,
setYomitanParserWindow: (window) => {
yomitanState!.parserWindow = window;
},
getYomitanParserReadyPromise: () => yomitanState!.parserReadyPromise as never,
setYomitanParserReadyPromise: (promise) => {
yomitanState!.parserReadyPromise = promise;
},
getYomitanParserInitPromise: () => yomitanState!.parserInitPromise as never,
setYomitanParserInitPromise: (promise) => {
yomitanState!.parserInitPromise = promise;
},
isKnownWord: (text, reading, options) => cache.isKnownWord(text, reading, options),
getKnownWordTier: (text, reading, options) => cache.getKnownWordTier(text, reading, options),
getKnownWordMatchMode: () => matchMode,
getKnownWordsEnabled: () => true,
// Other annotation layers are off so every colored token below is a
// known-word decision, not an N+1/frequency/name override.
getNPlusOneEnabled: () => false,
getNameMatchEnabled: () => false,
getJlptEnabled: () => false,
getFrequencyDictionaryEnabled: () => false,
getJlptLevel: () => null,
getMecabTokenizer: () => ({ tokenize: (text: string) => mecabTokenizer.tokenize(text) }),
});
const styleColors = {
...FALLBACK_TIER_COLORS,
...(config.subtitleStyle?.knownWordMaturityColors ?? {}),
} as Record<KnownWordMaturityTier, string>;
const knownWordColor = config.subtitleStyle?.knownWordColor ?? '#a6da95';
const reports: TokenReport[] = [];
const tierCounts: Record<string, number> = {};
let knownTokens = 0;
let totalTokens = 0;
const lines: string[] = [];
for (const [cueIndex, cue] of selectedCues.entries()) {
const { text, tokens } = await withTimeout(
tokenizeSubtitle(cue.text, deps),
20_000,
`Tokenizer (cue ${cueIndex + 1})`,
);
if (!tokens || tokens.length === 0) {
continue;
}
let cursor = 0;
let rendered = '';
const ordered = [...tokens].sort((a, b) => (a.startPos ?? 0) - (b.startPos ?? 0));
for (const token of ordered) {
totalTokens += 1;
const start = Math.min(Math.max(0, token.startPos ?? 0), text.length);
const end = Math.min(Math.max(start, token.endPos ?? start), text.length);
if (start > cursor) {
rendered += text.slice(cursor, start);
}
const surfaceText = text.slice(start, end);
cursor = end;
if (!token.isKnown) {
rendered += surfaceText;
continue;
}
knownTokens += 1;
const tier: TierOrFallback = token.knownMaturity ?? 'known-no-tier';
tierCounts[tier] = (tierCounts[tier] ?? 0) + 1;
rendered += colorize(
surfaceText,
tier === 'known-no-tier' ? knownWordColor : styleColors[tier],
);
reports.push({
cueIndex,
startTime: cue.startTime,
surface: token.surface,
headword: token.headword,
reading: token.reading,
tier,
noteIds: [...resolveTokenMatch(token, cache, matchMode).noteIds],
});
}
rendered += text.slice(cursor);
lines.push(`${formatTimestamp(cue.startTime)} ${rendered}`);
}
if (totalTokens === 0 && selectedCues.length > 0) {
throw new Error(
'Yomitan returned no tokens. SubMiner is probably running and holding the Electron ' +
'profile lock - quit it, or re-run with --profile-copy.',
);
}
let audit: { mismatches: AuditMismatch[]; auditedNotes: number } | null = null;
if (args.audit) {
audit = await auditTokens(reports, client, thresholdDays);
}
if (args.json) {
process.stdout.write(
`${JSON.stringify(
{
input: args.input,
cues: selectedCues.length,
totalTokens,
knownTokens,
tierCounts,
matureThresholdDays: thresholdDays,
matchMode,
tokens: reports,
audit,
},
null,
2,
)}\n`,
);
return;
}
if (!args.quiet) {
process.stdout.write(`${lines.join('\n')}\n\n`);
}
process.stdout.write(
[
`file : ${args.input}`,
`cues checked : ${selectedCues.length} of ${cues.length}`,
`tokens : ${totalTokens} (${knownTokens} known, ${totalTokens - knownTokens} unknown)`,
`match mode : ${matchMode} mature threshold: ${thresholdDays}d`,
'',
'known-token tiers:',
...TIERS.map(
(tier) =>
` ${colorize(tier.padEnd(9), styleColors[tier])} ${String(tierCounts[tier] ?? 0).padStart(5)}` +
` ${styleColors[tier]}`,
),
` ${colorize('no tier'.padEnd(9), knownWordColor)} ${String(tierCounts['known-no-tier'] ?? 0).padStart(5)} ${knownWordColor} (falls back to knownWordColor)`,
'',
].join('\n'),
);
if (audit) {
process.stdout.write(
`audit: ${reports.length - audit.mismatches.length}/${reports.length} highlighted tokens agree with live Anki card data ` +
`(${audit.auditedNotes} notes)\n`,
);
for (const mismatch of audit.mismatches.slice(0, 40)) {
process.stdout.write(
` ${formatTimestamp(mismatch.startTime)} ${mismatch.surface} (${mismatch.headword}) ` +
`shown=${mismatch.tier} live=${mismatch.liveTier} ivl=[${mismatch.intervals.join(', ')}] ` +
`notes=[${mismatch.noteIds.join(', ')}]\n`,
);
}
if (audit.mismatches.length > 40) {
process.stdout.write(` ... ${audit.mismatches.length - 40} more\n`);
}
}
} finally {
destroyParserWindow(yomitanState?.parserWindow ?? null);
for (const dir of [scratchDir, profileCopyDir]) {
if (dir) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
if (electronModule?.app) {
electronModule.app.quit();
}
}
}
main()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error(`Error: ${(error as Error).message}`);
process.exit(1);
});
+157
View File
@@ -0,0 +1,157 @@
import fs from 'node:fs';
import path from 'node:path';
import { resolveYomitanExtensionPath as resolveBuiltYomitanExtensionPath } from '../src/core/services/yomitan-extension-paths.js';
// Yomitan bootstrap for CLI scripts that need the app's real tokenizer. Mirrors
// what scripts/get_frequency.ts does inline; new scripts should import this.
export interface YomitanRuntimeState {
yomitanExt: unknown | null;
yomitanSession: unknown | null;
parserWindow: unknown | null;
parserReadyPromise: Promise<void> | null;
parserInitPromise: Promise<boolean> | null;
available: boolean;
note?: string;
}
export function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
promise
.then((value) => {
clearTimeout(timer);
resolve(value);
})
.catch((error) => {
clearTimeout(timer);
reject(error);
});
});
}
export function destroyParserWindow(window: unknown): void {
if (!window || typeof window !== 'object') {
return;
}
const candidate = window as { isDestroyed?: () => boolean; destroy?: () => void };
if (typeof candidate.isDestroyed !== 'function' || typeof candidate.destroy !== 'function') {
return;
}
if (!candidate.isDestroyed()) {
candidate.destroy();
}
}
export async function loadElectronModule(): Promise<typeof import('electron') | null> {
try {
const electronImport = await import('electron');
return (electronImport.default ?? electronImport) as typeof import('electron');
} catch {
return null;
}
}
async function createYomitanRuntimeState(
userDataPath: string,
extensionPath?: string,
): Promise<YomitanRuntimeState> {
const state: YomitanRuntimeState = {
yomitanExt: null,
yomitanSession: null,
parserWindow: null,
parserReadyPromise: null,
parserInitPromise: null,
available: false,
};
const electronImport = await loadElectronModule();
if (
!electronImport ||
!electronImport.app ||
typeof electronImport.app.whenReady !== 'function' ||
!electronImport.session
) {
state.note = electronImport
? 'electron runtime not available in this process'
: 'electron import failed';
return state;
}
try {
await electronImport.app.whenReady();
const loadYomitanExtension = (await import('../src/core/services/yomitan-extension-loader.js'))
.loadYomitanExtension as (options: {
userDataPath: string;
extensionPath?: string;
getYomitanParserWindow: () => unknown;
setYomitanParserWindow: (window: unknown) => void;
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
setYomitanExtension: (extension: unknown) => void;
setYomitanSession: (session: unknown) => void;
}) => Promise<unknown>;
const extension = await loadYomitanExtension({
userDataPath,
extensionPath,
getYomitanParserWindow: () => state.parserWindow,
setYomitanParserWindow: (window) => {
state.parserWindow = window;
},
setYomitanParserReadyPromise: (promise) => {
state.parserReadyPromise = promise;
},
setYomitanParserInitPromise: (promise) => {
state.parserInitPromise = promise;
},
setYomitanExtension: (loaded) => {
state.yomitanExt = loaded;
},
setYomitanSession: (nextSession) => {
state.yomitanSession = nextSession;
},
});
if (!extension) {
state.note = 'yomitan extension is not available';
return state;
}
state.yomitanExt = extension;
state.available = true;
return state;
} catch (error) {
state.note = error instanceof Error ? error.message : 'failed to initialize yomitan extension';
return state;
}
}
export async function createYomitanRuntimeStateWithSearch(
userDataPath: string,
extensionPath?: string,
): Promise<YomitanRuntimeState> {
const resolvedExtensionPath = resolveBuiltYomitanExtensionPath({
explicitPath: extensionPath,
cwd: process.cwd(),
});
if (resolvedExtensionPath) {
try {
if (fs.existsSync(path.join(resolvedExtensionPath, 'manifest.json'))) {
const state = await createYomitanRuntimeState(userDataPath, resolvedExtensionPath);
if (!state.available && !state.note) {
state.note = `Failed to load yomitan extension at ${resolvedExtensionPath}`;
}
return state;
}
} catch {
// fall through to the unconstrained loader below
}
}
// No usable manifest at the resolved path, so let the loader search on its own.
return createYomitanRuntimeState(userDataPath);
}
+166
View File
@@ -0,0 +1,166 @@
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 APK_BASE64 = 'QVBLLUJZVEVT';
const source = {
fingerprint: 'sha-1',
loadApkBase64: async () => APK_BASE64,
sourceId: 'source-1',
};
interface Recorded {
url: string;
body: Record<string, unknown>;
}
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<string, unknown>) : {},
};
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, APK_BASE64);
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, APK_BASE64);
assert.equal(calls[0]?.body.extensionId, undefined);
assert.equal(calls[1]?.body.data, undefined);
assert.equal(calls[1]?.body.extensionId, EXTENSION_ID);
});
test('an upgraded APK re-uploads instead of reusing the previous extension id', 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');
// Same source id, new build in the same file: the id cache must miss.
const upgraded = { ...source, fingerprint: 'sha-2', loadApkBase64: async () => 'TkVXLUFQSw==' };
await client.getVideoList(upgraded, 'https://origin.example/ep/2');
assert.equal(calls[1]?.body.extensionId, undefined);
assert.equal(calls[1]?.body.data, 'TkVXLUFQSw==');
});
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, APK_BASE64);
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');
});
+241
View File
@@ -0,0 +1,241 @@
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 {
/**
* Identity of the APK's contents. Keys the extension-id cache, so an upgraded
* APK is re-uploaded instead of reusing the previous build's id.
*/
fingerprint: string;
/**
* Reads and base64-encodes the APK. Called only when the bridge actually
* needs the bytes, so multi-megabyte payloads are not held on the heap.
*/
loadApkBase64: () => Promise<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;
/**
* Per-request deadline. Node's `fetch` has none, so a sidecar that accepts
* the socket and then stalls would leave every call pending forever.
*/
requestTimeoutMs?: number;
}
/** Extension calls can be slow (a source may scrape several pages). */
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
/** The readiness probe is a local health check; it should answer at once. */
const CAPABILITIES_TIMEOUT_MS = 5_000;
/** 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 requestTimeoutMs: number;
private readonly extensionIds = new Map<string, string>();
constructor(options: BridgeClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
this.fetchImpl = options.fetchImpl ?? fetch;
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
}
async getCapabilities(): Promise<BridgeCapabilities> {
const response = await this.fetchImpl(`${this.baseUrl}/capabilities`, {
signal: AbortSignal.timeout(Math.min(CAPABILITIES_TIMEOUT_MS, this.requestTimeoutMs)),
});
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<boolean> {
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<BridgeAnimePage> {
return this.call<BridgeAnimePage>(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<BridgeSourceDescriptor[]> {
return this.call<BridgeSourceDescriptor[]>(source, 'sourcesAnime', {});
}
/** The extension's own settings schema, with current values. */
async getSourcePreferences(source: BridgeSource): Promise<BridgePreference[]> {
return this.call<BridgePreference[]>(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<BridgePreference[]> {
return this.call<BridgePreference[]>(source, 'setPreferenceAnime', {}, changedPreferenceKey);
}
/** Full metadata for one anime: description, cover art, genres, status. */
async getAnimeDetails(source: BridgeSource, animeUrl: string): Promise<BridgeAnime> {
return this.call<BridgeAnime>(source, 'getDetailsAnime', {
animeData: { url: animeUrl },
});
}
async getPopularAnime(source: BridgeSource, page = 1): Promise<BridgeAnimePage> {
return this.call<BridgeAnimePage>(source, 'getPopularAnime', { page });
}
async getEpisodeList(source: BridgeSource, animeUrl: string): Promise<BridgeEpisode[]> {
return this.call<BridgeEpisode[]>(source, 'getEpisodeList', {
animeData: { url: animeUrl },
});
}
async getVideoList(source: BridgeSource, episodeUrl: string): Promise<BridgeVideo[]> {
return this.call<BridgeVideo[]>(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<T>(
source: BridgeSource,
method: string,
extras: Record<string, unknown>,
changedPreferenceKey?: string,
): Promise<T> {
// Keyed by APK contents, not by source id: an in-place upgrade keeps the
// same source id, and reusing its cached extension id would silently run
// the previous build (the bridge has no reason to answer 409).
const cacheKey = `${source.fingerprint}:${source.sourceId ?? ''}`;
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 async post(
method: string,
extras: Record<string, unknown>,
source: BridgeSource,
extensionId: string | undefined,
changedPreferenceKey?: string,
): Promise<Response> {
const payload: Record<string, unknown> = {
method,
...extras,
preferences: this.buildPreferences(source, changedPreferenceKey),
...(extensionId === undefined ? { data: await source.loadApkBase64() } : { extensionId }),
};
return this.fetchImpl(`${this.baseUrl}/dalvik`, {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
Accept: 'application/json',
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(this.requestTimeoutMs),
});
}
}
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,
);
}
@@ -0,0 +1,192 @@
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> = {}): 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<string, string> = {}): 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('<!DOCTYPE html>')), 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('<!DOCTYPE html><html>404</html>'));
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('the byte limit stops the read instead of buffering the whole body', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
let pushed = 0;
// Endless body: if the limit were only checked after buffering, this hangs.
const body = new ReadableStream<Uint8Array>({
pull(controller) {
pushed += 1;
controller.enqueue(new Uint8Array(512));
},
});
const fetchImpl = (async () => new Response(body, { status: 200 })) as typeof fetch;
await assert.rejects(
() =>
installExtension({
extensionsDir: dir,
extension: repoExtension(),
fetchImpl,
maxBytes: 1024,
}),
/larger than the 1024 byte limit/,
);
// Only enough chunks to cross the limit were ever read.
assert.ok(pushed <= 4, `read ${pushed} chunks before aborting`);
});
test('a package name carrying path separators cannot escape the extensions dir', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
const dir = path.join(root, 'extensions');
const escaping = `eu.kanade.tachiyomi.animeextension${path.sep}..${path.sep}..${path.sep}pwned`;
await assert.rejects(
() =>
installExtension({
extensionsDir: dir,
extension: repoExtension({ pkg: escaping }),
fetchImpl: respondWith(apkBytes()),
}),
/not a valid file name/,
);
assert.equal(existsSync(path.join(root, 'pwned.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);
});
+127
View File
@@ -0,0 +1,127 @@
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;
/** Cancels a stalled download; without it a hung repo blocks the install. */
signal?: AbortSignal;
/** Applied when no `signal` is given, so a download can never hang forever. */
timeoutMs?: number;
}
/** APKs are a few MB; anything far past that is not an extension. */
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
/** Generous enough for a large APK on a slow link, short of hanging forever. */
const DEFAULT_TIMEOUT_MS = 120_000;
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<string> {
const fetchImpl = options.fetchImpl ?? fetch;
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const signal = options.signal ?? AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const response = await fetchImpl(options.extension.apkUrl, { signal });
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 = await readBounded(response, maxBytes, options.extension.name);
if (!looksLikeApk(bytes)) {
throw new Error(`${options.extension.name} did not download as an APK.`);
}
await mkdir(options.extensionsDir, { recursive: true });
const target = resolveTarget(options.extensionsDir, options.extension.pkg);
await writeFile(target, bytes);
return target;
}
/**
* Read the body incrementally and stop the moment the limit is passed.
*
* Buffering first and measuring afterwards would let a repo that lies about
* (or omits) `content-length` push an unbounded amount into memory before the
* check ever runs.
*/
async function readBounded(
response: Response,
maxBytes: number,
name: string,
): Promise<Uint8Array> {
const reader = response.body?.getReader();
if (!reader) {
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > maxBytes) {
throw new Error(`${name} is larger than the ${maxBytes} byte limit.`);
}
return bytes;
}
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel();
throw new Error(`${name} is larger than the ${maxBytes} byte limit.`);
}
chunks.push(value);
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return bytes;
}
/**
* Defence in depth against a repository index that smuggles path separators
* into a package name: the write target must stay inside `extensionsDir`.
*/
function resolveTarget(extensionsDir: string, pkg: string): string {
const root = path.resolve(extensionsDir);
const target = path.resolve(root, extensionFileName(pkg));
if (path.dirname(target) !== root) {
throw new Error(`Refusing to install ${pkg}: the package name is not a valid file name.`);
}
return target;
}
/** Delete an installed extension. Missing files are treated as already gone. */
export async function removeExtension(extensionsDir: string, pkg: string): Promise<void> {
await rm(resolveTarget(extensionsDir, pkg), { force: true });
}
+197
View File
@@ -0,0 +1,197 @@
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<string, unknown> = {}): Record<string, unknown> {
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('a package name that is not a plain identifier is rejected', () => {
// The package name becomes the on-disk file name, and a repo index is
// unauthenticated: path separators here would write outside the extensions
// directory even though the prefix check passes.
const parsed = parseRepoIndex(INDEX, [
animeEntry({ pkg: 'eu.kanade.tachiyomi.animeextension/../../../../etc/cron.d/x' }),
animeEntry({ pkg: 'eu.kanade.tachiyomi.animeextension\\..\\evil' }),
animeEntry({ pkg: 'eu.kanade.tachiyomi.animeextension.all.ok' }),
]);
assert.deepEqual(
parsed.map((extension) => extension.pkg),
['eu.kanade.tachiyomi.animeextension.all.ok'],
);
});
test('an apk file name with path characters is rejected', () => {
const parsed = parseRepoIndex(INDEX, [animeEntry({ apk: '../../../etc/passwd' })]);
assert.deepEqual(parsed, []);
});
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',
);
});
+194
View File
@@ -0,0 +1,194 @@
/**
* 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';
/**
* A package name becomes the on-disk APK file name, and a repository index is
* unauthenticated content the user pointed us at. Only plain dotted identifiers
* are accepted, so nothing in an index can carry `/` or `..` into a file path.
*/
const PACKAGE_NAME_PATTERN = /^[A-Za-z0-9_.]+$/;
/** The APK file name is appended to the repo URL, so keep it a bare name. */
const APK_FILE_NAME_PATTERN = /^[A-Za-z0-9_.+-]+$/;
/**
* 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) || !PACKAGE_NAME_PATTERN.test(pkg)) continue;
if (apk.length === 0 || !APK_FILE_NAME_PATTERN.test(apk)) 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<RepoExtension[]> {
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<RepoCatalogue> {
const failures: RepoFetchFailure[] = [];
const byPackage = new Map<string, RepoExtension>();
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`;
}
+159
View File
@@ -0,0 +1,159 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
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<string, string>): Promise<string> {
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: { fingerprint: string }) => Promise<unknown[]>,
): AnimeBridgeClient {
return { listAnimeSources: impl } as unknown as AnimeBridgeClient;
}
test('readInstalledExtensions fingerprints apks without holding their bytes', 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(extensions[0]?.sha256, createHash('sha256').update('APK-BYTES').digest('hex'));
});
test('the fingerprint changes when an apk is replaced in place', async () => {
const dir = await makeExtensionDir({ 'my-source.apk': 'V1' });
const before = (await readInstalledExtensions(dir))[0]?.sha256;
await writeFile(path.join(dir, 'my-source.apk'), 'V2');
const after = (await readInstalledExtensions(dir))[0]?.sha256;
assert.notEqual(before, after);
});
test('toBridgeSource reads the apk only when the bridge asks for it', async () => {
const dir = await makeExtensionDir({ 'lazy.apk': 'APK-BYTES' });
const extension = (await readInstalledExtensions(dir))[0]!;
const bridgeSource = toBridgeSource(extension);
assert.equal(bridgeSource.fingerprint, extension.sha256);
assert.equal(Buffer.from(await bridgeSource.loadApkBase64(), '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', sha256: 'hash-a' };
assert.equal(toBridgeSource(extension).sourceId, undefined);
assert.equal(toBridgeSource(extension, 'src-1').sourceId, 'src-1');
assert.equal(toBridgeSource(extension, 'src-1').fingerprint, 'hash-a');
});
test('listExtensionSources flattens every source a factory apk provides', async () => {
const extensions: InstalledExtension[] = [
{ file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a' },
];
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', sha256: 'hash-a' },
];
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', sha256: 'hash-a' },
]);
assert.deepEqual(sources, []);
});
test('toInstalledExtensionViews names an extension after the sources it provides', () => {
const extensions: InstalledExtension[] = [
{ file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a' },
];
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', sha256: 'hash-a' },
];
// 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', sha256: 'hash-a' },
{ file: '/x/good.apk', fallbackName: 'good', sha256: 'hash-b' },
];
const failures: string[] = [];
const client = fakeClient(async (source) => {
if (source.fingerprint === 'hash-a') 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');
});
+145
View File
@@ -0,0 +1,145 @@
import { createReadStream } from 'node:fs';
import { readdir, readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { pipeline } from 'node:stream/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;
/**
* SHA-256 of the APK. Identifies the build rather than the slot, so the
* bridge's extension-id cache misses after an in-place upgrade.
*/
sha256: 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;
}
/**
* Discover every .apk in `directory`. A missing directory yields no extensions.
*
* Only a hash is kept, never the bytes: APKs run to several MB each and a
* base64 copy adds a third on top, so holding the whole set for the lifetime of
* the Anime Browser would cost far more than re-reading a file on the rare
* upload. Hashing streams, so peak memory stays flat regardless of APK size.
*/
export async function readInstalledExtensions(directory: string): Promise<InstalledExtension[]> {
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);
extensions.push({
file,
fallbackName: entry.name.replace(/\.apk$/i, ''),
sha256: await hashFile(file),
});
}
return extensions;
}
async function hashFile(file: string): Promise<string> {
const hash = createHash('sha256');
await pipeline(createReadStream(file), hash);
return hash.digest('hex');
}
/**
* 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.
*
* The APK is read on demand: after the first upload the bridge answers by
* extension id, so most calls never touch the file at all.
*/
export function toBridgeSource(extension: InstalledExtension, sourceId?: string): BridgeSource {
return {
fingerprint: extension.sha256,
loadApkBase64: async () => (await readFile(extension.file)).toString('base64'),
...(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<ExtensionSource[]> {
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;
}
+73
View File
@@ -0,0 +1,73 @@
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 escapes backslashes so a trailing one cannot eat the separator', () => {
const fields = toMpvHeaderFields({ Referer: 'https://origin.example/path\\', Cookie: 'a=1' });
// Without doubling, the value's trailing backslash would escape the comma
// and merge Cookie into the Referer entry.
assert.equal(fields, 'Referer: https://origin.example/path\\\\,Cookie: a=1');
});
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, '');
});
+56
View File
@@ -0,0 +1,56 @@
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<string, string> {
const flat = headers?.['namesAndValues$okhttp'];
if (!Array.isArray(flat)) return {};
const parsed: Record<string, string> = {};
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 and the
* backslash that does the escaping has to be escaped first, or a value ending
* in `\` would neutralise the separator and swallow the next header.
*/
export function toMpvHeaderFields(headers: Record<string, string>): string {
return Object.entries(headers)
.map(([name, value]) => `${name}: ${value.replace(/\\/g, '\\\\').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),
};
}
+69
View File
@@ -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');
});
+74
View File
@@ -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';
}
}
+231
View File
@@ -0,0 +1,231 @@
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> = {}): 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('the escape length is counted in utf-8 bytes, not js string units', () => {
// An extension may put a non-ASCII value in a header; mpv reads %n% as a
// byte count, so counting string units would truncate the value.
const options = buildLoadfileOptions({
stream: stream({ headers: { 'X-Title': '日本語' } }),
});
const fields = 'X-Title: 日本語';
assert.ok(options.includes(`%${Buffer.byteLength(fields, 'utf8')}%${fields}`));
assert.ok(!options.includes(`%${fields.length}%`));
});
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);
});
+238
View File
@@ -0,0 +1,238 @@
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<string | number>;
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.
*
* The count is in UTF-8 bytes of the decoded value, not JS string units, so a
* non-ASCII header value (extensions supply these) would otherwise under-count
* and mpv would cut the value short.
*/
function escapeOptionValue(value: string): string {
return `%${Buffer.byteLength(value, 'utf8')}%${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<string, string> = {
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<string>();
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;
}
@@ -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<string, number> = { 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([]), []);
});
+85
View File
@@ -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<T> {
/** 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<S extends SourceTarget, T>(
sources: S[],
task: (source: S) => Promise<T>,
concurrency: number = DEFAULT_CONCURRENCY,
): Promise<FanOutResult<T>> {
// 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<SourceSearchFailure | null> = sources.map(() => null);
let next = 0;
const worker = async (): Promise<void> => {
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<T>(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;
}
+69
View File
@@ -0,0 +1,69 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { PreferenceStore } from './preference-store';
async function storeFile(): Promise<string> {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-prefs-'));
return path.join(dir, 'anime-preferences.json');
}
test('values round-trip through the file', async () => {
const file = await storeFile();
await new PreferenceStore(file).set('src-1', [{ key: 'address' }]);
assert.deepEqual(await new PreferenceStore(file).get('src-1'), [{ key: 'address' }]);
});
test('concurrent writes on a cold cache do not lose an update', async () => {
const file = await storeFile();
const store = new PreferenceStore(file);
// Both start before either has loaded; unserialized they would each get their
// own object and the later persist would drop the other's entry.
await Promise.all([store.set('src-1', [{ key: 'a' }]), store.set('src-2', [{ key: 'b' }])]);
const reloaded = new PreferenceStore(file);
assert.deepEqual(await reloaded.get('src-1'), [{ key: 'a' }]);
assert.deepEqual(await reloaded.get('src-2'), [{ key: 'b' }]);
});
test('a clear racing a set is applied in order', async () => {
const file = await storeFile();
const store = new PreferenceStore(file);
await store.set('pkg:src', [{ key: 'password' }]);
await Promise.all([store.clear('pkg'), store.set('other:src', [{ key: 'x' }])]);
const reloaded = new PreferenceStore(file);
assert.deepEqual(await reloaded.get('pkg:src'), []);
assert.deepEqual(await reloaded.get('other:src'), [{ key: 'x' }]);
});
test('the file is written owner-only and leaves no temporary behind', async () => {
const file = await storeFile();
await new PreferenceStore(file).set('src-1', [{ key: 'password' }]);
const { stat } = await import('node:fs/promises');
assert.equal((await stat(file)).mode & 0o777, 0o600);
assert.deepEqual(await readdir(path.dirname(file)), [path.basename(file)]);
});
test('a corrupt file starts empty rather than blocking the browser', async () => {
const file = await storeFile();
await writeFile(file, '{ not json');
assert.deepEqual(await new PreferenceStore(file).get('src-1'), []);
});
test('a write replaces the previous contents wholesale', async () => {
const file = await storeFile();
const store = new PreferenceStore(file);
await store.set('src-1', [{ key: 'first' }]);
await store.set('src-1', [{ key: 'second' }]);
const parsed = JSON.parse(await readFile(file, 'utf8')) as Record<string, unknown[]>;
assert.deepEqual(parsed['src-1'], [{ key: 'second' }]);
});
+101
View File
@@ -0,0 +1,101 @@
import { readFile, writeFile, rename, rm, 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<string, BridgePreference[]> | null = null;
/**
* Mutations run one at a time. Two concurrent load-modify-persist cycles
* starting on a cold cache would each read their own object, and the later
* write would drop the earlier one's edit.
*/
private queue: Promise<unknown> = Promise.resolve();
constructor(file: string) {
this.file = file;
}
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
const result = this.queue.then(operation, operation);
// Keep the chain alive after a rejection so one failure cannot wedge it.
this.queue = result.catch(() => undefined);
return result;
}
private async load(): Promise<Record<string, BridgePreference[]>> {
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<string, BridgePreference[]>)
: {};
} catch {
// Missing or corrupt file starts empty rather than blocking the browser.
this.cache = {};
}
return this.cache;
}
async get(sourceId: string): Promise<BridgePreference[]> {
return this.enqueue(async () => {
const all = await this.load();
return all[sourceId] ?? [];
});
}
async set(sourceId: string, preferences: BridgePreference[]): Promise<void> {
await this.enqueue(async () => {
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<void> {
await this.enqueue(async () => {
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);
});
}
/**
* Write through a temporary file and rename into place.
*
* A write interrupted partway would otherwise leave truncated JSON, and
* `load()` treats unparseable content as empty which would quietly discard
* every saved credential.
*/
private async persist(all: Record<string, BridgePreference[]>): Promise<void> {
await mkdir(path.dirname(this.file), { recursive: true });
const temporary = `${this.file}.tmp`;
try {
await writeFile(temporary, JSON.stringify(all, null, 2), { mode: 0o600 });
await rename(temporary, this.file);
} catch (error) {
await rm(temporary, { force: true }).catch(() => undefined);
throw error;
}
}
}
+143
View File
@@ -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);
});
+132
View File
@@ -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<string, PreferenceKind>;
type WidgetName = keyof typeof WIDGETS;
function widgetOf(
entry: BridgePreference,
): { name: WidgetName; body: Record<string, unknown> } | 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<string, unknown> };
}
}
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}`);
}

Some files were not shown because too many files have changed in this diff Show More