mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 16:46:06 -08:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fbc36dc41
|
||
|
|
e93b696e71
|
||
|
|
7c161723cd | ||
|
|
b0fb39a8f5 | ||
|
|
a7a302bdd0 | ||
|
|
6a1d1211ae
|
||
|
|
7585d430ae
|
||
|
|
122ff9a3c5
|
||
|
|
786bd5af81
|
||
|
|
42d1384c68 | ||
|
|
1508863dbb | ||
|
|
4dd30f44d1
|
||
|
|
ab48a5678e | ||
|
|
2f21582666 | ||
|
|
383aed8bad | ||
|
|
f9c4e892dc | ||
|
|
7959e1a4e5 | ||
|
|
917d52dd94 | ||
|
|
05425cc5db | ||
|
|
8097ed27e5 | ||
|
|
76e43d7e6d | ||
|
|
4f0762e840 | ||
|
|
026d495fac | ||
|
|
dd76782d30
|
||
|
|
a8c16147ad | ||
|
|
186d4a0640 |
No files matched your search
@@ -13,6 +13,9 @@ on:
|
||||
required: true
|
||||
APPLE_TEAM_ID:
|
||||
required: true
|
||||
# Project TMDB key baked into release artifacts; builds stay valid without it.
|
||||
SUBMINER_TMDB_API_KEY:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -69,6 +72,8 @@ jobs:
|
||||
|
||||
- name: Build AppImage
|
||||
run: bun run build:appimage
|
||||
env:
|
||||
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
|
||||
|
||||
- name: Build unversioned AppImage
|
||||
run: |
|
||||
@@ -168,6 +173,7 @@ jobs:
|
||||
- name: Build signed + notarized macOS artifacts
|
||||
run: bun run build:mac
|
||||
env:
|
||||
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
@@ -248,6 +254,8 @@ jobs:
|
||||
|
||||
- name: Build unsigned Windows artifacts
|
||||
run: bun run build:win:unsigned
|
||||
env:
|
||||
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
|
||||
|
||||
- name: Smoke packaged runtime assets
|
||||
shell: bash
|
||||
|
||||
@@ -27,6 +27,7 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
|
||||
|
||||
release:
|
||||
needs: [package]
|
||||
|
||||
@@ -29,6 +29,9 @@ jobs:
|
||||
- name: Verify Windows launcher bootstrap
|
||||
run: bun test src/main/runtime/windows-launcher-bootstrap.test.ts
|
||||
|
||||
- name: Verify native mpv process launch
|
||||
run: bun test src/main/runtime/mpv-process.test.ts
|
||||
|
||||
- name: Verify POSIX launcher bootstrap
|
||||
run: bun test src/main/runtime/posix-launcher-bootstrap.test.ts
|
||||
|
||||
@@ -93,12 +96,20 @@ jobs:
|
||||
sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua
|
||||
lua -v
|
||||
|
||||
- name: Test suite (source)
|
||||
run: bun run test:fast
|
||||
- name: Launcher unit and script suites
|
||||
run: bun run test:launcher:unit:src && bun run test:scripts
|
||||
|
||||
- name: Environment suite
|
||||
run: bun run test:env
|
||||
|
||||
- name: Upload launcher smoke artifacts (on failure)
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: launcher-smoke
|
||||
path: .tmp/launcher-smoke/**
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Coverage suite (maintained source lane)
|
||||
run: bun run test:coverage:src
|
||||
|
||||
@@ -112,17 +123,6 @@ jobs:
|
||||
- name: Stats UI tests
|
||||
run: bun run test:stats
|
||||
|
||||
- name: Launcher smoke suite (source)
|
||||
run: bun run test:launcher:smoke:src
|
||||
|
||||
- name: Upload launcher smoke artifacts (on failure)
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: launcher-smoke
|
||||
path: .tmp/launcher-smoke/**
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Build (bundle)
|
||||
run: bun run build
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
|
||||
|
||||
release:
|
||||
needs: [package]
|
||||
|
||||
+121
@@ -1,5 +1,126 @@
|
||||
# Changelog
|
||||
|
||||
## v0.20.0 (2026-09-23)
|
||||
|
||||
### Added
|
||||
- **Japanese Subtitle Generation**:
|
||||
- Generate Japanese SRT subtitles locally with whisper.cpp. Start it from a new modal (Ctrl+Shift+G), from the generate button in an empty subtitle sidebar, or with `subminer generate-subs`.
|
||||
- Generation shows progress, can be cancelled, and loads the finished subtitles into mpv automatically.
|
||||
- Pick an official multilingual Whisper model, including quantized variants, with size and accuracy guidance. You can download it in the app or point Settings at a model you already have.
|
||||
- `large-v3-turbo` is recommended when CUDA support is detected, and `small` otherwise.
|
||||
- whisper-cli, ffmpeg, and ffprobe are found on PATH unless you override them. SubMiner names any missing tools before a download starts.
|
||||
- An optional "Focus on spoken dialogue" mode uses a separately downloaded Silero VAD model. It keeps audible sections it is unsure about, so dialogue under music is not dropped, but songs may also be transcribed.
|
||||
- Long passages are split near speech starts or quiet pauses to reduce subtitles that appear too early. When an eligible embedded or external subtitle track is loaded in mpv, it guides the split points.
|
||||
- Each passage runs in a fresh Whisper process, which prevents repeated-character output.
|
||||
- **Subtitle Selection Modal**:
|
||||
- An optional modal for choosing primary and secondary mpv subtitle tracks.
|
||||
- Turn it on in Settings under Behavior, then press g followed by s to open it. Turning it off restores mpv's own subtitle selection binding.
|
||||
- Single-key actions take priority over configured key sequence prefixes.
|
||||
- Conflicting sequences are disabled with a warning, and the existing y commands stay reserved.
|
||||
- **Subtitle Sidebar Copy**:
|
||||
- Select dialogue across several sidebar rows and copy it without timestamps using Ctrl/Cmd+C or the Copy button.
|
||||
- Selecting text does not seek playback and does not require mining a card.
|
||||
- **Media Timing Review Screenshot Picker**:
|
||||
- Choose the still screenshot separately from the audio range, with a live preview and its own time slider.
|
||||
- Step through decoded frames one at a time to get the exact frame you want.
|
||||
- Works with local video and with seekable remote streams such as Jellyfin.
|
||||
- **mpv Keybindings in the Overlay**:
|
||||
- The overlay now picks up keyboard bindings from mpv defaults, `input.conf`, and loaded scripts when they do not conflict with SubMiner.
|
||||
- SubMiner controls and bindings you explicitly disabled take precedence.
|
||||
- These bindings apply only to the current session and are not listed in the help menu.
|
||||
- **Jimaku Live Action Search**: The Jimaku modal has new Anime and Live action tabs, so you can search Jimaku's live action catalogue as well as anime. Use Arrow Left and Arrow Right to switch tabs.
|
||||
- **TMDB Live-Action Library**:
|
||||
- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB.
|
||||
- Release builds include a project key. Setting `tmdb.apiKey` or `tmdb.apiKeyCommand` overrides it, and one of them is required when running from source.
|
||||
- Titles that AniList cannot match are looked up on TMDB automatically when the parsed filename exactly matches a Japanese live-action title. For everything else, use the new **Link to TMDB** action.
|
||||
- Entries linked to the same TMDB title merge into one card, and the Library kind selector has a new Live Action option.
|
||||
- If a replacement download fails during provider reassignment, the previous link and artwork are kept. Merges and sync keep AniList and TMDB identities separate, and the merge dialog explains mixed selections instead of failing.
|
||||
- **YouTube Library Kind**:
|
||||
- YouTube channels are now their own Library media kind. Existing channel entries migrate automatically, and viewing history and manual video assignments are unchanged.
|
||||
- New All Titles, Anime, and YouTube Library filters.
|
||||
- Channels are excluded from AniList matching, season repair, and duplicate recommendations.
|
||||
- Merges and video moves can no longer combine an anime entry with a YouTube channel.
|
||||
|
||||
### Changed
|
||||
- **Launcher Uses Bundled Bun**:
|
||||
- Every installed and downloadable launcher now runs on the Bun runtime that ships with SubMiner. A system Bun is no longer needed.
|
||||
- Recognized legacy launchers migrate automatically.
|
||||
- Windows gets a `subminer.cmd` launcher download.
|
||||
- First-run setup is reduced to one optional launcher control. Runtime repair guidance appears only when it is needed.
|
||||
- **Faster Sync Transfers**:
|
||||
- Sync between compatible macOS and Linux machines now uses compressed, incremental rsync transfers.
|
||||
- The last snapshot received from each peer is cached, which reduces traffic on later syncs.
|
||||
- Machines without a compatible rsync, including Windows, fall back to compressed scp.
|
||||
- Older peers still work without the upload cache.
|
||||
- Transfers abort after 30 minutes.
|
||||
- **Stats Server Request Safety**:
|
||||
- The stats server now accepts loopback hosts only and rejects requests from browser origins other than its own.
|
||||
- Requests that change data must send an `application/json` body. Scripts that POST to the server need to set a JSON content type.
|
||||
- The in-app stats overlay now loads from the local server, so it gets the same protection.
|
||||
- Dashboards served through a reverse proxy or Tailscale Serve are no longer supported.
|
||||
- **Smaller Downloads**:
|
||||
- Installers and unpacked apps are smaller. Demo media, source maps, TypeScript sources, test fixtures, and unused Koffi binaries are no longer packaged.
|
||||
- All windows now share one Japanese UI font.
|
||||
- Release builds publish package size reports that compare against the previous release.
|
||||
- **Bundled Yomitan**: Updated with upstream Yomitan 26.9.8 changes, including historical Japanese kana transformations, Ukrainian language support, and improvements to Anki duplicate searches and audio retrieval.
|
||||
|
||||
### Fixed
|
||||
- **Jellyfin 12 Compatibility**:
|
||||
- Playback, subtitle, artwork, and remote-control requests now authenticate with the `ApiKey` query parameter, so the integration works on Jellyfin 12, where legacy authorization is off by default.
|
||||
- "Play on SubMiner" stays available. The cast connection now answers keep-alive requests and reconnects when the server stops responding, instead of silently dying after about a minute.
|
||||
- The Jellyfin "now playing" bar clears when you close or finish a cast video instead of running on to the end of the episode.
|
||||
- Cards mined during Jellyfin playback get the episode title in the misc info field again instead of "Unknown media".
|
||||
- **Jellyfin Privacy and Playback**:
|
||||
- Jellyfin streams no longer leak titles taken from the stream URL, or stream URLs that contain credentials, into metadata lookups, Anki source fields, Discord presence, stats, or AniList retries.
|
||||
- Previously cached metadata that contained credentials is cleaned up. Watch history and library assignments are not touched.
|
||||
- Jellyfin playback and casting now use your configured mpv executable, so they work when mpv is installed outside PATH. Portable plugins next to that executable are detected.
|
||||
- **Anki Mining**:
|
||||
- New `ankiConnect.fields.wordAudio` setting reads word audio separately from the sentence audio field. This fixes animated images that started moving immediately when `fields.audio` pointed to `SentenceAudio`. Existing animated images need to be regenerated to pick up the fix.
|
||||
- Sentence furigana on word cards stays in sync with the full stats-search context and with expanded timing review selections. Stale readings are cleared if regeneration fails.
|
||||
- Closing the overlay while media timing review is still loading now cancels the review, resumes playback if the review paused it, and cleans up the hidden preview player.
|
||||
- `ankiConnect.media.maxMediaDuration: 0` now means unlimited when mining from the stats dashboard, matching overlay mining.
|
||||
- Invalid AnkiConnect, Kiku, and Senren settings are now rejected with a warning and fall back to defaults.
|
||||
- **Stats Server Stability**:
|
||||
- A port conflict is now reported in a status notification instead of crashing SubMiner.
|
||||
- Simultaneous startup requests share one server start. Stopping the background server no longer disconnects dashboards open in the foreground.
|
||||
- Shutdown waits only a limited time for active requests to finish.
|
||||
- Malformed resource IDs, and ID lists with any invalid entries, are rejected before Library changes or cover backfills run.
|
||||
- **Subtitle Sidebar**:
|
||||
- Clicking a cue no longer leaves the row focused, and Space no longer seeks back to a focused cue. Enter still seeks to the focused cue, and Space keeps its configured playback action.
|
||||
- The sidebar stays near the current playback position during gaps when the subtitle file has a cue that starts at zero.
|
||||
- **Settings Save Feedback**:
|
||||
- Settings marked LIVE no longer show false restart warnings, including for notifications and subtitle generation.
|
||||
- When a save mixes live and restart-only changes, the live changes apply right away and only the changed sections that need a restart are listed.
|
||||
- **Overlay Windows**:
|
||||
- On Hyprland, recovery dialogs stay above SubMiner windows so overlay placement updates no longer cover their Wait and Close buttons.
|
||||
- On Linux, a delayed close callback during teardown can no longer reopen the overlay.
|
||||
- **First Launch on macOS**: SubMiner no longer exits on first launch when the config directory does not exist yet.
|
||||
|
||||
### Docs
|
||||
- **Launcher**: Documented the launcher install that uses the bundled runtime, migration from legacy launchers, package-managed updates, and the bundled Bun runtime's MIT and LGPL notices. The AUR package installs these notices under `/usr/share/licenses/subminer-bin`, and they are also included in `subminer-assets.tar.gz`.
|
||||
- **Subtitle Generation**: Documented model choice, VAD behavior, splitting guided by a reference track, fallback behavior, and known limits.
|
||||
- **Subtitle Selection**: Documented the subtitle selector setting, its shortcut override, and the primary and secondary track controls.
|
||||
- **Settings**: Clarified save feedback for live settings, warnings for saves that mix live and restart-only changes, and how subtitle generation settings reload.
|
||||
- **Jellyfin**:
|
||||
- Clarified that Windows mpv playback and Jellyfin casting can use a configured executable path instead of PATH.
|
||||
- Documented how Jellyfin media titles and stats identities keep stream credentials out of metadata.
|
||||
- **Stats Library**:
|
||||
- Documented TMDB linking, provider reassignment, merge compatibility, and caching of the credential command's output.
|
||||
- Documented YouTube channel filtering and video statistics in the Library.
|
||||
- **Mining**:
|
||||
- Documented choosing the screenshot separately in media timing review.
|
||||
- Documented the separate word audio field mapping, including that existing animated images need to be regenerated.
|
||||
- **Sync**: Documented compressed transfers, where the incremental sync cache is stored, and compatibility with older peers.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- Removed duplicate source and launcher smoke runs from the reusable CI quality gate. Every distinct test lane and failure artifact is kept.
|
||||
- Replaced mislabeled dist source reruns with a small Electron-runtime smoke check. It covers compiled stats startup, the HTTP service, native SQLite, port conflicts, and cleanup.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.19.6 (2026-09-04)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -86,10 +86,6 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
||||
<td><b>Jellyfin</b></td>
|
||||
<td>Browse, launch, and cast media from your Jellyfin server with setup and discovery controls in the app tray</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Anime Browser</b></td>
|
||||
<td>Search anime sources you supply as <a href="https://github.com/aniyomiorg/aniyomi">Aniyomi</a> extension APKs and play an episode in mpv with the overlay attached (<code>subminer anime</code>); SubMiner ships no repositories and bundles no sources</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Jimaku</b></td>
|
||||
<td>Search and download Japanese subtitles</td>
|
||||
@@ -186,8 +182,6 @@ See the [full requirements list](https://docs.subminer.moe/installation#_1-insta
|
||||
|
||||
```bash
|
||||
paru -S subminer-bin
|
||||
# optional: the anime browser bridge, shared with Mangatan and updated by pacman
|
||||
paru -S mangatan-extension-server
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -270,31 +264,22 @@ Full guides on configuration, Anki setup, Jellyfin, immersion tracking, and more
|
||||
|
||||
SubMiner builds on the work of these open-source projects:
|
||||
|
||||
| Project | Role |
|
||||
| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| [ani-skip](https://github.com/synacktraa/ani-skip) | AniSkip API client for anime intro/outro skip timestamps |
|
||||
| [Anacreon-Script](https://github.com/friedrich-de/Anacreon-Script) | Inspiration for the mining workflow |
|
||||
| [Aniyomi](https://github.com/aniyomiorg/aniyomi) | Anime extension API and data model the anime browser targets |
|
||||
| [asbplayer](https://github.com/killergerbah/asbplayer) | Inspiration for subtitle sidebar and logic for YouTube subtitle parsing |
|
||||
| [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) | Character name recognition in subtitles |
|
||||
| Project | Role |
|
||||
| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| [ani-skip](https://github.com/synacktraa/ani-skip) | AniSkip API client for anime intro/outro skip timestamps |
|
||||
| [Anacreon-Script](https://github.com/friedrich-de/Anacreon-Script) | Inspiration for the mining workflow |
|
||||
| [asbplayer](https://github.com/killergerbah/asbplayer) | Inspiration for subtitle sidebar and logic for YouTube subtitle parsing |
|
||||
| [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) | Character name recognition in subtitles |
|
||||
| [Bun](https://github.com/oven-sh/bun) | Bundled runtime for the `subminer` command-line launcher |
|
||||
| [GameSentenceMiner](https://github.com/bpwhelan/GameSentenceMiner) | Inspiration for Electron overlay with Yomitan integration |
|
||||
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
|
||||
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
|
||||
| [M-Extension-Server](https://github.com/1Selxo/M-Extension-Server) | Runs Aniyomi extension APKs off Android; the bridge the anime browser drives |
|
||||
| [Mangatan](https://github.com/1Selxo/Mangatan) | Reference client for the bridge protocol the anime browser speaks |
|
||||
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
|
||||
| [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 |
|
||||
| [GameSentenceMiner](https://github.com/bpwhelan/GameSentenceMiner) | Inspiration for Electron overlay with Yomitan integration |
|
||||
| [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 |
|
||||
| [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 |
|
||||
|
||||
## License
|
||||
|
||||
[GNU General Public License v3.0](LICENSE)
|
||||
|
||||
The anime browser drives [M-Extension-Server](https://github.com/1Selxo/M-Extension-Server),
|
||||
downloaded from its upstream releases at runtime rather than
|
||||
bundled or redistributed here; its bundles carry their own dependencies, including a JRE and
|
||||
GPL-3.0 NewPipe Extractor. SubMiner includes none of them and talks to the bridge
|
||||
over its own HTTP protocol. It ships no extensions or repositories by default.
|
||||
SubMiner is released under the [GNU General Public License v3.0](LICENSE).
|
||||
|
||||
Release packages also bundle an unmodified copy of [Bun](https://github.com/oven-sh/bun), which is MIT licensed and statically links JavaScriptCore (LGPL 2.0) and TinyCC (LGPL 2.1). Its license texts and third-party notices ship inside the app under `resources/bun/licenses`, and each release publishes `bun-v1.3.5-source.tar.gz` with the corresponding source. See [Bundled Bun runtime](https://docs.subminer.moe/installation#bundled-bun-runtime).
|
||||
@@ -22,7 +22,7 @@
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"electron": "42.6.0",
|
||||
"electron-builder": "26.15.3",
|
||||
"electron-builder": "26.16.1",
|
||||
"esbuild": "^0.25.12",
|
||||
"eslint": "^10.8.0",
|
||||
"prettier": "^3.8.1",
|
||||
@@ -36,9 +36,9 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@xmldom/xmldom": "0.8.15",
|
||||
"app-builder-lib": "26.15.3",
|
||||
"app-builder-lib": "26.16.1",
|
||||
"brace-expansion": "5.0.9",
|
||||
"electron-builder-squirrel-windows": "26.15.3",
|
||||
"electron-builder-squirrel-windows": "26.16.1",
|
||||
"fast-uri": "3.1.6",
|
||||
"form-data": "4.0.6",
|
||||
"ip-address": "10.2.0",
|
||||
@@ -181,7 +181,7 @@
|
||||
|
||||
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
|
||||
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -243,7 +243,7 @@
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"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=="],
|
||||
"app-builder-lib": ["app-builder-lib@26.16.1", "", { "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": "^1.8.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.16.0", "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.16.0", "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.16.1", "electron-builder-squirrel-windows": "26.16.1" } }, "sha512-FhaO6YOup01ZfQW0Z6gt3AyukJjv1gW4uFK47jTgwcHZKqyN/fSlK2LqPf9tAeZYLP2bRJLDzeOkRImsw2X4Pg=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
@@ -273,7 +273,7 @@
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"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": ["builder-util@26.16.0", "", { "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-RLyJhB7Si3YkzKR9ubQslWuXW3Vhs3CGe1i+SeixBZ0qTd1mk3XBmssvY22TlB6CS5blyko8Gu1JzpYk8UkYAg=="],
|
||||
|
||||
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
|
||||
|
||||
@@ -335,7 +335,7 @@
|
||||
|
||||
"discord-api-types": ["discord-api-types@0.38.49", "", {}, "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg=="],
|
||||
|
||||
"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=="],
|
||||
"dmg-builder": ["dmg-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-pnI/3Qb24Uk+rMTgIUrsVUKosVgwmBUdF8Zeb8TexOSbpq8MWc7v6l+n+FrEqVkjNZwzBN+XpDS9ENgZ/rkWAw=="],
|
||||
|
||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
|
||||
@@ -349,11 +349,11 @@
|
||||
|
||||
"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.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": ["electron-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.16.1", "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-LrLK65QX5PUYYODXqp23FKrV7CILTtVY7mrJckNknO9jLNSMiqFkKbSMiDRw4CjOADMPVDdWLxY4mezOZWswxg=="],
|
||||
|
||||
"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-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "electron-winstaller": "5.4.0" } }, "sha512-w0y44wSaT1l6R7CAGmeHn4nHPfvzDyCAU1xJyi1w9SbPYJpYn76SmHDzqHf8Y7l91cPWTdPYBpGQtB2T5mJ08A=="],
|
||||
|
||||
"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-publish": ["electron-publish@26.16.0", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.16.0", "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-Vt3KzQIiw9BImvNOYtndg9Mjki+tl4+1sQiC/+G5j8khWaENOJFWodiB+sUl6yyHwtd37avehskdtPw7f8y/+Q=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
type: fixed
|
||||
area: anilist
|
||||
|
||||
- Resolving "season N" against AniList no longer lands one season short for franchises whose broadcast seasons are listed as several entries. AniList records the back half of a split cour ("… Season 2 Part 2", "… Cour 2", "第2クール") as its own sequel, and the resolver counted each as a season — so Mushoku Tensei season 3 resolved to the season 2 entry and Re:ZERO season 4 to season 3, which then drove the character dictionary and AniList progress updates to the wrong series. A sequel carrying a part or cour marker that matches the season it continues is now followed without advancing the season count, in both the relation walk and the air-order fallback. Titles are compared ignoring punctuation, and the marker is looked for in every title and synonym rather than just the display title. A wrong match cached before this fix is remembered in `character-dictionaries/anilist-resolution-cache.json` and needs removing (or overriding from the character dictionary picker) for the affected series.
|
||||
- A special or OVA sitting in a franchise's sequel chain is walked through without counting as a season. Dr. STONE links STONE WARS to New World through a one-episode special, which made New World resolve as season 4 and every later season shift with it.
|
||||
- An AniList rate limit or network failure while walking sequel relations is reported so the caller can retry, instead of falling through to the air-date fallback. That fallback is for a franchise with missing relation edges; running it after a failed lookup turned a transient 429 into a confidently wrong season.
|
||||
- The shared title parser recognizes spelled-out episode labels (`Episode 4`, `第4話`) and a season named at the end of the title (`… Season 3`, `… 3rd Season`, `… S3`), which now fill the Season field instead of being searched for as part of the series name. The TsukiHime modal gained a Season field to match Jimaku, and seasons after the first are included in its search query so a later season's releases are actually found.
|
||||
@@ -1,36 +0,0 @@
|
||||
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.
|
||||
- Browser failures show a dismissible explanation with next steps and expandable technical details, including the bridge's error response instead of only its HTTP status.
|
||||
- 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 extension and source, and updated extension schemas replace stale saved field definitions without losing values. Older unscoped preferences are discarded once because their package ownership cannot be proven safely.
|
||||
- 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. Repository requests time out instead of hanging, APK updates are staged before replacing the installed copy, and removing an extension refreshes the runtime even when saved credential cleanup fails while still reporting that failure.
|
||||
- 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, with per-source progress in the status bar. Results are tagged with their source, failures do not blank the grid, and **Load more** appends later pages without duplicating streamed entries.
|
||||
- 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. It compares APK and repository version codes, enables Update only for newer builds, marks current extensions as Up to date, and offers Update all when multiple updates are waiting.
|
||||
- Added `anime.repos`, `anime.extensionsDir`, `anime.preferredQuality`, and `anime.defaultSource` config keys; **Set default** on an Installed row in the Extensions tab makes that source (or All sources) the one the browser opens on, and the current default carries a tag. SubMiner ships no extension repositories and performs no discovery.
|
||||
- The settings app now groups Anime Browser config under an **Aniyomi** section in Integrations. A new `anime.autoOpenJimaku` option pauses newly loaded Anime Browser episodes, waits for mpv's video window to appear, closes the in-player browser, hides the standalone browser window so it is not pulled in front of mpv when the modals close, opens Jimaku with the episode details filled in, and resumes after a subtitle loads or the modal closes without overriding playback that was already paused.
|
||||
- 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.
|
||||
- HLS streams pass through a local strip proxy that removes fake image headers some hosts glue onto their video segments and gives disguised segment URLs (`.image`, `.jpg`, `.css`, and other rotating fake extensions) a media-safe local alias, so those streams play in mpv and support Anki audio and image extraction with current ffmpeg releases. Upstream responses use identity encoding so playlists remain readable for URL rewriting.
|
||||
- The strip proxy retries a failed segment fetch once after a short pause and logs upstream error statuses; a host that errors on the very first fetches right after an episode resolves no longer kills the whole playback, disconnecting clients release their active upstream fetch instead of consuming the socket and bandwidth in the background, stalled response bodies retain the upstream inactivity timeout, and only origin-form requests can reach the configured local bridge.
|
||||
- The strip proxy no longer forwards `Range` headers to the bridge: ffmpeg opens every HLS segment with `Range: bytes=0-`, the bridge answers some of those with 206, and a partial response bypassed the disguise strip, so whether an episode played depended on the bridge's cache state.
|
||||
- A bridge that dies out from under the app (killed, crashed, or stopped mid-operation) no longer leaves the browser failing every request until an app restart: the exit is detected, surfaced in the status bar, and the bridge restarts on the next request.
|
||||
- "Playing" is only reported once mpv actually configures a video output; when a stream fails to decode, the browser shows mpv's error instead of claiming playback started while no window ever appeared.
|
||||
- The bridge bundle is downloaded from the newest upstream release that ships a bundle for the platform (skipping releases older than the oldest server SubMiner is known to work with), the same way Mangatan fetches it, so all four published bundles (macOS arm64 and x64, Linux x64, Windows x64) work without a maintainer hashing each one first.
|
||||
- The Extensions tab's available list has a language chip row: pick one or more languages to narrow a repository index that otherwise lists every language it knows, or "All" to clear the filter. Rows name the language ("Japanese" instead of `ja`) and the Available heading counts what the filter leaves.
|
||||
- A stream's subtitle tracks are downloaded to a temp directory and loaded into mpv as files rather than streamed from the source URL, so they can serve as the alass reference in Subsync the way Jellyfin subtitles do. The format is detected from the file's own content, a track that fails to download falls back to its URL so the episode still plays, and the directory is removed when the next episode starts or the app exits.
|
||||
- An episode launched from the browser carries its series, season and episode number through the app instead of a single joined string: stats group by series (every stream previously landed in one entry named `m3u8`), rewatching reuses the existing entry, the Jimaku and TsukiHime modals prefill Title/Season/Episode from the source's own listing, AniList updates use those fields directly, and the mpv title reads `Series S03E04 - Episode Name`.
|
||||
- Opening the browser shows a tray icon on every platform and — on macOS — puts the app in the Cmd+Tab switcher and Dock, so you can switch between it and mpv; the Dock icon is released again when the window closes during playback. Launching a video starts a regular SubMiner session, and in `subminer anime` standalone mode closing the window during playback no longer quits the app and kills the stream.
|
||||
- The episode list has a filter box: type an episode number (`12`), a range (`12-18`), or part of an episode name to narrow a long list, with a `6 of 25` counter while it is applied. Escape inside the box clears the filter instead of leaving the detail page.
|
||||
- Episodes already watched are dimmed and marked in the episode list, with a watched count in the header. The marks come from the stats history playback already writes (an episode is marked once a session passes the completion threshold), so they match the stats window and survive the stream URL changing between playbacks. They refresh when the browser window comes back to the front, and stay empty when immersion tracking is disabled.
|
||||
- Right-clicking an episode opens a menu for marking it watched or unwatched by hand, plus "Mark this and N below watched/unwatched" for the episode and every episode listed under it. Sources list newest first, so a span covers the back catalogue, which is how a series watched elsewhere gets caught up. A filter never narrows what a span covers, and the status bar reports how many episodes were touched.
|
||||
- Marking an episode that was never played creates its stats row, carrying the same series, season and episode fields playback would have recorded. Both stats library views join the lifetime tables, so a manual mark does not show up there as watch time nobody spent, and clearing a mark creates nothing.
|
||||
- Episodes can be queued instead of replacing what is playing. Every episode row has **Play** and **Queue** buttons (clicking the row still plays now), the right-click menu offers the same two, and a queued episode shows its place in line ("next up", "#2 in queue") with a queue count and **Clear queue** in the episode header. The queue spans anime, resolves and appends each episode to mpv's real playlist as soon as it is queued while subtitle tracks cache in the background, so next/previous navigation works immediately and the next episode starts without a resolution pause when the current one ends. Queueing with nothing playing just plays.
|
||||
- Added an in-player Anime Browser modal on `Ctrl+Alt+A`. The shortcut toggles it without losing its page or scroll position. It stays within the player bounds and shares the active episode, playback queue, source configuration, and watch history with the standalone browser, while each surface keeps independent search and navigation state. The modal validates its embedded page before changing overlay state, so a load setup failure leaves it closed rather than revealing a broken modal. The SubMiner logo identifies the Anime Browser in both the modal and standalone window.
|
||||
- The bridge is reused from a package-manager install when one exists: on Arch the AUR `mangatan-extension-server` package (shared with Mangatan) is picked up from `/usr/share/mangatan/extension_server`, so nothing is downloaded and pacman keeps it current. `anime.bridgeDir` points SubMiner at a bundle anywhere else. `subminer-bin` lists the package as an optional dependency.
|
||||
- Once the bridge is running, SubMiner checks GitHub for a newer release, including for AUR and custom installations. AUR update notices direct users to their AUR helper; custom installs get manual-update guidance. Only SubMiner-managed copies offer an **Update to vX** button. The new release is downloaded beside the running bridge, then the bridge restarts on it. The updater waits for a bridge still starting to stop and keeps the previous bundle until the replacement is active. The Extensions tab shows the bridge version, where it lives, and who updates it without describing an unchecked install as current.
|
||||
- The detail page keeps its episode list when only the details call fails: the episodes render with a "Details unavailable" note and the error in the status bar, instead of an empty page.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: docs
|
||||
area: launcher
|
||||
|
||||
- Documented private-runtime launcher installation, simplified first-run setup, custom app locations, legacy launcher migration, and package-managed updates. Updated release-note download guidance for the bundled runtime and Windows launcher.
|
||||
- Documented the bundled Bun runtime's MIT and LGPL licensing, where the notices live in the app, and the corresponding-source release asset. The AUR package now lists the bundled runtime licenses and installs their notices under `/usr/share/licenses/subminer-bin`, and `subminer-assets.tar.gz` includes the same notices.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: changed
|
||||
area: launcher
|
||||
|
||||
- Made every installed and downloadable launcher use the Bun runtime included with SubMiner. Added a Windows `subminer.cmd` download, persistent private runtime caches for Linux and Windows, and migration for recognized legacy launchers. Deferred migrations survive overlapping update checks and remain pending when startup cannot safely refresh the launcher.
|
||||
- Simplified first-run setup to a single optional launcher control, with runtime repair guidance shown only when needed.
|
||||
@@ -1,6 +0,0 @@
|
||||
type: changed
|
||||
area: sync
|
||||
|
||||
- Sync uses compressed, incremental rsync transfers on compatible macOS and Linux machines, caching the last received snapshot per peer to reduce traffic on subsequent syncs. Cache helpers work through the launcher; older apps and launchers fall back to compressed transfers without an upload cache.
|
||||
- Machines without compatible rsync, including Windows endpoints, automatically use compressed scp transfers.
|
||||
- Rsync explicitly uses SSH and aborts transfers that exceed 30 minutes before merging.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: runtime
|
||||
|
||||
- Shutdown finishes independent media and Discord cleanup and waits for sync shutdown even when Jellyfin cleanup fails, then reports the first cleanup error.
|
||||
- Tsukihime ignores delayed media-info results and errors from a closed modal session.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: startup
|
||||
|
||||
- Fixed first launch exiting on macOS when the SubMiner config directory did not yet exist by creating it before acquiring the startup lock.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Clicking a subtitle sidebar cue releases row focus, and Space no longer seeks back to a focused cue. Enter still seeks the focused cue, and Space keeps its configured playback action.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Keep Hyprland recovery dialogs above SubMiner windows so overlay placement updates do not cover their Wait and Close buttons.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: docs
|
||||
area: subtitles
|
||||
|
||||
- Explain how dialogue generation retains uncertain audible sections, why songs may also be transcribed, how detected speech starts guide long-passage cuts to reduce early subtitles, and why each passage uses a fresh Whisper process.
|
||||
@@ -1,6 +0,0 @@
|
||||
type: added
|
||||
area: subtitles
|
||||
|
||||
- Generate local Japanese SRT subtitles with whisper.cpp from a standalone modal opened with Ctrl+Shift+G, the empty subtitle sidebar's generation button, or `subminer generate-subs`, with shared progress reporting, cancellation, safe output files, and automatic loading into the matching mpv video. The sidebar button hides while subtitle lines are loaded.
|
||||
- Configure an existing multilingual model in Settings or choose an official multilingual model, including quantized variants, in the modal or launcher. The modal shows download sizes, speed and accuracy guidance, and a recommended starting model before explicitly downloading a verified SubMiner-managed model. Executable paths are optional overrides; empty fields find whisper-cli, ffmpeg, and ffprobe on PATH. The modal's Local tools check and the launcher name any missing executable and its setting before downloading a model or extracting audio, and generation confirms the destination directory grants write and search permissions up front.
|
||||
- Optionally select Focus on spoken dialogue in the modal and use Download speech detection model to install the separate Silero model with progress and cancellation. The choice lasts for the session; a configured VAD model path sets the default. Retain uncertain audible sections so VAD rejection does not discard dialogue under music, accepting that songs may also be transcribed. Keep passages intact within Whisper's audio window, split longer passages near detected speech starts or quiet pauses with overlapping context to reduce early subtitle timing, and combine overlapping duplicate cues even when punctuation differs. Run each passage in a fresh Whisper process to prevent repeated-character output caused by state carried between files, at the cost of reloading the model per passage. Preserve original media timing and separate repeated dialogue.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed the overlay never loading (stuck on the "Overlay loading" OSD spinner) when the Yomitan content-script reload raced overlay window creation at startup, most visible when playing from the anime browser on Linux/Wayland: a hidden window stops painting after that reload, so the ready-to-show signal that gates showing the overlay never fired. Content-ready now falls back to did-finish-load after a short grace period.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: macos
|
||||
|
||||
- `subminer anime` (and `--settings` / `--sync`) now bring their window to the front on macOS. `show()`/`focus()` only reorder windows inside the app that is already active, so the window opened behind the terminal that launched it; SubMiner now activates itself when opening one. The anime browser also restores its Dock icon before showing rather than after, because the overlay's fullscreen transform leaves the app as an accessory process that macOS refuses to bring forward at all.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- The overlay discovers non-conflicting keyboard bindings from mpv defaults, input.conf, and loaded scripts in the background. SubMiner controls and explicitly disabled bindings take precedence. Discovered bindings stay session-only and do not appear in SubMiner's help menu.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: changed
|
||||
area: release
|
||||
|
||||
- Reduced installer and unpacked app size by excluding documentation demo media, dependency source maps, TypeScript files, test and fixture directories, other development files, and unused Koffi platform binaries, and sharing the existing Japanese UI font across windows.
|
||||
- Added package content checks, published size reports with release comparisons, and packaged asset/native-module smoke checks to the shared stable and prerelease build workflow. Size growth is reported without blocking releases.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Select dialogue across subtitle sidebar rows and copy it without timestamps using Ctrl/Cmd+C or the Copy button. Selection keeps the excerpt in view during playback and does not seek or require mining a card.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: subsync
|
||||
|
||||
- Subsync no longer fails with `Protocol "file:" not supported` on a subtitle that was dropped onto mpv. mpv reports such a track as a percent-encoded `file://` URL, which subsync read as a stream and tried to fetch over HTTP; the URL is now decoded back to its path, so both the retimed target and an alass reference work. A `file://` video path is treated as local too, which restores the video reference and ffsubsync for a dropped file.
|
||||
@@ -1,7 +0,0 @@
|
||||
type: fixed
|
||||
area: subsync
|
||||
|
||||
- Subsync now works when mpv loaded a subtitle track from a URL, which is how Aniyomi extension streams and Jellyfin add theirs. The track is downloaded to a temporary file first — reusing mpv's own request headers, so authenticated and referer-gated hosts stay reachable — instead of being rejected with "Subtitle file not found: https://…". This applies to both the sync target and the alass reference, so a Jimaku or TsukiHime download can be retimed against a stream's own subtitles. Internal tracks of a stream also pass mpv's headers through to `ffmpeg`.
|
||||
- Alass now works on tracks that arrive as WebVTT, which is what Aniyomi extension streams serve. Alass picks its parser from the file extension and has no WebVTT support, so it treated a `.vtt` reference as a video file and failed with "no audio stream in file". Both the target and the reference are rewritten as SRT for alass, keeping the cue text as-is, and the originals are left untouched.
|
||||
- Leaving `subsync.alass_path`, `ffsubsync_path`, or `ffmpeg_path` empty now actually auto-discovers the binary, as the config help has always claimed. Previously it fell back to a hard-coded `/usr/bin/<tool>`, which does not exist on macOS and broke subsync for every default-config install there. Discovery searches `PATH` plus the usual install prefixes (a GUI launch inherits a minimal `PATH`) and accepts `alass-cli` as well as `alass`. An explicitly configured path is still used verbatim and never silently substituted.
|
||||
- Subsync failures are written to the application log. Previously the only trace was an OSD toast that vanished after a few seconds, leaving nothing to diagnose from.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: subtitles
|
||||
|
||||
- Keep the subtitle sidebar near playback during gaps when the subtitle file has a cue starting at zero.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: docs
|
||||
area: sync
|
||||
|
||||
- Documented compressed transfers, incremental sync cache storage, and compatibility with older peers.
|
||||
+23
-23
@@ -6,6 +6,15 @@
|
||||
*/
|
||||
{
|
||||
|
||||
// ==========================================
|
||||
// Subtitle Selection
|
||||
// Select primary and secondary mpv subtitle tracks from the overlay.
|
||||
// Hot-reload: enabling or disabling updates the session shortcut immediately.
|
||||
// ==========================================
|
||||
"subtitleSelection": {
|
||||
"enabled": false // Use the SubMiner modal to select primary and secondary subtitle tracks. When enabled, its shortcut overrides mpv subtitle selection. Values: true | false
|
||||
}, // Select primary and secondary mpv subtitle tracks from the overlay.
|
||||
|
||||
// ==========================================
|
||||
// Japanese Subtitle Generation
|
||||
// Generate timed Japanese subtitles from local audio using whisper.cpp.
|
||||
@@ -223,6 +232,7 @@
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||
"openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).
|
||||
"openSubtitleSelection": "g-s", // Open subtitle selection when enabled. Use g-s to press g then s. Set null to unbind.
|
||||
"openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal.
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||
@@ -359,12 +369,6 @@
|
||||
"__playlist-browser-open"
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Ctrl+Alt+KeyA", // Key setting.
|
||||
"command": [
|
||||
"__anime-browser-open"
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Ctrl+Shift+KeyH", // Key setting.
|
||||
"command": [
|
||||
@@ -547,7 +551,7 @@
|
||||
// ==========================================
|
||||
// AnkiConnect Integration
|
||||
// Automatic Anki updates and media generation options.
|
||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/wordAudio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.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.
|
||||
// ==========================================
|
||||
@@ -568,6 +572,7 @@
|
||||
"fields": {
|
||||
"word": "Expression", // Card field for the mined word or expression text.
|
||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||
"wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.
|
||||
"image": "Picture", // Card field that receives the captured screenshot or animated image.
|
||||
"sentence": "Sentence", // Card field that receives the source sentence text.
|
||||
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
|
||||
@@ -596,7 +601,7 @@
|
||||
"reviewTiming": false, // Review and preview subtitle media timing before SubMiner creates or enriches a mined card. Values: true | false
|
||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. 0 disables the cap.
|
||||
}, // Media setting.
|
||||
"knownWords": {
|
||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||
@@ -641,21 +646,6 @@
|
||||
} // 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: autoOpenJimaku applies to the next episode; other anime changes apply the next time the anime browser opens.
|
||||
// ==========================================
|
||||
"anime": {
|
||||
"autoOpenJimaku": false, // Pause Anime Browser playback and open Jimaku when an episode loads. Playback resumes after a subtitle loads or the modal closes only when auto-open initiated the pause. Values: true | false
|
||||
"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.
|
||||
"defaultSource": "", // Source the Anime Browser selects when it opens: a source id (<package>:<source>) or "all" for every installed source. Empty selects the first installed source. "Set default" in the Extensions tab writes this value.
|
||||
"bridgeDir": "" // Directory holding an M-Extension-Server bundle (java runtime plus server jar) to run instead of the copy SubMiner downloads. Empty checks the package-manager install (Arch: mangatan-extension-server), then <userData>/anime-bridge.
|
||||
}, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
|
||||
|
||||
// ==========================================
|
||||
// Jimaku
|
||||
// Jimaku API configuration and defaults.
|
||||
@@ -679,6 +669,16 @@
|
||||
"maxSearchResults": 10 // Maximum TsukiHime search results returned.
|
||||
}, // TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.
|
||||
|
||||
// ==========================================
|
||||
// TMDB
|
||||
// TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.
|
||||
// Hot-reload: TMDB changes apply to the next TMDB request.
|
||||
// ==========================================
|
||||
"tmdb": {
|
||||
"apiKey": "", // Your own TMDB API key or read access token for live-action posters and synopses in the stats Library. Release builds bundle a project key, so set this only to use your own quota or when running from source (free under Settings > API on themoviedb.org).
|
||||
"apiKeyCommand": "" // Shell command that prints the TMDB API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
|
||||
}, // TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.
|
||||
|
||||
// ==========================================
|
||||
// YouTube Playback Settings
|
||||
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||
|
||||
@@ -368,7 +368,6 @@ 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: 'Subtitle Generation', link: '/subtitle-generation' },
|
||||
{ text: 'TsukiHime', link: '/tsukihime-integration' },
|
||||
|
||||
@@ -47,6 +47,8 @@ The update flow:
|
||||
|
||||
Failed AniList updates are persisted to a retry queue on disk and retried with exponential backoff.
|
||||
|
||||
Updates are skipped if the media path cannot produce a safe, nonempty identity. Invalid entries are discarded when loading or adding to the retry queue.
|
||||
|
||||
| Parameter | Value |
|
||||
| ---------------- | ---------- |
|
||||
| Initial backoff | 30 seconds |
|
||||
@@ -69,6 +71,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.
|
||||
|
||||
When AniList has no match, SubMiner tries [TMDB](/configuration#tmdb) next so live-action dramas and movies get a poster and synopsis too. See [Immersion tracking](/immersion-tracking#library) for how live-action entries are grouped.
|
||||
|
||||
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
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
# 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.
|
||||
|
||||
During playback, `Ctrl+Alt+A` toggles the same browser as a modal inside the mpv
|
||||
player bounds. It uses a dedicated modal surface, so it stays above fullscreen
|
||||
playback and closes like the other in-player tools. Toggling it off keeps its
|
||||
current page and scroll position ready for the next toggle. The standalone
|
||||
window and the modal keep their own search, selected source, tab, and scroll
|
||||
state, so using one does not replace or cancel what you were doing in the other.
|
||||
|
||||
Both surfaces use the same playback queue, source configuration, and stats
|
||||
history. Queue changes and the currently playing episode appear in both
|
||||
immediately, and watched marks come from the same history that playback and the
|
||||
stats window update. Closing and reopening the modal therefore picks up progress
|
||||
made from either browser surface.
|
||||
|
||||
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.
|
||||
|
||||
Every row carries the extension's icon, as the repository publishes it, so a
|
||||
site is recognisable before you read the name. A repository row shows its host's
|
||||
favicon instead. Icons are the only part of a row that is fetched from the
|
||||
network, and a row whose icon is missing falls back to the first letter of its
|
||||
name rather than an empty box.
|
||||
|
||||
A repository index lists every language it knows about, which is far more than
|
||||
any one person reads, so the **Available** list has a language chip row above
|
||||
it. Pick one or more languages to narrow it, or **All** to clear the filter;
|
||||
picking a language replaces **All** rather than sitting beside it. Rows name the
|
||||
language in full ("Japanese" rather than `ja`), extensions whose sources span
|
||||
languages are grouped under **Multi-language**, and the Available heading counts
|
||||
how many of the offered extensions the filter leaves.
|
||||
|
||||
### 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. An installed extension borrows
|
||||
its icon from the catalogue, so one no repository carries shows its monogram.
|
||||
|
||||
SubMiner compares the APK's Android version code with the newest build in the
|
||||
configured repositories. **Update** is clickable only when the repository has a
|
||||
newer build. A current extension says **Up to date**. If an unusual APK has no
|
||||
readable version code, its disabled button says **Version unknown**. **Update
|
||||
all** installs every newer build in one pass and disables itself when there is
|
||||
nothing to update.
|
||||
|
||||
### 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.
|
||||
|
||||
### Default source
|
||||
|
||||
The browser opens on the first installed source. To open on a different one,
|
||||
press **Set default** on its row in the Installed list; the row then carries a
|
||||
**default** tag, and only one source can carry it. The choice is written to
|
||||
`anime.defaultSource` and applies the next time a browser window or the
|
||||
in-player modal opens. With more than one source installed, an **All sources**
|
||||
row at the top of the list can be the default too. A default that is no longer
|
||||
installed falls back to the first source.
|
||||
|
||||
## 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.
|
||||
|
||||
When a source reports another page, **Load more** appears below the covers.
|
||||
It appends the next page without duplicating entries that already arrived in
|
||||
the live result stream. A failed next-page request remains available to retry.
|
||||
|
||||
Source settings belong to a single extension, so the **Source settings** tab
|
||||
asks you to pick one while **All sources** is selected.
|
||||
|
||||
## Finding an episode, and what you have watched
|
||||
|
||||
An episode list can run to hundreds of entries, so the episode header carries a
|
||||
filter box:
|
||||
|
||||
- A number, `12`, keeps that episode. Sources that report no numbers at all
|
||||
are still searched by name, so `12` also matches `Episode 12` in a title.
|
||||
- A range, `12-18`, keeps the episodes between the two, in either order
|
||||
(`18-12` reads the same). Episodes the source gave no number are left out of
|
||||
a range.
|
||||
- Anything else is a case-insensitive substring of the episode name, so `beach`
|
||||
finds `OVA: Beach Special`.
|
||||
|
||||
The counter next to **Episodes** reads `6 of 25` while a filter is applied.
|
||||
Pressing Escape in the filter box clears it; pressing it anywhere else goes back
|
||||
to the results grid.
|
||||
|
||||
Episodes you have already watched are dimmed and marked `✓ watched`, with a
|
||||
count in the header. This is not a separate list the browser keeps: it reads the
|
||||
same stats history the rest of SubMiner writes to, where an episode is marked
|
||||
watched once a session runs past the completion threshold. Streams are recorded
|
||||
under a stable per-episode identity, so the mark survives the stream URL
|
||||
changing between playbacks, and it is the same mark the stats window and
|
||||
`--mark-watched` use.
|
||||
|
||||
Because playback marks an episode partway through the session, the marks
|
||||
refresh when the browser window comes back to the front: finish an episode in
|
||||
mpv, switch back, and it is marked. With
|
||||
[immersion tracking](configuration.md) disabled there is no history to read, so
|
||||
no episode is marked.
|
||||
|
||||
### Marking by hand, and catching up
|
||||
|
||||
Right-click an episode for:
|
||||
|
||||
- **Mark watched** / **Mark unwatched**: the single episode, whichever way it
|
||||
is not already.
|
||||
- **Mark this and N below watched** / **... unwatched**: that episode and every
|
||||
episode listed below it. Sources list newest first, so "below" is the back
|
||||
catalogue: right-click the last episode you saw and mark everything down to
|
||||
the start, which is how you catch up a series you watched somewhere else.
|
||||
|
||||
A filter narrows what you are looking at, not what you mark: a span always
|
||||
covers the full episode list, and the status bar says how many episodes it
|
||||
touched. The oldest episode has nothing below it, so it only offers the single
|
||||
entry. Escape closes the menu.
|
||||
|
||||
Marking an episode you have never played creates its stats row so the mark has
|
||||
somewhere to live. That row carries the same series, season and episode fields
|
||||
playback would have recorded, and both stats library views join the lifetime
|
||||
tables, so a manually marked episode does not appear there as watch time you
|
||||
never spent. Clearing a mark never creates anything.
|
||||
|
||||
Marks are written to the stats history, so with immersion tracking disabled
|
||||
there is nowhere to write them and the status bar says so.
|
||||
|
||||
## Settings
|
||||
|
||||
| Key | Purpose |
|
||||
| ------------------------ | ------------------------------------------------------------------------ |
|
||||
| `anime.autoOpenJimaku` | Pause new episodes and open Jimaku for Japanese subtitles. |
|
||||
| `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`). |
|
||||
| `anime.defaultSource` | Source the browser opens on: a source id or `all`. Empty uses the first. |
|
||||
| `anime.bridgeDir` | A bridge bundle to run instead of the downloaded one. Empty by default. |
|
||||
|
||||
Enable `anime.autoOpenJimaku` to hand each newly loaded Anime Browser episode
|
||||
to Jimaku. SubMiner pauses playback on its first frame, waits for mpv's video
|
||||
window to appear, closes the in-player browser if it is open, hides the
|
||||
standalone browser window, and opens Jimaku with the source title, season, and
|
||||
episode already filled in. Playback resumes after the selected subtitle loads.
|
||||
Closing Jimaku also releases the automatic pause, while playback that was
|
||||
already paused stays paused. A stream that never shows a window resumes without
|
||||
opening Jimaku. Reopen the browser window with `subminer anime`, the tray
|
||||
entry, or the shortcut to keep browsing.
|
||||
|
||||
## 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. Values are scoped to
|
||||
the exact extension package and source, so two extensions that reuse the same
|
||||
internal source ID cannot read each other's settings. Preferences saved by an
|
||||
older build without package ownership are discarded; re-enter those source
|
||||
settings once after upgrading.
|
||||
|
||||
## The bridge
|
||||
|
||||
SubMiner looks for the server in three places, in order, and runs the first
|
||||
one it finds:
|
||||
|
||||
1. `anime.bridgeDir`, if set. It must hold a Java runtime and the
|
||||
`MExtensionServer-*.jar`, laid out as upstream ships them. A directory that
|
||||
holds neither is an error rather than a silent fallback.
|
||||
2. A package-manager install. On Arch that is the AUR
|
||||
[`mangatan-extension-server`](https://aur.archlinux.org/packages/mangatan-extension-server)
|
||||
package, which Mangatan also uses, at `/usr/share/mangatan/extension_server`.
|
||||
Installing it means no download, and pacman keeps the bridge current. The
|
||||
`subminer-bin` package lists it as an optional dependency.
|
||||
3. SubMiner's own copy in `<userData>/anime-bridge`. The first launch downloads
|
||||
the newest upstream release that ships a bundle for your platform (~130 MB,
|
||||
containing the server and a matching Java runtime, so no system JDK is
|
||||
required), the same way Mangatan does. Releases older than the oldest server
|
||||
SubMiner is known to work with are skipped. Progress appears in the banner
|
||||
at the top of the window.
|
||||
|
||||
The **Bridge** note at the bottom of the Extensions tab says which one is in
|
||||
use, its version, and who updates it.
|
||||
|
||||
### Updating the bridge
|
||||
|
||||
Only the copy SubMiner downloaded is ever updated by SubMiner. A package-manager
|
||||
install or an `anime.bridgeDir` belongs to whoever put it there.
|
||||
|
||||
Once the bridge is running, SubMiner asks GitHub for the newest release and
|
||||
compares it with the installed version, including package-manager installs and
|
||||
custom bridge directories. When upstream has published a newer release, the
|
||||
banner reads "Extension bridge v… is installed; v… is available".
|
||||
|
||||
For an AUR bridge, the banner directs you to update `mangatan-extension-server`
|
||||
through your AUR helper, for example `paru -S mangatan-extension-server`, then
|
||||
restart SubMiner. Custom installations get a reminder to use their package
|
||||
manager or original installation method. These notices have no in-app update
|
||||
button. The check uses upstream releases, so AUR packaging may lag behind the
|
||||
notice. External installations with an unreadable version do not show a notice.
|
||||
|
||||
For SubMiner's own copy, the banner includes an **Update to v…** button.
|
||||
Clicking it downloads the new release beside the
|
||||
running bridge, so a failed download changes nothing, then stops the bridge,
|
||||
swaps the directories, and starts it again. The restart takes a few seconds
|
||||
and kills the stream of an episode that is playing, the same as when the
|
||||
bridge exits for any other reason; the queue and browser state survive. The
|
||||
check is one unauthenticated GitHub API call per bridge start; if it fails
|
||||
(offline, rate limited) it is logged and no update is offered until the next
|
||||
start.
|
||||
|
||||
Upstream publishes no checksums, so the download is trusted the way Mangatan
|
||||
and the AUR package trust it: TLS to GitHub and the maintainer's account. That
|
||||
is the same trust running the server implies in the first place.
|
||||
|
||||
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.
|
||||
|
||||
If the bridge dies anyway (killed by hand, crashed, or stopped mid-operation),
|
||||
the exit is detected and named in the status bar, and the next request starts a
|
||||
new one. Playback already in flight still ends when its stream URL dies, but the
|
||||
browser recovers without an app restart.
|
||||
|
||||
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 outright.
|
||||
|
||||
## 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.
|
||||
|
||||
HLS streams are routed through a small local proxy before mpv sees them. Some
|
||||
hosts disguise their video segments by prepending a fake image header (a real
|
||||
1x1 PNG) so scrapers back off; Aniyomi's own player strips this, but ffmpeg
|
||||
probes the segment as a picture and playback dies with "no audio or video data
|
||||
played". The proxy scans each segment for the first genuine MPEG-TS packet run
|
||||
and drops whatever junk sits in front of it. Segments that are not TS (fMP4,
|
||||
subtitles, encryption keys) pass through untouched, and direct-file streams
|
||||
skip the proxy entirely. Segment URLs disguised behind fake extensions
|
||||
(`.image`, `.jpg`, `.css`, and friends) are exposed locally with a `.ts`
|
||||
suffix so current ffmpeg releases accept them when Anki extracts audio,
|
||||
screenshots, or animated images from the playing stream.
|
||||
|
||||
"Playing" in the status bar means playing: after handing mpv the stream,
|
||||
SubMiner waits until mpv actually configures a video output before reporting
|
||||
success. If mpv gives up instead — a dead host, an undecodable stream — the
|
||||
browser shows mpv's error rather than pretending playback started (a failed
|
||||
load leaves no mpv window, because the player idles windowless).
|
||||
|
||||
Failed requests appear in a dismissible panel at the bottom of the browser.
|
||||
It explains the failed action and suggests a next step. Expand **Technical
|
||||
details** to read the bridge or mpv error when reporting a problem. Missing
|
||||
bridge APIs and incomplete extension data have distinct explanations; an
|
||||
update may be needed, but a compatible release may not yet be available.
|
||||
|
||||
Choosing **Queue** resolves the episode and appends the playable stream to mpv's
|
||||
own playlist immediately, while its subtitle tracks cache in the background.
|
||||
That makes mpv's next command available at once and lets the next episode begin
|
||||
automatically when the current one ends, without waiting for another source
|
||||
request. Resolved stream URLs can be short-lived, so a very long queue can still
|
||||
outlive what its source issued; dequeue and queue that episode again to refresh
|
||||
it.
|
||||
|
||||
### 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).
|
||||
|
||||
The extension hands over subtitle tracks as URLs, but SubMiner downloads each
|
||||
one to a temporary directory and gives mpv the local file. mpv is happy either
|
||||
way; [Subsync](/troubleshooting#subtitle-sync-subsync) is not, because alass
|
||||
needs a file on disk to use as the timing reference. A track that fails to
|
||||
download falls back to its URL so the episode still plays, the format is
|
||||
detected from the file's own content rather than its URL, and the directory is
|
||||
removed when the next episode starts or the app exits.
|
||||
|
||||
### Series, season, and episode
|
||||
|
||||
The episode's identity travels with it instead of being guessed back out of the
|
||||
stream URL, which carries nothing but a proxy path and a file extension. The
|
||||
title and episode label from the source's own listing are split into series,
|
||||
season, and episode number once, at launch, and everything downstream reads
|
||||
those fields:
|
||||
|
||||
- mpv's title reads `Series S03E04 - Episode Name`.
|
||||
- Stats group by series, and rewatching an episode reuses its entry instead of
|
||||
creating a new one.
|
||||
- The [Jimaku](/jimaku-integration) and [TsukiHime](/tsukihime-integration)
|
||||
modals open with Title, Season, and Episode already filled in, so a subtitle
|
||||
search is one keypress rather than a retype.
|
||||
- [AniList](/anilist-integration) progress updates use those fields directly.
|
||||
|
||||
[mes]: https://github.com/1Selxo/M-Extension-Server
|
||||
@@ -19,6 +19,8 @@ This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [La
|
||||
|
||||
AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the port in AnkiConnect's settings, update `ankiConnect.url` in your SubMiner config.
|
||||
|
||||
AnkiConnect and Kiku/Senren settings follow the [configuration validation rules](/configuration#configuration-file): invalid values produce a warning and fall back to the option's default. Use JSON booleans such as `true`, not strings such as `"true"`, and a positive number for `ankiConnect.pollingRate`.
|
||||
|
||||
## Auto-enrichment transport
|
||||
|
||||
When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, and image fields automatically. Two detection methods are available:
|
||||
@@ -125,6 +127,7 @@ SubMiner maps its data to your Anki note fields. Configure these under `ankiConn
|
||||
"fields": {
|
||||
"word": "Expression", // mined word / expression text
|
||||
"audio": "SentenceAudio", // sentence audio clip cut from the video
|
||||
"wordAudio": "ExpressionAudio", // existing Yomitan word audio, read for animation sync
|
||||
"image": "Picture", // screenshot or animated clip
|
||||
"sentence": "Sentence", // subtitle text
|
||||
"miscInfo": "MiscInfo" // metadata (filename, timestamp)
|
||||
@@ -136,6 +139,8 @@ SubMiner maps its data to your Anki note fields. Configure these under `ankiConn
|
||||
|
||||
Field names are matched against your Anki note type case-insensitively (an exact match wins, then a lowercase comparison). If a configured field does not exist on the note type, SubMiner skips it without error.
|
||||
|
||||
`fields.wordAudio` selects the existing dictionary-audio field used to calculate the animated image's opening freeze. This mapping only reads audio; `fields.audio` still controls where generated sentence audio is written. See [config.example.jsonc](/config.example.jsonc) for defaults.
|
||||
|
||||
These mappings always control normal word-card enrichment, including Yomitan proxy/polling updates and manual clipboard updates. Enabling Lapis or Kiku does not replace the configured word-card sentence and audio fields with `Sentence` and `SentenceAudio`. The dedicated sentence-card and audio-card shortcuts still use those Lapis/Kiku field names.
|
||||
|
||||
Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `<br>` newline).
|
||||
@@ -183,12 +188,18 @@ Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMine
|
||||
|
||||
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
|
||||
|
||||
Overlay and stats-dashboard mining use the same `media.maxMediaDuration` limit. See the [configuration example](/config.example.jsonc) for its default and how to disable the cap.
|
||||
|
||||
Set `media.reviewTiming` to `true` to pause playback and check the clip before its media is generated. It applies to word, sentence, and audio cards.
|
||||
|
||||
The review opens on the subtitle range plus your configured audio padding. Subtitles usually hang around after the dialogue has stopped, so once the waveform loads, an untouched clip end pulls back to just after the last speech in the line. The Line end rail still marks the original subtitle timing, Reset puts it back, and a line whose speech runs right through its end is left alone.
|
||||
|
||||
**Adjusting the clip.** Drag either edge to trim, drag the middle to slide the whole clip without changing its length, or click anywhere on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys: 100 ms per press, or 500 ms with Shift. The 100 ms buttons do the same thing. Earlier and Later each reveal two more seconds of timeline without moving the selection.
|
||||
|
||||
**Choosing the screenshot.** With still images enabled, drag the screenshot slider or use the Frame buttons to choose a video frame independently of the audio. Earlier and Later reveal more time for both sliders. The image follows the audio midpoint until you pick a frame, then stays fixed while you trim audio. Reset below the image restores automatic selection; the audio Reset affects only the audio. The screenshot slider also supports arrow keys, Home, and End.
|
||||
|
||||
The picker supports local video and seekable remote streams, including Jellyfin, without seeking the main player. A selected frame must load before you can confirm; if it fails, choose another frame or Reset. The picker is hidden when image generation is disabled or animated AVIF is selected. Animated images continue to use the reviewed audio range.
|
||||
|
||||
**Keys.** Space previews the selection with a playhead sweeping the clip. The preview ends when the hidden player has actually played the last sample, so Bluetooth output latency does not clip the tail. Enter confirms and Escape cancels.
|
||||
|
||||
**The waveform.** SubMiner reads a center channel when one carries dialogue and falls back to a mono mix otherwise, keeps only the 250 to 3500 Hz speech band, and draws each slice's loudness against the clip's own noise floor. Steady background music flattens out and dialogue stands up, which makes it much easier to tell adjacent lines apart. The mined subtitle appears as a tinted band with labeled line-start and line-end rails. If waveform analysis fails, the timing controls still work.
|
||||
@@ -201,8 +212,12 @@ Confirming writes the combined lines to the sentence field. Reset drops the adde
|
||||
|
||||
**Canceling.** You can go back to editing, finish with the original timing, create the card without audio or an image, or discard it. Discard deletes an existing Yomitan or audio card, and skips creation entirely for a direct sentence card. A failed audio preview does not block confirmation or card creation.
|
||||
|
||||
When word-card enrichment changes the sentence context, including an expanded timing-review selection, SubMiner regenerates `SentenceFurigana` from the final sentence. Unchanged sentences keep their existing furigana formatting. If generation fails, SubMiner clears stale furigana so compatible templates can fall back to `Sentence`.
|
||||
|
||||
Clipboard updates and stats-dashboard mining never open timing review. The option is off by default and hot-reloads. **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`) toggles it for the current session.
|
||||
|
||||
If SubMiner closes the overlay while a timing review is still loading, it cancels pending setup and modal retries and restores playback if the review paused it. A new timing review can start after the overlay reopens.
|
||||
|
||||
### Screenshots (static)
|
||||
|
||||
A single frame is captured at the current playback position.
|
||||
@@ -237,7 +252,7 @@ SubMiner can produce an animated AVIF spanning the subtitle duration instead of
|
||||
}
|
||||
```
|
||||
|
||||
Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) in your FFmpeg build. Generation timeout is 60 seconds. `media.syncAnimatedImageToWordAudio` (default `true`) prepends a frozen first frame matching the existing word-audio duration, so the motion starts together with the sentence audio.
|
||||
Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) in your FFmpeg build. Generation timeout is 60 seconds. `media.syncAnimatedImageToWordAudio` (default `true`) prepends a frozen first frame matching the existing audio duration in `fields.wordAudio`, so the motion starts together with the sentence audio. The freeze is baked into the image when mined; changing the mapping does not repair previously generated images.
|
||||
|
||||
### Behavior options
|
||||
|
||||
|
||||
@@ -73,8 +73,8 @@ src/
|
||||
shared/ipc/ # Cross-process IPC channel constants + payload validators
|
||||
renderer/ # Overlay renderer (modularized UI/runtime)
|
||||
handlers/ # Keyboard/mouse/gamepad interaction modules
|
||||
modals/ # Modal flows (Anime Browser, Jimaku, Kiku, subsync, runtime options,
|
||||
# session help, changelog, character dictionary, playlist browser, subtitle
|
||||
modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help,
|
||||
# changelog, character dictionary, playlist browser, subtitle
|
||||
# sidebar, YouTube track picker, controller config/debug/select)
|
||||
positioning/ # Subtitle position controller (drag-to-reposition)
|
||||
settings/ # Settings window UI (model, controls, markup)
|
||||
|
||||
+134
-8
@@ -1,6 +1,132 @@
|
||||
# Changelog
|
||||
|
||||
## v0.19.6 (2026-09-04)
|
||||
## v0.20.0 (2026-09-23)
|
||||
|
||||
**Added**
|
||||
- **Japanese Subtitle Generation**:
|
||||
- Generate Japanese SRT subtitles locally with whisper.cpp. Start it from a new modal (Ctrl+Shift+G), from the generate button in an empty subtitle sidebar, or with `subminer generate-subs`.
|
||||
- Generation shows progress, can be cancelled, and loads the finished subtitles into mpv automatically.
|
||||
- Pick an official multilingual Whisper model, including quantized variants, with size and accuracy guidance. You can download it in the app or point Settings at a model you already have.
|
||||
- `large-v3-turbo` is recommended when CUDA support is detected, and `small` otherwise.
|
||||
- whisper-cli, ffmpeg, and ffprobe are found on PATH unless you override them. SubMiner names any missing tools before a download starts.
|
||||
- An optional "Focus on spoken dialogue" mode uses a separately downloaded Silero VAD model. It keeps audible sections it is unsure about, so dialogue under music is not dropped, but songs may also be transcribed.
|
||||
- Long passages are split near speech starts or quiet pauses to reduce subtitles that appear too early. When an eligible embedded or external subtitle track is loaded in mpv, it guides the split points.
|
||||
- Each passage runs in a fresh Whisper process, which prevents repeated-character output.
|
||||
- **Subtitle Selection Modal**:
|
||||
- An optional modal for choosing primary and secondary mpv subtitle tracks.
|
||||
- Turn it on in Settings under Behavior, then press g followed by s to open it. Turning it off restores mpv's own subtitle selection binding.
|
||||
- Single-key actions take priority over configured key sequence prefixes.
|
||||
- Conflicting sequences are disabled with a warning, and the existing y commands stay reserved.
|
||||
- **Subtitle Sidebar Copy**:
|
||||
- Select dialogue across several sidebar rows and copy it without timestamps using Ctrl/Cmd+C or the Copy button.
|
||||
- Selecting text does not seek playback and does not require mining a card.
|
||||
- **Media Timing Review Screenshot Picker**:
|
||||
- Choose the still screenshot separately from the audio range, with a live preview and its own time slider.
|
||||
- Step through decoded frames one at a time to get the exact frame you want.
|
||||
- Works with local video and with seekable remote streams such as Jellyfin.
|
||||
- **mpv Keybindings in the Overlay**:
|
||||
- The overlay now picks up keyboard bindings from mpv defaults, `input.conf`, and loaded scripts when they do not conflict with SubMiner.
|
||||
- SubMiner controls and bindings you explicitly disabled take precedence.
|
||||
- These bindings apply only to the current session and are not listed in the help menu.
|
||||
- **Jimaku Live Action Search**: The Jimaku modal has new Anime and Live action tabs, so you can search Jimaku's live action catalogue as well as anime. Use Arrow Left and Arrow Right to switch tabs.
|
||||
- **TMDB Live-Action Library**:
|
||||
- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB.
|
||||
- Release builds include a project key. Setting `tmdb.apiKey` or `tmdb.apiKeyCommand` overrides it, and one of them is required when running from source.
|
||||
- Titles that AniList cannot match are looked up on TMDB automatically when the parsed filename exactly matches a Japanese live-action title. For everything else, use the new **Link to TMDB** action.
|
||||
- Entries linked to the same TMDB title merge into one card, and the Library kind selector has a new Live Action option.
|
||||
- If a replacement download fails during provider reassignment, the previous link and artwork are kept. Merges and sync keep AniList and TMDB identities separate, and the merge dialog explains mixed selections instead of failing.
|
||||
- **YouTube Library Kind**:
|
||||
- YouTube channels are now their own Library media kind. Existing channel entries migrate automatically, and viewing history and manual video assignments are unchanged.
|
||||
- New All Titles, Anime, and YouTube Library filters.
|
||||
- Channels are excluded from AniList matching, season repair, and duplicate recommendations.
|
||||
- Merges and video moves can no longer combine an anime entry with a YouTube channel.
|
||||
|
||||
**Changed**
|
||||
- **Launcher Uses Bundled Bun**:
|
||||
- Every installed and downloadable launcher now runs on the Bun runtime that ships with SubMiner. A system Bun is no longer needed.
|
||||
- Recognized legacy launchers migrate automatically.
|
||||
- Windows gets a `subminer.cmd` launcher download.
|
||||
- First-run setup is reduced to one optional launcher control. Runtime repair guidance appears only when it is needed.
|
||||
- **Faster Sync Transfers**:
|
||||
- Sync between compatible macOS and Linux machines now uses compressed, incremental rsync transfers.
|
||||
- The last snapshot received from each peer is cached, which reduces traffic on later syncs.
|
||||
- Machines without a compatible rsync, including Windows, fall back to compressed scp.
|
||||
- Older peers still work without the upload cache.
|
||||
- Transfers abort after 30 minutes.
|
||||
- **Stats Server Request Safety**:
|
||||
- The stats server now accepts loopback hosts only and rejects requests from browser origins other than its own.
|
||||
- Requests that change data must send an `application/json` body. Scripts that POST to the server need to set a JSON content type.
|
||||
- The in-app stats overlay now loads from the local server, so it gets the same protection.
|
||||
- Dashboards served through a reverse proxy or Tailscale Serve are no longer supported.
|
||||
- **Smaller Downloads**:
|
||||
- Installers and unpacked apps are smaller. Demo media, source maps, TypeScript sources, test fixtures, and unused Koffi binaries are no longer packaged.
|
||||
- All windows now share one Japanese UI font.
|
||||
- Release builds publish package size reports that compare against the previous release.
|
||||
- **Bundled Yomitan**: Updated with upstream Yomitan 26.9.8 changes, including historical Japanese kana transformations, Ukrainian language support, and improvements to Anki duplicate searches and audio retrieval.
|
||||
|
||||
**Fixed**
|
||||
- **Jellyfin 12 Compatibility**:
|
||||
- Playback, subtitle, artwork, and remote-control requests now authenticate with the `ApiKey` query parameter, so the integration works on Jellyfin 12, where legacy authorization is off by default.
|
||||
- "Play on SubMiner" stays available. The cast connection now answers keep-alive requests and reconnects when the server stops responding, instead of silently dying after about a minute.
|
||||
- The Jellyfin "now playing" bar clears when you close or finish a cast video instead of running on to the end of the episode.
|
||||
- Cards mined during Jellyfin playback get the episode title in the misc info field again instead of "Unknown media".
|
||||
- **Jellyfin Privacy and Playback**:
|
||||
- Jellyfin streams no longer leak titles taken from the stream URL, or stream URLs that contain credentials, into metadata lookups, Anki source fields, Discord presence, stats, or AniList retries.
|
||||
- Previously cached metadata that contained credentials is cleaned up. Watch history and library assignments are not touched.
|
||||
- Jellyfin playback and casting now use your configured mpv executable, so they work when mpv is installed outside PATH. Portable plugins next to that executable are detected.
|
||||
- **Anki Mining**:
|
||||
- New `ankiConnect.fields.wordAudio` setting reads word audio separately from the sentence audio field. This fixes animated images that started moving immediately when `fields.audio` pointed to `SentenceAudio`. Existing animated images need to be regenerated to pick up the fix.
|
||||
- Sentence furigana on word cards stays in sync with the full stats-search context and with expanded timing review selections. Stale readings are cleared if regeneration fails.
|
||||
- Closing the overlay while media timing review is still loading now cancels the review, resumes playback if the review paused it, and cleans up the hidden preview player.
|
||||
- `ankiConnect.media.maxMediaDuration: 0` now means unlimited when mining from the stats dashboard, matching overlay mining.
|
||||
- Invalid AnkiConnect, Kiku, and Senren settings are now rejected with a warning and fall back to defaults.
|
||||
- **Stats Server Stability**:
|
||||
- A port conflict is now reported in a status notification instead of crashing SubMiner.
|
||||
- Simultaneous startup requests share one server start. Stopping the background server no longer disconnects dashboards open in the foreground.
|
||||
- Shutdown waits only a limited time for active requests to finish.
|
||||
- Malformed resource IDs, and ID lists with any invalid entries, are rejected before Library changes or cover backfills run.
|
||||
- **Subtitle Sidebar**:
|
||||
- Clicking a cue no longer leaves the row focused, and Space no longer seeks back to a focused cue. Enter still seeks to the focused cue, and Space keeps its configured playback action.
|
||||
- The sidebar stays near the current playback position during gaps when the subtitle file has a cue that starts at zero.
|
||||
- **Settings Save Feedback**:
|
||||
- Settings marked LIVE no longer show false restart warnings, including for notifications and subtitle generation.
|
||||
- When a save mixes live and restart-only changes, the live changes apply right away and only the changed sections that need a restart are listed.
|
||||
- **Overlay Windows**:
|
||||
- On Hyprland, recovery dialogs stay above SubMiner windows so overlay placement updates no longer cover their Wait and Close buttons.
|
||||
- On Linux, a delayed close callback during teardown can no longer reopen the overlay.
|
||||
- **First Launch on macOS**: SubMiner no longer exits on first launch when the config directory does not exist yet.
|
||||
|
||||
**Docs**
|
||||
- **Launcher**: Documented the launcher install that uses the bundled runtime, migration from legacy launchers, package-managed updates, and the bundled Bun runtime's MIT and LGPL notices. The AUR package installs these notices under `/usr/share/licenses/subminer-bin`, and they are also included in `subminer-assets.tar.gz`.
|
||||
- **Subtitle Generation**: Documented model choice, VAD behavior, splitting guided by a reference track, fallback behavior, and known limits.
|
||||
- **Subtitle Selection**: Documented the subtitle selector setting, its shortcut override, and the primary and secondary track controls.
|
||||
- **Settings**: Clarified save feedback for live settings, warnings for saves that mix live and restart-only changes, and how subtitle generation settings reload.
|
||||
- **Jellyfin**:
|
||||
- Clarified that Windows mpv playback and Jellyfin casting can use a configured executable path instead of PATH.
|
||||
- Documented how Jellyfin media titles and stats identities keep stream credentials out of metadata.
|
||||
- **Stats Library**:
|
||||
- Documented TMDB linking, provider reassignment, merge compatibility, and caching of the credential command's output.
|
||||
- Documented YouTube channel filtering and video statistics in the Library.
|
||||
- **Mining**:
|
||||
- Documented choosing the screenshot separately in media timing review.
|
||||
- Documented the separate word audio field mapping, including that existing animated images need to be regenerated.
|
||||
- **Sync**: Documented compressed transfers, where the incremental sync cache is stored, and compatibility with older peers.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
**Internal**
|
||||
- Removed duplicate source and launcher smoke runs from the reusable CI quality gate. Every distinct test lane and failure artifact is kept.
|
||||
- Replaced mislabeled dist source reruns with a small Electron-runtime smoke check. It covers compiled stats startup, the HTTP service, native SQLite, port conflicts, and cleanup.
|
||||
|
||||
</details>
|
||||
|
||||
## Previous Versions
|
||||
|
||||
<details>
|
||||
<summary>v0.19.x</summary>
|
||||
|
||||
<h2>v0.19.6 (2026-09-04)</h2>
|
||||
|
||||
**Added**
|
||||
|
||||
@@ -31,7 +157,7 @@
|
||||
- **Jellyfin Subtitle Sync**: Jellyfin subtitle files now load with zero mpv delay instead of inferring and saving an offset from Japanese and English cue timelines.
|
||||
- **Secondary Subtitle Visibility**: Native mpv secondary subtitles stay hidden when switching secondary subtitle tracks during playback.
|
||||
|
||||
## v0.19.5 (2026-08-30)
|
||||
<h2>v0.19.5 (2026-08-30)</h2>
|
||||
|
||||
**Fixed**
|
||||
|
||||
@@ -46,7 +172,7 @@
|
||||
- Long speech is paged instead of covering the video with a wall of text.
|
||||
- Explicitly timed sound cues like `[音楽]` no longer cover later dialogue.
|
||||
|
||||
## v0.19.4 (2026-08-25)
|
||||
<h2>v0.19.4 (2026-08-25)</h2>
|
||||
|
||||
**Added**
|
||||
- **Library Merge & Move**: Duplicate library cards for the same show can now be combined. Select cards in the library grid and use "Merge Selected" to pick which entry to keep and move every episode onto it, preserving sessions, mined cards, and watch time. Episodes can also be reassigned individually via the "→" button, useful when a file lands under a stray title; manual assignments survive later filename parsing, Jellyfin refreshes, and season repair. Exact AniList title matches with compatible seasons now merge automatically, while fuzzy matches surface as dismissible "Possible duplicate" reviews instead of merging silently.
|
||||
@@ -87,7 +213,7 @@
|
||||
|
||||
</details>
|
||||
|
||||
## v0.19.3 (2026-08-13)
|
||||
<h2>v0.19.3 (2026-08-13)</h2>
|
||||
|
||||
**Added**
|
||||
- Changelog Modal: Adds an in-app changelog you can open from the tray ("View Changelog") or the "What's New" button on the update notification, so the notification stays reachable while you read. It shows the newest published release notes (falling back to the bundled changelog if that fetch fails), folds older versions while keeping the current one expanded, and supports keyboard navigation (`J`/`K`/arrows, `Enter`, `R`, `Esc`).
|
||||
@@ -111,7 +237,7 @@
|
||||
|
||||
</details>
|
||||
|
||||
## v0.19.2 (2026-08-04)
|
||||
<h2>v0.19.2 (2026-08-04)</h2>
|
||||
|
||||
**Changed**
|
||||
- Subsync: The sync modal now lets you choose both the reference subtitle (correct timing) and the out-of-sync subtitle to retime, for both alass and ffsubsync. alass can also use the loaded video's audio as a reference for local files. Retiming the secondary track now reloads the result into the secondary slot instead of overwriting the primary subtitle.
|
||||
@@ -129,7 +255,7 @@
|
||||
|
||||
</details>
|
||||
|
||||
## v0.19.1 (2026-08-01)
|
||||
<h2>v0.19.1 (2026-08-01)</h2>
|
||||
|
||||
**Added**
|
||||
- Word Card Type: Adds a setting (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), `click`, `sentence`, `audio`, or `none`. Click cards (`IsClickCard`) can now be flagged, and setting any card-type flag clears the others so a note can't claim two types at once.
|
||||
@@ -138,7 +264,7 @@
|
||||
- Yomitan Popup: Fixes the macOS Yomitan popup going inert after mining a card — clicks outside the popup no longer pass through to mpv, and scrolling over the popup scrolls its definitions instead of seeking playback.
|
||||
- YouTube Playlist Links: Fixes opening a video from a playlist URL (e.g. a Watch Later link with `list=`/`index=`) timing out while probing subtitles, metadata, or the playback URL.
|
||||
|
||||
## v0.19.0 (2026-07-29)
|
||||
<h2>v0.19.0 (2026-07-29)</h2>
|
||||
|
||||
**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.
|
||||
@@ -174,7 +300,7 @@
|
||||
|
||||
</details>
|
||||
|
||||
## Previous Versions
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v0.18.x</summary>
|
||||
|
||||
+43
-44
@@ -61,7 +61,7 @@ The Settings window preserves existing JSONC comments, trailing commas, and unre
|
||||
|
||||
Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `jimaku.apiKeyCommand` when available.
|
||||
|
||||
Saving validates the candidate config before writing. Live-reloadable changes are applied immediately; other changes return a restart-required banner in the window.
|
||||
Saving validates the candidate config before writing. Saving only fields marked **LIVE** shows "Saved. Live settings applied." If a save also changes fields that need a restart, the banner lists only the sections containing those changed fields. Live changes still apply in the same save.
|
||||
|
||||
## Configuration file
|
||||
|
||||
@@ -103,7 +103,7 @@ SubMiner watches the active config file (`config.jsonc` or `config.json`) while
|
||||
|
||||
Hot-reloadable settings include subtitle appearance, sidebar controls, keybindings,
|
||||
shortcuts, notifications, logging level, selected source-language preferences,
|
||||
Jimaku/Subsync settings, AniSkip settings (`mpv.aniskipEnabled`, `mpv.aniskipButtonKey`),
|
||||
Jimaku/Subsync and subtitle-generation settings, AniSkip settings (`mpv.aniskipEnabled`, `mpv.aniskipButtonKey`),
|
||||
stats keys (`stats.toggleKey`, `stats.markWatchedKey`), the secondary-subtitle default
|
||||
mode, and the Anki deck, known-word, N+1, field, sentence-card, and Kiku options
|
||||
listed in the reference tables below.
|
||||
@@ -154,9 +154,9 @@ The configuration file includes several main sections:
|
||||
|
||||
**External Integrations**
|
||||
|
||||
- [**Anime Browser**](#anime-browser) - Extension repositories and stream preferences for the anime browser
|
||||
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
||||
- [**TsukiHime**](#tsukihime) - Multi-language subtitle search and download
|
||||
- [**TMDB**](#tmdb) - Posters and synopses for live-action dramas and movies in the stats Library
|
||||
- [**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
|
||||
@@ -591,7 +591,6 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
||||
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
|
||||
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
|
||||
| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser |
|
||||
| `Ctrl+Alt+KeyA` | `["__anime-browser-open"]` | Toggle Anime Browser in the player |
|
||||
| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
|
||||
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
|
||||
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
|
||||
@@ -633,7 +632,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
||||
{ "key": "Space", "command": null }
|
||||
```
|
||||
|
||||
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__anime-browser-open` opens the Anime Browser inside the player bounds. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
|
||||
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
|
||||
|
||||
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
|
||||
|
||||
@@ -978,6 +977,7 @@ This example is intentionally compact. The option table below documents availabl
|
||||
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. |
|
||||
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
||||
| `fields.audio` | string | Card field for the generated sentence audio clip (default: `ExpressionAudio`). Set this to a dedicated field such as `SentenceAudio` so it does not collide with the word audio Yomitan writes. |
|
||||
| `fields.wordAudio` | string | Existing word-audio field read for the animated image's opening freeze. Independent of the sentence-audio destination in `fields.audio`; this mapping does not write audio. See [config.example.jsonc](/config.example.jsonc) for defaults. |
|
||||
| `fields.image` | string | Card field for images (default: `Picture`) |
|
||||
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
|
||||
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
|
||||
@@ -998,7 +998,7 @@ This example is intentionally compact. The option table below documents availabl
|
||||
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
|
||||
| `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. |
|
||||
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
|
||||
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
|
||||
| `media.maxMediaDuration` | number (seconds) | Maximum generated clip duration for overlay and stats-dashboard mining. See the [configuration example](/config.example.jsonc) for the default and disabling the cap. |
|
||||
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
|
||||
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) |
|
||||
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
|
||||
@@ -1117,37 +1117,16 @@ When the manual merge popup opens, SubMiner pauses playback and closes any open
|
||||
|
||||
<a :href="withBase('/assets/kiku-integration.webm')" target="_blank" rel="noreferrer">Open demo in a new tab</a>
|
||||
|
||||
## Subtitle Selection
|
||||
|
||||
Enable **Settings → Behavior → Subtitle Selection → Enabled** to choose mpv's primary and secondary subtitle tracks from a SubMiner modal. The feature is disabled by default. The dialog uses the same overlay focus and subtitle suppression behavior as the other modals.
|
||||
|
||||
Press `g` then `s` to open it. Both selectors include **None**. Choose different tracks and click **Apply** to load them into mpv, or close the dialog to keep the current selection. Embedded and already-loaded external subtitle tracks are listed with their title, language, and codec when available.
|
||||
|
||||
`subtitleSelection.enabled` controls the feature. `shortcuts.openSubtitleSelection` changes its shortcut, or accepts `null` to unbind it. Enabling the feature overrides mpv's binding for that shortcut when its first key is free; disabling it restores mpv's binding. Existing single-key actions take priority over sequences; see [shortcut conflicts](/shortcuts). Both settings apply immediately. See the [generated configuration example](/config.example.jsonc) for defaults.
|
||||
|
||||
## External integrations
|
||||
|
||||
### Anime Browser
|
||||
|
||||
Sources for the [anime browser](/anime-browser). SubMiner ships no extension repositories and bundles no sources, so these are empty until you add one:
|
||||
|
||||
```json
|
||||
{
|
||||
"anime": {
|
||||
"autoOpenJimaku": false,
|
||||
"extensionsDir": "",
|
||||
"repos": [],
|
||||
"preferredQuality": "",
|
||||
"defaultSource": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ------------------------ | ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `anime.autoOpenJimaku` | `boolean` | `false` | Pause Anime Browser playback and open Jimaku when an episode loads. Playback resumes after a subtitle loads or the modal closes. Playback that was already paused stays paused. |
|
||||
| `anime.extensionsDir` | `string` | `""` | Directory holding Aniyomi extension `.apk` files. Empty uses `<userData>/anime-extensions`. |
|
||||
| `anime.repos` | `string[]` | `[]` | Extension repository index URLs. Any `https` URL ending in `.json` works; `index.min.json` is only the common name. |
|
||||
| `anime.preferredQuality` | `string` | `""` | Preferred stream quality label, matched as a substring (for example `1080`). Empty keeps the source's own order. A Japanese-audio entry always outranks a higher-quality dub. |
|
||||
| `anime.defaultSource` | `string` | `""` | Source the Anime Browser selects when it opens: a source id (`<package>:<source>`) or `all` for every installed source. Empty selects the first installed source. **Set default** in the Extensions tab writes this value. |
|
||||
| `anime.bridgeDir` | `string` | `""` | Directory holding an M-Extension-Server bundle (Java runtime plus server jar) to run instead of the downloaded copy. Empty checks the package-manager install first, then `<userData>/anime-bridge`. See [the bridge](anime-browser.md#the-bridge). |
|
||||
|
||||
Repositories added from the browser's Extensions tab are written back to `anime.repos`, so the list can also be kept in a dotfile. Changes apply the next time the anime browser opens.
|
||||
|
||||
Per-source settings (server addresses, credentials, per-extension quality or language options) are not part of `config.jsonc`. They belong to the extension, are edited in the browser's **Source settings** tab, and persist in `<userData>/anime-source-preferences.json` with owner-only permissions.
|
||||
|
||||
### Jimaku
|
||||
|
||||
Configure Jimaku API access and defaults:
|
||||
@@ -1188,6 +1167,32 @@ The keyboard shortcut lives under `shortcuts.openTsukihime` (default `Ctrl+Shift
|
||||
|
||||
See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, language tabs, and troubleshooting.
|
||||
|
||||
### TMDB
|
||||
|
||||
TMDB (The Movie Database) supplies posters, synopses, and show grouping for live-action dramas and movies in the stats [Library](/immersion-tracking#library). AniList only covers anime, so TMDB is what gives live-action titles a cover and a description.
|
||||
|
||||
Release builds ship with a project TMDB key, so nothing needs to be configured. Set your own key to use your own quota, or when running SubMiner from source, where no key is bundled. Create one for free under **Settings > API** on [themoviedb.org](https://www.themoviedb.org/settings/api); either the short API key or the long "API Read Access Token" works.
|
||||
|
||||
```json
|
||||
{
|
||||
"tmdb": {
|
||||
"apiKey": "",
|
||||
"apiKeyCommand": "cat ~/.tmdb_key"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| -------------------- | ------ | -------------------------------------------------------------------------------------------------- |
|
||||
| `tmdb.apiKey` | string | Your own TMDB API key or read access token; overrides the bundled key (default: empty) |
|
||||
| `tmdb.apiKeyCommand` | string | Shell command that prints the key to stdout, used instead of `apiKey` to keep it out of the config |
|
||||
|
||||
Successful `apiKeyCommand` output is cached for the running client until `tmdb.apiKey` or `tmdb.apiKeyCommand` changes. Failed or empty command output uses the bundled key when available and waits 30 seconds before the next request can retry the command. Changing either credential setting resets this cooldown.
|
||||
|
||||
Changes apply to the next TMDB request without a restart.
|
||||
|
||||
This product uses the TMDB API but is not endorsed or certified by TMDB.
|
||||
|
||||
### Japanese subtitle generation
|
||||
|
||||
Open the standalone modal with `Ctrl+Shift+G`, configurable through `shortcuts.openSubtitleGeneration`, or use the subtitle sidebar button. See [shortcuts](/shortcuts) for the shared mpv and overlay keybindings.
|
||||
@@ -1216,17 +1221,11 @@ Sync a subtitle track from the overlay picker using `alass` or `ffsubsync`. The
|
||||
|
||||
| Option | Values | Description |
|
||||
| ---------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `alass_path` | string path | Path to `alass` executable. Empty auto-discovers `alass` or `alass-cli`. `alass` must be installed separately. |
|
||||
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty auto-discovers `ffsubsync`. `ffsubsync` must be installed separately. |
|
||||
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` auto-discovers `ffmpeg`. |
|
||||
| `alass_path` | string path | Path to `alass` executable. Empty falls back to `/usr/bin/alass`. `alass` must be installed separately. |
|
||||
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty falls back to `/usr/bin/ffsubsync`. `ffsubsync` must be installed separately. |
|
||||
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
|
||||
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
|
||||
|
||||
Auto-discovery searches `PATH`, then the usual install prefixes (`/opt/homebrew/bin`, `/usr/local/bin`, `/opt/local/bin`, `/usr/bin`, `/bin`) — a GUI launch inherits a minimal `PATH` that often omits the first two. Set the option explicitly if your binary lives elsewhere.
|
||||
|
||||
Subtitle tracks that mpv loaded from a URL (Aniyomi extension streams, Jellyfin) are downloaded to a temporary file first, reusing mpv's own request headers, so they can be used as either the sync target or the alass reference.
|
||||
|
||||
Stats dashboard sentence mining also uses `alass_path` when available to align a local English sidecar against the local Japanese sidecar before filling the card translation field. This stats-only retime writes a temporary cached copy and never edits the original subtitle files.
|
||||
|
||||
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
||||
Customize it there, or set it to `null` to disable.
|
||||
|
||||
|
||||
@@ -31,12 +31,42 @@ Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_
|
||||
|
||||
The same immersion data powers the stats dashboard.
|
||||
|
||||
The browser dashboard and in-app stats overlay both load from the local HTTP server.
|
||||
The server accepts loopback hosts only and rejects requests from other browser origins,
|
||||
including opaque origins such as `file://`. API clients without a browser origin can
|
||||
still use the local API. Mutation requests with a body must use `application/json`;
|
||||
bodyless deletion and Anki browse requests remain supported. Requests rejected by the
|
||||
host or origin checks receive `403`; mutation bodies without a JSON content type
|
||||
receive `415`.
|
||||
|
||||
Use the loopback dashboard URL directly. Reverse-proxied dashboards and Tailscale
|
||||
Serve URLs are unsupported because their host or browser origin is not the local
|
||||
server's origin. SSH stats synchronization is unchanged.
|
||||
|
||||
Scripts sending a JSON body must include the content type. For example, this
|
||||
requests a duplicate-line cleanup preview without changing the database. Replace
|
||||
the port if you configured a different `stats.serverPort`:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:6969/api/stats/maintenance/duplicate-lines \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"dryRun":true}'
|
||||
```
|
||||
|
||||
- In-app overlay: focus the visible overlay, then press the key from `stats.toggleKey` (default: `` ` `` / `Backquote`).
|
||||
- Launcher command: run `subminer stats` to start the local stats server on demand (it also opens the dashboard in your browser when `stats.autoOpenBrowser` is enabled; the default is `false`).
|
||||
- Background server: run `subminer stats -b` to start or reuse a dedicated background stats daemon without keeping the launcher attached, and `subminer stats -s` to stop that daemon.
|
||||
- Maintenance commands: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand; `subminer stats cleanup -l` repairs lifetime summary tables non-destructively (recomputed from per-episode history, so lifetime totals older than the session retention window are kept); `subminer stats cleanup --duplicate-lines` collapses repeated lines left behind by typeset subtitles (see [Repeated Line Cleanup](#repeated-line-cleanup)). `subminer stats rebuild` and `subminer stats backfill` rebuild or backfill rollup data.
|
||||
- Browser page: open `http://127.0.0.1:6969` directly if the local stats server is already running.
|
||||
|
||||
SubMiner waits for the local server to bind before reporting that the dashboard is available. If another process already uses the configured port, the command reports the startup error and the desktop app stays open. Opening the in-app dashboard also reports startup failures through your configured status notifications.
|
||||
|
||||
`subminer stats -s` stops a background stats server or cancels a pending background start. It leaves a foreground-only server running, so an open in-app dashboard stays connected. Shutdown gives active HTTP requests one second to finish before closing their connections and finalizing stats.
|
||||
|
||||
### Stats API resource IDs
|
||||
|
||||
Resource IDs in URLs must be positive safe integers written as decimal digits without leading zeros, fractions, or exponent notation. ID lists in JSON bodies must contain positive safe integer numbers. Invalid IDs or list entries return `400` before any mutation; bulk requests do not apply just the valid subset. Pagination limits keep their existing rounding and bounds.
|
||||
|
||||
### Dashboard tabs
|
||||
|
||||
#### Overview
|
||||
@@ -53,14 +83,16 @@ Local files and Jellyfin items with detected season numbers are split into seaso
|
||||
|
||||
When older stats already grouped multiple seasons under one series entry, SubMiner moves parsed episodes into the season-specific entries on startup and rebuilds the affected summaries.
|
||||
|
||||
**Live-action dramas and movies.** Anime covers come from AniList, which has no live-action titles. A title that AniList cannot match is looked up on [TMDB](/configuration#tmdb) instead (release builds bundle a key; source builds need your own): only a Japanese-language, non-animated result whose known titles match the parsed filename exactly is accepted, and it supplies the poster, synopsis, English and Japanese titles, and episode count. If nothing matches automatically, open the title and use **Link to TMDB** to search and pick it by hand. A TMDB show spans all of its seasons, so entries that resolve to the same TMDB title are merged into one card regardless of the season folder they came from, and the merged season titles are remembered so later episodes land on the same card. The **All Titles** / **Anime** / **Live Action** / **YouTube** selector above the grid narrows the Library to one kind, and a title's detail view shows whether it is a drama or a movie. Linking a title to AniList again turns it back into an anime entry. Changing providers downloads the replacement cover before saving the new link; a failed download leaves the previous link and artwork intact. A title without a cover clears the previous artwork. Automatic TMDB matching leaves existing AniList links unchanged.
|
||||
|
||||
Jellyfin stream URLs are normalized to stable item links before stats titles are shown, so playback query parameters are not displayed in the dashboard.
|
||||
|
||||
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.
|
||||
When YouTube channel metadata is available, the Library tab groups videos by creator/channel. Use the kind selector above the grid (**All Titles**, **Anime**, **Live Action**, **YouTube**) to filter the library. Channel pages show tracked videos and their stats without AniList controls. Existing channel entries are classified as YouTube automatically on startup, preserving viewing history and manual video assignments. Anime and YouTube entries with the same normalized title remain separate, including during stats sync. Channels are excluded from anime metadata matching, season repair, and duplicate recommendations.
|
||||
|
||||
A library entry is identified by its parsed title plus any detected season, so the same show can end up on several cards when releases disagree about the title or omit the season tag. Two fixes are available:
|
||||
|
||||
- **Merge duplicates.** Hit **Select** above the grid, tick the cards that are the same show, and choose **Merge Selected**. Pick which entry to keep in the dialog; every episode moves onto it and the other cards are removed. Nothing is deleted, so sessions, mined cards and watch time all carry over. SubMiner remembers the merged title variants, so future episodes parsed with one of those names join the kept entry instead of recreating a duplicate card.
|
||||
- **Move a single episode.** Hover an episode row in a title's episode list and use the **→** button to reassign it to another library entry. The correction is remembered, so later filename parsing or Jellyfin metadata cannot move that episode back. For local files, later episodes in the same directory inherit the correction when their detected seasons are compatible and every manual correction there points to the same entry; a file that parses to a title which already has its own library entry keeps that identity instead. Conflicting seasons or manual destinations are left for review. If the move empties the old entry, that card is removed and you are returned to the grid.
|
||||
- **Merge duplicates.** Hit **Select** above the grid, tick the cards that are the same show, and choose **Merge Selected**. Pick which entry to keep in the dialog; every episode moves onto it and the other cards are removed. Nothing is deleted, so sessions, mined cards and watch time all carry over. AniList-linked and TMDB-linked entries cannot be merged together, and YouTube channels cannot be merged with anime or live-action entries. SubMiner remembers the merged title variants, so future episodes parsed with one of those names join the kept entry instead of recreating a duplicate card.
|
||||
- **Move a single episode.** Hover an episode row in a title's episode list and use the **→** button to reassign it to another library entry. Anime and live-action entries are interchangeable here, but a YouTube video can only move between channels. The correction is remembered, so later filename parsing or Jellyfin metadata cannot move that episode back. For local files, later episodes in the same directory inherit the correction when their detected seasons are compatible and every manual correction there points to the same entry; a file that parses to a title which already has its own library entry keeps that identity instead. Conflicting seasons or manual destinations are left for review. If the move empties the old entry, that card is removed and you are returned to the grid.
|
||||
|
||||
Once cover art resolves a series to an AniList entry, cards with compatible seasons are folded together automatically only when the searched title exactly matches an AniList title or synonym. A fuzzy result that points at an AniList entry already used by another card appears as a **Possible duplicate** review above the Library grid instead. Choose **Review merge** to compare the cards and pick which one to keep, or **Not duplicates** to dismiss that suggestion permanently. Entries with conflicting explicit season numbers are left alone rather than merged or suggested.
|
||||
|
||||
@@ -120,7 +152,7 @@ Stats server config lives under `stats`:
|
||||
|
||||
The Search tab and the Vocabulary tab's word detail panel both mine from subtitle lines in your viewing history. Search matches sentence text and media titles, and **Search by headword** is enabled by default so dictionary-form searches such as `知らない` can find tracked subtitle lines with inflected variants. Turn that toggle off for exact text/title matching only. Each line with a valid source file offers sentence-card mining; word/audio mining is available when the selected word or searched word appears in the sentence:
|
||||
|
||||
- **Mine Word** - performs a full Yomitan dictionary lookup for the word (definition, reading, pitch accent, etc.) via a short-lived hidden helper, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, and metadata extracted from the source video file. Requires Anki and Yomitan dictionaries to be loaded.
|
||||
- **Mine Word** - performs a full Yomitan dictionary lookup for the word (definition, reading, pitch accent, etc.) via a short-lived hidden helper, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, full-sentence readings in `SentenceFurigana` when that field exists, and metadata extracted from the source video file. Requires Anki and Yomitan dictionaries to be loaded.
|
||||
- **Mine Sentence** - creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio and image from the source video.
|
||||
- **Mine Audio** - creates an audio-only card with the `IsAudioCard` flag, attaching only the sentence audio clip.
|
||||
|
||||
@@ -327,14 +359,14 @@ LIMIT ?;
|
||||
- Large-table reads are index-backed for `sample_ms`, session time windows, frequency-ranked words/kanji, and cover-art identity lookups.
|
||||
- Workload-dependent tuning knobs remain at defaults unless you change them: `cache_size`, `mmap_size`, `temp_store`, `auto_vacuum`.
|
||||
|
||||
### Schema (v23)
|
||||
### Schema (v24)
|
||||
|
||||
The exact schema version lives in `SCHEMA_VERSION` (`src/core/services/immersion-tracker/types.ts`) and is recorded in the `imm_schema_version` table.
|
||||
|
||||
Core tables:
|
||||
|
||||
- `imm_videos` - video key/title/source metadata
|
||||
- `imm_anime` - anime/series metadata referenced by videos and lifetime tables
|
||||
- `imm_anime` - series or YouTube channel metadata referenced by videos and lifetime tables, including the media kind (`anime`, `live_action` or `youtube`) and the AniList or TMDB link
|
||||
- `imm_anime_title_aliases` - alternate titles that resolve to the same anime row
|
||||
- `imm_anime_merge_recommendations` - candidate duplicate-series merges surfaced in the dashboard
|
||||
- `imm_sessions` - session UUID, video reference, timing/status, final denormalized totals
|
||||
|
||||
@@ -120,10 +120,10 @@ pip install ffsubsync
|
||||
|
||||
Windows 10 or later. 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`.
|
||||
You need **mpv** (required) and **ffmpeg** (strongly recommended, for card audio and screenshots). Put mpv on `PATH` or set `mpv.executablePath` during setup. ffmpeg must be on `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.
|
||||
`PATH` is the list of folders Windows searches when a program asks to run another program by name. SubMiner uses it to find ffmpeg and, unless an executable path is configured, mpv. 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:
|
||||
@@ -221,10 +221,6 @@ Install [`subminer-bin`](https://aur.archlinux.org/packages/subminer-bin) from t
|
||||
paru -S subminer-bin
|
||||
```
|
||||
|
||||
For the [anime browser](anime-browser.md#the-bridge), optionally add
|
||||
`mangatan-extension-server`. SubMiner picks the package up instead of
|
||||
downloading its own copy of the bridge, and pacman keeps it updated.
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -12,7 +12,7 @@ This is the recommended way to use Jellyfin with SubMiner. A terminal-only optio
|
||||
|
||||
## Requirements
|
||||
|
||||
- A Jellyfin server plus your username and password
|
||||
- A Jellyfin server plus your username and password (Jellyfin 12, which disables legacy authorization by default, is supported)
|
||||
- SubMiner installed and running (see [Installation](/installation))
|
||||
- On Linux, the session token is stored with `gnome-libsecret` by default
|
||||
|
||||
@@ -50,12 +50,16 @@ From then on, pause / resume / seek / stop and audio or subtitle track changes y
|
||||
## What happens during playback
|
||||
|
||||
- **mpv launches automatically.** If mpv isn't already running when you cast, SubMiner starts it with SubMiner defaults and the bundled mpv plugin, so keybindings work right away.
|
||||
- **Windows respects your mpv settings.** Casting checks `mpv.executablePath`, then `SUBMINER_MPV_PATH`, then `PATH`. An invalid configured path prevents automatic startup.
|
||||
- **The overlay is managed by SubMiner,** so your configured `subtitleStyle` controls how subtitles look. Use the [overlay-toggle shortcut](/shortcuts) to hide it for a session.
|
||||
- **Resume works.** If Jellyfin has a saved position for the item, SubMiner seeks there on load.
|
||||
- **Titles and credentials stay separate.** AniList, character dictionaries, Anki source fields, and Discord presence use media titles, never authenticated stream URLs. If a usable title is unavailable, lookups are skipped and source fields show an unknown-media label. Stats identifies Jellyfin videos by server and item ID without the stream URL or API key.
|
||||
- **Direct play first.** When the source allows it and the container is in your direct-play allowlist, SubMiner streams the original file; otherwise it requests a transcoded stream from Jellyfin.
|
||||
- **Japanese subtitles are auto-selected,** preferring Jellyfin's default and embedded tracks over external sidecar files when several match.
|
||||
- **Downloaded subtitles keep their original timing.** SubMiner removes Jellyfin's server-selected subtitle stream from the mpv load URL, suppresses the mpv plugin's one-shot subtitle auto-selection and overlay auto-start for managed Jellyfin loads, stages the subtitle files exposed by Jellyfin without letting mpv auto-switch between tracks, resets mpv's subtitle delay to zero, then selects the Japanese track. SubMiner does not compare Japanese and English cue timelines or save an inferred delay.
|
||||
|
||||
On startup, SubMiner clears cached anime parser metadata containing both API-key text and Jellyfin stream markers. Metadata containing only one of these is preserved.
|
||||
|
||||
## Settings
|
||||
|
||||
All Jellyfin options live under **Settings → Integrations → Jellyfin** (open settings from the tray's **Open SubMiner Settings**). The ones that matter for casting:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Jimaku integration
|
||||
|
||||
[Jimaku](https://jimaku.cc) is a community subtitle repository for anime, built from files other learners uploaded. SubMiner talks to the Jimaku API, so you search, browse, and download Japanese subtitle files from inside the overlay. No alt-tabbing, no moving files around. A downloaded track loads into mpv right away.
|
||||
[Jimaku](https://jimaku.cc) is a community subtitle repository for anime and Japanese live action, built from files other learners uploaded. SubMiner talks to the Jimaku API, so you search, browse, and download Japanese subtitle files from inside the overlay. No alt-tabbing, no moving files around. A downloaded track loads into mpv right away.
|
||||
|
||||
::: tip Prerequisite: a free API key
|
||||
You need a Jimaku account and an API key (a personal access string) before this feature works. Create an account at [jimaku.cc](https://jimaku.cc), copy your key, and add it to your config as shown under [Configuration](#configuration) below. Without a key, the search modal will report "Jimaku API key not set."
|
||||
@@ -14,21 +14,23 @@ When you open the modal, SubMiner parses the current video filename to extract a
|
||||
|
||||
From there:
|
||||
|
||||
1. **Search** - SubMiner queries the Jimaku API with the parsed title. Results appear as a list of anime entries (Japanese and English names).
|
||||
2. **Browse entries** - Select an entry to load its available subtitle files, filtered by episode if one was detected.
|
||||
3. **Browse files** - Files show name, size, and last-modified date. If a language preference is configured, files are sorted accordingly (e.g., Japanese-tagged files first).
|
||||
4. **Download** - Selecting a file downloads it to the same directory as the video (or a temp directory for remote/streamed media) and loads it into mpv as a new subtitle track.
|
||||
1. **Pick a catalogue** - The **Anime** and **Live action** tabs at the top of the modal choose which Jimaku catalogue to search. Switching tabs re-runs the current search. The choice persists until SubMiner restarts.
|
||||
2. **Search** - SubMiner queries the Jimaku API with the parsed title. Results appear as a list of entries (Japanese and English names).
|
||||
3. **Browse entries** - Select an entry to load its available subtitle files, filtered by episode if one was detected.
|
||||
4. **Browse files** - Files show name, size, and last-modified date. If a language preference is configured, files are sorted accordingly (e.g., Japanese-tagged files first).
|
||||
5. **Download** - Selecting a file downloads it to the same directory as the video (or a temp directory for remote/streamed media) and loads it into mpv as a new subtitle track.
|
||||
|
||||
If no files match the current episode filter, a "Show all files" button lets you broaden the search to all episodes for that entry.
|
||||
|
||||
### 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 |
|
||||
| `Arrow Left` / `Arrow Right` | Switch between the Anime and Live action tabs |
|
||||
| `Escape` | Close modal |
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -52,7 +54,7 @@ Add a `jimaku` section to your `config.jsonc`:
|
||||
| `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. |
|
||||
| `jimaku.maxEntryResults` | `number` | `10` | Maximum number of entries returned per search. |
|
||||
|
||||
The keyboard shortcut is configured separately under `shortcuts`:
|
||||
|
||||
@@ -79,8 +81,6 @@ SubMiner extracts media info from the current video path to pre-fill the search
|
||||
|
||||
- **Season + episode patterns:** `S01E03`, `1x03`
|
||||
- **Episode-only patterns:** `E03`, `EP03`, or dash-separated numbers like `Title - 03 -`
|
||||
- **Spelled-out episode labels:** `Episode 4`, `第4話`
|
||||
- **Season named at the end of the title:** `… Season 3`, `… 3rd Season`, `… S3`, `… 第3期` - the season goes in the Season field instead of being searched for as part of the series name
|
||||
- **Season folders:** a parent directory named `Season 2` or `S2` fills in the season when the filename lacks one
|
||||
- **Bracket tags:** `[SubGroup]`, `[1080p]`, `[HEVC]` - stripped before title extraction
|
||||
- **Year tags:** `(2024)` - stripped
|
||||
@@ -89,8 +89,6 @@ SubMiner extracts media info from the current video path to pre-fill the search
|
||||
|
||||
If the parser produces a high-confidence result (title + episode both detected), the search runs automatically when the modal opens. Otherwise, you can adjust the fields manually before searching.
|
||||
|
||||
Episodes launched from the [anime browser](/anime-browser) skip parsing entirely: Title, Season, and Episode come from the source's own listing, which is why the search runs immediately even though the stream URL says nothing about the episode.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Jimaku API key not set"**
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
*/
|
||||
{
|
||||
|
||||
// ==========================================
|
||||
// Subtitle Selection
|
||||
// Select primary and secondary mpv subtitle tracks from the overlay.
|
||||
// Hot-reload: enabling or disabling updates the session shortcut immediately.
|
||||
// ==========================================
|
||||
"subtitleSelection": {
|
||||
"enabled": false // Use the SubMiner modal to select primary and secondary subtitle tracks. When enabled, its shortcut overrides mpv subtitle selection. Values: true | false
|
||||
}, // Select primary and secondary mpv subtitle tracks from the overlay.
|
||||
|
||||
// ==========================================
|
||||
// Japanese Subtitle Generation
|
||||
// Generate timed Japanese subtitles from local audio using whisper.cpp.
|
||||
@@ -223,6 +232,7 @@
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||
"openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).
|
||||
"openSubtitleSelection": "g-s", // Open subtitle selection when enabled. Use g-s to press g then s. Set null to unbind.
|
||||
"openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal.
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||
@@ -359,12 +369,6 @@
|
||||
"__playlist-browser-open"
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Ctrl+Alt+KeyA", // Key setting.
|
||||
"command": [
|
||||
"__anime-browser-open"
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Ctrl+Shift+KeyH", // Key setting.
|
||||
"command": [
|
||||
@@ -547,7 +551,7 @@
|
||||
// ==========================================
|
||||
// AnkiConnect Integration
|
||||
// Automatic Anki updates and media generation options.
|
||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/wordAudio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.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.
|
||||
// ==========================================
|
||||
@@ -568,6 +572,7 @@
|
||||
"fields": {
|
||||
"word": "Expression", // Card field for the mined word or expression text.
|
||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||
"wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.
|
||||
"image": "Picture", // Card field that receives the captured screenshot or animated image.
|
||||
"sentence": "Sentence", // Card field that receives the source sentence text.
|
||||
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
|
||||
@@ -596,7 +601,7 @@
|
||||
"reviewTiming": false, // Review and preview subtitle media timing before SubMiner creates or enriches a mined card. Values: true | false
|
||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. 0 disables the cap.
|
||||
}, // Media setting.
|
||||
"knownWords": {
|
||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||
@@ -641,21 +646,6 @@
|
||||
} // 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: autoOpenJimaku applies to the next episode; other anime changes apply the next time the anime browser opens.
|
||||
// ==========================================
|
||||
"anime": {
|
||||
"autoOpenJimaku": false, // Pause Anime Browser playback and open Jimaku when an episode loads. Playback resumes after a subtitle loads or the modal closes only when auto-open initiated the pause. Values: true | false
|
||||
"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.
|
||||
"defaultSource": "", // Source the Anime Browser selects when it opens: a source id (<package>:<source>) or "all" for every installed source. Empty selects the first installed source. "Set default" in the Extensions tab writes this value.
|
||||
"bridgeDir": "" // Directory holding an M-Extension-Server bundle (java runtime plus server jar) to run instead of the copy SubMiner downloads. Empty checks the package-manager install (Arch: mangatan-extension-server), then <userData>/anime-bridge.
|
||||
}, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
|
||||
|
||||
// ==========================================
|
||||
// Jimaku
|
||||
// Jimaku API configuration and defaults.
|
||||
@@ -679,6 +669,16 @@
|
||||
"maxSearchResults": 10 // Maximum TsukiHime search results returned.
|
||||
}, // TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.
|
||||
|
||||
// ==========================================
|
||||
// TMDB
|
||||
// TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.
|
||||
// Hot-reload: TMDB changes apply to the next TMDB request.
|
||||
// ==========================================
|
||||
"tmdb": {
|
||||
"apiKey": "", // Your own TMDB API key or read access token for live-action posters and synopses in the stats Library. Release builds bundle a project key, so set this only to use your own quota or when running from source (free under Settings > API on themoviedb.org).
|
||||
"apiKeyCommand": "" // Shell command that prints the TMDB API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
|
||||
}, // TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.
|
||||
|
||||
// ==========================================
|
||||
// YouTube Playback Settings
|
||||
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||
|
||||
@@ -51,7 +51,6 @@ These control playback and subtitle display. They require overlay window focus.
|
||||
| `J` | Cycle primary subtitle track |
|
||||
| `Shift+J` | Cycle secondary subtitle track |
|
||||
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
|
||||
| `Ctrl+Alt+A` | Toggle Anime Browser inside the player |
|
||||
| `ArrowRight` | Seek forward 5 seconds |
|
||||
| `ArrowLeft` | Seek backward 5 seconds |
|
||||
| `ArrowUp` | Seek forward 60 seconds |
|
||||
@@ -70,7 +69,7 @@ These control playback and subtitle display. They require overlay window focus.
|
||||
| `Right-click` | Toggle pause (outside subtitle area) |
|
||||
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
|
||||
|
||||
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-step/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V` and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right. The Anime Browser shortcut toggles a dedicated overlay modal bounded to the player; the standalone Anime Browser window remains independent, while active playback is shared.
|
||||
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-step/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V` and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
|
||||
|
||||
On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions.
|
||||
|
||||
@@ -89,6 +88,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
||||
| `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` |
|
||||
| `g` then `s` | Select primary and secondary subtitles, when enabled | `shortcuts.openSubtitleSelection` |
|
||||
| `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) |
|
||||
@@ -99,6 +99,8 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
||||
|
||||
The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`.
|
||||
|
||||
Enable the subtitle selector in **Settings → Behavior → Subtitle Selection**. Its shortcut overrides mpv subtitle selection only while enabled. In the focused overlay, press the second key within one second. Single-key bindings take priority: if `g` already has an action in SubMiner or mpv, `g-s` is disabled with a conflict warning, and `g` still runs immediately. Remap the sequence or remove the conflicting single-key binding. The existing `y` prefix is reserved for its built-in commands. mpv bindings are checked on connection, configuration changes, and overlay focus; refresh the overlay after changing another script's bindings. See [subtitle selection](/configuration#subtitle-selection).
|
||||
|
||||
The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source.
|
||||
|
||||
In the sidebar, `Enter` seeks the keyboard-focused cue. `Space` keeps its configured playback action, normally pause/resume, even when a cue has focus.
|
||||
|
||||
@@ -29,11 +29,21 @@ With speech detection configured, SubMiner keeps detected speech and other audib
|
||||
|
||||
Passages that fit within Whisper's 30-second audio window stay intact. Longer passages prefer nearby detected speech starts when choosing cuts, falling back to quiet pauses, with a small overlap to provide context. This reduces early subtitles caused by starting a clip well before its dialogue, while keeping all retained audio covered. Matching overlapping cues are combined even when punctuation differs; repeated dialogue at separate times remains separate. SubMiner runs each passage in a fresh Whisper process so decoder state from earlier audio cannot affect later passages. This reloads the model for each passage and can increase generation time. Subtitle cues stay within the supplied audio and retain each passage's position on the original timeline. Progress reports the current passage.
|
||||
|
||||
This mode favors retaining dialogue over excluding music, so songs and background sounds may also produce subtitles. It can take longer than transcribing only VAD-approved speech. Whisper can still miss or misrecognize dialogue, and its timestamps remain estimates. Uncheck **Focus on spoken dialogue** to return to full-audio transcription for the session, or clear `vadModelPath` to change the default. A selected detector or model that fails stops generation with an error. Existing subtitles are preserved.
|
||||
This mode favors retaining dialogue over excluding music, so songs and background sounds may also produce subtitles. It can take longer than transcribing only VAD-approved speech. Whisper can still miss or misrecognize dialogue, and its timestamps remain estimates. Uncheck **Focus on spoken dialogue** to disable VAD for the session, or clear `vadModelPath` to change the default. Without a usable subtitle reference, disabling VAD returns to full-audio transcription. A selected detector or model that fails stops generation with an error. Existing subtitles are preserved.
|
||||
|
||||
## Using loaded subtitles as timing references
|
||||
|
||||
When generating for the video currently open in mpv, SubMiner automatically looks for a dialogue subtitle track among its embedded subtitles and loaded external SRT, ASS/SSA, or WebVTT files. It prefers English, then tracks labeled full or dialogue. Forced tracks, image subtitles, generated subtitles, and tracks whose titles or filenames identify signs, songs, lyrics, karaoke, or opening/ending subtitles are skipped. These checks rely on metadata; an unlabeled signs-only file cannot always be identified.
|
||||
|
||||
The selected reference appears in generation progress. SubMiner reads its timestamps, including the active primary or secondary track's subtitle delay, and uses nearby cue starts to guide cuts in long audio passages. Short passages stay intact. The reference works with or without **Focus on spoken dialogue**. With that option enabled, reference starts take priority over VAD starts when choosing a nearby cut; VAD still helps identify speech. Audio outside reference cues remains eligible for transcription, and Whisper still supplies the Japanese text and final timestamps. The reference is assumed to be timed for the playing video; this does not automatically sync a mistimed reference.
|
||||
|
||||
Unreadable or empty references are skipped in favor of another eligible loaded track. If none can be read, generation uses its normal audio timing. The launcher uses loaded references only when its input matches the video currently open in mpv; standalone generation keeps its existing behavior. It captures reference tracks and delays together with the initial audio selection, before checking or downloading a model. When relying on mpv's selected audio, it stops and asks you to retry if the media changes or cannot be verified during capture.
|
||||
|
||||
## Choosing a model
|
||||
|
||||
Start with **small** for a balance of Japanese recognition quality and CPU time. This is a general starting recommendation, not a benchmark for your hardware. Tiny and base need less memory and usually finish sooner, with more recognition errors. Medium and large models favor accuracy but need more resources. Large-v3-turbo is optimized for speed compared with large-v3, with some accuracy tradeoff; actual performance depends on your CPU, GPU, whisper.cpp build, and audio.
|
||||
The modal recommends **large-v3-turbo** when it detects an NVIDIA GPU through `nvidia-smi` and the selected `whisper-cli` discovers an available CUDA device. Otherwise it recommends **small** for a balance of Japanese recognition quality and CPU time. The check works before downloading a model and falls back to small if a tool is missing, fails, times out, or reports an unrecognized result. Vulkan, AMD, and Apple GPU support do not qualify for the turbo recommendation. Checks are cached for up to 30 seconds; changing the Whisper executable path triggers a new check.
|
||||
|
||||
The recommendation labels the model picker and explains the detected support. It does not change your configured model, current selection, external model path, or launcher's model choice. It also does not force a GPU backend during transcription. These are starting recommendations, not hardware benchmarks or guarantees that every model fits in available GPU memory. Tiny and base need less memory and usually finish sooner, with more recognition errors. Medium and large models favor accuracy but need more resources. Large-v3-turbo is optimized for speed compared with large-v3, with some accuracy tradeoff; actual performance depends on your CPU, GPU, whisper.cpp build, and audio.
|
||||
|
||||
The picker includes whisper.cpp's official multilingual tiny, base, small, medium, large-v1, large-v2, large-v3, and large-v3-turbo downloads, including their available quantized variants. Quantized models use less disk space and memory, with possible accuracy loss. English-only `.en` models are excluded. See the [upstream model list](https://github.com/ggml-org/whisper.cpp/blob/master/models/download-ggml-model.sh) and [Whisper's model guidance](https://github.com/openai/whisper#available-models-and-languages).
|
||||
|
||||
@@ -47,7 +57,7 @@ A configured external Model Path takes precedence and hides the managed model pi
|
||||
4. Optionally check **Focus on spoken dialogue** and click **Download speech detection model** if prompted.
|
||||
5. Click **Generate subtitles**.
|
||||
|
||||
The modal shows audio preparation, transcription, and saving progress. Percentages appear when the underlying tool reports them. **Cancel** stops the current operation. Closing the modal lets the job continue; reopening it shows the current progress or result.
|
||||
The modal adapts to the player window, using a wider layout when space allows and scrolling in smaller windows. It shows audio preparation, transcription, and saving progress. Percentages appear when the underlying tool reports them. **Cancel** stops the current operation. Closing the modal lets the job continue; reopening it shows the current progress or result.
|
||||
|
||||
**Escape** or **Close** closes the modal using the same focus and overlay restoration as other SubMiner modals. Change or disable its shortcut with `shortcuts.openSubtitleGeneration` in Settings. Ctrl+G remains assigned to field grouping.
|
||||
|
||||
|
||||
@@ -211,26 +211,21 @@ Resume playback and wait for the next subtitle to appear, then try mining again.
|
||||
|
||||
Both **alass** and **ffsubsync** are optional external dependencies. Subtitle syncing requires at least one of them to be installed.
|
||||
|
||||
Subsync writes to the application log under the `subsync` scope, so the full command failure — exit code, stderr, resolved file paths — is recorded there as well as on the OSD.
|
||||
|
||||
**"Could not find alass" / "Configured alass executable not found"**
|
||||
**"Configured alass executable not found"**
|
||||
|
||||
Install alass or configure the path:
|
||||
|
||||
- **Homebrew**: `brew install alass`
|
||||
- **Arch Linux (AUR)**: `paru -S alass`
|
||||
- **Cargo**: `cargo install alass-cli`
|
||||
- Set the path: `subsync.alass_path` in your config.
|
||||
|
||||
Leaving the option empty searches `PATH` plus the usual install prefixes, and accepts either `alass` or `alass-cli`. Set the option explicitly when the binary lives somewhere else. The second message means the configured path itself does not exist — SubMiner never silently substitutes a different binary for one you named.
|
||||
|
||||
**"Could not find ffsubsync" / "Configured ffsubsync executable not found"**
|
||||
**"Configured ffsubsync executable not found"**
|
||||
|
||||
Install ffsubsync or configure the path:
|
||||
|
||||
- **Arch Linux (AUR)**: `paru -S python-ffsubsync`
|
||||
- **pip**: `pip install ffsubsync`
|
||||
- Must be discoverable, or configured via `subsync.ffsubsync_path` in your config.
|
||||
- Must be on `PATH` or configured via `subsync.ffsubsync_path` in your config.
|
||||
|
||||
**"alass synchronization failed" / "ffsubsync synchronization failed"**
|
||||
|
||||
@@ -241,38 +236,6 @@ 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).
|
||||
|
||||
**Syncing subtitles on a stream (Aniyomi extensions, Jellyfin)**
|
||||
|
||||
Subtitle tracks mpv loaded from a URL are downloaded to a temporary file first, reusing mpv's own request headers, so they work as either the sync target or the alass reference. Downloading a Japanese track from Jimaku or TsukiHime while a stream is playing also works — it becomes the primary track and therefore the sync target.
|
||||
|
||||
Internal subtitle tracks of a stream still go through `ffmpeg`, which has to reach the origin itself. If that fails, prefer an external track or a Jimaku download as the reference.
|
||||
|
||||
Streams usually serve WebVTT, which alass cannot parse — it picks its parser from the file extension and treats a `.vtt` file as a video, failing with "no audio stream in file". SubMiner rewrites both the target and the reference as SRT for alass, so this is handled automatically; the retimed track mpv loads is that SRT.
|
||||
|
||||
## Anime Browser
|
||||
|
||||
See the [anime browser guide](/anime-browser) for how sources and the bridge work.
|
||||
|
||||
**Nothing to search**
|
||||
|
||||
SubMiner ships no extension repositories and bundles no sources. Until you add a repository index URL in the **Extensions** tab (or drop Aniyomi `.apk` files into the extensions directory) the browser has nothing to query.
|
||||
|
||||
**"No pinned checksum for …"**
|
||||
|
||||
The bridge bundle is verified against a SHA-256 that a maintainer has checked by hand. Bundles exist for macOS (arm64, x64), Linux (x64), and Windows (x64), but only macOS arm64 and Linux x64 are pinned so far; the rest stop with this message rather than running an unverified download. Other platforms are unsupported outright.
|
||||
|
||||
**A source returns nothing, or asks for a login**
|
||||
|
||||
Most extensions need configuration first - a server address, credentials, a preferred quality. Open the **Source settings** tab and fill them in; each save is handed straight back to the extension. Sources that need an Android WebView (typically for Cloudflare challenges) cannot work here, because the bridge has none.
|
||||
|
||||
**"Playback failed" with an mpv error**
|
||||
|
||||
"Playing" is only reported once mpv actually configures a video output, so this is a real failure rather than a silent one: a dead host, an expired stream URL, or an undecodable stream. Resolve the episode again; if it keeps failing, try another source or quality entry.
|
||||
|
||||
**The browser starts failing every request**
|
||||
|
||||
A bridge that dies (killed, crashed, stopped mid-operation) is named in the status bar and restarted on the next request. Anything already playing ends with it, since stream URLs point at its loopback proxy.
|
||||
|
||||
## TsukiHime
|
||||
|
||||
**"xz binary not found"**
|
||||
|
||||
@@ -16,11 +16,11 @@ Unlike Jimaku, TsukiHime needs no account or API key. The only requirement is th
|
||||
|
||||
The integration runs through an in-overlay modal opened with `Ctrl+Shift+T` by default. The modal has two tabs that filter both the release list and the subtitle tracks of the selected release by role: the first follows `secondarySub.secondarySubLanguages` (English when unset), and the second is always **Japanese**, the currently supported primary subtitle language. Each tab lists only the releases whose reported subtitle languages include the tab's language, so the Japanese tab hides the many releases that ship English subtitles only. Releases and tracks with no language tag stay visible on the secondary tab. If nothing on the active tab qualifies, the status line says so and points at the other tab.
|
||||
|
||||
When you open the modal, SubMiner parses the current video filename to extract a title, season, and episode number (same parser as Jimaku - `S01E03`, `1x03`, `E03`, and dash-separated numbers all work). If the filename yields a high-confidence match, SubMiner auto-searches immediately. Episodes launched from the [anime browser](/anime-browser) fill all three fields from the source's own listing instead of from a filename.
|
||||
When you open the modal, SubMiner parses the current video filename to extract a title and episode number (same parser as Jimaku - `S01E03`, `1x03`, `E03`, and dash-separated numbers all work). If the filename yields a high-confidence match, SubMiner auto-searches immediately.
|
||||
|
||||
From there:
|
||||
|
||||
1. **Search** - SubMiner queries TsukiHime with `<title> <season> <episode>`. Season 1 is left out on purpose: releases of a first season almost never carry `S01` in their name, so including it would match nothing. Later seasons are included, which is what makes their releases findable at all. Results appear as a list of releases (e.g. `[SubsPlease] ... - 28 (1080p)`), each showing size, file count, and the subtitle languages the release carries.
|
||||
1. **Search** - SubMiner queries TsukiHime with `<title> <episode>`. Results appear as a list of releases (e.g. `[SubsPlease] ... - 28 (1080p)`), each showing size, file count, and the subtitle languages the release carries.
|
||||
2. **Browse releases** - Select a release to list the text subtitle tracks extracted from its files. English tracks sort first; image-based tracks (PGS/VobSub) are filtered out.
|
||||
3. **Download** - Selecting a track downloads the xz-compressed subtitle from TsukiHime's storage, decompresses it, saves it next to the video (or a temp directory for remote/streamed media), and loads it into mpv. Japanese tracks are selected as mpv's **primary** subtitle. Tracks from the configured secondary tab are assigned to mpv's **secondary** subtitle slot without replacing the primary. The filename carries the track's language - `<video basename>.en.<ext>` for English, `.ja` for Japanese, and so on - so mpv and media servers detect the language correctly.
|
||||
|
||||
|
||||
+4
-2
@@ -72,7 +72,6 @@ subminer https://youtu.be/... # Play a YouTube URL
|
||||
subminer stats # Open the immersion stats dashboard
|
||||
subminer doctor # Check dependencies, config, and the mpv socket
|
||||
subminer settings # Open the SubMiner settings window
|
||||
subminer anime # Open the anime browser window
|
||||
subminer generate-subs video.mkv # Generate Japanese subtitles from local audio
|
||||
subminer app --setup # Re-open first-run setup
|
||||
subminer -u # Check for updates
|
||||
@@ -93,6 +92,10 @@ delay. It also works with no running
|
||||
SubMiner app or mpv instance when you provide a file path. Omit the path to use the current
|
||||
mpv file and selected audio track.
|
||||
|
||||
When the input matches the current mpv file, generation automatically uses an eligible embedded
|
||||
or loaded external subtitle track as a timing reference, preferring English dialogue and skipping
|
||||
tracks marked as signs, songs, or forced. See [timing references](/subtitle-generation#using-loaded-subtitles-as-timing-references).
|
||||
|
||||
```bash
|
||||
subminer generate-subs video.mkv --download-model
|
||||
subminer generate-subs video.mkv --model-path ~/models/ggml-medium.bin
|
||||
@@ -173,7 +176,6 @@ SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
|
||||
SubMiner.AppImage --yomitan # Open Yomitan settings
|
||||
SubMiner.AppImage --settings # Open the SubMiner settings window
|
||||
SubMiner.AppImage --jellyfin # Open the Jellyfin setup window
|
||||
SubMiner.AppImage --anime # Open the anime browser window
|
||||
SubMiner.AppImage --dictionary # Generate a character dictionary ZIP
|
||||
SubMiner.AppImage --start --dev # Enable app/dev mode
|
||||
SubMiner.AppImage --start --log-level debug # Verbose logging without dev mode
|
||||
|
||||
@@ -149,6 +149,10 @@ These settings come from `config.jsonc` (or built-in defaults); there are no CLI
|
||||
- **Secondary subtitle fails**: Secondary track failures never block playback. The primary subtitle loads independently.
|
||||
- **Native mpv secondary rendering**: Stays hidden during YouTube flows so the SubMiner overlay remains the visible secondary subtitle surface.
|
||||
|
||||
## Viewing stats
|
||||
|
||||
The stats Library groups tracked YouTube videos by channel when channel metadata is available. Select **YouTube** in the Library filter to see those channels separately from anime. Each channel page lists its videos, watch time, vocabulary, and mined cards. See [Immersion Tracking](/immersion-tracking#library).
|
||||
|
||||
## Related pages
|
||||
|
||||
- [Usage - YouTube Playback](/usage#youtube-playback)
|
||||
|
||||
+5
-2
@@ -6,7 +6,7 @@
|
||||
|
||||
- `claude` (Claude Code CLI) installed, on `PATH`, and authenticated.
|
||||
`changelog:build` and `changelog:prerelease-notes` invoke
|
||||
`claude -p --model sonnet` to merge and rewrite `changes/*.md` fragments into
|
||||
`claude -p --model opus --effort medium` to merge and rewrite `changes/*.md` fragments into
|
||||
a polished, user-facing release body. Either OAuth login (`claude /login`) or
|
||||
`ANTHROPIC_API_KEY` works. Install from <https://claude.com/claude-code> if
|
||||
you don't already have it.
|
||||
@@ -15,7 +15,10 @@
|
||||
|
||||
Stable and prerelease workflows share `.github/workflows/package-release.yml`.
|
||||
Both callers explicitly pass the five required macOS signing/notarization
|
||||
secrets; `GITHUB_TOKEN` remains automatically available to the reusable workflow.
|
||||
secrets plus the optional `SUBMINER_TMDB_API_KEY` (the project TMDB key that
|
||||
`scripts/prepare-build-assets.mjs` stages into `dist/bundled-integration-keys.json`;
|
||||
artifacts built without it simply require users to set `tmdb.apiKey`).
|
||||
`GITHUB_TOKEN` remains automatically available to the reusable workflow.
|
||||
Each platform verifies its ASAR and external resources before signing, then
|
||||
measures the signed app and installers before upload. Missing runtime assets,
|
||||
foreign SQLite/Koffi binaries, duplicate UI fonts, demo media, source maps,
|
||||
|
||||
@@ -64,6 +64,7 @@ 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
|
||||
@@ -76,6 +77,7 @@ interface SubtitleCue {
|
||||
```
|
||||
|
||||
**Supported formats:**
|
||||
|
||||
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
|
||||
- ASS: Parse the `[Events]` section, read the field order from the `Format:` row, and extract timed `Dialogue:` lines. Timed `Comment:` lines are normally ignored, but can supply canonical authored text when they match a nearby generated animation from the same style and actor. Text can itself contain commas.
|
||||
|
||||
@@ -165,6 +167,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.
|
||||
@@ -181,18 +184,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 };
|
||||
});
|
||||
@@ -233,6 +232,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)
|
||||
@@ -268,27 +268,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)
|
||||
|
||||
@@ -30,8 +30,13 @@ Update checks and startup launcher migration share a serialized update-state sto
|
||||
|
||||
- `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
|
||||
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
|
||||
- `src/main/runtime/linux-overlay-mode-runtime.ts` owns Linux fullscreen mode state and window replacement. App cleanup cancels pending replacements; `main.ts` supplies window creation and subtitle refresh hooks.
|
||||
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
|
||||
- `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same local media; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
|
||||
- Subtitle model recommendations use bounded `nvidia-smi` and Whisper CUDA discovery probes in `subtitle-generation-acceleration.ts`. The overlay runtime caches results by executable path for 30 seconds and exposes acceleration status through the existing status IPC. Recommendations do not alter model selection or transcription arguments.
|
||||
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
|
||||
- `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry.
|
||||
- `src/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes.
|
||||
- `src/renderer/` owns overlay rendering and input behavior.
|
||||
- `src/config/` owns config definitions, defaults, loading, and resolution.
|
||||
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Domain Ownership
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-08-02
|
||||
Last verified: 2026-07-15
|
||||
Owner: Kyle Yasuda
|
||||
Read when: you need to find the owner module for a behavior or test surface
|
||||
|
||||
@@ -21,6 +21,7 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
`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`
|
||||
Windows executable lookup and detached process creation are shared in `src/main/runtime/mpv-process.ts`. The Windows launcher and Jellyfin handlers retain their own playback and connection workflows.
|
||||
- Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/`
|
||||
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
|
||||
- Immersion tracking: `src/core/services/immersion-tracker/`
|
||||
@@ -30,17 +31,8 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
- Immersion sync: `src/core/services/stats-sync/`, bound by `src/main/sync-cli.ts`.
|
||||
`snapshot-transfer.ts` selects compressed rsync or scp. `transfer-cache.ts` atomically retains the last successfully received snapshot per hashed peer/database identity under the config directory's `sync-transfer-cache/`. Cache copies seed isolated transfer directories; rsync verifies reconstructed files before the existing merge engine runs. The `--make-temp` / `--remove-temp` helpers accept an internal `--transfer-cache` key, with a fallback for older peers that do not recognize it.
|
||||
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
|
||||
- TMDB live-action metadata: `src/core/services/tmdb/` (client + exact-title resolver), `src/core/services/immersion-tracker/live-action-link.ts` (links an entry to a TMDB title and merges other holders of the same title). The AniList cover-art fetcher calls the resolver as its fallback; `imm_anime.media_kind` marks the result and keeps the entry out of AniList season repair.
|
||||
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
||||
- Anime browser: extension bridge client, sidecar, and stream handling in `src/anime-bridge/`;
|
||||
the loopback stream proxy separates request transport/retry from response transformation in
|
||||
`stream-strip-transport.ts` and `stream-strip-response.ts`;
|
||||
browser window UI in `src/animeui/` (preload `src/preload-animeui.ts`); runtime wiring in
|
||||
`src/main/runtime/anime-browser-application-runtime.ts`, `src/main/runtime/anime-browser-runtime.ts`,
|
||||
`src/main/runtime/anime-browser-ipc-handlers.ts`, `src/main/runtime/anime-browser-sessions.ts`,
|
||||
`src/main/runtime/anime-bridge-installer.ts`, and `src/main/runtime/stream-playback-metadata.ts`.
|
||||
The play queue resolves episodes on click and appends them to mpv's real playlist
|
||||
(`src/main/runtime/anime-browser-queue.ts`), then observes media-path changes to
|
||||
attach prepared external tracks and update the browser queue state.
|
||||
- Window trackers: `src/window-trackers/`
|
||||
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
|
||||
in `src/core/services/stats-server/`
|
||||
@@ -77,7 +69,6 @@ on Chromium document focus or require activating the overlay window.
|
||||
- Settings UI contracts: `src/types/settings.ts`
|
||||
- Session-binding contracts: `src/types/session-bindings.ts`
|
||||
- Stats HTTP wire contracts: `src/types/stats-wire.ts`, `src/types/stats-http-contract.ts`
|
||||
- Anime browser contracts: `src/types/anime-browser.ts`, bridge wire types in `src/anime-bridge/types.ts`
|
||||
- Compatibility-only barrel: `src/types.ts`
|
||||
|
||||
## Ownership Heuristics
|
||||
|
||||
@@ -11,7 +11,7 @@ Read when: finding internal docs or checking verification status
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 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-08-02 | runtime and feature ownership |
|
||||
| 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 |
|
||||
|
||||
@@ -22,9 +22,15 @@ Read when: selecting the right verification lane for a change
|
||||
pull requests, stable tags, and prerelease tags. Keep common quality steps
|
||||
there instead of copying them into caller workflows.
|
||||
- The reusable gate installs Lua and runs `bun run test:env`, so the shipped mpv
|
||||
plugin tests run for every pull request and tagged release.
|
||||
plugin tests and launcher smoke run for every pull request and tagged release.
|
||||
Lua installation uses only the runner's Ubuntu package sources so unrelated
|
||||
third-party repository failures do not block the gate.
|
||||
- In the reusable gate, `test:coverage:src` is also the blocking execution of the
|
||||
discovered `src/**` test lane. The coverage runner returns the failing test's
|
||||
status, so CI does not rerun that lane through `test:fast`. Launcher unit and
|
||||
script tests still run separately because they are outside the coverage lane.
|
||||
- Launcher smoke artifacts are uploaded after `test:env` fails. CI does not rerun
|
||||
launcher smoke solely to collect the same artifacts.
|
||||
|
||||
## Default Handoff Gate
|
||||
|
||||
@@ -49,7 +55,7 @@ bun run docs:build
|
||||
- Internal KB, `AGENTS.md`, or `.agents/skills/**` changes: `bun run test:docs:kb`
|
||||
- Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed
|
||||
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||
- Runtime-compat / compiled behavior: `bun run test:runtime:compat`
|
||||
- Runtime-compat / compiled behavior after `bun run build`: `bun run test:runtime:compat`
|
||||
- Stats dashboard UI: `bun run test:stats`
|
||||
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||
- Packaging: build the platform package, then run `bun run test:package <resources-directory>`.
|
||||
@@ -62,11 +68,30 @@ bun run docs:build
|
||||
|
||||
## Coverage Reporting
|
||||
|
||||
- `bun run test:coverage:src` runs the maintained `test:src` lane through a sharded coverage runner: one Bun coverage process per test file, then merged LCOV output.
|
||||
- `bun run test:coverage:src` runs the same discovered `bun-src-full` membership as
|
||||
`test:src` through a sharded coverage runner: one Bun coverage process per test
|
||||
file, then merged LCOV output.
|
||||
- A failing coverage shard stops the runner with a nonzero status. Coverage is a
|
||||
source test gate, not a report-only step.
|
||||
- Machine-readable output lands at `coverage/test-src/lcov.info`.
|
||||
- Every reusable quality-gate run uploads that LCOV file as the
|
||||
`coverage-test-src` artifact.
|
||||
|
||||
## Compiled Runtime Smoke
|
||||
|
||||
- `bun run test:smoke:dist` and its `test:runtime:compat` alias require an existing
|
||||
full build and fail with the missing artifact paths when `dist/` or the stats UI
|
||||
bundle is absent.
|
||||
- The check runs the emitted stats daemon under Electron's Node runtime. It opens
|
||||
the production HTTP server, queries the overview endpoint through native
|
||||
libsql-backed storage, and leaves an HTTP request body unfinished before
|
||||
shutting the daemon down. It verifies a clean exit and that the port and
|
||||
ownership state are released despite the unfinished request.
|
||||
- The check also occupies the configured port, requires startup to fail without
|
||||
stale ownership state, releases the conflict, and verifies a clean retry.
|
||||
- This is not a full Electron UI startup check. It does not require a display and
|
||||
makes no claims about renderer, window, tray, or mpv behavior.
|
||||
|
||||
## Dependency Audit Policy
|
||||
|
||||
- `bun audit --audit-level high` blocks the reusable quality gate.
|
||||
|
||||
@@ -16,10 +16,6 @@ type AppCommandDeps = {
|
||||
appPath: string,
|
||||
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||
) => void;
|
||||
launchAnimeBrowserDetached: (
|
||||
appPath: string,
|
||||
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||
) => void;
|
||||
};
|
||||
|
||||
const defaultAppCommandDeps: AppCommandDeps = {
|
||||
@@ -27,8 +23,6 @@ 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(
|
||||
@@ -43,11 +37,6 @@ 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,7 +207,6 @@ 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}`);
|
||||
},
|
||||
@@ -228,7 +227,6 @@ 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}`);
|
||||
},
|
||||
@@ -250,7 +248,6 @@ test('app command keeps explicit passthrough args attached', () => {
|
||||
forwarded.push(appArgs);
|
||||
},
|
||||
launchSyncUiDetached: () => detached.push('sync-ui'),
|
||||
launchAnimeBrowserDetached: () => {},
|
||||
launchAppBackgroundDetached: () => {
|
||||
detached.push('detached');
|
||||
},
|
||||
@@ -269,7 +266,6 @@ 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'),
|
||||
});
|
||||
|
||||
|
||||
@@ -136,25 +136,28 @@ test('current mpv generation requires an identifiable selected audio track', asy
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit local file leaves Japanese track selection to the shared generator', async () => {
|
||||
test('explicit playing file leaves audio selection to the generator but inspects subtitle references', async () => {
|
||||
const f = fixture();
|
||||
await runGenerateSubtitlesCommand(f.context, f.deps);
|
||||
assert.equal(f.generations[0]?.audioStreamIndex, undefined);
|
||||
assert.equal(
|
||||
f.commands.some((command) => command[1] === 'track-list'),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('launcher does not load generated subtitles after mpv switches files', async () => {
|
||||
const f = fixture(['generate-subs']);
|
||||
let pathRequests = 0;
|
||||
let changed = false;
|
||||
f.deps.mpvCommand = async (_socket, command) => {
|
||||
f.commands.push(command);
|
||||
if (command[1] === 'path')
|
||||
return ++pathRequests === 1 ? '/media/episode.mkv' : '/media/next.mkv';
|
||||
if (command[1] === 'path') return changed ? '/media/next.mkv' : '/media/episode.mkv';
|
||||
return [{ type: 'audio', selected: true, 'ff-index': 2 }];
|
||||
};
|
||||
f.deps.generate = async () => {
|
||||
changed = true;
|
||||
return '/media/episode.ja.srt';
|
||||
};
|
||||
await runGenerateSubtitlesCommand(f.context, f.deps);
|
||||
assert.equal(
|
||||
f.commands.some((command) => command[0] === 'sub-add'),
|
||||
@@ -163,6 +166,86 @@ test('launcher does not load generated subtitles after mpv switches files', asyn
|
||||
assert.match(f.output.join(''), /Saved Japanese subtitles/);
|
||||
});
|
||||
|
||||
test('current mpv generation rejects tracks when playback changes during capture', async () => {
|
||||
for (const nextMedia of ['/media/next.mkv', null]) {
|
||||
const f = fixture(['generate-subs']);
|
||||
let media: string | null = '/media/episode.mkv';
|
||||
let modelChecks = 0;
|
||||
f.deps.mpvCommand = async (_socket, command) => {
|
||||
if (command[1] === 'path') return media;
|
||||
if (command[1] === 'track-list') {
|
||||
media = nextMedia;
|
||||
return [{ type: 'audio', selected: true, 'ff-index': 99 }];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
f.deps.resolveModel = async () => {
|
||||
modelChecks += 1;
|
||||
return { kind: 'external', path: '/models/model.bin' };
|
||||
};
|
||||
await assert.rejects(
|
||||
runGenerateSubtitlesCommand(f.context, f.deps),
|
||||
/mpv media.*Run generate-subs again/,
|
||||
);
|
||||
assert.equal(f.generations.length, 0);
|
||||
assert.equal(modelChecks, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit media or audio stream remains usable when the mpv snapshot changes', async () => {
|
||||
for (const options of [['/media/episode.mkv'], ['--audio-stream', '7']]) {
|
||||
const f = fixture(['generate-subs', ...options]);
|
||||
let media = '/media/episode.mkv';
|
||||
f.deps.mpvCommand = async (_socket, command) => {
|
||||
if (command[1] === 'path') return media;
|
||||
if (command[1] === 'track-list') {
|
||||
media = '/media/next.mkv';
|
||||
return [
|
||||
{ type: 'audio', selected: true, 'ff-index': 99 },
|
||||
{ type: 'sub', lang: 'eng', 'ff-index': 100 },
|
||||
];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
await runGenerateSubtitlesCommand(f.context, f.deps);
|
||||
assert.equal(f.generations[0]?.mediaPath, '/media/episode.mkv');
|
||||
assert.equal(
|
||||
f.generations[0]?.audioStreamIndex,
|
||||
options[0] === '--audio-stream' ? 7 : undefined,
|
||||
);
|
||||
assert.deepEqual(f.generations[0]?.references, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('launcher captures loaded external references only for the matching media', async () => {
|
||||
for (const matching of [true, false]) {
|
||||
const f = fixture();
|
||||
f.deps.mpvCommand = async (_socket, command) => {
|
||||
if (command[1] === 'path') return matching ? '/media/episode.mkv' : '/media/other.mkv';
|
||||
if (command[1] === 'working-directory') return '/mpv';
|
||||
if (command[1] === 'track-list')
|
||||
return [
|
||||
{ type: 'sub', external: true, 'external-filename': 'episode.en.signs.ass' },
|
||||
{ type: 'sub', external: true, 'external-filename': 'episode.en.srt' },
|
||||
];
|
||||
return undefined;
|
||||
};
|
||||
await runGenerateSubtitlesCommand(f.context, f.deps);
|
||||
assert.deepEqual(
|
||||
f.generations[0]?.references,
|
||||
matching
|
||||
? [
|
||||
{
|
||||
label: 'episode.en.srt',
|
||||
delaySeconds: 0,
|
||||
source: { kind: 'external', path: '/mpv/episode.en.srt' },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit managed model overrides external config and downloads before generation', async () => {
|
||||
const f = fixture([
|
||||
'generate-subs',
|
||||
@@ -191,6 +274,69 @@ test('explicit managed model overrides external config and downloads before gene
|
||||
assert.equal(f.generations.length, 1);
|
||||
});
|
||||
|
||||
test('audio and subtitle timing use the initial mpv snapshot across model setup', async () => {
|
||||
for (const changeDuring of ['resolve', 'download']) {
|
||||
const f = fixture(['generate-subs', '--download-model']);
|
||||
let changed = false;
|
||||
let trackReads = 0;
|
||||
f.deps.mpvCommand = async (_socket, command) => {
|
||||
if (command[1] === 'path') return '/media/episode.mkv';
|
||||
if (command[1] === 'track-list') {
|
||||
trackReads += 1;
|
||||
return [
|
||||
{ type: 'audio', selected: true, 'ff-index': changed ? 3 : 2 },
|
||||
{ type: 'sub', id: 1, title: 'English Full', lang: 'eng', 'ff-index': changed ? 5 : 4 },
|
||||
];
|
||||
}
|
||||
if (command[1] === 'sid') return 1;
|
||||
if (command[1] === 'sub-delay') return changed ? 9 : 1.5;
|
||||
return undefined;
|
||||
};
|
||||
f.deps.resolveModel = async () => {
|
||||
if (changeDuring === 'resolve') changed = true;
|
||||
return { kind: 'missing', path: '/models/model.bin' };
|
||||
};
|
||||
f.deps.downloadModel = async () => {
|
||||
changed = true;
|
||||
return '/models/model.bin';
|
||||
};
|
||||
await runGenerateSubtitlesCommand(f.context, f.deps);
|
||||
assert.equal(f.generations[0]?.audioStreamIndex, 2);
|
||||
assert.deepEqual(f.generations[0]?.references, [
|
||||
{
|
||||
label: 'English Full',
|
||||
delaySeconds: 1.5,
|
||||
source: { kind: 'embedded', streamIndex: 4 },
|
||||
},
|
||||
]);
|
||||
assert.equal(trackReads, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit audio stream bypasses mpv audio selection while retaining subtitle references', async () => {
|
||||
const f = fixture(['generate-subs', '--audio-stream', '7']);
|
||||
f.deps.mpvCommand = async (_socket, command) => {
|
||||
if (command[1] === 'path') return '/media/episode.mkv';
|
||||
if (command[1] === 'track-list')
|
||||
return [
|
||||
{ type: 'audio', selected: true, external: true, 'ff-index': 0 },
|
||||
{ type: 'sub', id: 2, title: 'English Full', lang: 'eng', 'ff-index': 4 },
|
||||
];
|
||||
if (command[1] === 'secondary-sid') return 2;
|
||||
if (command[1] === 'secondary-sub-delay') return -0.5;
|
||||
return undefined;
|
||||
};
|
||||
await runGenerateSubtitlesCommand(f.context, f.deps);
|
||||
assert.equal(f.generations[0]?.audioStreamIndex, 7);
|
||||
assert.deepEqual(f.generations[0]?.references, [
|
||||
{
|
||||
label: 'English Full',
|
||||
delaySeconds: -0.5,
|
||||
source: { kind: 'embedded', streamIndex: 4 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('generation can run standalone and never loads subtitles into another video', async () => {
|
||||
for (const playing of [null, '/media/different.mkv']) {
|
||||
const f = fixture();
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
resolveSubtitleGenerationTools,
|
||||
} from '../../src/core/services/subtitle-generation.js';
|
||||
import { requireSubtitleGenerationTools } from '../../src/core/services/subtitle-generation-tools.js';
|
||||
import {
|
||||
readSubtitleGenerationReferences,
|
||||
type SubtitleGenerationReference,
|
||||
} from '../../src/core/services/subtitle-generation-reference.js';
|
||||
import {
|
||||
resolveSubtitleGenerationConfig,
|
||||
type SubtitleGenerationProgress,
|
||||
@@ -69,12 +73,9 @@ async function readMpvMedia(socketPath: string, command: GenerationCommandDeps['
|
||||
return localMediaPath(media, workingDirectory);
|
||||
}
|
||||
|
||||
async function readMpvAudioStream(
|
||||
socketPath: string,
|
||||
command: GenerationCommandDeps['mpvCommand'],
|
||||
) {
|
||||
const tracks = await command(socketPath, ['get_property', 'track-list'], 1000);
|
||||
for (const track of Array.isArray(tracks) ? tracks : []) {
|
||||
function selectedMpvAudioStream(value: unknown) {
|
||||
const tracks: unknown[] = Array.isArray(value) ? value : [];
|
||||
for (const track of tracks) {
|
||||
if (
|
||||
typeof track === 'object' &&
|
||||
track !== null &&
|
||||
@@ -160,11 +161,29 @@ export async function runGenerateSubtitlesCommand(
|
||||
const mediaPath = options.mediaPath ? localMediaPath(options.mediaPath) : currentMedia;
|
||||
if (!mediaPath)
|
||||
throw new Error('Pass a local video file or open one in mpv before running generate-subs.');
|
||||
// Capture audio and subtitle timing together before model setup can yield to playback changes.
|
||||
const matchesCurrentMedia = currentMedia !== null && sameFile(currentMedia, mediaPath);
|
||||
const tracks = matchesCurrentMedia
|
||||
? await deps
|
||||
.mpvCommand(context.mpvSocketPath, ['get_property', 'track-list'], 1000)
|
||||
.catch(() => null)
|
||||
: null;
|
||||
let references: SubtitleGenerationReference[] = [];
|
||||
if (matchesCurrentMedia) {
|
||||
const candidates = await readSubtitleGenerationReferences(tracks, (name) =>
|
||||
deps.mpvCommand(context.mpvSocketPath, ['get_property', name], 1000),
|
||||
);
|
||||
const stillPlaying = await readMpvMedia(context.mpvSocketPath, deps.mpvCommand).catch(
|
||||
() => null,
|
||||
);
|
||||
if (stillPlaying && sameFile(stillPlaying, mediaPath)) references = candidates;
|
||||
else if (!options.mediaPath && options.audioStreamIndex === undefined)
|
||||
throw new Error(
|
||||
'The current mpv media changed or could not be verified while reading tracks. Run generate-subs again.',
|
||||
);
|
||||
}
|
||||
const audioStreamIndex =
|
||||
options.audioStreamIndex ??
|
||||
(!options.mediaPath
|
||||
? await readMpvAudioStream(context.mpvSocketPath, deps.mpvCommand)
|
||||
: undefined);
|
||||
options.audioStreamIndex ?? (!options.mediaPath ? selectedMpvAudioStream(tracks) : undefined);
|
||||
const onProgress = createGenerationProgressReporter(write);
|
||||
// Missing executables fail here, before any model download starts.
|
||||
requireSubtitleGenerationTools(await deps.resolveTools(config));
|
||||
@@ -183,6 +202,7 @@ export async function runGenerateSubtitlesCommand(
|
||||
modelDirectory,
|
||||
mediaPath,
|
||||
audioStreamIndex,
|
||||
references,
|
||||
outputPath: options.outputPath
|
||||
? path.resolve(resolvePathMaybe(options.outputPath))
|
||||
: undefined,
|
||||
|
||||
@@ -63,7 +63,6 @@ function createContext(): LauncherCommandContext {
|
||||
logsExport: false,
|
||||
version: false,
|
||||
settings: false,
|
||||
animeBrowser: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
|
||||
@@ -120,7 +120,6 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
||||
logLevel: 'warn',
|
||||
},
|
||||
settingsInvocation: null,
|
||||
animeInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
@@ -175,7 +174,6 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
|
||||
settingsInvocation: {
|
||||
logLevel: undefined,
|
||||
},
|
||||
animeInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
@@ -223,7 +221,6 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
|
||||
action: undefined,
|
||||
},
|
||||
settingsInvocation: null,
|
||||
animeInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
@@ -269,7 +266,6 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
||||
jellyfinInvocation: null,
|
||||
configInvocation: null,
|
||||
settingsInvocation: null,
|
||||
animeInvocation: null,
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
|
||||
@@ -170,7 +170,6 @@ export function createDefaultArgs(
|
||||
version: false,
|
||||
update: false,
|
||||
settings: false,
|
||||
animeBrowser: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
@@ -357,12 +356,6 @@ 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);
|
||||
|
||||
@@ -29,7 +29,6 @@ export interface CliInvocations {
|
||||
jellyfinInvocation: JellyfinInvocation | null;
|
||||
configInvocation: CommandActionInvocation | null;
|
||||
settingsInvocation: CommandActionInvocation | null;
|
||||
animeInvocation: CommandActionInvocation | null;
|
||||
mpvInvocation: CommandActionInvocation | null;
|
||||
appInvocation: { appArgs: string[] } | null;
|
||||
dictionaryTriggered: boolean;
|
||||
@@ -122,7 +121,6 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
|
||||
'doctor',
|
||||
'config',
|
||||
'settings',
|
||||
'anime',
|
||||
'mpv',
|
||||
'logs',
|
||||
'dictionary',
|
||||
@@ -177,7 +175,6 @@ 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;
|
||||
@@ -515,16 +512,6 @@ 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')
|
||||
@@ -580,7 +567,6 @@ export function parseCliPrograms(
|
||||
jellyfinInvocation,
|
||||
configInvocation,
|
||||
settingsInvocation,
|
||||
animeInvocation,
|
||||
mpvInvocation,
|
||||
appInvocation,
|
||||
dictionaryTriggered,
|
||||
|
||||
@@ -57,7 +57,6 @@ function createArgs(): Args {
|
||||
logsExport: false,
|
||||
version: false,
|
||||
settings: false,
|
||||
animeBrowser: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
|
||||
@@ -89,7 +89,6 @@ export async function jellyfinApiRequest<T>(
|
||||
const url = `${session.serverUrl}${requestPath}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'X-Emby-Token': session.accessToken,
|
||||
Authorization: `MediaBrowser Token="${session.accessToken}"`,
|
||||
},
|
||||
});
|
||||
@@ -103,7 +102,7 @@ export async function jellyfinApiRequest<T>(
|
||||
}
|
||||
|
||||
function itemPreviewUrl(session: JellyfinSessionConfig, id: string): string {
|
||||
return `${session.serverUrl}/Items/${id}/Images/Primary?maxHeight=720&quality=85&api_key=${encodeURIComponent(session.accessToken)}`;
|
||||
return `${session.serverUrl}/Items/${id}/Images/Primary?maxHeight=720&quality=85&ApiKey=${encodeURIComponent(session.accessToken)}`;
|
||||
}
|
||||
|
||||
function jellyfinIconCacheDir(session: JellyfinSessionConfig): string {
|
||||
|
||||
@@ -627,7 +627,6 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
logsExport: false,
|
||||
version: false,
|
||||
settings: false,
|
||||
animeBrowser: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
|
||||
+3
-3
@@ -228,7 +228,7 @@ export function pickLibrary(
|
||||
commandExists('chafa') && commandExists('curl')
|
||||
? `
|
||||
id={1}
|
||||
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)}
|
||||
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&ApiKey=${escapeShellSingle(session.accessToken)}
|
||||
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
|
||||
`.trim()
|
||||
: 'echo "Install curl + chafa for image preview"';
|
||||
@@ -266,7 +266,7 @@ export function pickItem(
|
||||
commandExists('chafa') && commandExists('curl')
|
||||
? `
|
||||
id={1}
|
||||
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)}
|
||||
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&ApiKey=${escapeShellSingle(session.accessToken)}
|
||||
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
|
||||
`.trim()
|
||||
: 'echo "Install curl + chafa for image preview"';
|
||||
@@ -304,7 +304,7 @@ export function pickGroup(
|
||||
commandExists('chafa') && commandExists('curl')
|
||||
? `
|
||||
id={1}
|
||||
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)}
|
||||
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&ApiKey=${escapeShellSingle(session.accessToken)}
|
||||
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
|
||||
`.trim()
|
||||
: 'echo "Install curl + chafa for image preview"';
|
||||
|
||||
@@ -31,6 +31,7 @@ const SYNC_SCHEMA_OBJECTS = [
|
||||
'imm_lifetime_applied_sessions',
|
||||
'imm_stats_excluded_words',
|
||||
'idx_anime_normalized_title',
|
||||
'idx_anime_namespace_title',
|
||||
'idx_anime_anilist_id',
|
||||
'idx_videos_anime_id',
|
||||
'idx_sessions_video_started',
|
||||
|
||||
@@ -12,7 +12,7 @@ export const IMMERSION_DB_FIXTURE_DDL = `
|
||||
);
|
||||
CREATE TABLE imm_anime(
|
||||
anime_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
normalized_title_key TEXT NOT NULL UNIQUE,
|
||||
normalized_title_key TEXT NOT NULL,
|
||||
canonical_title TEXT NOT NULL,
|
||||
anilist_id INTEGER UNIQUE,
|
||||
title_romaji TEXT,
|
||||
@@ -20,10 +20,14 @@ export const IMMERSION_DB_FIXTURE_DDL = `
|
||||
title_native TEXT,
|
||||
episodes_total INTEGER,
|
||||
description TEXT,
|
||||
media_kind TEXT NOT NULL DEFAULT 'anime',
|
||||
tmdb_id INTEGER,
|
||||
tmdb_type TEXT,
|
||||
metadata_json TEXT,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_anime_namespace_title ON imm_anime((media_kind = 'youtube'), normalized_title_key);
|
||||
CREATE TABLE imm_videos(
|
||||
video_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
video_key TEXT NOT NULL UNIQUE,
|
||||
|
||||
@@ -161,7 +161,6 @@ export interface Args {
|
||||
version: boolean;
|
||||
update?: boolean;
|
||||
settings: boolean;
|
||||
animeBrowser: boolean;
|
||||
configPath: boolean;
|
||||
configShow: boolean;
|
||||
mpvIdle: boolean;
|
||||
|
||||
+7
-8
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.19.6",
|
||||
"version": "0.20.0",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
@@ -21,11 +21,10 @@
|
||||
"build:launcher": "bun run scripts/build-launcher.ts",
|
||||
"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:animeui && 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: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",
|
||||
@@ -52,7 +51,7 @@
|
||||
"test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts",
|
||||
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-process-start-retries.lua && lua scripts/test-plugin-restart-feedback.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
|
||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
||||
"test:smoke:dist": "bun scripts/run-test-lane.mjs bun-src-full",
|
||||
"test:smoke:dist": "env ELECTRON_RUN_AS_NODE=1 electron scripts/compiled-runtime-smoke.mjs",
|
||||
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
|
||||
"test:immersion:sqlite:src": "bun test src/core/services/immersion-tracker-service.test.ts src/core/services/immersion-tracker/storage-session.test.ts",
|
||||
"test:immersion:sqlite:dist": "bun test dist/core/services/immersion-tracker-service.test.js dist/core/services/immersion-tracker/storage-session.test.js",
|
||||
@@ -64,7 +63,7 @@
|
||||
"test:scripts": "bun scripts/run-test-lane.mjs scripts",
|
||||
"test:stats": "bun scripts/run-test-lane.mjs stats",
|
||||
"test:env": "bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src",
|
||||
"test:runtime:compat": "bun run tsc && bun scripts/run-test-lane.mjs bun-src-full",
|
||||
"test:runtime:compat": "bun run test:smoke:dist",
|
||||
"test": "bun run test:fast",
|
||||
"test:config": "bun scripts/run-test-lane.mjs config",
|
||||
"test:launcher": "bun scripts/run-test-lane.mjs launcher && bun run test:plugin:src",
|
||||
@@ -86,9 +85,9 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@xmldom/xmldom": "0.8.15",
|
||||
"app-builder-lib": "26.15.3",
|
||||
"app-builder-lib": "26.16.1",
|
||||
"brace-expansion": "5.0.9",
|
||||
"electron-builder-squirrel-windows": "26.15.3",
|
||||
"electron-builder-squirrel-windows": "26.16.1",
|
||||
"fast-uri": "3.1.6",
|
||||
"form-data": "4.0.6",
|
||||
"ip-address": "10.2.0",
|
||||
@@ -130,7 +129,7 @@
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"electron": "42.6.0",
|
||||
"electron-builder": "26.15.3",
|
||||
"electron-builder": "26.16.1",
|
||||
"esbuild": "^0.25.12",
|
||||
"eslint": "^10.8.0",
|
||||
"prettier": "^3.8.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pkgbase = subminer-bin
|
||||
pkgdesc = All-in-one sentence mining overlay with AnkiConnect and dictionary integration
|
||||
pkgver = 0.19.5
|
||||
pkgrel = 2
|
||||
pkgver = 0.6.2
|
||||
pkgrel = 1
|
||||
url = https://github.com/ksyasuda/SubMiner
|
||||
arch = x86_64
|
||||
license = GPL-3.0-or-later
|
||||
@@ -25,17 +25,16 @@ pkgbase = subminer-bin
|
||||
optdepends = python-guessit: improved AniSkip title and episode inference
|
||||
optdepends = alass-git: preferred subtitle synchronization engine
|
||||
optdepends = python-ffsubsync: fallback subtitle synchronization engine
|
||||
optdepends = mangatan-extension-server: anime browser bridge shared with Mangatan; skips the bundle download and is updated by pacman
|
||||
provides = subminer=0.19.5
|
||||
provides = subminer=0.6.2
|
||||
conflicts = subminer
|
||||
noextract = SubMiner-0.19.5.AppImage
|
||||
noextract = SubMiner-0.6.2.AppImage
|
||||
options = !strip
|
||||
options = !debug
|
||||
source = SubMiner-0.19.5.AppImage::https://github.com/ksyasuda/SubMiner/releases/download/v0.19.5/SubMiner-0.19.5.AppImage
|
||||
source = subminer-0.19.5::https://github.com/ksyasuda/SubMiner/releases/download/v0.19.5/subminer
|
||||
source = subminer-assets-0.19.5.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v0.19.5/subminer-assets.tar.gz
|
||||
sha256sums = bd683d949956ff847927f23acfede49efad6320cf7ec4cdc3cbd586a5287884e
|
||||
sha256sums = 2fa292f672df715461765aef8d5c6bf3612004c8b8070b1b1f0afa10d4b74c1e
|
||||
sha256sums = 591468a1fc68a13de315acaf1154df9da41fe3bcbc0e5522a04ba21e939ed1ae
|
||||
source = SubMiner-0.6.2.AppImage::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/SubMiner-0.6.2.AppImage
|
||||
source = subminer-0.6.2::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/subminer
|
||||
source = subminer-assets-0.6.2.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/subminer-assets.tar.gz
|
||||
sha256sums = c91667adbbc47a0fba34855358233454a9ea442ab57510546b2219abd1f2461e
|
||||
sha256sums = 85050918e14cb2512fcd34be83387a2383fa5c206dc1bdc11e8d98f7d37817e5
|
||||
sha256sums = 210113be64a06840f4dfaebc22a8e6fc802392f1308413aa00d9348c804ab2a1
|
||||
|
||||
pkgname = subminer-bin
|
||||
@@ -1,8 +1,8 @@
|
||||
# Maintainer: Kyle Yasuda <suda@sudacode.com>
|
||||
|
||||
pkgname=subminer-bin
|
||||
pkgver=0.19.5
|
||||
pkgrel=2
|
||||
pkgver=0.6.2
|
||||
pkgrel=1
|
||||
pkgdesc='All-in-one sentence mining overlay with AnkiConnect and dictionary integration'
|
||||
arch=('x86_64')
|
||||
url='https://github.com/ksyasuda/SubMiner'
|
||||
@@ -26,7 +26,6 @@ optdepends=(
|
||||
'python-guessit: improved AniSkip title and episode inference'
|
||||
'alass-git: preferred subtitle synchronization engine'
|
||||
'python-ffsubsync: fallback subtitle synchronization engine'
|
||||
'mangatan-extension-server: anime browser bridge shared with Mangatan; skips the bundle download and is updated by pacman'
|
||||
)
|
||||
provides=("subminer=${pkgver}")
|
||||
conflicts=('subminer')
|
||||
@@ -36,9 +35,9 @@ source=(
|
||||
"subminer-assets-${pkgver}.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v${pkgver}/subminer-assets.tar.gz"
|
||||
)
|
||||
sha256sums=(
|
||||
'bd683d949956ff847927f23acfede49efad6320cf7ec4cdc3cbd586a5287884e'
|
||||
'2fa292f672df715461765aef8d5c6bf3612004c8b8070b1b1f0afa10d4b74c1e'
|
||||
'591468a1fc68a13de315acaf1154df9da41fe3bcbc0e5522a04ba21e939ed1ae'
|
||||
'c91667adbbc47a0fba34855358233454a9ea442ab57510546b2219abd1f2461e'
|
||||
'85050918e14cb2512fcd34be83387a2383fa5c206dc1bdc11e8d98f7d37817e5'
|
||||
'210113be64a06840f4dfaebc22a8e6fc802392f1308413aa00d9348c804ab2a1'
|
||||
)
|
||||
noextract=("SubMiner-${pkgver}.AppImage")
|
||||
|
||||
|
||||
@@ -91,6 +91,10 @@ function M.create(ctx)
|
||||
end
|
||||
|
||||
local function key_code_to_mpv_name(code)
|
||||
local first, second = code:match("^Key([A-Z])%-Key([A-Z])$")
|
||||
if first and second then
|
||||
return string.lower(first) .. "-" .. string.lower(second)
|
||||
end
|
||||
if KEY_NAME_MAP[code] then
|
||||
return KEY_NAME_MAP[code]
|
||||
end
|
||||
@@ -187,6 +191,110 @@ function M.create(ctx)
|
||||
return bindings
|
||||
end
|
||||
|
||||
-- Match letter strokes, including mpv's uppercase spelling for Shift.
|
||||
local function letter_key_signature(value)
|
||||
if type(value) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local modifiers = {}
|
||||
while true do
|
||||
local modifier, rest = value:match("^([%a]+)%+(.+)$")
|
||||
if not modifier then
|
||||
break
|
||||
end
|
||||
modifier = string.lower(modifier)
|
||||
if not MODIFIER_MAP[modifier] then
|
||||
return nil
|
||||
end
|
||||
modifiers[modifier] = true
|
||||
value = rest
|
||||
end
|
||||
if not value:match("^[a-zA-Z]$") then
|
||||
return nil
|
||||
end
|
||||
if value:match("^[A-Z]$") then
|
||||
modifiers.shift = true
|
||||
end
|
||||
local parts = {}
|
||||
for _, modifier in ipairs({ "ctrl", "alt", "shift", "meta" }) do
|
||||
if modifiers[modifier] then
|
||||
parts[#parts + 1] = modifier
|
||||
end
|
||||
end
|
||||
parts[#parts + 1] = string.lower(value)
|
||||
return table.concat(parts, "+")
|
||||
end
|
||||
|
||||
local function external_single_keys()
|
||||
local keys = {}
|
||||
local native = mp.get_property_native and mp.get_property_native("input-bindings") or {}
|
||||
for _, entry in ipairs(native or {}) do
|
||||
local signature = letter_key_signature(entry.key)
|
||||
if
|
||||
signature
|
||||
and type(entry.cmd) == "string"
|
||||
and type(entry.priority) == "number"
|
||||
and entry.priority >= 0
|
||||
then
|
||||
local owned = entry.owner == "subminer"
|
||||
or (
|
||||
entry.owner == nil
|
||||
and (
|
||||
entry.cmd:match("script%-binding%s+['\"]?subminer/")
|
||||
or entry.cmd:match("script%-message%s+['\"]?subminer%-")
|
||||
)
|
||||
)
|
||||
local previous = keys[signature]
|
||||
if
|
||||
not previous
|
||||
or entry.priority > previous.priority
|
||||
or (entry.priority == previous.priority and owned)
|
||||
then
|
||||
local command = entry.cmd:match("^%s*(.-)%s*$")
|
||||
local flags = {
|
||||
["no-osd"] = true,
|
||||
["osd-bar"] = true,
|
||||
["osd-msg"] = true,
|
||||
["osd-msg-bar"] = true,
|
||||
["osd-auto"] = true,
|
||||
["expand-properties"] = true,
|
||||
["raw"] = true,
|
||||
["repeatable"] = true,
|
||||
["nonrepeatable"] = true,
|
||||
["nonscalable"] = true,
|
||||
["async"] = true,
|
||||
["sync"] = true,
|
||||
}
|
||||
while true do
|
||||
local flag, rest = command:match("^(%S+)%s+(.+)$")
|
||||
if not flags[flag] then
|
||||
break
|
||||
end
|
||||
command = rest
|
||||
end
|
||||
keys[signature] = { priority = entry.priority, owned = owned, ignored = command == "ignore" }
|
||||
end
|
||||
end
|
||||
end
|
||||
return keys
|
||||
end
|
||||
|
||||
local function sequence_conflict(binding, singles)
|
||||
local code = binding.key and binding.key.code
|
||||
local prefix = type(code) == "string" and code:match("^(Key[A-Z])%-Key[A-Z]$")
|
||||
if not prefix then
|
||||
return nil
|
||||
end
|
||||
local names = key_spec_to_mpv_bindings({ code = prefix, modifiers = binding.key.modifiers }) or {}
|
||||
for _, name in ipairs(names) do
|
||||
local existing = singles[letter_key_signature(name)]
|
||||
if existing and not existing.owned and not existing.ignored then
|
||||
return name
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function normalize_cli_args(cli_args)
|
||||
if type(cli_args) ~= "table" then
|
||||
return nil
|
||||
@@ -268,8 +376,6 @@ function M.create(ctx)
|
||||
return { "--open-controller-debug" }
|
||||
elseif action_id == "openPlaylistBrowser" then
|
||||
return { "--open-playlist-browser" }
|
||||
elseif action_id == "openAnimeBrowser" then
|
||||
return { "--session-action", '{"actionId":"openAnimeBrowser"}' }
|
||||
elseif action_id == "replayCurrentSubtitle" then
|
||||
return { "--replay-current-subtitle" }
|
||||
elseif action_id == "playNextSubtitle" then
|
||||
@@ -393,17 +499,23 @@ function M.create(ctx)
|
||||
local next_binding_names = {}
|
||||
state.session_binding_generation = (state.session_binding_generation or 0) + 1
|
||||
local generation = state.session_binding_generation
|
||||
local singles = external_single_keys()
|
||||
|
||||
for index, binding in ipairs(artifact.bindings) do
|
||||
if not is_supported_binding(binding) then
|
||||
subminer_log(
|
||||
"warn",
|
||||
"session-bindings",
|
||||
"Skipped unsupported session binding from artifact"
|
||||
)
|
||||
subminer_log("warn", "session-bindings", "Skipped unsupported session binding from artifact")
|
||||
else
|
||||
local key_names = key_spec_to_mpv_bindings(binding.key)
|
||||
if key_names then
|
||||
local conflict = sequence_conflict(binding, singles)
|
||||
if conflict then
|
||||
local message = "Disabled sequence "
|
||||
.. tostring(binding.originalKey or binding.key.code)
|
||||
.. ": mpv already uses "
|
||||
.. conflict
|
||||
.. ". Single-key bindings take priority."
|
||||
subminer_log("warn", "session-bindings", message)
|
||||
show_osd(message)
|
||||
elseif key_names then
|
||||
for key_index, key_name in ipairs(key_names) do
|
||||
local name = "subminer-session-binding-"
|
||||
.. tostring(generation)
|
||||
@@ -420,7 +532,8 @@ function M.create(ctx)
|
||||
subminer_log(
|
||||
"warn",
|
||||
"session-bindings",
|
||||
"Skipped unsupported key code from artifact: " .. tostring(binding.key and binding.key.code or "unknown")
|
||||
"Skipped unsupported key code from artifact: "
|
||||
.. tostring(binding.key and binding.key.code or "unknown")
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
+104
-47
@@ -1,68 +1,125 @@
|
||||
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
|
||||
|
||||
<!-- prerelease-base-version: 0.19.4 -->
|
||||
<!-- prerelease-version: 0.20.0-beta.2; since: v0.20.0-beta.1 -->
|
||||
|
||||
## Changes since v0.20.0-beta.1
|
||||
|
||||
- Added an optional subtitle selection modal for primary and secondary mpv subtitle tracks. Enable it in Settings > Behavior, then press `g` followed by `s`. Disabling it restores mpv's subtitle selection binding.
|
||||
- Single-key shortcut actions now take priority over configured multi-key sequence prefixes, conflicting sequences are disabled with a warning, and existing `y` commands remain reserved.
|
||||
- Jellyfin casting and playback now honor a configured mpv executable path, allowing playback when mpv is installed outside the system PATH, and portable plugins located beside that executable are now detected.
|
||||
- Fixed word-card sentence furigana falling out of sync with full stats-search context and expanded timing-review selections; stale furigana is now cleared if regeneration fails.
|
||||
|
||||
## Highlights
|
||||
|
||||
### Added
|
||||
|
||||
- Library Merge & Reassignment
|
||||
- Duplicate library cards for the same show can be combined: select entries in "Select" mode and use "Merge Selected" to combine their sessions, mined cards, and watch time onto one card.
|
||||
- Episodes can be moved to a different entry with a per-episode "→" button, fixing stray files that split off their own entry; manual assignments now survive later filename parsing, Jellyfin refreshes, and season repair.
|
||||
- Exact AniList matches with compatible seasons merge automatically, while likely (fuzzy) matches surface as a dismissible "Possible duplicate" suggestion instead of merging silently.
|
||||
- **Japanese Subtitle Generation**:
|
||||
- Generate Japanese subtitles locally with whisper.cpp, right from a modal (`Ctrl+Shift+G`), the subtitle sidebar's generation button when no subtitles are loaded, or `subminer generate-subs`, with progress, cancellation, and automatic loading into mpv when it's done.
|
||||
- Pick and download an official multilingual Whisper model in-app (including smaller quantized variants), or point Settings at one you already have. SubMiner recommends `large-v3-turbo` when CUDA is available and `small` otherwise, and tells you up front if `whisper-cli`, `ffmpeg`, or `ffprobe` can't be found.
|
||||
- An optional "Focus on spoken dialogue" mode uses a Silero VAD model to keep quiet or music-covered dialogue that would otherwise get dropped.
|
||||
- Long passages split near natural speech pauses, guided by an existing subtitle track when one is loaded, giving tighter timing and fewer repeated-word glitches.
|
||||
|
||||
- Duplicate Line Cleanup
|
||||
- The Vocabulary tab's new **Duplicates** button scans a chosen time window for the repeated-line bursts described under Fixed below and collapses each burst to a single line once confirmed.
|
||||
- A matching `subminer stats cleanup --duplicate-lines` command (with `--dry-run` and `--lookback-days <n>`) is available from the terminal.
|
||||
- Only the affected subtitle lines and the vocabulary counts they inflated are touched; watch time and lines-seen totals are left as recorded.
|
||||
- **Media Timing Review Frame Picker**:
|
||||
- The screenshot used for a mined card can now be chosen independently of the audio clip, with its own live preview, time slider, and frame-by-frame stepping.
|
||||
- Works for local video and for seekable remote streams like Jellyfin.
|
||||
|
||||
- **Overlay Keybinding Pickup**: The overlay now recognizes your mpv keybindings (from mpv's defaults, `input.conf`, and loaded scripts) as long as they don't conflict with SubMiner's own controls. Picked-up bindings work for the session but won't show up in the help menu.
|
||||
|
||||
- **Subtitle Selection Modal**:
|
||||
- An optional subtitle selection modal lets you pick primary and secondary mpv subtitle tracks without leaving the overlay.
|
||||
- Enable it in Settings under Behavior, then trigger it with `g` followed by `s`; turning it off restores mpv's normal subtitle selection binding.
|
||||
- Single-key shortcuts always take priority over multi-key sequences, and any conflicting sequence is disabled with a warning instead of misbehaving.
|
||||
|
||||
- **Subtitle Sidebar Selection & Copy**: You can now select dialogue across multiple subtitle sidebar rows and copy it, without timestamps, using Ctrl/Cmd+C or the Copy button, without seeking or mining a card.
|
||||
|
||||
- **Jimaku Live Action Search**: The Jimaku modal has separate Anime and Live Action tabs (switch with Arrow Left/Right) so you can search Jimaku's live-action subtitle catalogue directly.
|
||||
|
||||
- **Live-Action TMDB Library**:
|
||||
- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB.
|
||||
- Titles AniList can't match are looked up on TMDB automatically when the parsed filename matches a title exactly; otherwise use the new **Link to TMDB** action. Entries linked to the same TMDB title merge into one card, and the Library kind selector gained a Live Action option.
|
||||
- Release builds already include a TMDB key; if you run from source, set `tmdb.apiKey` (or `tmdb.apiKeyCommand`) yourself.
|
||||
|
||||
- **YouTube Library Kind**:
|
||||
- YouTube channels are now their own Library media kind, with new All Titles, Anime, and YouTube filters. Existing channel entries migrate automatically with viewing history and manual video assignments intact.
|
||||
- Channels stay out of AniList matching, season repair, and duplicate recommendations, and can't be merged or moved into an anime entry.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Bundled Bun Runtime**: Every SubMiner launcher, installed or downloaded, now runs on the Bun runtime bundled with the app instead of a system-wide Bun install. Recognized legacy launchers migrate automatically, Windows users get a new `subminer.cmd` download, and first-run setup now shows a single optional launcher control with runtime repair guidance only when something actually needs it.
|
||||
|
||||
- **Compressed Incremental Sync**: Cross-machine sync between compatible macOS/Linux machines now transfers only what changed, compressed, using a cached snapshot from the last sync to cut traffic further. Machines without a compatible rsync (including Windows) fall back to compressed scp automatically, older peers keep working, and transfers now time out after 30 minutes instead of hanging indefinitely.
|
||||
|
||||
- **Smaller Install Size**: Installers and the unpacked app are smaller after dropping demo media, source maps, TypeScript sources, test fixtures, and unused binaries, and sharing one Japanese UI font across windows. Release builds now publish a package-size comparison against the previous release.
|
||||
|
||||
- **Stats Server Request Safety**: The stats server, including the in-app stats overlay which now loads through it, only accepts requests from the local machine and requires a JSON content type for anything that changes data. If you were exposing the dashboard through a reverse proxy or Tailscale Serve, that's no longer supported, and any script posting to the stats API needs to send `Content-Type: application/json`.
|
||||
|
||||
- **Yomitan Updated**: Bundled Yomitan is updated to upstream 26.9.8, adding historical Japanese kana transformations and Ukrainian language support, plus improvements to Anki duplicate search and audio retrieval.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Subtitle Duplication from Karaoke & Animated Signs
|
||||
- Typeset ASS karaoke and animated signs no longer flood the overlay, subtitle sidebar, immersion history, mined cards, or stats with repeated glyph fragments or per-frame duplicates; the complete authored line is recovered instead, without merging genuinely repeated dialogue or separately positioned signs.
|
||||
- Fragmented karaoke now preserves the spaces the author placed between words instead of joining them together, and lyric transitions (including seeking into the middle of a line) resolve to the clean line instead of a stray entrance or exit frame.
|
||||
- The secondary overlay shares the same deduplication logic as the primary overlay, including collapsing lines that differ only by whitespace or trailing punctuation, and sidebar navigation moves between clean lyric lines while keeping the right line selected.
|
||||
- **Jellyfin**:
|
||||
- Playback, subtitles, artwork, and remote control now authenticate with an `ApiKey` parameter instead of legacy headers, so Jellyfin 12 works correctly even with legacy authorization disabled.
|
||||
- "Play on SubMiner" no longer silently drops the connection after about a minute on Jellyfin 12.
|
||||
- Casting now honors your configured mpv executable path, so playback works and portable plugins are detected correctly when mpv isn't on PATH.
|
||||
- The "now playing" bar clears when you close or finish a cast video instead of running to the end of the episode.
|
||||
- Anki cards mined from Jellyfin now get the real episode title in the misc info field instead of "Unknown media".
|
||||
- Jellyfin streams no longer leak URL-derived titles or credential-bearing URLs into metadata, Anki fields, Discord presence, stats, or AniList lookups; previously cached data that had credentials in it is cleaned up automatically.
|
||||
|
||||
- Anki Media Generation
|
||||
- Sentence-audio generation no longer times out on slow network-mounted video files with many subtitle and font streams, and a failed extraction now reports a clear error instead of a raw `ENOENT`.
|
||||
- Mined audio and animated AVIF clips now capture the subtitle line you actually mined, instead of whatever line happened to be on screen once slow audio extraction finished.
|
||||
- **Anki & Mining**:
|
||||
- Word audio now reads from its own configured field (`ankiConnect.fields.wordAudio`) instead of the sentence-audio field, fixing animated word images that started moving immediately instead of on demand.
|
||||
- Setting `ankiConnect.media.maxMediaDuration` to `0` for unlimited duration now also applies when mining from the stats dashboard, matching overlay mining.
|
||||
- Closing the overlay while a media timing review is still loading now properly cancels setup, restores playback if the review had paused it, and cleans up the hidden preview player.
|
||||
- Word-card sentence furigana now stays in sync with the full stats-search context and expanded timing-review selections, and clears stale readings automatically if regeneration fails.
|
||||
|
||||
- Character Dictionary Performance & Notifications
|
||||
- Character dictionary generation, merged rebuilds, and imports no longer freeze the app on large dictionaries, and cached results (including character portraits) are reused across launches instead of regenerating everything every time.
|
||||
- Portraits also now display correctly if their cache finishes loading after subtitles have already started showing.
|
||||
- Desktop progress notifications, including on Linux AppImage installs, now update in place instead of flickering closed and reopening.
|
||||
- **Settings**:
|
||||
- AnkiConnect, Kiku, and Senren settings are now validated before use, with a warning and a safe default for anything invalid instead of a bad value reaching runtime.
|
||||
- Settings marked as applying live now correctly avoid showing a restart warning, and mixed saves apply the live parts immediately while listing only the sections that actually need a restart.
|
||||
|
||||
- Overlay Reliability
|
||||
- Overlay modals (settings, stats, etc.) now open promptly on the first shortcut press, including on repeated sessions on Windows, and appear above fullscreen mpv on macOS instead of switching Spaces or opening off-screen.
|
||||
- The macOS window-tracking helper is now built for macOS 12.0+, so the overlay attaches to mpv on older systems like Ventura instead of crashing and getting stuck on "Overlay loading."
|
||||
- The overlay no longer gets stuck on "Overlay loading" indefinitely if mpv's connection stalls; it now retries and shows an actionable error after 30 seconds.
|
||||
- Fixed native Wayland drag-and-drop from file managers like Thunar, and fixed system-wide mouse lag on Windows caused by the overlay's click-through handling.
|
||||
- **Overlay**:
|
||||
- Clicking a subtitle sidebar cue no longer leaves Space bound to seeking back to it; Enter still seeks the focused cue, and Space keeps whatever playback action you've configured.
|
||||
- Hyprland recovery dialogs now stay above SubMiner windows instead of being covered by overlay placement updates.
|
||||
- Fixed a rare case on Linux where a delayed window-close callback could reopen the overlay after it was torn down.
|
||||
|
||||
- Stats Dashboard
|
||||
- Deletes, library merges, video moves, and AniList reassignments no longer freeze the stats dashboard or rebuild lifetime totals from scratch; large deletes that used to take minutes now finish in milliseconds.
|
||||
- Vocabulary totals and charts now count all tracked vocabulary instead of just the first page, and new-word history uses corrected daily rollups.
|
||||
- Calendar labels respect time zones west of UTC, and vocabulary cards refresh automatically after editing the word exclusion list (with a Retry option if a load fails).
|
||||
- **Stats**:
|
||||
- Malformed or partly invalid resource IDs are now rejected before they can affect library mutations or cover-art backfills.
|
||||
- Stats server port conflicts now surface as a status notification instead of crashing SubMiner, and startup/shutdown are more robust: concurrent startup requests share one attempt, stopping a background instance no longer disconnects an open dashboard, and shutdown no longer waits indefinitely on active requests.
|
||||
|
||||
- Linux Launcher Thumbnails
|
||||
- Fixed missing MKV thumbnails in the Linux rofi picker when the system thumbnailer only registers legacy Matroska MIME aliases.
|
||||
- **Subtitle Sidebar Gap Follow**: The subtitle sidebar now stays near actual playback position during gaps in files where a cue starts at time zero.
|
||||
|
||||
- **First Launch on macOS**: Fixed first launch exiting immediately when the SubMiner config directory didn't exist yet.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat(stats): add library entry merge and episode move by @ksyasuda in #190
|
||||
- fix(stats): stop counting duplicate typeset subtitle lines by @ksyasuda in #191
|
||||
- fix(media): tolerate slow MKV audio extraction by @ksyasuda in #195
|
||||
- fix(stats): subtract lifetime totals incrementally on delete by @ksyasuda in #196
|
||||
- fix(anki): snapshot mining media clip timing by @ksyasuda in #197
|
||||
- fix(notifications): replace Linux progress updates in place by @ksyasuda in #198
|
||||
- fix(overlay): support native Wayland file drag-and-drop by @ksyasuda in #199
|
||||
- fix(overlay): keep macOS modal windows on fullscreen Spaces by @ksyasuda in #200
|
||||
- fix(overlay): prevent Windows mouse lag during click-through tracking by @ksyasuda in #201
|
||||
- fix(stats): report complete vocabulary totals and new-word history by @ksyasuda in #202
|
||||
- fix(mpv): recover from stalled IPC connects by @ksyasuda in #204
|
||||
- fix(dictionary): prevent freezes and restore AppImage notifications by @ksyasuda in #205
|
||||
- fix(subtitles): recover canonical lines from ASS animation by @ksyasuda in #207
|
||||
- fix(overlay): deduplicate secondary subtitle rendering by @ksyasuda in #208
|
||||
- fix(launcher): restore Matroska thumbnails in Linux rofi picker by @ksyasuda in #210
|
||||
- fix(character-dictionary): cache completed MeCab refreshes by @ksyasuda in #212
|
||||
- feat(sidebar): add dialogue selection and copying by @ksyasuda in #238
|
||||
- feat(subtitles): add local Japanese subtitle generation by @ksyasuda in #240
|
||||
- perf(stats): use compressed incremental snapshot transfers by @ksyasuda in #241
|
||||
- fix(startup): create config directory before singleton lock by @ksyasuda in #242
|
||||
- feat(launcher): bundle Bun and use it across all launchers by @ksyasuda in #243
|
||||
- build(release): reduce package size and report release sizes by @ksyasuda in #244
|
||||
- fix(overlay): keep Hyprland recovery dialogs above overlays by @ksyasuda in #245
|
||||
- feat(overlay): discover unclaimed mpv key bindings by @ksyasuda in #246
|
||||
- fix(sidebar): preserve Space playback after cue seeking by @ksyasuda in #247
|
||||
- fix(jellyfin): fix jellyfin media metadata by @ksyasuda in #250
|
||||
- feat(jimaku): add live-action subtitle search by @ksyasuda in #251
|
||||
- feat(stats): add TMDB metadata for live-action dramas in the Library by @ksyasuda in #252
|
||||
- feat(stats): separate YouTube channels in the Library by @ksyasuda in #253
|
||||
- feat(mining): add a screenshot frame picker to media review by @aalhendi in #254
|
||||
- fix(config): align live save feedback with hot reload policy by @ksyasuda in #255
|
||||
- fix(anki): separate word audio mapping for animation sync by @ksyasuda in #256
|
||||
- fix(config): validate AnkiConnect and field grouping settings by @ksyasuda in #257
|
||||
- fix(anki): honor unlimited duration in stats mining by @ksyasuda in #258
|
||||
- fix(stats): reject malformed resource IDs before mutations by @ksyasuda in #259
|
||||
- fix(stats): harden server lifecycle and verify compiled runtime by @ksyasuda in #261
|
||||
- fix(overlay): cancel pending window transitions and timing reviews by @ksyasuda in #262
|
||||
- fix(stats): restrict local requests and serve the dashboard over HTTP by @ksyasuda in #263
|
||||
- fix(jellyfin): support modern authentication by @ksyasuda in #264
|
||||
- feat(overlay): add optional subtitle selection modal by @ksyasuda in #265
|
||||
- fix(jellyfin): respect Windows mpv configuration when casting by @aalhendi in #267
|
||||
- fix(anki): regenerate sentence furigana from the final sentence by @ksyasuda in #268
|
||||
|
||||
## New Contributors
|
||||
|
||||
- @aalhendi made their first contribution in #254
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
## Highlights
|
||||
### Added
|
||||
- **Japanese Subtitle Generation**:
|
||||
- Generate Japanese SRT subtitles locally with whisper.cpp. Start it from a new modal (Ctrl+Shift+G), from the generate button in an empty subtitle sidebar, or with `subminer generate-subs`.
|
||||
- Generation shows progress, can be cancelled, and loads the finished subtitles into mpv automatically.
|
||||
- Pick an official multilingual Whisper model, including quantized variants, with size and accuracy guidance. You can download it in the app or point Settings at a model you already have.
|
||||
- `large-v3-turbo` is recommended when CUDA support is detected, and `small` otherwise.
|
||||
- whisper-cli, ffmpeg, and ffprobe are found on PATH unless you override them. SubMiner names any missing tools before a download starts.
|
||||
- An optional "Focus on spoken dialogue" mode uses a separately downloaded Silero VAD model. It keeps audible sections it is unsure about, so dialogue under music is not dropped, but songs may also be transcribed.
|
||||
- Long passages are split near speech starts or quiet pauses to reduce subtitles that appear too early. When an eligible embedded or external subtitle track is loaded in mpv, it guides the split points.
|
||||
- Each passage runs in a fresh Whisper process, which prevents repeated-character output.
|
||||
- **Subtitle Selection Modal**:
|
||||
- An optional modal for choosing primary and secondary mpv subtitle tracks.
|
||||
- Turn it on in Settings under Behavior, then press g followed by s to open it. Turning it off restores mpv's own subtitle selection binding.
|
||||
- Single-key actions take priority over configured key sequence prefixes.
|
||||
- Conflicting sequences are disabled with a warning, and the existing y commands stay reserved.
|
||||
- **Subtitle Sidebar Copy**:
|
||||
- Select dialogue across several sidebar rows and copy it without timestamps using Ctrl/Cmd+C or the Copy button.
|
||||
- Selecting text does not seek playback and does not require mining a card.
|
||||
- **Media Timing Review Screenshot Picker**:
|
||||
- Choose the still screenshot separately from the audio range, with a live preview and its own time slider.
|
||||
- Step through decoded frames one at a time to get the exact frame you want.
|
||||
- Works with local video and with seekable remote streams such as Jellyfin.
|
||||
- **mpv Keybindings in the Overlay**:
|
||||
- The overlay now picks up keyboard bindings from mpv defaults, `input.conf`, and loaded scripts when they do not conflict with SubMiner.
|
||||
- SubMiner controls and bindings you explicitly disabled take precedence.
|
||||
- These bindings apply only to the current session and are not listed in the help menu.
|
||||
- **Jimaku Live Action Search**: The Jimaku modal has new Anime and Live action tabs, so you can search Jimaku's live action catalogue as well as anime. Use Arrow Left and Arrow Right to switch tabs.
|
||||
- **TMDB Live-Action Library**:
|
||||
- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB.
|
||||
- Release builds include a project key. Setting `tmdb.apiKey` or `tmdb.apiKeyCommand` overrides it, and one of them is required when running from source.
|
||||
- Titles that AniList cannot match are looked up on TMDB automatically when the parsed filename exactly matches a Japanese live-action title. For everything else, use the new **Link to TMDB** action.
|
||||
- Entries linked to the same TMDB title merge into one card, and the Library kind selector has a new Live Action option.
|
||||
- If a replacement download fails during provider reassignment, the previous link and artwork are kept. Merges and sync keep AniList and TMDB identities separate, and the merge dialog explains mixed selections instead of failing.
|
||||
- **YouTube Library Kind**:
|
||||
- YouTube channels are now their own Library media kind. Existing channel entries migrate automatically, and viewing history and manual video assignments are unchanged.
|
||||
- New All Titles, Anime, and YouTube Library filters.
|
||||
- Channels are excluded from AniList matching, season repair, and duplicate recommendations.
|
||||
- Merges and video moves can no longer combine an anime entry with a YouTube channel.
|
||||
|
||||
### Changed
|
||||
- **Launcher Uses Bundled Bun**:
|
||||
- Every installed and downloadable launcher now runs on the Bun runtime that ships with SubMiner. A system Bun is no longer needed.
|
||||
- Recognized legacy launchers migrate automatically.
|
||||
- Windows gets a `subminer.cmd` launcher download.
|
||||
- First-run setup is reduced to one optional launcher control. Runtime repair guidance appears only when it is needed.
|
||||
- **Faster Sync Transfers**:
|
||||
- Sync between compatible macOS and Linux machines now uses compressed, incremental rsync transfers.
|
||||
- The last snapshot received from each peer is cached, which reduces traffic on later syncs.
|
||||
- Machines without a compatible rsync, including Windows, fall back to compressed scp.
|
||||
- Older peers still work without the upload cache.
|
||||
- Transfers abort after 30 minutes.
|
||||
- **Stats Server Request Safety**:
|
||||
- The stats server now accepts loopback hosts only and rejects requests from browser origins other than its own.
|
||||
- Requests that change data must send an `application/json` body. Scripts that POST to the server need to set a JSON content type.
|
||||
- The in-app stats overlay now loads from the local server, so it gets the same protection.
|
||||
- Dashboards served through a reverse proxy or Tailscale Serve are no longer supported.
|
||||
- **Smaller Downloads**:
|
||||
- Installers and unpacked apps are smaller. Demo media, source maps, TypeScript sources, test fixtures, and unused Koffi binaries are no longer packaged.
|
||||
- All windows now share one Japanese UI font.
|
||||
- Release builds publish package size reports that compare against the previous release.
|
||||
- **Bundled Yomitan**: Updated with upstream Yomitan 26.9.8 changes, including historical Japanese kana transformations, Ukrainian language support, and improvements to Anki duplicate searches and audio retrieval.
|
||||
|
||||
### Fixed
|
||||
- **Jellyfin 12 Compatibility**:
|
||||
- Playback, subtitle, artwork, and remote-control requests now authenticate with the `ApiKey` query parameter, so the integration works on Jellyfin 12, where legacy authorization is off by default.
|
||||
- "Play on SubMiner" stays available. The cast connection now answers keep-alive requests and reconnects when the server stops responding, instead of silently dying after about a minute.
|
||||
- The Jellyfin "now playing" bar clears when you close or finish a cast video instead of running on to the end of the episode.
|
||||
- Cards mined during Jellyfin playback get the episode title in the misc info field again instead of "Unknown media".
|
||||
- **Jellyfin Privacy and Playback**:
|
||||
- Jellyfin streams no longer leak titles taken from the stream URL, or stream URLs that contain credentials, into metadata lookups, Anki source fields, Discord presence, stats, or AniList retries.
|
||||
- Previously cached metadata that contained credentials is cleaned up. Watch history and library assignments are not touched.
|
||||
- Jellyfin playback and casting now use your configured mpv executable, so they work when mpv is installed outside PATH. Portable plugins next to that executable are detected.
|
||||
- **Anki Mining**:
|
||||
- New `ankiConnect.fields.wordAudio` setting reads word audio separately from the sentence audio field. This fixes animated images that started moving immediately when `fields.audio` pointed to `SentenceAudio`. Existing animated images need to be regenerated to pick up the fix.
|
||||
- Sentence furigana on word cards stays in sync with the full stats-search context and with expanded timing review selections. Stale readings are cleared if regeneration fails.
|
||||
- Closing the overlay while media timing review is still loading now cancels the review, resumes playback if the review paused it, and cleans up the hidden preview player.
|
||||
- `ankiConnect.media.maxMediaDuration: 0` now means unlimited when mining from the stats dashboard, matching overlay mining.
|
||||
- Invalid AnkiConnect, Kiku, and Senren settings are now rejected with a warning and fall back to defaults.
|
||||
- **Stats Server Stability**:
|
||||
- A port conflict is now reported in a status notification instead of crashing SubMiner.
|
||||
- Simultaneous startup requests share one server start. Stopping the background server no longer disconnects dashboards open in the foreground.
|
||||
- Shutdown waits only a limited time for active requests to finish.
|
||||
- Malformed resource IDs, and ID lists with any invalid entries, are rejected before Library changes or cover backfills run.
|
||||
- **Subtitle Sidebar**:
|
||||
- Clicking a cue no longer leaves the row focused, and Space no longer seeks back to a focused cue. Enter still seeks to the focused cue, and Space keeps its configured playback action.
|
||||
- The sidebar stays near the current playback position during gaps when the subtitle file has a cue that starts at zero.
|
||||
- **Settings Save Feedback**:
|
||||
- Settings marked LIVE no longer show false restart warnings, including for notifications and subtitle generation.
|
||||
- When a save mixes live and restart-only changes, the live changes apply right away and only the changed sections that need a restart are listed.
|
||||
- **Overlay Windows**:
|
||||
- On Hyprland, recovery dialogs stay above SubMiner windows so overlay placement updates no longer cover their Wait and Close buttons.
|
||||
- On Linux, a delayed close callback during teardown can no longer reopen the overlay.
|
||||
- **First Launch on macOS**: SubMiner no longer exits on first launch when the config directory does not exist yet.
|
||||
|
||||
### Docs
|
||||
- **Launcher**: Documented the launcher install that uses the bundled runtime, migration from legacy launchers, package-managed updates, and the bundled Bun runtime's MIT and LGPL notices. The AUR package installs these notices under `/usr/share/licenses/subminer-bin`, and they are also included in `subminer-assets.tar.gz`.
|
||||
- **Subtitle Generation**: Documented model choice, VAD behavior, splitting guided by a reference track, fallback behavior, and known limits.
|
||||
- **Subtitle Selection**: Documented the subtitle selector setting, its shortcut override, and the primary and secondary track controls.
|
||||
- **Settings**: Clarified save feedback for live settings, warnings for saves that mix live and restart-only changes, and how subtitle generation settings reload.
|
||||
- **Jellyfin**:
|
||||
- Clarified that Windows mpv playback and Jellyfin casting can use a configured executable path instead of PATH.
|
||||
- Documented how Jellyfin media titles and stats identities keep stream credentials out of metadata.
|
||||
- **Stats Library**:
|
||||
- Documented TMDB linking, provider reassignment, merge compatibility, and caching of the credential command's output.
|
||||
- Documented YouTube channel filtering and video statistics in the Library.
|
||||
- **Mining**:
|
||||
- Documented choosing the screenshot separately in media timing review.
|
||||
- Documented the separate word audio field mapping, including that existing animated images need to be regenerated.
|
||||
- **Sync**: Documented compressed transfers, where the incremental sync cache is stored, and compatibility with older peers.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat(sidebar): add dialogue selection and copying by @ksyasuda in #238
|
||||
- feat(subtitles): add local Japanese subtitle generation by @ksyasuda in #240
|
||||
- perf(stats): use compressed incremental snapshot transfers by @ksyasuda in #241
|
||||
- fix(startup): create config directory before singleton lock by @ksyasuda in #242
|
||||
- feat(launcher): bundle Bun and use it across all launchers by @ksyasuda in #243
|
||||
- build(release): reduce package size and report release sizes by @ksyasuda in #244
|
||||
- fix(overlay): keep Hyprland recovery dialogs above overlays by @ksyasuda in #245
|
||||
- feat(overlay): discover unclaimed mpv key bindings by @ksyasuda in #246
|
||||
- fix(sidebar): preserve Space playback after cue seeking by @ksyasuda in #247
|
||||
- fix(jellyfin): fix jellyfin media metadata by @ksyasuda in #250
|
||||
- feat(jimaku): add live-action subtitle search by @ksyasuda in #251
|
||||
- feat(stats): add TMDB metadata for live-action dramas in the Library by @ksyasuda in #252
|
||||
- feat(stats): separate YouTube channels in the Library by @ksyasuda in #253
|
||||
- feat(mining): add a screenshot frame picker to media review by @aalhendi in #254
|
||||
- fix(config): align live save feedback with hot reload policy by @ksyasuda in #255
|
||||
- fix(anki): separate word audio mapping for animation sync by @ksyasuda in #256
|
||||
- fix(config): validate AnkiConnect and field grouping settings by @ksyasuda in #257
|
||||
- fix(anki): honor unlimited duration in stats mining by @ksyasuda in #258
|
||||
- fix(stats): reject malformed resource IDs before mutations by @ksyasuda in #259
|
||||
- fix(stats): harden server lifecycle and verify compiled runtime by @ksyasuda in #261
|
||||
- fix(overlay): cancel pending window transitions and timing reviews by @ksyasuda in #262
|
||||
- fix(stats): restrict local requests and serve the dashboard over HTTP by @ksyasuda in #263
|
||||
- fix(jellyfin): support modern authentication by @ksyasuda in #264
|
||||
- feat(overlay): add optional subtitle selection modal by @ksyasuda in #265
|
||||
- fix(jellyfin): respect Windows mpv configuration when casting by @aalhendi in #267
|
||||
- fix(anki): regenerate sentence furigana from the final sentence by @ksyasuda in #268
|
||||
|
||||
## New Contributors
|
||||
|
||||
- @aalhendi made their first contribution in #254
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz`, the `subminer` launcher, and the Windows `subminer.cmd` launcher
|
||||
- Bun corresponding source: `bun-v1.3.5-source.tar.gz` and its `.sha256` file
|
||||
|
||||
Both launcher downloads use Bun included with the SubMiner app. Download `subminer` on Linux or macOS and `subminer.cmd` on Windows.
|
||||
|
||||
The app bundles an unmodified Bun 1.3.5 runtime. Bun is MIT licensed and statically links JavaScriptCore (LGPL 2.0) and TinyCC (LGPL 2.1). License texts and third-party notices ship inside the app under `resources/bun/licenses`, and the source archive above contains the matching Bun, WebKit, and dependency sources for relinking.
|
||||
@@ -436,7 +436,9 @@ function readChangeFragments(cwd: string, deps?: ChangelogFsDeps): ChangeFragmen
|
||||
const CLAUDE_CLI_ARGS = [
|
||||
'-p',
|
||||
'--model',
|
||||
'sonnet',
|
||||
'opus',
|
||||
'--effort',
|
||||
'medium',
|
||||
'--permission-mode',
|
||||
'bypassPermissions',
|
||||
'--output-format',
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Release builds carry a project-owned TMDB key so live-action lookups work
|
||||
* without user setup. The key is injected from the SUBMINER_TMDB_API_KEY
|
||||
* environment variable at build time (a GitHub Actions secret in CI) and never
|
||||
* lives in the repository. The runtime reader is
|
||||
* src/core/services/tmdb/bundled-api-key.ts; keep the file name in sync.
|
||||
*/
|
||||
export const BUNDLED_INTEGRATION_KEYS_FILENAME = 'bundled-integration-keys.json';
|
||||
export const TMDB_API_KEY_ENV = 'SUBMINER_TMDB_API_KEY';
|
||||
|
||||
/**
|
||||
* Write the bundled keys file into `distDir`, or remove a stale one when no
|
||||
* key is present so a keyless build never ships an older key by accident.
|
||||
* Returns the names of the keys staged.
|
||||
*/
|
||||
export function stageBundledIntegrationKeys(distDir, env = process.env) {
|
||||
const outputPath = path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME);
|
||||
const tmdbApiKey = env[TMDB_API_KEY_ENV]?.trim() ?? '';
|
||||
if (!tmdbApiKey) {
|
||||
fs.rmSync(outputPath, { force: true });
|
||||
return [];
|
||||
}
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify({ tmdbApiKey })}\n`, { mode: 0o644 });
|
||||
return ['tmdb'];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
BUNDLED_INTEGRATION_KEYS_FILENAME,
|
||||
stageBundledIntegrationKeys,
|
||||
} from './bundled-integration-keys.mjs';
|
||||
|
||||
function withDistDir(work: (distDir: string) => void): void {
|
||||
const distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-bundled-keys-'));
|
||||
try {
|
||||
work(distDir);
|
||||
} finally {
|
||||
fs.rmSync(distDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('stageBundledIntegrationKeys writes the TMDB key from the environment', () => {
|
||||
withDistDir((distDir) => {
|
||||
const staged = stageBundledIntegrationKeys(distDir, { SUBMINER_TMDB_API_KEY: ' abc123 ' });
|
||||
assert.deepEqual(staged, ['tmdb']);
|
||||
const written = JSON.parse(
|
||||
fs.readFileSync(path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME), 'utf8'),
|
||||
);
|
||||
assert.deepEqual(written, { tmdbApiKey: 'abc123' });
|
||||
});
|
||||
});
|
||||
|
||||
test('stageBundledIntegrationKeys removes a stale file when the variable is unset', () => {
|
||||
withDistDir((distDir) => {
|
||||
const outputPath = path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME);
|
||||
fs.writeFileSync(outputPath, '{"tmdbApiKey":"old"}');
|
||||
assert.deepEqual(stageBundledIntegrationKeys(distDir, {}), []);
|
||||
assert.equal(fs.existsSync(outputPath), false);
|
||||
assert.deepEqual(stageBundledIntegrationKeys(distDir, { SUBMINER_TMDB_API_KEY: ' ' }), []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { createServer } from 'node:net';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const START_TIMEOUT_MS = 15_000;
|
||||
const STOP_TIMEOUT_MS = 5_000;
|
||||
const POLL_INTERVAL_MS = 25;
|
||||
const MAX_CHILD_OUTPUT_BYTES = 64 * 1024;
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRootArgIndex = process.argv.indexOf('--repo-root');
|
||||
const repoRoot = resolve(
|
||||
repoRootArgIndex === -1 ? join(scriptDir, '..') : (process.argv[repoRootArgIndex + 1] ?? ''),
|
||||
);
|
||||
const paths = {
|
||||
mainEntry: join(repoRoot, 'dist', 'main-entry.js'),
|
||||
daemonEntry: join(repoRoot, 'dist', 'stats-daemon-runner.js'),
|
||||
statsServer: join(repoRoot, 'dist', 'core', 'services', 'stats-server.js'),
|
||||
tracker: join(repoRoot, 'dist', 'core', 'services', 'immersion-tracker-service.js'),
|
||||
statsIndex: join(repoRoot, 'stats', 'dist', 'index.html'),
|
||||
};
|
||||
|
||||
function requireCompiledArtifacts() {
|
||||
const missing = Object.values(paths).filter((artifactPath) => !existsSync(artifactPath));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Compiled runtime artifacts are missing. Run \`bun run build\` before this check:\n${missing
|
||||
.map((artifactPath) => ` - ${artifactPath}`)
|
||||
.join('\n')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requireElectronNodeRuntime() {
|
||||
if (!process.versions.electron || process.env.ELECTRON_RUN_AS_NODE !== '1') {
|
||||
throw new Error(
|
||||
'This check must run with Electron in Node mode. Use `bun run test:smoke:dist`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
||||
}
|
||||
|
||||
function listen(server, port = 0) {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const onError = (error) => {
|
||||
server.off('listening', onListening);
|
||||
reject(error);
|
||||
};
|
||||
const onListening = () => {
|
||||
server.off('error', onError);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
reject(new Error('Could not resolve the stats smoke port.'));
|
||||
return;
|
||||
}
|
||||
resolvePromise(address.port);
|
||||
};
|
||||
server.once('error', onError);
|
||||
server.once('listening', onListening);
|
||||
server.listen(port, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolvePromise();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function findAvailablePort() {
|
||||
const reservation = createServer();
|
||||
const port = await listen(reservation);
|
||||
await closeServer(reservation);
|
||||
return port;
|
||||
}
|
||||
|
||||
function writeSmokeConfig(userDataPath, port) {
|
||||
writeFileSync(
|
||||
join(userDataPath, 'config.json'),
|
||||
`${JSON.stringify({ stats: { serverPort: port } })}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function spawnDaemon(userDataPath, responsePath) {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
paths.daemonEntry,
|
||||
'--stats-user-data-path',
|
||||
userDataPath,
|
||||
'--stats-response-path',
|
||||
responsePath,
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
);
|
||||
let output = '';
|
||||
const appendOutput = (chunk) => {
|
||||
if (output.length >= MAX_CHILD_OUTPUT_BYTES) return;
|
||||
output += String(chunk);
|
||||
if (output.length > MAX_CHILD_OUTPUT_BYTES) {
|
||||
output = `${output.slice(0, MAX_CHILD_OUTPUT_BYTES)}\n[child output truncated]\n`;
|
||||
}
|
||||
};
|
||||
child.stdout.on('data', appendOutput);
|
||||
child.stderr.on('data', appendOutput);
|
||||
let exited = false;
|
||||
const exit = new Promise((resolvePromise) => {
|
||||
child.once('error', (error) => {
|
||||
exited = true;
|
||||
resolvePromise({ code: null, signal: null, error });
|
||||
});
|
||||
child.once('exit', (code, signal) => {
|
||||
exited = true;
|
||||
resolvePromise({ code, signal, error: null });
|
||||
});
|
||||
});
|
||||
return { child, exit, hasExited: () => exited, getOutput: () => output };
|
||||
}
|
||||
|
||||
async function waitForResponse(responsePath, daemon) {
|
||||
const deadline = Date.now() + START_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
if (existsSync(responsePath)) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(responsePath, 'utf8'));
|
||||
} catch {
|
||||
// The daemon may still be finishing the response file write.
|
||||
}
|
||||
}
|
||||
if (daemon.hasExited()) {
|
||||
const result = await daemon.exit;
|
||||
throw new Error(
|
||||
`Stats daemon exited before writing a startup response (${formatExit(result)}).\n${daemon.getOutput()}`,
|
||||
);
|
||||
}
|
||||
await delay(POLL_INTERVAL_MS);
|
||||
}
|
||||
throw new Error(`Timed out waiting for stats daemon startup.\n${daemon.getOutput()}`);
|
||||
}
|
||||
|
||||
function formatExit(result) {
|
||||
if (result.error) return result.error.message;
|
||||
if (result.signal) return `signal ${result.signal}`;
|
||||
return `exit code ${result.code}`;
|
||||
}
|
||||
|
||||
async function waitForExit(daemon, timeoutMs) {
|
||||
return await Promise.race([daemon.exit, delay(timeoutMs).then(() => null)]);
|
||||
}
|
||||
|
||||
async function stopDaemon(daemon) {
|
||||
if (daemon.hasExited()) {
|
||||
return await daemon.exit;
|
||||
}
|
||||
daemon.child.kill('SIGTERM');
|
||||
const result = await waitForExit(daemon, STOP_TIMEOUT_MS);
|
||||
if (result) return result;
|
||||
daemon.child.kill('SIGKILL');
|
||||
await daemon.exit;
|
||||
throw new Error(`Stats daemon did not stop after SIGTERM.\n${daemon.getOutput()}`);
|
||||
}
|
||||
|
||||
async function assertPortCanBind(port) {
|
||||
const server = createServer();
|
||||
try {
|
||||
await listen(server, port);
|
||||
} finally {
|
||||
if (server.listening) await closeServer(server);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOverview(url, daemon) {
|
||||
const deadline = Date.now() + START_TIMEOUT_MS;
|
||||
let lastError = null;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
return await fetch(`${url}/api/stats/overview`, {
|
||||
signal: AbortSignal.timeout(START_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
if (daemon.hasExited()) {
|
||||
const result = await daemon.exit;
|
||||
throw new Error(
|
||||
`Stats daemon exited before accepting HTTP requests (${formatExit(result)}).\n${daemon.getOutput()}`,
|
||||
);
|
||||
}
|
||||
await delay(POLL_INTERVAL_MS);
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for the stats HTTP server: ${lastError instanceof Error ? lastError.message : String(lastError)}\n${daemon.getOutput()}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function runHealthyStartup(userDataPath, port, responseName) {
|
||||
writeSmokeConfig(userDataPath, port);
|
||||
const responsePath = join(userDataPath, responseName);
|
||||
const statePath = join(userDataPath, 'stats-daemon.json');
|
||||
const databasePath = join(userDataPath, 'immersion.sqlite');
|
||||
const daemon = spawnDaemon(userDataPath, responsePath);
|
||||
let shutdownResult;
|
||||
let unfinishedRequest;
|
||||
|
||||
try {
|
||||
const startup = await waitForResponse(responsePath, daemon);
|
||||
assert.deepEqual(startup, { ok: true, url: `http://127.0.0.1:${port}` });
|
||||
|
||||
const response = await fetchOverview(startup.url, daemon);
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(response.headers.get('content-type') ?? '', /^application\/json\b/);
|
||||
const overview = await response.json();
|
||||
assert.equal(typeof overview, 'object');
|
||||
assert.ok(overview !== null);
|
||||
assert.ok(Array.isArray(overview.sessions));
|
||||
assert.ok(Array.isArray(overview.rollups));
|
||||
assert.equal(typeof overview.hints, 'object');
|
||||
|
||||
const dashboard = await fetch(`${startup.url}/?overlay=1`, {
|
||||
signal: AbortSignal.timeout(START_TIMEOUT_MS),
|
||||
});
|
||||
assert.equal(dashboard.status, 200);
|
||||
assert.match(dashboard.headers.get('content-type') ?? '', /^text\/html\b/);
|
||||
assert.match(await dashboard.text(), /id="root"/);
|
||||
|
||||
assert.ok(existsSync(databasePath), 'The compiled tracker did not create its SQLite database.');
|
||||
assert.ok(
|
||||
statSync(databasePath).size > 0,
|
||||
'The compiled tracker created an empty SQLite file.',
|
||||
);
|
||||
|
||||
// Leave a real request body unfinished so shutdown must bound its drain wait.
|
||||
unfinishedRequest = httpRequest(new URL('/api/stats/anki/notesInfo', startup.url), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': '1000',
|
||||
Expect: '100-continue',
|
||||
},
|
||||
signal: AbortSignal.timeout(START_TIMEOUT_MS),
|
||||
});
|
||||
await new Promise((resolvePromise, reject) => {
|
||||
unfinishedRequest.on('error', reject);
|
||||
unfinishedRequest.once('continue', () => {
|
||||
unfinishedRequest.write('{');
|
||||
resolvePromise();
|
||||
});
|
||||
unfinishedRequest.flushHeaders();
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
shutdownResult = await stopDaemon(daemon);
|
||||
} finally {
|
||||
unfinishedRequest?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(shutdownResult.code, 0, `Stats daemon shutdown failed.\n${daemon.getOutput()}`);
|
||||
assert.equal(existsSync(statePath), false, 'Stats daemon state remained after shutdown.');
|
||||
await assertPortCanBind(port);
|
||||
}
|
||||
|
||||
async function runConflictRecovery() {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'subminer-compiled-conflict-'));
|
||||
const reservation = createServer();
|
||||
const port = await listen(reservation);
|
||||
const responsePath = join(userDataPath, 'conflict-response.json');
|
||||
const statePath = join(userDataPath, 'stats-daemon.json');
|
||||
writeSmokeConfig(userDataPath, port);
|
||||
const daemon = spawnDaemon(userDataPath, responsePath);
|
||||
const failures = [];
|
||||
|
||||
try {
|
||||
const response = await waitForResponse(responsePath, daemon);
|
||||
if (response.ok !== false) {
|
||||
failures.push('The stats daemon reported success while its configured port was occupied.');
|
||||
}
|
||||
const result = await waitForExit(daemon, STOP_TIMEOUT_MS);
|
||||
if (!result) {
|
||||
failures.push('The stats daemon did not exit after its configured port failed to bind.');
|
||||
} else if (result.code === 0) {
|
||||
failures.push(
|
||||
'The stats daemon exited successfully after its configured port failed to bind.',
|
||||
);
|
||||
}
|
||||
if (existsSync(statePath)) {
|
||||
failures.push(
|
||||
'The stats daemon left ownership state behind after its configured port failed to bind.',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await closeServer(reservation);
|
||||
if (!daemon.hasExited()) {
|
||||
await stopDaemon(daemon);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await runHealthyStartup(userDataPath, port, 'recovery-response.json');
|
||||
} finally {
|
||||
rmSync(userDataPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
assert.deepEqual(failures, []);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
requireCompiledArtifacts();
|
||||
requireElectronNodeRuntime();
|
||||
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'subminer-compiled-runtime-'));
|
||||
try {
|
||||
await runHealthyStartup(userDataPath, await findAvailablePort(), 'startup-response.json');
|
||||
} finally {
|
||||
rmSync(userDataPath, { recursive: true, force: true });
|
||||
}
|
||||
await runConflictRecovery();
|
||||
|
||||
process.stdout.write(
|
||||
`Compiled runtime smoke passed with Electron ${process.versions.electron}, Node ${process.versions.node}, HTTP, and native SQLite.\n`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
test('compiled runtime smoke fails clearly when build artifacts are missing', () => {
|
||||
const emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-compiled-missing-'));
|
||||
try {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['scripts/compiled-runtime-smoke.mjs', '--repo-root', emptyRepo],
|
||||
{
|
||||
cwd: path.resolve(import.meta.dir, '..'),
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /Compiled runtime artifacts are missing/);
|
||||
assert.match(result.stderr, /dist\/main-entry\.js/);
|
||||
assert.match(result.stderr, /dist\/stats-daemon-runner\.js/);
|
||||
assert.doesNotMatch(result.stderr, /must run with Electron/);
|
||||
} finally {
|
||||
fs.rmSync(emptyRepo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -51,10 +51,13 @@ function listFiles(root, prefix = '') {
|
||||
});
|
||||
}
|
||||
|
||||
// asar resolves lookups with the platform separator, so stat with the listed
|
||||
// native path and only normalize the reported name.
|
||||
function listAppFiles(archive) {
|
||||
return asar.listPackage(archive).flatMap((entry) => {
|
||||
const name = entry.replaceAll('\\', '/').replace(/^\//, '');
|
||||
const stat = asar.statFile(archive, name);
|
||||
const native = entry.replace(/^[\\/]/, '');
|
||||
const stat = asar.statFile(archive, native);
|
||||
const name = native.replaceAll('\\', '/');
|
||||
return 'size' in stat ? [{ path: name, bytes: stat.size }] : [];
|
||||
});
|
||||
}
|
||||
@@ -129,7 +132,7 @@ function verifyContents(archive, resources, platform, arch) {
|
||||
assert(!name.path.startsWith('minecard'), `Demo media shipped: ${name.path}`);
|
||||
}
|
||||
for (const ui of ['renderer', 'settings', 'syncui']) {
|
||||
const css = asar.extractFile(archive, `dist/${ui}/style.css`).toString();
|
||||
const css = asar.extractFile(archive, path.join('dist', ui, 'style.css')).toString();
|
||||
assert(css.includes('../fonts/MPLUS1[wght].ttf'), `Shared font missing from ${ui} CSS`);
|
||||
}
|
||||
return entries;
|
||||
|
||||
@@ -134,10 +134,12 @@ test('archive inventory handles native files without counting them twice on disk
|
||||
mkdirSync(output);
|
||||
writeFileSync(path.join(input, 'main.js'), 'hello');
|
||||
writeFileSync(path.join(input, 'native.node'), 'native');
|
||||
mkdirSync(path.join(input, 'dist', 'ai'), { recursive: true });
|
||||
writeFileSync(path.join(input, 'dist', 'ai', 'client.js'), 'nested');
|
||||
const archive = path.join(output, 'app.asar');
|
||||
await createPackageFromStreams(
|
||||
archive,
|
||||
['main.js', 'native.node'].map((name) => ({
|
||||
['main.js', 'native.node', 'dist/ai/client.js'].map((name) => ({
|
||||
path: name,
|
||||
type: 'file',
|
||||
unpacked: name.endsWith('.node'),
|
||||
@@ -148,6 +150,7 @@ test('archive inventory handles native files without counting them twice on disk
|
||||
assert.deepEqual(listAppFiles(archive), [
|
||||
{ path: 'main.js', bytes: 5 },
|
||||
{ path: 'native.node', bytes: 6 },
|
||||
{ path: 'dist/ai/client.js', bytes: 6 },
|
||||
]);
|
||||
assert.equal(
|
||||
listFiles(output).reduce((sum: number, entry: { bytes: number }) => sum + entry.bytes, 0),
|
||||
|
||||
@@ -3,40 +3,14 @@ import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function buildScript(name: string): string {
|
||||
test('build:syncui bundles the sandboxed preload and keeps Electron external', () => {
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(import.meta.dir, '..', 'package.json'), 'utf8'),
|
||||
) as { scripts: Record<string, string> };
|
||||
return packageJson.scripts[name] ?? '';
|
||||
}
|
||||
|
||||
test('build:syncui bundles the sandboxed preload and keeps Electron external', () => {
|
||||
const command = buildScript('build:syncui');
|
||||
const command = packageJson.scripts['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');
|
||||
const sharedPreloadSource = fs.readFileSync(
|
||||
path.join(import.meta.dir, '..', 'src', 'preload-anime-browser-api.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// 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/);
|
||||
// The standalone Anime window uses Electron's sandboxed preload runtime,
|
||||
// which exposes `electron` but cannot require arbitrary Node built-ins.
|
||||
assert.doesNotMatch(sharedPreloadSource, /from ['"]node:/);
|
||||
});
|
||||
|
||||
test('build:animeui runs as part of the top-level build', () => {
|
||||
assert.match(buildScript('build'), /bun run build:animeui/);
|
||||
});
|
||||
@@ -3,18 +3,16 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { stageBundledIntegrationKeys, TMDB_API_KEY_ENV } from './bundled-integration-keys.mjs';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const assetsSourceDir = path.join(repoRoot, 'assets');
|
||||
const rendererSourceDir = path.join(repoRoot, 'src', 'renderer');
|
||||
const rendererOutputDir = path.join(repoRoot, 'dist', 'renderer');
|
||||
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');
|
||||
@@ -29,11 +27,9 @@ function copyFile(sourcePath, outputPath) {
|
||||
fs.copyFileSync(sourcePath, outputPath);
|
||||
}
|
||||
|
||||
function copyAssets(sourceDir, outputDir, label, stylesheets = ['style.css']) {
|
||||
function copyAssets(sourceDir, outputDir, label) {
|
||||
copyFile(path.join(sourceDir, 'index.html'), path.join(outputDir, 'index.html'));
|
||||
for (const stylesheet of stylesheets) {
|
||||
copyFile(path.join(sourceDir, stylesheet), path.join(outputDir, stylesheet));
|
||||
}
|
||||
copyFile(path.join(sourceDir, 'style.css'), path.join(outputDir, 'style.css'));
|
||||
process.stdout.write(`Staged ${label} assets in ${outputDir}\n`);
|
||||
}
|
||||
|
||||
@@ -49,15 +45,6 @@ function copySyncUiAssets() {
|
||||
copyAssets(syncUiSourceDir, syncUiOutputDir, 'syncui');
|
||||
}
|
||||
|
||||
function copyAnimeUiAssets() {
|
||||
copyAssets(animeUiSourceDir, animeUiOutputDir, 'animeui', [
|
||||
'style.css',
|
||||
'detail.css',
|
||||
'panels.css',
|
||||
]);
|
||||
copyFile(path.join(assetsSourceDir, 'SubMiner.png'), path.join(animeUiOutputDir, 'SubMiner.png'));
|
||||
}
|
||||
|
||||
function fallbackToMacosSource() {
|
||||
copyFile(macosHelperSourcePath, macosHelperSourceCopyPath);
|
||||
process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`);
|
||||
@@ -111,6 +98,17 @@ function buildMacosHelper() {
|
||||
}
|
||||
}
|
||||
|
||||
// Only the key names are logged, never the values: CI masks secrets, but a
|
||||
// stray echo would still leak them into local build logs.
|
||||
function stageIntegrationKeys() {
|
||||
const staged = stageBundledIntegrationKeys(path.join(repoRoot, 'dist'));
|
||||
process.stdout.write(
|
||||
staged.length > 0
|
||||
? `Staged bundled integration keys: ${staged.join(', ')}\n`
|
||||
: `No bundled integration keys (${TMDB_API_KEY_ENV} unset)\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function main() {
|
||||
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(repoRoot, 'dist', 'fonts'), {
|
||||
recursive: true,
|
||||
@@ -119,8 +117,8 @@ function main() {
|
||||
copyRendererAssets();
|
||||
copySettingsAssets();
|
||||
copySyncUiAssets();
|
||||
copyAnimeUiAssets();
|
||||
buildMacosHelper();
|
||||
stageIntegrationKeys();
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync('scripts/prepare-build-assets.mjs', 'utf8');
|
||||
@@ -19,26 +19,6 @@ test('macOS helper build creates dist scripts directory before swiftc output', (
|
||||
);
|
||||
});
|
||||
|
||||
test('anime UI stylesheet files exist and are all staged', () => {
|
||||
const html = readFileSync('src/animeui/index.html', 'utf8');
|
||||
const stylesheets = [...html.matchAll(/<link rel="stylesheet" href="\.\/(.+?\.css)"/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
|
||||
assert.deepEqual(stylesheets, ['style.css', 'detail.css', 'panels.css']);
|
||||
for (const stylesheet of stylesheets) {
|
||||
assert.equal(existsSync(`src/animeui/${stylesheet}`), true, `${stylesheet} must exist`);
|
||||
}
|
||||
assert.match(
|
||||
source,
|
||||
/copyAssets\(animeUiSourceDir, animeUiOutputDir, 'animeui', \[\s*'style\.css',\s*'detail\.css',\s*'panels\.css',?\s*\]\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/copyFile\(\s*path\.join\(assetsSourceDir, 'SubMiner\.png'\),\s*path\.join\(animeUiOutputDir, 'SubMiner\.png'\),?\s*\)/,
|
||||
);
|
||||
});
|
||||
|
||||
// Regression guard for #213: an untargeted swiftc stamps the build machine's OS
|
||||
// version as the helper's minimum, so released builds refuse to load on older macOS.
|
||||
test('macOS helper is compiled with an explicit deployment target', () => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolve } from 'node:path';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { mergeLcovReports, resolveCoverageDir } from './run-coverage-lane';
|
||||
import { mergeLcovReports, resolveCoverageDir, runCoverageLane } from './run-coverage-lane';
|
||||
|
||||
test('mergeLcovReports combines duplicate source-file counters across shard outputs', () => {
|
||||
const merged = mergeLcovReports([
|
||||
@@ -72,3 +74,33 @@ test('resolveCoverageDir keeps coverage output inside the repository', () => {
|
||||
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '../escape']));
|
||||
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '/tmp/escape']));
|
||||
});
|
||||
|
||||
test('runCoverageLane returns a failure when a discovered test fails', () => {
|
||||
const repoRoot = mkdtempSync(join(tmpdir(), 'subminer-coverage-failure-'));
|
||||
try {
|
||||
mkdirSync(join(repoRoot, 'src'));
|
||||
writeFileSync(
|
||||
join(repoRoot, 'src', 'failure.test.ts'),
|
||||
[
|
||||
"import assert from 'node:assert/strict';",
|
||||
"import test from 'node:test';",
|
||||
'',
|
||||
"test('intentional coverage failure', () => {",
|
||||
" assert.fail('coverage runner must propagate this failure');",
|
||||
'});',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.notEqual(
|
||||
runCoverageLane({
|
||||
repoRootDir: repoRoot,
|
||||
argv: ['bun-src-full', '--coverage-dir', 'coverage/test-src'],
|
||||
stdio: 'pipe',
|
||||
}),
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
rmSync(repoRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -201,14 +201,18 @@ export function mergeLcovReports(reports: string[]): string {
|
||||
return chunks.length > 0 ? `${chunks.join('\n')}\n` : '';
|
||||
}
|
||||
|
||||
function runCoverageLane(): number {
|
||||
const laneName = process.argv[2];
|
||||
export function runCoverageLane(
|
||||
options: { repoRootDir?: string; argv?: string[]; stdio?: 'inherit' | 'pipe' } = {},
|
||||
): number {
|
||||
const repoRootDir = options.repoRootDir ?? repoRoot;
|
||||
const argv = options.argv ?? process.argv.slice(2);
|
||||
const laneName = argv[0];
|
||||
if (laneName === undefined) {
|
||||
process.stderr.write('Missing coverage lane name\n');
|
||||
return 1;
|
||||
}
|
||||
|
||||
const coverageDir = resolveCoverageDir(repoRoot, process.argv.slice(3));
|
||||
const coverageDir = resolveCoverageDir(repoRootDir, argv.slice(1));
|
||||
const shardRoot = join(coverageDir, '.shards');
|
||||
mkdirSync(coverageDir, { recursive: true });
|
||||
rmSync(shardRoot, { recursive: true, force: true });
|
||||
@@ -216,7 +220,7 @@ function runCoverageLane(): number {
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = collectLaneFiles(repoRoot, laneName);
|
||||
files = collectLaneFiles(repoRootDir, laneName);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
|
||||
return 1;
|
||||
@@ -230,8 +234,8 @@ function runCoverageLane(): number {
|
||||
'bun',
|
||||
['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
cwd: repoRootDir,
|
||||
stdio: options.stdio ?? 'inherit',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -253,7 +257,7 @@ function runCoverageLane(): number {
|
||||
|
||||
writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8');
|
||||
process.stdout.write(
|
||||
`Merged LCOV written to ${relative(repoRoot, join(coverageDir, 'lcov.info'))}\n`,
|
||||
`Merged LCOV written to ${relative(repoRootDir, join(coverageDir, 'lcov.info'))}\n`,
|
||||
);
|
||||
return 0;
|
||||
} finally {
|
||||
|
||||
@@ -14,7 +14,12 @@ delete env.ELECTRON_RUN_AS_NODE;
|
||||
try {
|
||||
const result = spawnSync(
|
||||
require('electron'),
|
||||
[fileURLToPath(new URL('./smoke-package.cjs', import.meta.url)), path.resolve(resources)],
|
||||
[
|
||||
fileURLToPath(new URL('./smoke-package.cjs', import.meta.url)),
|
||||
path.resolve(resources),
|
||||
// CI runners lack a setuid chrome-sandbox; this harness never loads remote content.
|
||||
...(process.platform === 'linux' ? ['--no-sandbox'] : []),
|
||||
],
|
||||
{ env, stdio: 'inherit', timeout: 75_000 },
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Run with the pinned Electron runtime against a finished app's resources folder.
|
||||
const { app, BrowserWindow, session } = require('electron');
|
||||
const fs = require('node:fs');
|
||||
const http = require('node:http');
|
||||
const path = require('node:path');
|
||||
const { createRequire } = require('node:module');
|
||||
const assert = require('node:assert/strict');
|
||||
@@ -21,6 +22,39 @@ const timeout = setTimeout(() => {
|
||||
app.exit(1);
|
||||
}, 60_000);
|
||||
|
||||
const STATIC_TYPES = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'text/javascript',
|
||||
'.css': 'text/css',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.json': 'application/json',
|
||||
};
|
||||
|
||||
// The stats dashboard is served by the stats HTTP server in the app, so load it
|
||||
// over loopback HTTP from the packaged stats/dist and treat missing static
|
||||
// assets as failures. API routes are not part of this smoke and may 404.
|
||||
function serveStatsDist(root, failedRequests) {
|
||||
const server = http.createServer((req, res) => {
|
||||
const pathname = new URL(req.url, 'http://127.0.0.1').pathname;
|
||||
const relative = pathname === '/' ? 'index.html' : pathname.slice(1);
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(root, relative));
|
||||
res.writeHead(200, {
|
||||
'Content-Type': STATIC_TYPES[path.extname(relative)] ?? 'application/octet-stream',
|
||||
});
|
||||
res.end(body);
|
||||
} catch {
|
||||
if (!pathname.startsWith('/api/')) failedRequests.push(`${req.url}: missing static asset`);
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
return server;
|
||||
}
|
||||
|
||||
async function smoke() {
|
||||
await app.whenReady();
|
||||
const packagedRequire = createRequire(path.join(archive, 'package.json'));
|
||||
@@ -50,10 +84,17 @@ async function smoke() {
|
||||
);
|
||||
assert(extension.id, 'Yomitan extension failed to load');
|
||||
const failedRequests = [];
|
||||
session.defaultSession.webRequest.onErrorOccurred({ urls: ['file://*/*'] }, (details) => {
|
||||
if (details.error !== 'net::ERR_ABORTED')
|
||||
failedRequests.push(`${details.url}: ${details.error}`);
|
||||
});
|
||||
session.defaultSession.webRequest.onErrorOccurred(
|
||||
{ urls: ['file://*/*', 'http://127.0.0.1/*'] },
|
||||
(details) => {
|
||||
// Chromium probes the cache before fetching @font-face fonts; an uncached
|
||||
// font reports ERR_CACHE_MISS and is then fetched normally.
|
||||
if (!['net::ERR_ABORTED', 'net::ERR_CACHE_MISS'].includes(details.error))
|
||||
failedRequests.push(`${details.url}: ${details.error}`);
|
||||
},
|
||||
);
|
||||
const statsServer = serveStatsDist(path.join(archive, 'stats', 'dist'), failedRequests);
|
||||
await once(statsServer, 'listening');
|
||||
for (const ui of ['renderer', 'settings', 'syncui', 'stats']) {
|
||||
const win = new BrowserWindow({
|
||||
show: false,
|
||||
@@ -63,10 +104,12 @@ async function smoke() {
|
||||
},
|
||||
});
|
||||
try {
|
||||
await win.loadFile(
|
||||
path.join(archive, ui === 'stats' ? 'stats/dist/index.html' : `dist/${ui}/index.html`),
|
||||
);
|
||||
if (ui !== 'stats') {
|
||||
if (ui === 'stats') {
|
||||
await win.loadURL(`http://127.0.0.1:${statsServer.address().port}/`);
|
||||
// Let in-flight font requests settle before the window goes away.
|
||||
await win.webContents.executeJavaScript('document.fonts.ready.then(() => true)');
|
||||
} else {
|
||||
await win.loadFile(path.join(archive, `dist/${ui}/index.html`));
|
||||
const loaded = await win.webContents.executeJavaScript(
|
||||
`document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`,
|
||||
);
|
||||
@@ -76,6 +119,7 @@ async function smoke() {
|
||||
win.destroy();
|
||||
}
|
||||
}
|
||||
statsServer.close();
|
||||
assert.deepEqual(failedRequests, [], 'Packaged UI resources failed to load');
|
||||
console.log(
|
||||
'Package smoke passed: SQLite, platform FFI, texthooker, Yomitan loading, UI pages, shared Japanese font.',
|
||||
|
||||
@@ -69,6 +69,12 @@ local ctx = {
|
||||
return {
|
||||
numericSelectionTimeoutMs = 3000,
|
||||
bindings = {
|
||||
{
|
||||
key = { code = "KeyG-KeyS", modifiers = {} },
|
||||
actionType = "session-action",
|
||||
actionId = "openSubtitleSelection",
|
||||
cliArgs = { "--session-action", '{"actionId":"openSubtitleSelection"}' },
|
||||
},
|
||||
{
|
||||
key = {
|
||||
code = "KeyO",
|
||||
@@ -312,7 +318,8 @@ local ctx = {
|
||||
cliArgs = { "--session-action", '{"actionId":"openFuturePanel"}' },
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
nil
|
||||
end,
|
||||
},
|
||||
state = {
|
||||
@@ -430,17 +437,11 @@ assert_true(play_next_call ~= nil, "play-next binding should invoke CLI action")
|
||||
assert_true(play_next_call[2] == "--play-next-subtitle", "play-next binding should pass CLI flag")
|
||||
|
||||
local character_dictionary_manager = find_binding("Ctrl+d")
|
||||
assert_true(
|
||||
character_dictionary_manager ~= nil,
|
||||
"character dictionary manager binding should be registered"
|
||||
)
|
||||
assert_true(character_dictionary_manager ~= nil, "character dictionary manager binding should be registered")
|
||||
|
||||
character_dictionary_manager.fn()
|
||||
local character_dictionary_manager_call = recorded.async_calls[#recorded.async_calls]
|
||||
assert_true(
|
||||
character_dictionary_manager_call ~= nil,
|
||||
"character dictionary manager binding should invoke CLI action"
|
||||
)
|
||||
assert_true(character_dictionary_manager_call ~= nil, "character dictionary manager binding should invoke CLI action")
|
||||
assert_true(
|
||||
character_dictionary_manager_call[2] == "--session-action",
|
||||
"character dictionary manager binding should use generic session action CLI flag"
|
||||
@@ -474,3 +475,35 @@ assert_true(call[2] == "--mine-sentence-multiple", "CLI action should enter mine
|
||||
assert_true(call[3] == nil, "CLI action should not bind a plugin-side digit count")
|
||||
|
||||
print("plugin session binding regression tests: OK")
|
||||
|
||||
local selector = find_binding("g-s")
|
||||
assert_true(selector ~= nil, "subtitle selection should override mpv g-s with a forced sequence")
|
||||
selector.fn()
|
||||
local selection_call = recorded.async_calls[#recorded.async_calls]
|
||||
assert_true(
|
||||
selection_call[3] == '{"actionId":"openSubtitleSelection"}',
|
||||
"subtitle selection should dispatch its session action"
|
||||
)
|
||||
|
||||
local native_bindings = {}
|
||||
function mp.get_property_native(name)
|
||||
assert_true(name == "input-bindings", "only native input bindings should be queried")
|
||||
return native_bindings
|
||||
end
|
||||
|
||||
for _, case in ipairs({
|
||||
{ key = "g", priority = 1, enabled = false },
|
||||
{ key = "G", priority = 1, enabled = true },
|
||||
{ key = "Shift+g", priority = 1, enabled = true },
|
||||
{ key = "Ctrl+g", priority = 1, enabled = true },
|
||||
{ key = "g", priority = -1, enabled = true },
|
||||
{ key = "g", priority = 1, cmd = "ignore", enabled = true },
|
||||
{ key = "g", priority = 1, cmd = "no-osd ignore", enabled = true },
|
||||
}) do
|
||||
native_bindings = { { key = case.key, cmd = case.cmd or "show-text single", priority = case.priority } }
|
||||
recorded.bindings = {}
|
||||
assert_true(bindings.reload_bindings(), "binding reload should succeed")
|
||||
assert_true((find_binding("g-s") ~= nil) == case.enabled, "sequence prefix conflict: " .. case.key)
|
||||
end
|
||||
assert_true(#recorded.osd > 0, "native prefix conflicts should be visible")
|
||||
print("plugin sequence conflict tests: OK")
|
||||
@@ -1,112 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { deflateRawSync } from 'node:zlib';
|
||||
import { parseAndroidManifestVersionCode, readApkVersionCode } from './apk-version';
|
||||
|
||||
const NO_STRING = 0xffffffff;
|
||||
|
||||
function writeChunkHeader(buffer: Buffer, type: number, headerSize: number, size: number): void {
|
||||
buffer.writeUInt16LE(type, 0);
|
||||
buffer.writeUInt16LE(headerSize, 2);
|
||||
buffer.writeUInt32LE(size, 4);
|
||||
}
|
||||
|
||||
function makeStringPool(strings: string[]): Buffer {
|
||||
const encoded = strings.map((value) => {
|
||||
const bytes = Buffer.from(value, 'utf8');
|
||||
return Buffer.concat([Buffer.from([value.length, bytes.length]), bytes, Buffer.from([0])]);
|
||||
});
|
||||
const offsets = encoded.map((_value, index) =>
|
||||
encoded.slice(0, index).reduce((total, value) => total + value.length, 0),
|
||||
);
|
||||
const dataLength = encoded.reduce((total, value) => total + value.length, 0);
|
||||
const paddedDataLength = Math.ceil(dataLength / 4) * 4;
|
||||
const headerSize = 28;
|
||||
const stringsStart = headerSize + strings.length * 4;
|
||||
const chunk = Buffer.alloc(stringsStart + paddedDataLength);
|
||||
writeChunkHeader(chunk, 0x0001, headerSize, chunk.length);
|
||||
chunk.writeUInt32LE(strings.length, 8);
|
||||
chunk.writeUInt32LE(0x00000100, 16);
|
||||
chunk.writeUInt32LE(stringsStart, 20);
|
||||
offsets.forEach((offset, index) => chunk.writeUInt32LE(offset, headerSize + index * 4));
|
||||
Buffer.concat(encoded).copy(chunk, stringsStart);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
function makeBinaryManifest(versionCode: number): Buffer {
|
||||
const stringPool = makeStringPool(['manifest', 'versionCode']);
|
||||
const startElement = Buffer.alloc(56);
|
||||
writeChunkHeader(startElement, 0x0102, 16, startElement.length);
|
||||
startElement.writeUInt32LE(NO_STRING, 12);
|
||||
startElement.writeUInt32LE(NO_STRING, 16);
|
||||
startElement.writeUInt32LE(0, 20);
|
||||
startElement.writeUInt16LE(20, 24);
|
||||
startElement.writeUInt16LE(20, 26);
|
||||
startElement.writeUInt16LE(1, 28);
|
||||
const attributeOffset = 36;
|
||||
startElement.writeUInt32LE(NO_STRING, attributeOffset);
|
||||
startElement.writeUInt32LE(1, attributeOffset + 4);
|
||||
startElement.writeUInt32LE(NO_STRING, attributeOffset + 8);
|
||||
startElement.writeUInt16LE(8, attributeOffset + 12);
|
||||
startElement[attributeOffset + 15] = 0x10;
|
||||
startElement.writeUInt32LE(versionCode, attributeOffset + 16);
|
||||
|
||||
const document = Buffer.alloc(8);
|
||||
writeChunkHeader(document, 0x0003, 8, document.length + stringPool.length + startElement.length);
|
||||
return Buffer.concat([document, stringPool, startElement]);
|
||||
}
|
||||
|
||||
function makeDeflatedZip(name: string, data: Buffer): Buffer {
|
||||
const fileName = Buffer.from(name, 'utf8');
|
||||
const compressed = deflateRawSync(data);
|
||||
const local = Buffer.alloc(30 + fileName.length);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE(8, 8);
|
||||
local.writeUInt32LE(compressed.length, 18);
|
||||
local.writeUInt32LE(data.length, 22);
|
||||
local.writeUInt16LE(fileName.length, 26);
|
||||
fileName.copy(local, 30);
|
||||
|
||||
const central = Buffer.alloc(46 + fileName.length);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(20, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt16LE(8, 10);
|
||||
central.writeUInt32LE(compressed.length, 20);
|
||||
central.writeUInt32LE(data.length, 24);
|
||||
central.writeUInt16LE(fileName.length, 28);
|
||||
fileName.copy(central, 46);
|
||||
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(1, 8);
|
||||
end.writeUInt16LE(1, 10);
|
||||
end.writeUInt32LE(central.length, 12);
|
||||
end.writeUInt32LE(local.length + compressed.length, 16);
|
||||
return Buffer.concat([local, compressed, central, end]);
|
||||
}
|
||||
|
||||
test('parseAndroidManifestVersionCode reads the typed manifest attribute', () => {
|
||||
assert.equal(parseAndroidManifestVersionCode(makeBinaryManifest(42)), 42);
|
||||
assert.equal(parseAndroidManifestVersionCode(Buffer.from('plain xml')), null);
|
||||
});
|
||||
|
||||
test('readApkVersionCode reads a deflated AndroidManifest.xml from an APK', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'subminer-apk-version-'));
|
||||
const apkPath = path.join(directory, 'extension.apk');
|
||||
await writeFile(apkPath, makeDeflatedZip('AndroidManifest.xml', makeBinaryManifest(730)));
|
||||
|
||||
assert.equal(await readApkVersionCode(apkPath), 730);
|
||||
});
|
||||
|
||||
test('readApkVersionCode returns null for a malformed APK', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'subminer-apk-version-'));
|
||||
const apkPath = path.join(directory, 'broken.apk');
|
||||
await writeFile(apkPath, 'not a zip');
|
||||
|
||||
assert.equal(await readApkVersionCode(apkPath), null);
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
import { open, type FileHandle } from 'node:fs/promises';
|
||||
import { inflateRawSync } from 'node:zlib';
|
||||
|
||||
const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
|
||||
const CENTRAL_FILE_HEADER_SIGNATURE = 0x02014b50;
|
||||
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
||||
const ANDROID_XML_TYPE = 0x0003;
|
||||
const STRING_POOL_TYPE = 0x0001;
|
||||
const START_ELEMENT_TYPE = 0x0102;
|
||||
const UTF8_STRING_POOL_FLAG = 0x00000100;
|
||||
const NO_STRING = 0xffffffff;
|
||||
const TYPE_INT_DEC = 0x10;
|
||||
const TYPE_INT_HEX = 0x11;
|
||||
const MANIFEST_ENTRY = 'AndroidManifest.xml';
|
||||
const MAX_ZIP_TAIL_BYTES = 65_535 + 22;
|
||||
const MAX_CENTRAL_DIRECTORY_BYTES = 16 * 1024 * 1024;
|
||||
const MAX_MANIFEST_BYTES = 1024 * 1024;
|
||||
|
||||
interface ZipEntryLocation {
|
||||
compressionMethod: number;
|
||||
compressedSize: number;
|
||||
uncompressedSize: number;
|
||||
localHeaderOffset: number;
|
||||
}
|
||||
|
||||
async function readExactly(handle: FileHandle, length: number, position: number): Promise<Buffer> {
|
||||
const buffer = Buffer.alloc(length);
|
||||
const { bytesRead } = await handle.read(buffer, 0, length, position);
|
||||
if (bytesRead !== length) throw new Error('Unexpected end of APK.');
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function findEndOfCentralDirectory(tail: Buffer): number | null {
|
||||
for (let offset = tail.length - 22; offset >= 0; offset -= 1) {
|
||||
if (tail.readUInt32LE(offset) !== END_OF_CENTRAL_DIRECTORY_SIGNATURE) continue;
|
||||
const commentLength = tail.readUInt16LE(offset + 20);
|
||||
if (offset + 22 + commentLength === tail.length) return offset;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findZipEntry(
|
||||
central: Buffer,
|
||||
entryCount: number,
|
||||
wantedName: string,
|
||||
): ZipEntryLocation | null {
|
||||
let offset = 0;
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
if (offset + 46 > central.length) return null;
|
||||
if (central.readUInt32LE(offset) !== CENTRAL_FILE_HEADER_SIGNATURE) return null;
|
||||
|
||||
const flags = central.readUInt16LE(offset + 8);
|
||||
const compressionMethod = central.readUInt16LE(offset + 10);
|
||||
const compressedSize = central.readUInt32LE(offset + 20);
|
||||
const uncompressedSize = central.readUInt32LE(offset + 24);
|
||||
const nameLength = central.readUInt16LE(offset + 28);
|
||||
const extraLength = central.readUInt16LE(offset + 30);
|
||||
const commentLength = central.readUInt16LE(offset + 32);
|
||||
const recordLength = 46 + nameLength + extraLength + commentLength;
|
||||
if (offset + recordLength > central.length) return null;
|
||||
|
||||
const name = central.subarray(offset + 46, offset + 46 + nameLength).toString('utf8');
|
||||
if (name === wantedName) {
|
||||
if ((flags & 0x0001) !== 0) return null;
|
||||
if (compressedSize > MAX_MANIFEST_BYTES || uncompressedSize > MAX_MANIFEST_BYTES) return null;
|
||||
return {
|
||||
compressionMethod,
|
||||
compressedSize,
|
||||
uncompressedSize,
|
||||
localHeaderOffset: central.readUInt32LE(offset + 42),
|
||||
};
|
||||
}
|
||||
offset += recordLength;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readZipEntry(apkPath: string, wantedName: string): Promise<Buffer | null> {
|
||||
let handle: FileHandle | null = null;
|
||||
try {
|
||||
handle = await open(apkPath, 'r');
|
||||
const fileSize = (await handle.stat()).size;
|
||||
const tailSize = Math.min(fileSize, MAX_ZIP_TAIL_BYTES);
|
||||
if (tailSize < 22) return null;
|
||||
const tail = await readExactly(handle, tailSize, fileSize - tailSize);
|
||||
const endOffset = findEndOfCentralDirectory(tail);
|
||||
if (endOffset === null) return null;
|
||||
|
||||
const diskNumber = tail.readUInt16LE(endOffset + 4);
|
||||
const centralDisk = tail.readUInt16LE(endOffset + 6);
|
||||
const diskEntryCount = tail.readUInt16LE(endOffset + 8);
|
||||
const entryCount = tail.readUInt16LE(endOffset + 10);
|
||||
const centralSize = tail.readUInt32LE(endOffset + 12);
|
||||
const centralOffset = tail.readUInt32LE(endOffset + 16);
|
||||
if (
|
||||
diskNumber !== 0 ||
|
||||
centralDisk !== 0 ||
|
||||
diskEntryCount !== entryCount ||
|
||||
entryCount === 0 ||
|
||||
centralSize === 0 ||
|
||||
centralSize > MAX_CENTRAL_DIRECTORY_BYTES ||
|
||||
centralOffset + centralSize > fileSize
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const central = await readExactly(handle, centralSize, centralOffset);
|
||||
const entry = findZipEntry(central, entryCount, wantedName);
|
||||
if (!entry || entry.localHeaderOffset + 30 > centralOffset) return null;
|
||||
|
||||
const localHeader = await readExactly(handle, 30, entry.localHeaderOffset);
|
||||
if (localHeader.readUInt32LE(0) !== LOCAL_FILE_HEADER_SIGNATURE) return null;
|
||||
const nameLength = localHeader.readUInt16LE(26);
|
||||
const extraLength = localHeader.readUInt16LE(28);
|
||||
const dataOffset = entry.localHeaderOffset + 30 + nameLength + extraLength;
|
||||
if (dataOffset + entry.compressedSize > centralOffset) return null;
|
||||
const compressed = await readExactly(handle, entry.compressedSize, dataOffset);
|
||||
|
||||
let data: Buffer;
|
||||
if (entry.compressionMethod === 0) {
|
||||
data = compressed;
|
||||
} else if (entry.compressionMethod === 8) {
|
||||
data = inflateRawSync(compressed, { maxOutputLength: MAX_MANIFEST_BYTES });
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return data.length === entry.uncompressedSize ? data : null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function readUtf8Length(buffer: Buffer, offset: number): { length: number; next: number } | null {
|
||||
if (offset >= buffer.length) return null;
|
||||
const first = buffer[offset]!;
|
||||
if ((first & 0x80) === 0) return { length: first, next: offset + 1 };
|
||||
if (offset + 1 >= buffer.length) return null;
|
||||
return { length: ((first & 0x7f) << 8) | buffer[offset + 1]!, next: offset + 2 };
|
||||
}
|
||||
|
||||
function readUtf16Length(buffer: Buffer, offset: number): { length: number; next: number } | null {
|
||||
if (offset + 2 > buffer.length) return null;
|
||||
const first = buffer.readUInt16LE(offset);
|
||||
if ((first & 0x8000) === 0) return { length: first, next: offset + 2 };
|
||||
if (offset + 4 > buffer.length) return null;
|
||||
return {
|
||||
length: ((first & 0x7fff) << 16) | buffer.readUInt16LE(offset + 2),
|
||||
next: offset + 4,
|
||||
};
|
||||
}
|
||||
|
||||
interface AndroidStringPool {
|
||||
stringAt: (index: number) => string | null;
|
||||
}
|
||||
|
||||
function parseStringPool(
|
||||
buffer: Buffer,
|
||||
chunkOffset: number,
|
||||
chunkSize: number,
|
||||
): AndroidStringPool | null {
|
||||
const headerSize = buffer.readUInt16LE(chunkOffset + 2);
|
||||
if (headerSize < 28 || chunkOffset + chunkSize > buffer.length) return null;
|
||||
const stringCount = buffer.readUInt32LE(chunkOffset + 8);
|
||||
const flags = buffer.readUInt32LE(chunkOffset + 16);
|
||||
const stringsStart = buffer.readUInt32LE(chunkOffset + 20);
|
||||
if (headerSize + stringCount * 4 > chunkSize || stringsStart >= chunkSize) return null;
|
||||
|
||||
return {
|
||||
stringAt(index) {
|
||||
if (index === NO_STRING || index >= stringCount) return null;
|
||||
const relativeOffset = buffer.readUInt32LE(chunkOffset + headerSize + index * 4);
|
||||
let stringOffset = chunkOffset + stringsStart + relativeOffset;
|
||||
const chunkEnd = chunkOffset + chunkSize;
|
||||
if (stringOffset >= chunkEnd) return null;
|
||||
|
||||
if ((flags & UTF8_STRING_POOL_FLAG) !== 0) {
|
||||
const utf16Length = readUtf8Length(buffer, stringOffset);
|
||||
if (!utf16Length) return null;
|
||||
const byteLength = readUtf8Length(buffer, utf16Length.next);
|
||||
if (!byteLength || byteLength.next + byteLength.length > chunkEnd) return null;
|
||||
return buffer.toString('utf8', byteLength.next, byteLength.next + byteLength.length);
|
||||
}
|
||||
|
||||
const length = readUtf16Length(buffer, stringOffset);
|
||||
if (!length) return null;
|
||||
stringOffset = length.next;
|
||||
const byteLength = length.length * 2;
|
||||
if (stringOffset + byteLength > chunkEnd) return null;
|
||||
return buffer.toString('utf16le', stringOffset, stringOffset + byteLength);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Read Android's numeric version code from a binary AndroidManifest.xml. */
|
||||
export function parseAndroidManifestVersionCode(buffer: Buffer): number | null {
|
||||
try {
|
||||
if (buffer.length < 8 || buffer.readUInt16LE(0) !== ANDROID_XML_TYPE) return null;
|
||||
const documentSize = buffer.readUInt32LE(4);
|
||||
if (documentSize > buffer.length) return null;
|
||||
|
||||
let strings: AndroidStringPool | null = null;
|
||||
let offset = buffer.readUInt16LE(2);
|
||||
while (offset + 8 <= documentSize) {
|
||||
const chunkType = buffer.readUInt16LE(offset);
|
||||
const headerSize = buffer.readUInt16LE(offset + 2);
|
||||
const chunkSize = buffer.readUInt32LE(offset + 4);
|
||||
if (headerSize < 8 || chunkSize < headerSize || offset + chunkSize > documentSize)
|
||||
return null;
|
||||
|
||||
if (chunkType === STRING_POOL_TYPE) {
|
||||
strings = parseStringPool(buffer, offset, chunkSize);
|
||||
} else if (chunkType === START_ELEMENT_TYPE && strings && headerSize >= 16) {
|
||||
const elementName = strings.stringAt(buffer.readUInt32LE(offset + 20));
|
||||
if (elementName === 'manifest') {
|
||||
const attributeStart = buffer.readUInt16LE(offset + 24);
|
||||
const attributeSize = buffer.readUInt16LE(offset + 26);
|
||||
const attributeCount = buffer.readUInt16LE(offset + 28);
|
||||
const attributesOffset = offset + 16 + attributeStart;
|
||||
if (
|
||||
attributeSize < 20 ||
|
||||
attributesOffset + attributeSize * attributeCount > offset + chunkSize
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let index = 0; index < attributeCount; index += 1) {
|
||||
const attributeOffset = attributesOffset + index * attributeSize;
|
||||
const name = strings.stringAt(buffer.readUInt32LE(attributeOffset + 4));
|
||||
if (name !== 'versionCode') continue;
|
||||
const valueType = buffer[attributeOffset + 15];
|
||||
if (valueType === TYPE_INT_DEC || valueType === TYPE_INT_HEX) {
|
||||
return buffer.readUInt32LE(attributeOffset + 16);
|
||||
}
|
||||
const rawValue = strings.stringAt(buffer.readUInt32LE(attributeOffset + 8));
|
||||
if (rawValue === null) return null;
|
||||
const parsed = Number(rawValue);
|
||||
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
offset += chunkSize;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the installed version without extracting the APK or running Android tooling. */
|
||||
export async function readApkVersionCode(apkPath: string): Promise<number | null> {
|
||||
const manifest = await readZipEntry(apkPath, MANIFEST_ENTRY);
|
||||
return manifest ? parseAndroidManifestVersionCode(manifest) : null;
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
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 caps the probe at the deadline the caller passes', async () => {
|
||||
// A bridge that accepts the socket and then stalls: only the abort ends it.
|
||||
const fetchImpl = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
await new Promise((resolve) => init?.signal?.addEventListener('abort', resolve));
|
||||
throw new Error('aborted');
|
||||
}) as typeof fetch;
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
|
||||
const started = Date.now();
|
||||
assert.equal(await client.isReady(50), false);
|
||||
// Well under the 5s default, so the per-call deadline is what applied.
|
||||
assert.ok(Date.now() - started < 1000, 'probe outlived the caller deadline');
|
||||
});
|
||||
|
||||
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('HTTP failures preserve the bridge error and status for diagnosis', async () => {
|
||||
for (const detail of [
|
||||
"'java.lang.Object eu.kanade.tachiyomi.animesource.online.AnimeHttpSource.getHosterList(eu.kanade.tachiyomi.animesource.model.SEpisode, kotlin.coroutines.Continuation)'",
|
||||
'lateinit property url has not been initialized',
|
||||
]) {
|
||||
const { fetchImpl } = stubFetch(
|
||||
() => new Response(JSON.stringify({ error: detail, code: 500 }), { status: 500 }),
|
||||
);
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
await assert.rejects(
|
||||
() => client.getVideoList(source, '/episode/301'),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof BridgeExtensionError);
|
||||
assert.equal(error.code, 500);
|
||||
assert.equal(error.message, `Anime bridge getVideoList failed (500). ${detail}`);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('non-JSON and invalid bridge errors keep the HTTP fallback without exposing response bodies', async () => {
|
||||
for (const body of ['<html>Proxy error</html>', '', '{"error":{}}', '{"error":" "}', 'null']) {
|
||||
const { fetchImpl } = stubFetch(() => new Response(body, { status: 502 }));
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
await assert.rejects(() => client.getAnimeDetails(source, '/anime/1'), {
|
||||
message: 'Anime bridge getDetailsAnime failed (502).',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('bridge diagnostics normalize whitespace and bound long messages', async () => {
|
||||
const { fetchImpl } = stubFetch(
|
||||
() =>
|
||||
new Response(JSON.stringify({ error: ` Missing field\n\t${'x'.repeat(3_000)}` }), {
|
||||
status: 500,
|
||||
}),
|
||||
);
|
||||
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||
await assert.rejects(() => client.getAnimeDetails(source, '/anime/1'), {
|
||||
message: `Anime bridge getDetailsAnime failed (500). ${`Missing field ${'x'.repeat(3_000)}`.slice(0, 1_999)}…`,
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
@@ -1,258 +0,0 @@
|
||||
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;
|
||||
|
||||
/** Extension failures may arrive as HTTP errors or 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* `timeoutMs` lets a caller with its own deadline (the readiness loop) cap the
|
||||
* probe below the default, so a short readiness budget is actually honored.
|
||||
*/
|
||||
async getCapabilities(timeoutMs = CAPABILITIES_TIMEOUT_MS): Promise<BridgeCapabilities> {
|
||||
const response = await this.fetchImpl(`${this.baseUrl}/capabilities`, {
|
||||
signal: AbortSignal.timeout(Math.max(0, Math.min(timeoutMs, 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(timeoutMs?: number): Promise<boolean> {
|
||||
try {
|
||||
const capabilities = await this.getCapabilities(timeoutMs);
|
||||
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) {
|
||||
const body: unknown = await response.json().catch(() => null);
|
||||
const detail = extensionErrorMessage(body);
|
||||
throw new BridgeExtensionError(
|
||||
`Anime bridge ${method} failed (${response.status}).${detail ? ` ${detail}` : ''}`,
|
||||
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 {
|
||||
const error = extensionErrorMessage(body);
|
||||
if (error === null) return;
|
||||
const code = body !== null && typeof body === 'object' && 'code' in body ? body.code : undefined;
|
||||
throw new BridgeExtensionError(
|
||||
`Anime bridge ${method} failed: ${error}`,
|
||||
typeof code === 'number' ? code : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** Only expose the bridge's JSON error field, never an HTML error page or stack object. */
|
||||
function extensionErrorMessage(body: unknown): string | null {
|
||||
if (body === null || typeof body !== 'object' || Array.isArray(body)) return null;
|
||||
if (!('error' in body) || typeof body.error !== 'string') return null;
|
||||
const message = body.error.replace(/\s+/g, ' ').trim();
|
||||
if (!message) return null;
|
||||
return message.length > 2_000 ? `${message.slice(0, 1_999)}…` : message;
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildAnimeStreamMetadata,
|
||||
buildAnimeStreamStatsPath,
|
||||
buildStreamDisplayTitle,
|
||||
splitEpisodeLabel,
|
||||
splitSeasonFromTitle,
|
||||
} from './episode-metadata';
|
||||
|
||||
test('splitSeasonFromTitle pulls a trailing season marker off the title', () => {
|
||||
assert.deepEqual(splitSeasonFromTitle('Mushoku Tensei: Jobless Reincarnation Season 3'), {
|
||||
title: 'Mushoku Tensei: Jobless Reincarnation',
|
||||
season: 3,
|
||||
});
|
||||
assert.deepEqual(splitSeasonFromTitle('Spy x Family 2nd Season'), {
|
||||
title: 'Spy x Family',
|
||||
season: 2,
|
||||
});
|
||||
assert.deepEqual(splitSeasonFromTitle('Bocchi the Rock! S2'), {
|
||||
title: 'Bocchi the Rock!',
|
||||
season: 2,
|
||||
});
|
||||
assert.deepEqual(splitSeasonFromTitle('シャングリラ・フロンティア 第2期'), {
|
||||
title: 'シャングリラ・フロンティア',
|
||||
season: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('splitSeasonFromTitle leaves a title without a trailing marker alone', () => {
|
||||
assert.deepEqual(splitSeasonFromTitle('My Teen Romantic Comedy SNAFU Climax!'), {
|
||||
title: 'My Teen Romantic Comedy SNAFU Climax!',
|
||||
season: null,
|
||||
});
|
||||
// "Season" inside the name is not a season marker.
|
||||
assert.deepEqual(splitSeasonFromTitle('A Season of Snow and Ash'), {
|
||||
title: 'A Season of Snow and Ash',
|
||||
season: null,
|
||||
});
|
||||
// Nothing would be left of the title, so the marker is not a marker.
|
||||
assert.deepEqual(splitSeasonFromTitle('Season 2'), { title: 'Season 2', season: null });
|
||||
});
|
||||
|
||||
test('splitEpisodeLabel reads the number and the episode name', () => {
|
||||
assert.deepEqual(splitEpisodeLabel('Episode 4'), { number: 4, title: null });
|
||||
assert.deepEqual(splitEpisodeLabel('Episode 10: Gallantly, Shizuka Hiratsuka Moves Forward.'), {
|
||||
number: 10,
|
||||
title: 'Gallantly, Shizuka Hiratsuka Moves Forward.',
|
||||
});
|
||||
assert.deepEqual(splitEpisodeLabel('Ep. 7 - The Long Road'), {
|
||||
number: 7,
|
||||
title: 'The Long Road',
|
||||
});
|
||||
assert.deepEqual(splitEpisodeLabel('第12話 決戦'), { number: 12, title: '決戦' });
|
||||
assert.deepEqual(splitEpisodeLabel('5. Homecoming'), { number: 5, title: 'Homecoming' });
|
||||
assert.deepEqual(splitEpisodeLabel('13'), { number: 13, title: null });
|
||||
assert.deepEqual(splitEpisodeLabel('Episode 6.5'), { number: 6.5, title: null });
|
||||
});
|
||||
|
||||
test('splitEpisodeLabel keeps a label that carries no number as a name', () => {
|
||||
assert.deepEqual(splitEpisodeLabel('Movie'), { number: null, title: 'Movie' });
|
||||
assert.deepEqual(splitEpisodeLabel('OVA - Beach Episode'), {
|
||||
number: null,
|
||||
title: 'OVA - Beach Episode',
|
||||
});
|
||||
assert.deepEqual(splitEpisodeLabel(''), { number: null, title: null });
|
||||
});
|
||||
|
||||
test('buildStreamDisplayTitle emits a form guessit and the jimaku parser both read', () => {
|
||||
assert.equal(
|
||||
buildStreamDisplayTitle('Mushoku Tensei: Jobless Reincarnation', 3, 4, null),
|
||||
'Mushoku Tensei: Jobless Reincarnation S03E04',
|
||||
);
|
||||
assert.equal(
|
||||
buildStreamDisplayTitle('My Teen Romantic Comedy SNAFU Climax!', null, 10, 'Gallantly'),
|
||||
'My Teen Romantic Comedy SNAFU Climax! E10 - Gallantly',
|
||||
);
|
||||
assert.equal(buildStreamDisplayTitle('Some Movie', null, null, null), 'Some Movie');
|
||||
});
|
||||
|
||||
test('buildAnimeStreamStatsPath is stable across playbacks of the same episode', () => {
|
||||
const first = buildAnimeStreamStatsPath('9001', '/anime/mushoku', '/watch/ep-4');
|
||||
const second = buildAnimeStreamStatsPath('9001', '/anime/mushoku', '/watch/ep-4');
|
||||
assert.equal(first, second);
|
||||
assert.notEqual(first, buildAnimeStreamStatsPath('9001', '/anime/mushoku', '/watch/ep-5'));
|
||||
assert.match(first, /^animebrowser:\/\//);
|
||||
});
|
||||
|
||||
test('buildAnimeStreamMetadata resolves the browser strings into fields', () => {
|
||||
const metadata = buildAnimeStreamMetadata({
|
||||
sourceId: '9001',
|
||||
animeUrl: '/anime/mushoku',
|
||||
animeTitle: 'Mushoku Tensei: Jobless Reincarnation Season 3',
|
||||
episodeUrl: '/watch/ep-4',
|
||||
episodeName: 'Episode 4',
|
||||
episodeNumber: 4,
|
||||
mediaPath: 'http://127.0.0.1:41234/video/abc123.m3u8',
|
||||
});
|
||||
|
||||
assert.equal(metadata.seriesTitle, 'Mushoku Tensei: Jobless Reincarnation');
|
||||
assert.equal(metadata.seasonNumber, 3);
|
||||
assert.equal(metadata.episodeNumber, 4);
|
||||
assert.equal(metadata.episodeTitle, null);
|
||||
assert.equal(metadata.displayTitle, 'Mushoku Tensei: Jobless Reincarnation S03E04');
|
||||
assert.equal(metadata.mediaPath, 'http://127.0.0.1:41234/video/abc123.m3u8');
|
||||
assert.equal(
|
||||
metadata.statsPath,
|
||||
buildAnimeStreamStatsPath('9001', '/anime/mushoku', '/watch/ep-4'),
|
||||
);
|
||||
});
|
||||
|
||||
test('buildAnimeStreamMetadata prefers the extension episode number over the label', () => {
|
||||
const metadata = buildAnimeStreamMetadata({
|
||||
sourceId: '1',
|
||||
animeUrl: '/a',
|
||||
animeTitle: 'Show',
|
||||
episodeUrl: '/e',
|
||||
episodeName: 'Finale',
|
||||
episodeNumber: 24,
|
||||
mediaPath: 'http://host/x.m3u8',
|
||||
});
|
||||
assert.equal(metadata.episodeNumber, 24);
|
||||
assert.equal(metadata.episodeTitle, 'Finale');
|
||||
assert.equal(metadata.displayTitle, 'Show E24 - Finale');
|
||||
});
|
||||
|
||||
test('buildAnimeStreamMetadata falls back to the label when the source reports no number', () => {
|
||||
const metadata = buildAnimeStreamMetadata({
|
||||
sourceId: '1',
|
||||
animeUrl: '/a',
|
||||
animeTitle: 'Show 2nd Season',
|
||||
episodeUrl: '/e',
|
||||
episodeName: 'Episode 3: Rain',
|
||||
episodeNumber: null,
|
||||
mediaPath: 'http://host/x.m3u8',
|
||||
});
|
||||
assert.equal(metadata.seasonNumber, 2);
|
||||
assert.equal(metadata.episodeNumber, 3);
|
||||
assert.equal(metadata.displayTitle, 'Show S02E03 - Rain');
|
||||
});
|
||||
@@ -1,211 +0,0 @@
|
||||
/**
|
||||
* Structured metadata for a streamed episode.
|
||||
*
|
||||
* Extensions hand us two free-form strings — an anime title that usually
|
||||
* carries the season ("… Season 3") and an episode label that usually carries
|
||||
* the number ("Episode 4: …"). Everything downstream (stats grouping, AniList,
|
||||
* the subtitle modals) wants those as separate fields, so they are split once
|
||||
* here rather than re-parsed out of the mpv title by each consumer.
|
||||
*/
|
||||
|
||||
/** Where a stream came from, resolved into the fields consumers actually want. */
|
||||
export interface AnimeStreamMetadata {
|
||||
sourceId: string;
|
||||
animeUrl: string;
|
||||
episodeUrl: string;
|
||||
/** The URL handed to mpv. Matches what mpv reports as `path`. */
|
||||
mediaPath: string;
|
||||
/**
|
||||
* Stable identity for this episode. The stream URL carries a per-playback
|
||||
* proxy port and token, so it cannot be the key stats stores.
|
||||
*/
|
||||
statsPath: string;
|
||||
/** Series name with the season suffix removed. */
|
||||
seriesTitle: string;
|
||||
seasonNumber: number | null;
|
||||
episodeNumber: number | null;
|
||||
/** The episode's own name, or null when the label was only a number. */
|
||||
episodeTitle: string | null;
|
||||
/** Shown by mpv, and the fallback every string parser sees. */
|
||||
displayTitle: string;
|
||||
}
|
||||
|
||||
export interface AnimeStreamMetadataInput {
|
||||
sourceId: string;
|
||||
animeUrl: string;
|
||||
animeTitle: string;
|
||||
episodeUrl: string;
|
||||
episodeName: string;
|
||||
/** Extension-reported number; trusted over anything parsed from the label. */
|
||||
episodeNumber: number | null;
|
||||
/** The URL playback actually uses, after proxy rewriting. */
|
||||
mediaPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Season suffixes, anchored to the end of the title so a "Season" that is part
|
||||
* of the name ("A Season of Snow") cannot be mistaken for one.
|
||||
*/
|
||||
const SEASON_SUFFIX_PATTERNS: RegExp[] = [
|
||||
/[\s:_-]+season\s*(\d{1,2})\s*$/i,
|
||||
/[\s:_-]+(\d{1,2})(?:st|nd|rd|th)\s+season\s*$/i,
|
||||
/[\s:_-]+s(\d{1,2})\s*$/i,
|
||||
/[\s:_-]*第\s*(\d{1,2})\s*期\s*$/,
|
||||
/[\s:_-]+(\d{1,2})\s*期\s*$/,
|
||||
];
|
||||
|
||||
/**
|
||||
* Episode labels, most specific first. The trailing group is the episode's own
|
||||
* name when the label carries one.
|
||||
*/
|
||||
const EPISODE_LABEL_PATTERNS: RegExp[] = [
|
||||
/^\s*(?:episodio|épisode|episode|ep|e)\s*[.#]?\s*(\d{1,4}(?:\.\d+)?)\s*(?:[:\-–—.)]+\s*(.*))?$/i,
|
||||
/^\s*第\s*(\d{1,4})\s*話\s*(?:[:\-–—]+\s*)?(.*)$/,
|
||||
/^\s*(\d{1,4}(?:\.\d+)?)\s*[:\-–—.)]+\s*(.*)$/,
|
||||
/^\s*(\d{1,4}(?:\.\d+)?)\s*$/,
|
||||
];
|
||||
|
||||
function collapseWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims separators a split left dangling on either end. `.` is deliberately not
|
||||
* one of them: an episode name often ends in a full stop that belongs to it.
|
||||
*/
|
||||
function trimSeparators(value: string): string {
|
||||
return collapseWhitespace(value)
|
||||
.replace(/^[\s:_\-–—]+/, '')
|
||||
.replace(/[\s:_\-–—]+$/, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function toEpisodeNumber(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a trailing season marker off an anime title.
|
||||
*
|
||||
* "Mushoku Tensei: Jobless Reincarnation Season 3" becomes the series plus
|
||||
* season 3, which is what both AniList and the stats grouping key want. A title
|
||||
* with no marker is returned unchanged with a null season — season 1 is *not*
|
||||
* assumed, because "unknown" and "one" behave differently when grouping.
|
||||
*/
|
||||
export function splitSeasonFromTitle(animeTitle: string): {
|
||||
title: string;
|
||||
season: number | null;
|
||||
} {
|
||||
const normalized = collapseWhitespace(animeTitle);
|
||||
for (const pattern of SEASON_SUFFIX_PATTERNS) {
|
||||
const match = normalized.match(pattern);
|
||||
if (!match || match.index === undefined) continue;
|
||||
const season = Number.parseInt(match[1]!, 10);
|
||||
if (!Number.isInteger(season) || season <= 0) continue;
|
||||
const title = trimSeparators(normalized.slice(0, match.index));
|
||||
// A title that is *only* a season marker is not a title; keep the original.
|
||||
if (!title) continue;
|
||||
return { title, season };
|
||||
}
|
||||
return { title: normalized, season: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an episode label into its number and its own name.
|
||||
*
|
||||
* Sources are inconsistent here: "Episode 4", "4. Title", "第4話 タイトル" and a
|
||||
* bare "4" all show up. A label that matches nothing is treated as a pure
|
||||
* episode name, which is right for movies and specials.
|
||||
*/
|
||||
export function splitEpisodeLabel(episodeName: string): {
|
||||
number: number | null;
|
||||
title: string | null;
|
||||
} {
|
||||
const normalized = collapseWhitespace(episodeName);
|
||||
if (!normalized) return { number: null, title: null };
|
||||
|
||||
for (const pattern of EPISODE_LABEL_PATTERNS) {
|
||||
const match = normalized.match(pattern);
|
||||
if (!match) continue;
|
||||
const parsed = Number.parseFloat(match[1]!);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) continue;
|
||||
const title = trimSeparators(match[2] ?? '');
|
||||
return { number: parsed, title: title || null };
|
||||
}
|
||||
|
||||
return { number: null, title: normalized };
|
||||
}
|
||||
|
||||
function formatEpisodePart(value: number): string {
|
||||
return Number.isInteger(value) ? String(value).padStart(2, '0') : String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The title mpv shows.
|
||||
*
|
||||
* `SxxEyy` is not just for looks: it is the one form both guessit and
|
||||
* SubMiner's own filename parser read reliably, so any consumer that only ever
|
||||
* sees the title string still lands on the right series and episode.
|
||||
*/
|
||||
export function buildStreamDisplayTitle(
|
||||
seriesTitle: string,
|
||||
season: number | null,
|
||||
episode: number | null,
|
||||
episodeTitle: string | null,
|
||||
): string {
|
||||
const parts: string[] = [seriesTitle];
|
||||
if (episode !== null) {
|
||||
parts.push(
|
||||
season !== null
|
||||
? `S${String(season).padStart(2, '0')}E${formatEpisodePart(episode)}`
|
||||
: `E${formatEpisodePart(episode)}`,
|
||||
);
|
||||
} else if (season !== null) {
|
||||
parts.push(`S${String(season).padStart(2, '0')}`);
|
||||
}
|
||||
|
||||
const head = parts.join(' ');
|
||||
return episodeTitle ? `${head} - ${episodeTitle}` : head;
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-episode identity that survives across playbacks.
|
||||
*
|
||||
* The stream URL points at the strip proxy, whose port and token are minted per
|
||||
* playback, so keying stats on it makes every rewatch a new video. The source's
|
||||
* own episode url is stable, so that is what stats records instead — with the
|
||||
* real URL kept as an alias so mpv's path change still finds the row.
|
||||
*/
|
||||
export function buildAnimeStreamStatsPath(
|
||||
sourceId: string,
|
||||
animeUrl: string,
|
||||
episodeUrl: string,
|
||||
): string {
|
||||
const source = encodeURIComponent(sourceId || 'unknown');
|
||||
const anime = encodeURIComponent(animeUrl || 'unknown');
|
||||
const episode = encodeURIComponent(episodeUrl || 'unknown');
|
||||
return `animebrowser://${source}/${anime}/${episode}`;
|
||||
}
|
||||
|
||||
export function buildAnimeStreamMetadata(input: AnimeStreamMetadataInput): AnimeStreamMetadata {
|
||||
const { title: seriesTitle, season } = splitSeasonFromTitle(input.animeTitle);
|
||||
const label = splitEpisodeLabel(input.episodeName);
|
||||
const episodeNumber = toEpisodeNumber(input.episodeNumber) ?? label.number;
|
||||
const displayTitle = buildStreamDisplayTitle(seriesTitle, season, episodeNumber, label.title);
|
||||
|
||||
return {
|
||||
sourceId: input.sourceId,
|
||||
animeUrl: input.animeUrl,
|
||||
episodeUrl: input.episodeUrl,
|
||||
mediaPath: input.mediaPath,
|
||||
statsPath: buildAnimeStreamStatsPath(input.sourceId, input.animeUrl, input.episodeUrl),
|
||||
seriesTitle,
|
||||
seasonNumber: season,
|
||||
episodeNumber,
|
||||
episodeTitle: label.title,
|
||||
// A source that gave us neither a number nor a name leaves the series title
|
||||
// alone rather than showing an empty suffix.
|
||||
displayTitle: displayTitle || collapseWhitespace(input.animeTitle),
|
||||
};
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir, mkdtemp, readFile, rename, rm, 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 failed reader cancellation does not hide the size-limit error', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
let cancellationAttempted = false;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
controller.enqueue(new Uint8Array(1025));
|
||||
},
|
||||
async cancel() {
|
||||
cancellationAttempted = true;
|
||||
throw new Error('cancel failed');
|
||||
},
|
||||
});
|
||||
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/,
|
||||
);
|
||||
assert.ok(cancellationAttempted, 'the reader was never cancelled');
|
||||
});
|
||||
|
||||
test('a failed staged write preserves the installed apk and removes the partial file', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||
const target = path.join(dir, `${PKG}.apk`);
|
||||
await writeFile(target, apkBytes('OLD'));
|
||||
let stagedPath = '';
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
installExtension({
|
||||
extensionsDir: dir,
|
||||
extension: repoExtension({ version: '2.0.0', versionCode: 20 }),
|
||||
fetchImpl: respondWith(apkBytes('NEW')),
|
||||
fileIo: {
|
||||
mkdir: (dirPath) => mkdir(dirPath, { recursive: true }),
|
||||
async writeFile(filePath, bytes) {
|
||||
stagedPath = filePath;
|
||||
await writeFile(filePath, bytes.subarray(0, 5));
|
||||
throw new Error('simulated disk write failure');
|
||||
},
|
||||
rename,
|
||||
removeFile: (filePath) => rm(filePath, { force: true }),
|
||||
},
|
||||
}),
|
||||
/simulated disk write failure/,
|
||||
);
|
||||
|
||||
assert.match((await readFile(target)).toString(), /OLD/);
|
||||
assert.notEqual(stagedPath, target);
|
||||
assert.equal(existsSync(stagedPath), false);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, rename, 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;
|
||||
/** Injectable filesystem boundary for failure-path tests. */
|
||||
fileIo?: ExtensionInstallerFileIo;
|
||||
}
|
||||
|
||||
export interface ExtensionInstallerFileIo {
|
||||
mkdir: (dir: string) => Promise<unknown>;
|
||||
writeFile: (filePath: string, bytes: Uint8Array) => Promise<void>;
|
||||
rename: (from: string, to: string) => Promise<void>;
|
||||
removeFile: (filePath: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_FILE_IO: ExtensionInstallerFileIo = {
|
||||
mkdir: (dir) => mkdir(dir, { recursive: true }),
|
||||
writeFile: (filePath, bytes) => writeFile(filePath, bytes),
|
||||
rename,
|
||||
removeFile: (filePath) => rm(filePath, { force: true }),
|
||||
};
|
||||
|
||||
/** 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 fileIo = options.fileIo ?? DEFAULT_FILE_IO;
|
||||
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 fileIo.mkdir(options.extensionsDir);
|
||||
const target = resolveTarget(options.extensionsDir, options.extension.pkg);
|
||||
const staged = `${target}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await fileIo.writeFile(staged, bytes);
|
||||
await fileIo.rename(staged, target);
|
||||
} finally {
|
||||
try {
|
||||
await fileIo.removeFile(staged);
|
||||
} catch {}
|
||||
}
|
||||
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) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {}
|
||||
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 });
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
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('fetchRepoIndex applies a deadline when the caller supplies no signal', async () => {
|
||||
let receivedSignal: AbortSignal | undefined;
|
||||
const fetchImpl = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
receivedSignal = init?.signal instanceof AbortSignal ? init.signal : undefined;
|
||||
return new Response('[]');
|
||||
}) as typeof fetch;
|
||||
|
||||
await fetchRepoIndex(INDEX, { fetchImpl, timeoutMs: 50 });
|
||||
|
||||
assert.ok(receivedSignal, 'repository request should receive a deadline signal');
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
@@ -1,200 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
/** Applied when no signal is supplied, so one stalled repo cannot block the catalogue. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_REPO_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** 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 signal =
|
||||
options.signal ?? AbortSignal.timeout(options.timeoutMs ?? DEFAULT_REPO_TIMEOUT_MS);
|
||||
const response = await fetchImpl(indexUrl.trim(), {
|
||||
headers: { Accept: 'application/json' },
|
||||
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`;
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
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'));
|
||||
assert.equal(extensions[0]?.versionCode, null);
|
||||
});
|
||||
|
||||
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',
|
||||
versionCode: 1,
|
||||
};
|
||||
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', versionCode: 1 },
|
||||
];
|
||||
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 bridge ids are normalized and package-qualified for UI state.
|
||||
assert.equal(sources[0]?.id, 'multi:101');
|
||||
assert.equal(sources[0]?.bridgeId, '101');
|
||||
assert.equal(sources[0]?.name, 'Source One');
|
||||
assert.equal(sources[1]?.lang, 'ja');
|
||||
});
|
||||
|
||||
test('sources with the same bridge id in different packages have distinct runtime ids', async () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/one.apk', fallbackName: 'pkg.one', sha256: 'hash-one', versionCode: 1 },
|
||||
{ file: '/x/two.apk', fallbackName: 'pkg.two', sha256: 'hash-two', versionCode: 2 },
|
||||
];
|
||||
const client = fakeClient(async () => [{ id: 'shared', name: 'Source', lang: 'en' }]);
|
||||
|
||||
const sources = await listExtensionSources(client, extensions);
|
||||
|
||||
assert.deepEqual(
|
||||
sources.map(({ id, bridgeId, pkg }) => ({ id, bridgeId, pkg })),
|
||||
[
|
||||
{ id: 'pkg.one:shared', bridgeId: 'shared', pkg: 'pkg.one' },
|
||||
{ id: 'pkg.two:shared', bridgeId: 'shared', pkg: 'pkg.two' },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
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', versionCode: 1 },
|
||||
];
|
||||
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', versionCode: 1 },
|
||||
]);
|
||||
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', versionCode: 7 },
|
||||
];
|
||||
const sources: ExtensionSource[] = [
|
||||
{ id: 'multi:1', bridgeId: '1', name: 'One', lang: 'en', pkg: 'multi', file: '/x/multi.apk' },
|
||||
{ id: 'multi:2', bridgeId: '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,
|
||||
sources: [
|
||||
{ id: 'multi:1', name: 'One' },
|
||||
{ id: 'multi:2', name: 'Two' },
|
||||
],
|
||||
versionCode: 7,
|
||||
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', versionCode: null },
|
||||
];
|
||||
|
||||
// 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,
|
||||
sources: [],
|
||||
versionCode: null,
|
||||
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', versionCode: null },
|
||||
{ file: '/x/good.apk', fallbackName: 'good', sha256: 'hash-b', versionCode: 1 },
|
||||
];
|
||||
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');
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
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 { readApkVersionCode } from './apk-version';
|
||||
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;
|
||||
/** Android manifest version code, or null when the APK cannot declare one. */
|
||||
versionCode: number | null;
|
||||
}
|
||||
|
||||
export interface ExtensionSource {
|
||||
/** Package-qualified id used by the UI and runtime. */
|
||||
id: string;
|
||||
/** Raw bridge id, which selects this source inside a factory APK. */
|
||||
bridgeId: 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);
|
||||
const [sha256, versionCode] = await Promise.all([hashFile(file), readApkVersionCode(file)]);
|
||||
extensions.push({
|
||||
file,
|
||||
fallbackName: entry.name.replace(/\.apk$/i, ''),
|
||||
sha256,
|
||||
versionCode,
|
||||
});
|
||||
}
|
||||
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,
|
||||
sources: provided.map((source) => ({ id: source.id, name: source.name })),
|
||||
versionCode: extension.versionCode,
|
||||
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 bridgeId = descriptor.id === undefined ? null : String(descriptor.id);
|
||||
if (bridgeId === null || bridgeId.length === 0) continue;
|
||||
sources.push({
|
||||
id: `${extension.fallbackName}:${bridgeId}`,
|
||||
bridgeId,
|
||||
name: descriptor.name?.trim() || extension.fallbackName,
|
||||
lang: descriptor.lang ?? 'all',
|
||||
pkg: extension.fallbackName,
|
||||
file: extension.file,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
onError?.(extension, error);
|
||||
}
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
Loaded 100 of 516 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user