Compare commits

...
5 Commits
Author SHA1 Message Date
sudacode 96ecb4a136 fix(anime): stop superseded playEpisode calls from touching mpv
- Extend the generation guard from subtitleCacheDir to the whole
  playback: a stale playEpisode call now bails out before sending mpv
  commands, showing the overlay, or reading a playback outcome that
  belongs to a newer episode
- Add a regression test where a slow-resolving first call must not
  drive mpv after a second call for a newer episode has already
  started playing
2026-08-02 03:39:17 -07:00
sudacode 10ad19f934 fix(anime): guard playback races and harden install/extract tests
- Tag subtitle cache writes with a generation counter so overlapping playEpisode calls can't clobber the shared cache dir, and don't fail playback when track setup errors
- Ignore stale episode clicks in the detail panel via a LatestRequest guard on playback
- Extract a resetGrid helper in animeui to dedupe grid-clearing logic
- Assert reader cancellation and extracted-file writes actually happen in installer/subsync tests
2026-08-02 02:04:24 -07:00
sudacode fbfdea7c64 fix(anime): scope preferences, paginate sources, harden installs
- Preference store keys entries by extension package + bridge source id; legacy unscoped entries are discarded once instead of being handed to whichever extension asks first
- Source picker's "Load more" appends the next page without duplicating streamed results
- Repository index fetches and subtitle/APK downloads now time out and are size-bounded instead of hanging or growing unbounded
- APK installs are staged to a temp file and renamed into place
- Stream metadata lookup matches the requested path, not only the currently playing one
- Reworked animeui into browse-state/detail-panel/panels.css modules
- Reverted premature CHANGELOG unreleased entries; refreshed anime-browser docs
2026-08-02 00:50:14 -07:00
sudacode a2533118eb docs: catch up docs for anilist, anime browser, and subsync fixes
- Add changelog entries for the split-cour AniList season resolution fix and the subsync stream-track fixes
- Document anime browser language filter, stream subtitle download-to-disk behavior, and series/season/episode metadata threading
- Update domain ownership and knowledge-base catalog for the anime browser modules
- Trim the credits table and clarify the M-Extension-Server licensing note in README
2026-08-02 00:11:02 -07:00
sudacode 722222c651 fix(anime): thread episode metadata through stream playback and fix seas
- Split anime browser stream titles into series/season/episode fields (episode-metadata.ts) instead of one joined string, so stats grouping, the mpv title, and the Jimaku/TsukiHime modals all get real values instead of a filename-derived "m3u8"
- Fix AniList season resolver to walk through split-cour sequels and specials without counting them as a season, and to surface rate-limit/network failures instead of falling through to a wrong air-order guess
- Add a Season field to the TsukiHime modal and include later seasons in its search query
- Rewrite WebVTT subtitle tracks as SRT before handing them to alass, since alass has no VTT support and streams serve VTT
- Update CHANGELOG and troubleshooting docs
2026-08-01 23:34:23 -07:00
79 changed files with 4385 additions and 1652 deletions

No files matched your search

-32
View File
@@ -1,27 +1,8 @@
# Changelog
## Unreleased
### Added
- Anime Browser Language Filter: The Extensions tab's available list now has a language chip row above it. Pick one or more languages to narrow a repository index that otherwise lists every language it knows, or "All" to clear the filter — selecting a language replaces "All" rather than sitting beside it. Extension rows name the language ("Japanese" instead of `ja`), and the Available heading shows how many of the offered extensions the filter leaves.
### Changed
- Anime Browser Subtitles: A stream's subtitle tracks are now downloaded to a temp directory and loaded into mpv as files instead of streamed from the source URL, so they can serve as the alass reference in Subsync (the same way Jellyfin subtitles do) — a streamed track had no file on disk and was rejected by the source picker. 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.
### Fixed
- Subtitle Sync On Streams: Subsync now works when mpv loaded a subtitle track from a URL, which is how Aniyomi extension streams and Jellyfin add theirs. Such a 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 now be retimed against a stream's own subtitles. Internal tracks of a stream also pass mpv's headers through to `ffmpeg`.
- Subsync Tool Discovery: 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 Logging: Subsync failures are now written to the application log. Previously the only trace was an OSD toast that vanished after a few seconds, leaving nothing to diagnose from.
- Anime Browser Window Switching: Opening the anime browser now shows a tray icon on every platform and — on macOS — puts the app in the Cmd+Tab switcher (which requires the Dock icon; the two are inseparable on macOS), so you can switch between it and mpv. Previously the subtitle overlay's fullscreen support hid the whole app from the Dock and Cmd+Tab, leaving no way to reach the window. The Dock icon is released again when the window closes during playback.
- Anime Browser Playback Session: Launching a video from the anime browser now starts a regular SubMiner session (tray icon plus the on-demand overlay runtime), and in `subminer anime` standalone mode, closing the browser window during playback no longer quits the app and kills the stream — the window can be reopened from the tray while mpv keeps playing.
## v0.19.0 (2026-07-29)
### Added
- Anki Maturity Highlighting: Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Tier thresholds and colors are configurable, with a runtime toggle and an updated help legend.
- Post-Playback Menu: After a watch-history episode ends, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit. The pre-playback series menu now offers the previous episode too.
- Delete Library Entries: The stats Library detail view can now delete an entire title in one step (episodes, sessions, subtitle lines, rollups, cover art, and vocabulary counts). Delete progress is now shown app-wide via a progress bar and status toast instead of disappearing when you switch tabs.
@@ -29,11 +10,9 @@
- TsukiHime Subtitle Downloads: Added subtitle downloads for the current video via TsukiHime, loading Japanese as the primary track and your configured secondary language directly into mpv.
### Changed
- Clipboard-Video Shortcut: The "append clipboard video to queue" shortcut is now configurable.
### Fixed
- AniList Season Resolution: Season 2+ files now resolve to the correct AniList entry instead of silently falling back to season 1 (which mismatched character dictionaries and watch progress). Manual overrides now stay scoped per season, fix both the dictionary and progress tracking together, and also correct per-season cover art.
- Subtitle Annotation Accuracy: Fixed several annotation edge cases, including inconsistent POS exclusions on merged quote-particle tokens, dropped annotations on supplementary-plane kanji, katakana punctuation wrongly treated as noise, and certain kanji vocabulary losing N+1 highlighting eligibility.
- AnkiConnect Proxy Port Conflict: Video startup no longer crashes when another process already holds the configured AnkiConnect proxy port; a notification now explains how to resolve it.
@@ -49,7 +28,6 @@
<summary>Internal changes</summary>
### Internal
- Added a golden-file regression test corpus for the tokenizer/annotation pipeline, plus scripts to record new fixtures and diff against stock Yomitan.
- Consolidated renderer modal state handling into a descriptor registry.
- Consolidated CI quality checks (PR, stable, and prerelease) into one reusable workflow with mpv plugin tests and dependency audits.
@@ -61,12 +39,10 @@
## v0.18.0 (2026-07-10)
### Added
- Sentence Audio Normalization: Generated sentence audio is now normalized to -23 LUFS by default, and clips mined from playback mirror mpv's software volume curve with a limiter to prevent clipping. Both behaviors are configurable independently.
- Watch History Command: Added `subminer -H` / `--history` to browse watch history, replay or continue episodes, or pick one via fzf or rofi, with cover art shown in the rofi picker.
### Changed
- Fzf Preview Layout: Moved fzf previews below launcher menus, giving long titles and metadata more room.
- Known-Word Highlighting: Now compares subtitle and Anki-card readings, preventing false matches between homographs and unrelated words that share a reading, while still supporting matching across kana and kanji spellings.
- Annotation Filtering: Standalone suffix tokens (e.g. さん, れる) are now excluded from JLPT/frequency/N+1 highlighting by default, matching how particles and interjections are treated; configurable via the pos2 exclusion setting.
@@ -74,7 +50,6 @@
- Stats Trend Charts: Overhauled with persisted title visibility, per-chart title limits, "top" and "most recent" ranking modes, an option to show or hide empty days, calendar-aligned periods, and value-sorted tooltips.
### Fixed
- Background Stats Server: `subminer app` background launches now auto-start the stats server when enabled, and skip startup if one is already running.
- Character Name Highlighting: Character dictionaries now split unspaced native names more reliably, and portraits, highlights, and hover lookup survive punctuation, unmatched text, and competing dictionary matches without incorrectly splitting longer words.
- Highlighting Coverage: Frequency/JLPT highlighting and vocabulary stats now include content adverbs (e.g. 確かに, やはり) and kanji nouns MeCab tags as non-independent (e.g. 日, 点, 以外), while still suppressing interjections, pronouns, and grammar fragments; lexicalized kana expressions like かといって keep their annotations.
@@ -88,7 +63,6 @@
<summary>Internal changes</summary>
### Internal
- Test lanes moved to `scripts/test-lanes.ts` with per-directory discovery and isolated per-file timeouts; CI now covers previously orphaned stats, scripts, plugin process-retry, and runtime-compat suites, plus a new stats lane in the change-verification workflow.
</details>
@@ -96,18 +70,15 @@
## v0.17.2 (2026-06-28)
### Fixed
- YouTube Background Cache: Fixed Windows YouTube background media cache startup for YouTube URLs opened directly in mpv, including resolved stream URLs when mpv still exposes the original YouTube playlist entry, so queued Anki media updates can append audio and images after the cache finishes.
- YouTube Subtitle Picker: Manual subtitle picker requests now show an immediate configured notification while SubMiner probes tracks and opens the modal. Subtitle download progress is replaced with a transient success notification after tracks load.
## v0.17.1 (2026-06-27)
### Added
- YouTube Media Cache Mode: Adds `youtube.mediaCache.mode` with `direct` and `background` options. Background mode uses a yt-dlp cache download when direct stream extraction is unreliable — creates a text-only card immediately, queues media updates for mined notes, and fills audio/image fields once the download finishes. Progress is announced via overlay/OSD notifications. Downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`). Switching back to direct mode cancels any in-flight background download.
### Fixed
- Log Export: Fixed log filenames to use the local date so exports around UTC midnight include the current day's logs rather than stale prior-day files. Expanded export redaction to mask IPs, emails, auth and cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL parameters.
- YouTube Card Media: Improved media generation reliability by sending safer ffmpeg options for resolved streams and skipping stale stream maps (including cached YouTube files). Hardened background cache downloads with IPv4 and extractor retry flags; failed downloads now notify the user and clear queued media updates instead of leaving them silently pending. Stale background cache files are cleaned on startup and before each new download.
@@ -188,7 +159,6 @@
<summary>Internal changes</summary>
### Internal
- **Build**: `make deps` now initializes git submodules before installing dependencies on a fresh source checkout.
- **Release Tooling**: Release notes now credit contributors and first-time authors resolved from changelog fragments via git and the GitHub API.
- **Changelog Guidance**: PR fragment guidance updated to preserve separate-outcome fragments while directing contributors to consolidate same-PR follow-up notes before adding churn.
@@ -198,11 +168,9 @@
## v0.15.2 (2026-06-02)
### Changed
- Yomitan: Updated the bundled Yomitan build to the latest vendored revision.
### Fixed
- Anki - Animated AVIF: Clip timing no longer starts or ends early; word-audio lead-in and clip duration are now aligned to frame boundaries.
- Overlay (Hyprland): Fixed fullscreen overlay alignment - modal, stats, and sidebar content no longer shift below the mpv window.
- Overlay (macOS): Subtitle bars are now interactive immediately after autoplay starts with "wait for overlay to be ready" enabled, without requiring a manual click.
+24 -19
View File
@@ -86,6 +86,10 @@ 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>
@@ -256,27 +260,28 @@ 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 |
| [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) / [Mangayomi](https://github.com/kodjodevf/mangayomi) | Reference implementation for the extension bridge protocol |
| [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 |
| 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 |
| [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 |
## License
[GNU General Public License v3.0](LICENSE)
The anime browser drives [M-Extension-Server](https://github.com/1Selxo/M-Extension-Server)
(Mozilla Public License 2.0), downloaded at runtime rather than bundled. MPL-2.0 is
compatible with the GPL. Aniyomi and Mihon are Apache-2.0; Mangatan and Mangayomi are
GPL-3.0. SubMiner ships no extension repositories and bundles no extensions.
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.
+7
View File
@@ -0,0 +1,7 @@
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.
+7 -3
View File
@@ -4,11 +4,11 @@ area: anime
- Added an anime browser window that searches Aniyomi extension sources, shows cover art and episode lists, and plays an episode in mpv so the overlay and mining tools attach as usual.
- Added `subminer anime` and the `--anime` flag to open the browser, plus a "Browse Anime" tray entry.
- Anime extensions are read from `<userData>/anime-extensions`; drop Aniyomi `.apk` files there to add sources.
- Added a source settings tab so extensions that need configuration (server address, credentials, quality) can be set up from the browser; values persist per source.
- Added an Extensions tab for adding repository URLs and installing, updating, or removing extensions in place; extensions that fail to load are listed with the reason.
- 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, and APK updates are staged before replacing the installed copy.
- Browse, Extensions, and Source settings are tabs, so each one gets the full window instead of sharing it with the search results.
- Repository URLs only need to be an https URL to a `.json` index; the file name is not restricted to `index.min.json`.
- The source picker offers "All sources", which searches every installed source at once. Results stream in as each source answers — a fast source is on screen while a slow one is still resolving, with per-source progress in the status bar. Results are tagged with the source they came from, and a source that fails is named in the status bar instead of blanking the grid.
- The 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 — and Update where a configured repository still carries it.
- Added `anime.repos`, `anime.extensionsDir`, and `anime.preferredQuality` config keys. SubMiner ships no extension repositories and performs no discovery.
- Anime playback targets Japanese audio: dub-labelled entries are skipped when the source offers an alternative, `alang` prefers Japanese, and the source's own audio and subtitle tracks are loaded into mpv (Japanese selected) instead of being discarded, so all of them can be switched from mpv's track menu.
@@ -20,3 +20,7 @@ area: anime
- "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 Linux x64 bridge bundle is verified and pinned, so the anime browser starts on Linux instead of refusing with "No pinned checksum for linux-x64-bundle.zip".
- The bridge bundle is fetched from the pinned release tag rather than whatever release is newest, so an upstream publish no longer breaks every install with a checksum mismatch.
- 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.
+7
View File
@@ -0,0 +1,7 @@
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.
+2 -6
View File
@@ -45,9 +45,7 @@
border: 1px solid var(--vp-c-border);
border-radius: 0;
background: var(--vp-c-bg);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3),
0 24px 64px rgba(0, 0, 0, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), 0 24px 64px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
@@ -64,9 +62,7 @@
font-family: var(--tui-font-mono);
font-size: 12px;
cursor: pointer;
transition:
border-color 180ms ease,
color 180ms ease;
transition: border-color 180ms ease, color 180ms ease;
}
.mermaid-modal__close:hover {
@@ -10,5 +10,7 @@ test('status line file path formats version archive home without trailing slash'
});
test('status line file path keeps normal docs routes as markdown files', () => {
expect(formatStatusLineFilePath('/v/0.12.0/configuration')).toBe('v/0.12.0/configuration.md');
expect(formatStatusLineFilePath('/v/0.12.0/configuration')).toBe(
'v/0.12.0/configuration.md',
);
});
+38 -59
View File
@@ -22,8 +22,17 @@
:root {
--tui-font-mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace;
--tui-font-body:
'Manrope Default', 'M PLUS 1', 'Manrope', 'Noto Sans CJK JP', 'Noto Sans JP',
'Hiragino Kaku Gothic ProN', 'Meiryo', 'Yu Gothic', 'Hiragino Sans', system-ui, sans-serif;
'Manrope Default',
'M PLUS 1',
'Manrope',
'Noto Sans CJK JP',
'Noto Sans JP',
'Hiragino Kaku Gothic ProN',
'Meiryo',
'Yu Gothic',
'Hiragino Sans',
system-ui,
sans-serif;
--tui-transition: 180ms ease;
/* Theme-specific values — overridden in .dark below */
@@ -87,11 +96,8 @@ button,
.VPFeature,
.VPNavBarMenuLink,
.VPSidebarItem .text {
transition:
color var(--tui-transition),
background var(--tui-transition),
border-color var(--tui-transition),
opacity var(--tui-transition);
transition: color var(--tui-transition), background var(--tui-transition),
border-color var(--tui-transition), opacity var(--tui-transition);
}
/* === Nav bar === */
@@ -212,7 +218,8 @@ button,
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
color: var(--vp-c-brand-1);
font-family: var(--tui-font-mono), 'M PLUS 1', 'Noto Sans CJK JP', 'Noto Sans JP', monospace;
font-family: var(--tui-font-mono), 'M PLUS 1', 'Noto Sans CJK JP', 'Noto Sans JP',
monospace;
font-variant-ligatures: none;
}
@@ -221,7 +228,8 @@ button,
border-radius: 0;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-alt) !important;
font-family: var(--tui-font-mono), 'M PLUS 1', 'Noto Sans CJK JP', 'Noto Sans JP', monospace;
font-family: var(--tui-font-mono), 'M PLUS 1', 'Noto Sans CJK JP', 'Noto Sans JP',
monospace;
font-variant-ligatures: none;
}
@@ -265,9 +273,7 @@ button,
.vp-doc a {
text-decoration: none;
border-bottom: 1px solid var(--tui-link-underline);
transition:
border-color var(--tui-transition),
color var(--tui-transition);
transition: border-color var(--tui-transition), color var(--tui-transition);
}
.vp-doc a:hover {
@@ -306,45 +312,20 @@ button,
border-left-width: 1px;
}
.vp-doc .custom-block.tip {
border-color: var(--vp-c-brand-1);
}
.vp-doc .custom-block.tip::before {
content: '-- tip';
color: var(--vp-c-brand-1);
}
.vp-doc .custom-block.tip { border-color: var(--vp-c-brand-1); }
.vp-doc .custom-block.tip::before { content: '-- tip'; color: var(--vp-c-brand-1); }
.vp-doc .custom-block.info {
border-color: var(--vp-c-brand-2);
}
.vp-doc .custom-block.info::before {
content: '-- info';
color: var(--vp-c-brand-2);
}
.vp-doc .custom-block.info { border-color: var(--vp-c-brand-2); }
.vp-doc .custom-block.info::before { content: '-- info'; color: var(--vp-c-brand-2); }
.vp-doc .custom-block.warning {
border-color: var(--vp-c-warning-1);
}
.vp-doc .custom-block.warning::before {
content: '-- warning';
color: var(--vp-c-warning-1);
}
.vp-doc .custom-block.warning { border-color: var(--vp-c-warning-1); }
.vp-doc .custom-block.warning::before { content: '-- warning'; color: var(--vp-c-warning-1); }
.vp-doc .custom-block.danger {
border-color: var(--vp-c-danger-1);
}
.vp-doc .custom-block.danger::before {
content: '-- danger';
color: var(--vp-c-danger-1);
}
.vp-doc .custom-block.danger { border-color: var(--vp-c-danger-1); }
.vp-doc .custom-block.danger::before { content: '-- danger'; color: var(--vp-c-danger-1); }
.vp-doc .custom-block.details {
border-color: var(--vp-c-divider);
}
.vp-doc .custom-block.details::before {
content: '-- details';
color: var(--vp-c-text-2);
}
.vp-doc .custom-block.details { border-color: var(--vp-c-divider); }
.vp-doc .custom-block.details::before { content: '-- details'; color: var(--vp-c-text-2); }
.vp-doc .custom-block .custom-block-title {
font-family: var(--tui-font-mono);
@@ -432,15 +413,11 @@ button,
}
@keyframes tui-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* === Statusline === */
.tui-statusline {
position: fixed;
@@ -480,7 +457,7 @@ button,
margin-left: -12px;
}
.tui-statusline__mode[data-mode='HOME'] {
.tui-statusline__mode[data-mode="HOME"] {
background: var(--vp-c-brand-2);
}
@@ -584,9 +561,7 @@ body {
.VPFeatures .VPFeature {
border-radius: 8px !important;
border: 1px solid var(--vp-c-divider) !important;
transition:
border-color var(--tui-transition),
background var(--tui-transition),
transition: border-color var(--tui-transition), background var(--tui-transition),
transform var(--tui-transition);
position: relative;
overflow: hidden;
@@ -697,7 +672,11 @@ body {
transform: translateX(-50%);
width: 600px;
height: 400px;
background: radial-gradient(ellipse at center, var(--tui-hero-glow) 0%, transparent 70%);
background: radial-gradient(
ellipse at center,
var(--tui-hero-glow) 0%,
transparent 70%
);
pointer-events: none;
z-index: -1;
}
+46 -1
View File
@@ -58,6 +58,14 @@ 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.
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
@@ -103,6 +111,10 @@ 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.
@@ -124,7 +136,11 @@ 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.
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
@@ -140,6 +156,11 @@ 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
@@ -198,4 +219,28 @@ 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
+3 -3
View File
@@ -45,9 +45,9 @@ Results are cached per file for the app session; only definitive "no intro found
You can trigger AniSkip actions from mpv script-messages:
| Command | Effect |
| ----------------------------------------- | ----------------------------------------------------------------------- |
| `script-message subminer-skip-intro` | Skip to the intro end immediately (same as pressing the key) |
| Command | Effect |
| ------- | ------ |
| `script-message subminer-skip-intro` | Skip to the intro end immediately (same as pressing the key) |
| `script-message subminer-aniskip-refresh` | Force a fresh lookup for the current file, discarding any cached result |
These are handled by the SubMiner app over the IPC socket.
-14
View File
@@ -3,7 +3,6 @@
## v0.19.0 (2026-07-29)
**Added**
- Anki Maturity Highlighting: Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Tier thresholds and colors are configurable, with a runtime toggle and an updated help legend.
- Post-Playback Menu: After a watch-history episode ends, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit. The pre-playback series menu now offers the previous episode too.
- Delete Library Entries: The stats Library detail view can now delete an entire title in one step (episodes, sessions, subtitle lines, rollups, cover art, and vocabulary counts). Delete progress is now shown app-wide via a progress bar and status toast instead of disappearing when you switch tabs.
@@ -11,11 +10,9 @@
- TsukiHime Subtitle Downloads: Added subtitle downloads for the current video via TsukiHime, loading Japanese as the primary track and your configured secondary language directly into mpv.
**Changed**
- Clipboard-Video Shortcut: The "append clipboard video to queue" shortcut is now configurable.
**Fixed**
- AniList Season Resolution: Season 2+ files now resolve to the correct AniList entry instead of silently falling back to season 1 (which mismatched character dictionaries and watch progress). Manual overrides now stay scoped per season, fix both the dictionary and progress tracking together, and also correct per-season cover art.
- Subtitle Annotation Accuracy: Fixed several annotation edge cases, including inconsistent POS exclusions on merged quote-particle tokens, dropped annotations on supplementary-plane kanji, katakana punctuation wrongly treated as noise, and certain kanji vocabulary losing N+1 highlighting eligibility.
- AnkiConnect Proxy Port Conflict: Video startup no longer crashes when another process already holds the configured AnkiConnect proxy port; a notification now explains how to resolve it.
@@ -31,7 +28,6 @@
<summary>Internal changes</summary>
**Internal**
- Added a golden-file regression test corpus for the tokenizer/annotation pipeline, plus scripts to record new fixtures and diff against stock Yomitan.
- Consolidated renderer modal state handling into a descriptor registry.
- Consolidated CI quality checks (PR, stable, and prerelease) into one reusable workflow with mpv plugin tests and dependency audits.
@@ -48,12 +44,10 @@
<h2>v0.18.0 (2026-07-10)</h2>
**Added**
- Sentence Audio Normalization: Generated sentence audio is now normalized to -23 LUFS by default, and clips mined from playback mirror mpv's software volume curve with a limiter to prevent clipping. Both behaviors are configurable independently.
- Watch History Command: Added `subminer -H` / `--history` to browse watch history, replay or continue episodes, or pick one via fzf or rofi, with cover art shown in the rofi picker.
**Changed**
- Fzf Preview Layout: Moved fzf previews below launcher menus, giving long titles and metadata more room.
- Known-Word Highlighting: Now compares subtitle and Anki-card readings, preventing false matches between homographs and unrelated words that share a reading, while still supporting matching across kana and kanji spellings.
- Annotation Filtering: Standalone suffix tokens (e.g. さん, れる) are now excluded from JLPT/frequency/N+1 highlighting by default, matching how particles and interjections are treated; configurable via the pos2 exclusion setting.
@@ -61,7 +55,6 @@
- Stats Trend Charts: Overhauled with persisted title visibility, per-chart title limits, "top" and "most recent" ranking modes, an option to show or hide empty days, calendar-aligned periods, and value-sorted tooltips.
**Fixed**
- Background Stats Server: `subminer app` background launches now auto-start the stats server when enabled, and skip startup if one is already running.
- Character Name Highlighting: Character dictionaries now split unspaced native names more reliably, and portraits, highlights, and hover lookup survive punctuation, unmatched text, and competing dictionary matches without incorrectly splitting longer words.
- Highlighting Coverage: Frequency/JLPT highlighting and vocabulary stats now include content adverbs (e.g. 確かに, やはり) and kanji nouns MeCab tags as non-independent (e.g. 日, 点, 以外), while still suppressing interjections, pronouns, and grammar fragments; lexicalized kana expressions like かといって keep their annotations.
@@ -75,7 +68,6 @@
<summary>Internal changes</summary>
**Internal**
- Test lanes moved to `scripts/test-lanes.ts` with per-directory discovery and isolated per-file timeouts; CI now covers previously orphaned stats, scripts, plugin process-retry, and runtime-compat suites, plus a new stats lane in the change-verification workflow.
</details>
@@ -88,18 +80,15 @@
<h2>v0.17.2 (2026-06-28)</h2>
**Fixed**
- YouTube Background Cache: Fixed Windows YouTube background media cache startup for YouTube URLs opened directly in mpv, including resolved stream URLs when mpv still exposes the original YouTube playlist entry, so queued Anki media updates can append audio and images after the cache finishes.
- YouTube Subtitle Picker: Manual subtitle picker requests now show an immediate configured notification while SubMiner probes tracks and opens the modal. Subtitle download progress is replaced with a transient success notification after tracks load.
<h2>v0.17.1 (2026-06-27)</h2>
**Added**
- YouTube Media Cache Mode: Adds `youtube.mediaCache.mode` with `direct` and `background` options. Background mode uses a yt-dlp cache download when direct stream extraction is unreliable — creates a text-only card immediately, queues media updates for mined notes, and fills audio/image fields once the download finishes. Progress is announced via overlay/OSD notifications. Downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`). Switching back to direct mode cancels any in-flight background download.
**Fixed**
- Log Export: Fixed log filenames to use the local date so exports around UTC midnight include the current day's logs rather than stale prior-day files. Expanded export redaction to mask IPs, emails, auth and cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL parameters.
- YouTube Card Media: Improved media generation reliability by sending safer ffmpeg options for resolved streams and skipping stale stream maps (including cached YouTube files). Hardened background cache downloads with IPv4 and extractor retry flags; failed downloads now notify the user and clear queued media updates instead of leaving them silently pending. Stale background cache files are cleaned on startup and before each new download.
@@ -185,7 +174,6 @@
<summary>Internal changes</summary>
**Internal**
- **Build**: `make deps` now initializes git submodules before installing dependencies on a fresh source checkout.
- **Release Tooling**: Release notes now credit contributors and first-time authors resolved from changelog fragments via git and the GitHub API.
- **Changelog Guidance**: PR fragment guidance updated to preserve separate-outcome fragments while directing contributors to consolidate same-PR follow-up notes before adding churn.
@@ -200,11 +188,9 @@
<h2>v0.15.2 (2026-06-02)</h2>
**Changed**
- Yomitan: Updated the bundled Yomitan build to the latest vendored revision.
**Fixed**
- Anki - Animated AVIF: Clip timing no longer starts or ends early; word-audio lead-in and clip duration are now aligned to frame boundaries.
- Overlay (Hyprland): Fixed fullscreen overlay alignment - modal, stats, and sidebar content no longer shift below the mpv window.
- Overlay (macOS): Subtitle bars are now interactive immediately after autoplay starts with "wait for overlay to be ready" enabled, without requiring a manual click.
+25
View File
@@ -154,6 +154,7 @@ 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
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
@@ -1154,6 +1155,30 @@ When the manual merge popup opens, SubMiner pauses playback and closes any open
## 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": {
"extensionsDir": "",
"repos": [],
"preferredQuality": ""
}
}
```
| Option | Type | Default | Description |
| ------------------------ | ---------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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. |
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:
+10 -10
View File
@@ -38,7 +38,7 @@ flowchart TB
## Runtime Sockets
The renderer↔main bridge above lives _inside_ the Electron app. A separate set of OS sockets connects the app to the other runtimes - mpv and the launcher/plugin. These carry no renderer payloads and bypass the contract/validator layer; they are command and property channels between processes.
The renderer↔main bridge above lives *inside* the Electron app. A separate set of OS sockets connects the app to the other runtimes - mpv and the launcher/plugin. These carry no renderer payloads and bypass the contract/validator layer; they are command and property channels between processes.
- **mpv IPC socket** (`/tmp/subminer-socket`, or `\\.\pipe\subminer-socket` on Windows): the `MpvIpcClient` in the main process connects here to send JSON commands and subscribe to playback/subtitle properties via `observe_property`. Created by mpv's `--input-ipc-server`.
- **App control socket** (`/tmp/subminer-control-<uid>-<hash>.sock`, or a named pipe on Windows): the launcher and the mpv plugin send CLI-style commands (`--start`, `--show-visible-overlay`, `--texthooker`) to a running app here. It also dedupes a second `subminer` invocation into the existing instance instead of launching twice.
@@ -69,15 +69,15 @@ How these sockets are established during launch is covered in [Playback Startup
## Core Surfaces
| File | Role |
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `src/shared/ipc/contracts.ts` | Canonical channel names and payload type contracts. Single source of truth for both processes. |
| `src/shared/ipc/validators.ts` | Runtime payload parsers and type guards. Every `invoke` payload is validated here before the handler runs. |
| `src/preload.ts` | Renderer-side bridge. Exposes a typed API surface to the renderer - only approved channels are accessible. |
| `src/main/ipc-runtime.ts` | Main-process handler registration and routing. Wires validated channels to domain handlers. |
| `src/core/services/ipc.ts` | Service-level invoke handling. Applies guardrails (validation, error wrapping) before calling domain logic. |
| `src/core/services/anki-jimaku-ipc.ts` | Integration-specific IPC boundary for Anki and Jimaku operations. |
| `src/main/cli-runtime.ts` | CLI/runtime command boundary. Handles commands that originate from the launcher or mpv plugin rather than the renderer. |
| File | Role |
| --- | --- |
| `src/shared/ipc/contracts.ts` | Canonical channel names and payload type contracts. Single source of truth for both processes. |
| `src/shared/ipc/validators.ts` | Runtime payload parsers and type guards. Every `invoke` payload is validated here before the handler runs. |
| `src/preload.ts` | Renderer-side bridge. Exposes a typed API surface to the renderer - only approved channels are accessible. |
| `src/main/ipc-runtime.ts` | Main-process handler registration and routing. Wires validated channels to domain handlers. |
| `src/core/services/ipc.ts` | Service-level invoke handling. Applies guardrails (validation, error wrapping) before calling domain logic. |
| `src/core/services/anki-jimaku-ipc.ts` | Integration-specific IPC boundary for Anki and Jimaku operations. |
| `src/main/cli-runtime.ts` | CLI/runtime command boundary. Handles commands that originate from the launcher or mpv plugin rather than the renderer. |
## Contract Rules
+4
View File
@@ -79,6 +79,8 @@ 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
@@ -87,6 +89,8 @@ 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"**
+14 -14
View File
@@ -31,20 +31,20 @@ input-ipc-server=\\.\pipe\subminer-socket
The plugin reads options from `script-opts` with the `subminer-` prefix (for example `--script-opts=subminer-backend=hyprland`). Managed launches inject these automatically from your SubMiner config; the shipped `subminer.conf` is intentionally empty so command-line opts always win.
| Option | Default | Description |
| ---------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------- |
| `binary_path` | `""` | Path to the SubMiner binary; empty enables [auto-detection](#binary-auto-detection) |
| `socket_path` | platform default | mpv IPC socket path (`/tmp/subminer-socket`, or `\\.\pipe\subminer-socket` on Windows) |
| `texthooker_enabled` | `no` | Start the texthooker server with the overlay |
| `texthooker_port` | `5174` | Texthooker server port |
| `backend` | `auto` | Window backend (`auto`, `hyprland`, `sway`, `x11`, `macos`) |
| `auto_start` | `no` | Start the overlay app on `file-loaded` (managed launches set this from `mpv.autoStartSubMiner`) |
| `auto_start_visible_overlay` | `no` | Show the visible overlay on auto-start (from `auto_start_overlay` in config) |
| `overlay_loading_osd` | `no` | Show an OSD loading spinner while the overlay starts |
| `auto_start_pause_until_ready` | `yes` | Keep mpv paused until the overlay reports tokenization-ready |
| `auto_start_pause_until_ready_timeout_seconds` | `30` | Timeout before resuming playback anyway |
| `osd_messages` | `yes` | Show plugin OSD status messages |
| `log_level` | `info` | Plugin log verbosity |
| Option | Default | Description |
| -------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------- |
| `binary_path` | `""` | Path to the SubMiner binary; empty enables [auto-detection](#binary-auto-detection) |
| `socket_path` | platform default | mpv IPC socket path (`/tmp/subminer-socket`, or `\\.\pipe\subminer-socket` on Windows) |
| `texthooker_enabled` | `no` | Start the texthooker server with the overlay |
| `texthooker_port` | `5174` | Texthooker server port |
| `backend` | `auto` | Window backend (`auto`, `hyprland`, `sway`, `x11`, `macos`) |
| `auto_start` | `no` | Start the overlay app on `file-loaded` (managed launches set this from `mpv.autoStartSubMiner`) |
| `auto_start_visible_overlay` | `no` | Show the visible overlay on auto-start (from `auto_start_overlay` in config) |
| `overlay_loading_osd` | `no` | Show an OSD loading spinner while the overlay starts |
| `auto_start_pause_until_ready` | `yes` | Keep mpv paused until the overlay reports tokenization-ready |
| `auto_start_pause_until_ready_timeout_seconds` | `30` | Timeout before resuming playback anyway |
| `osd_messages` | `yes` | Show plugin OSD status messages |
| `log_level` | `info` | Plugin log verbosity |
## Keybindings
+18 -18
View File
@@ -12,10 +12,10 @@ All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindi
## App-Wide Shortcuts
| Shortcut | Action | Scope | Configurable |
| ------------- | ---------------------- | ---------------------------------------- | -------------------------------------- |
| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus | `shortcuts.toggleVisibleOverlayGlobal` |
| `Alt+Shift+Y` | Open Yomitan settings | OS-global (registered with the OS) | Fixed (not configurable) |
| Shortcut | Action | Scope | Configurable |
| ------------- | ---------------------- | -------------------------------------------- | -------------------------------------- |
| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus | `shortcuts.toggleVisibleOverlayGlobal` |
| `Alt+Shift+Y` | Open Yomitan settings | OS-global (registered with the OS) | Fixed (not configurable) |
::: tip
`Alt+Shift+O` is dispatched by the overlay window and the mpv plugin, so it works from either surface without OS registration. Only `Alt+Shift+Y` is registered with the OS; if it conflicts with another application, that binding cannot be changed. All `shortcuts.*` keys hot-reload - no restart needed.
@@ -75,21 +75,21 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
## Subtitle & Feature Shortcuts
| Shortcut | Action | Config key |
| ------------------ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
| `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` |
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
| `Ctrl+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` |
| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` |
| Shortcut | Action | Config key |
| ------------------ | -------------------------------------------------------- | ------------------------------------------ |
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
| `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` |
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
| `Ctrl+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` |
| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` |
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` (overlay) / `shortcuts.toggleSubtitleSidebar` (mpv session binding) |
| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` |
| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` |
`shortcuts.openAnimetosho` remains accepted as a deprecated alias for `shortcuts.openTsukihime`. The current name takes precedence when both are configured.
+15 -15
View File
@@ -62,23 +62,23 @@ Styling lives under the `css` object, using CSS property names and CSS custom pr
| `pauseVideoOnHover` | boolean | `true` | Pause playback while hovering the cue list |
| `autoScroll` | boolean | `true` | Keep the active cue in view during playback |
| `css` property | Default | Description |
| -------------------------------------------- | --------------------------------------------------------------- | ----------------------------------- |
| `font-family` | `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP` | Cue text font family |
| `color` | `#cad3f5` | Default cue text color |
| `background-color` | `rgba(73, 77, 100, 0.9)` | Sidebar shell background color |
| `font-size` | `16px` | Base cue font size |
| `opacity` | `0.95` | Sidebar opacity between `0` and `1` |
| `--subtitle-sidebar-max-width` | `420px` | Maximum sidebar width |
| `--subtitle-sidebar-timestamp-color` | `#a5adcb` | Cue timestamp color |
| `--subtitle-sidebar-active-line-color` | `#f5bde6` | Active cue text color |
| `--subtitle-sidebar-active-background-color` | `rgba(138, 173, 244, 0.22)` | Active cue background color |
| `--subtitle-sidebar-hover-background-color` | `rgba(54, 58, 79, 0.84)` | Hovered cue background color |
| `css` property | Default | Description |
| ------------------------------------------- | --------------------------- | ---------------------------- |
| `font-family` | `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP` | Cue text font family |
| `color` | `#cad3f5` | Default cue text color |
| `background-color` | `rgba(73, 77, 100, 0.9)` | Sidebar shell background color |
| `font-size` | `16px` | Base cue font size |
| `opacity` | `0.95` | Sidebar opacity between `0` and `1` |
| `--subtitle-sidebar-max-width` | `420px` | Maximum sidebar width |
| `--subtitle-sidebar-timestamp-color` | `#a5adcb` | Cue timestamp color |
| `--subtitle-sidebar-active-line-color` | `#f5bde6` | Active cue text color |
| `--subtitle-sidebar-active-background-color`| `rgba(138, 173, 244, 0.22)` | Active cue background color |
| `--subtitle-sidebar-hover-background-color` | `rgba(54, 58, 79, 0.84)` | Hovered cue background color |
## Keyboard Shortcut
| Key | Action | Config key |
| --- | ----------------------- | --------------------------- |
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
| Key | Action | Config key |
| --- | ----------------------- | ------------------------------ |
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
The toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source. See [Keyboard Shortcuts](/shortcuts) for the full shortcut reference.
+26
View File
@@ -243,6 +243,32 @@ Subtitle tracks mpv loaded from a URL are downloaded to a temporary file first,
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"**
+2 -2
View File
@@ -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 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. Tracks with no language tag stay visible on the secondary tab.
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.
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.
From there:
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.
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.
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.
+32 -32
View File
@@ -2,7 +2,7 @@
**Who this page is for:** developers and tinkerers who want to consume SubMiner's live subtitle stream from their own tools - a browser tab, an automation script, or another mpv plugin. If you just want subtitles in a browser tab for Yomitan, skip to [Texthooker Integration Guide](#texthooker-integration-guide); the rest is reference for building custom clients.
A _texthooker_ is a page/tool that receives the text currently on screen so a dictionary extension (like Yomitan) can look words up. SubMiner ships its own texthooker UI and also broadcasts subtitle text over local WebSockets that any client can connect to.
A *texthooker* is a page/tool that receives the text currently on screen so a dictionary extension (like Yomitan) can look words up. SubMiner ships its own texthooker UI and also broadcasts subtitle text over local WebSockets that any client can connect to.
SubMiner exposes a small set of local integration surfaces for browser tools, automation helpers, and mpv-driven workflows:
@@ -15,12 +15,12 @@ This page documents those integration points and shows how to build custom consu
## Quick Reference
| Surface | Default | Purpose |
| --------------------- | --------------------------- | -------------------------------------------------- |
| `websocket` | `ws://127.0.0.1:6677` | Basic subtitle broadcast stream |
| `annotationWebsocket` | `ws://127.0.0.1:6678` | Structured stream with token metadata |
| `texthooker` | `http://127.0.0.1:5174` | Local texthooker UI with injected websocket config |
| mpv plugin | `script-message subminer-*` | Start/stop/toggle/status automation inside mpv |
| Surface | Default | Purpose |
| --- | --- | --- |
| `websocket` | `ws://127.0.0.1:6677` | Basic subtitle broadcast stream |
| `annotationWebsocket` | `ws://127.0.0.1:6678` | Structured stream with token metadata |
| `texthooker` | `http://127.0.0.1:5174` | Local texthooker UI with injected websocket config |
| mpv plugin | `script-message subminer-*` | Start/stop/toggle/status automation inside mpv |
## Enable and Configure the Services
@@ -30,16 +30,16 @@ SubMiner's integration ports are configured in `config.jsonc`. All three service
{
"websocket": {
"enabled": "auto",
"port": 6677,
"port": 6677
},
"annotationWebsocket": {
"enabled": true,
"port": 6678,
"port": 6678
},
"texthooker": {
"launchAtStartup": true,
"openBrowser": false,
},
"openBrowser": false
}
}
```
@@ -79,12 +79,12 @@ When a client connects, SubMiner immediately sends the latest subtitle payload i
#### Field reference
| Field | Type | Notes |
| ---------- | ------ | ---------------------------------------------------------------------------------------------- |
| `version` | number | Current websocket payload version. Today this is `1`. |
| `text` | string | Raw subtitle text. |
| Field | Type | Notes |
| --- | --- | --- |
| `version` | number | Current websocket payload version. Today this is `1`. |
| `text` | string | Raw subtitle text. |
| `sentence` | string | Plain subtitle text with line breaks represented as `<br>`. No annotation spans or attributes. |
| `tokens` | array | Always empty on the basic subtitle websocket. |
| `tokens` | array | Always empty on the basic subtitle websocket. |
### 2. Annotation WebSocket
@@ -127,22 +127,22 @@ In practice, if you are building a new client, prefer `annotationWebsocket` unle
Each annotation token may include:
| Token field | Type | Notes |
| --------------------- | ---------------- | ------------------------------------------------ |
| `surface` | string | Display text for the token |
| `reading` | string | Kana reading when available |
| `headword` | string | Dictionary headword when available |
| `startPos` / `endPos` | number | Character offsets in the subtitle text |
| `partOfSpeech` | string | SubMiner token POS label |
| `isMerged` | boolean | Whether this token represents merged content |
| `isKnown` | boolean | Marked known by SubMiner's known-word logic |
| `isNPlusOneTarget` | boolean | True when the token is the sentence's N+1 target |
| `isNameMatch` | boolean | True for prioritized character-name matches |
| `frequencyRank` | number | Frequency rank when available |
| `jlptLevel` | string | JLPT level when available |
| `className` | string | CSS-ready class list derived from token state |
| `frequencyRankLabel` | string or `null` | Preformatted rank label for UIs |
| `jlptLevelLabel` | string or `null` | Preformatted JLPT label for UIs |
| Token field | Type | Notes |
| --- | --- | --- |
| `surface` | string | Display text for the token |
| `reading` | string | Kana reading when available |
| `headword` | string | Dictionary headword when available |
| `startPos` / `endPos` | number | Character offsets in the subtitle text |
| `partOfSpeech` | string | SubMiner token POS label |
| `isMerged` | boolean | Whether this token represents merged content |
| `isKnown` | boolean | Marked known by SubMiner's known-word logic |
| `isNPlusOneTarget` | boolean | True when the token is the sentence's N+1 target |
| `isNameMatch` | boolean | True for prioritized character-name matches |
| `frequencyRank` | number | Frequency rank when available |
| `jlptLevel` | string | JLPT level when available |
| `className` | string | CSS-ready class list derived from token state |
| `frequencyRankLabel` | string or `null` | Preformatted rank label for UIs |
| `jlptLevelLabel` | string or `null` | Preformatted JLPT label for UIs |
### 3. HTML markup conventions
+5 -5
View File
@@ -110,8 +110,8 @@ Background cache downloads are capped at 720p by default (`youtube.mediaCache.ma
}
```
| Option | Type | Description |
| --------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Option | Type | Description |
| --------------------- | ---------- | ------------------------------------------------------------------------------------- |
| `primarySubLanguages` | `string[]` | Languages that count as a satisfactory primary subtitle (default `["ja", "jpn"]`). Used by the "primary subtitle missing" notification and by managed local/playlist subtitle selection. |
YouTube auto-selection itself always picks a Japanese track first (manual over auto), then falls back to any manual track — `primarySubLanguages` does not change which YouTube track is auto-picked.
@@ -130,11 +130,11 @@ YouTube secondary selection is fixed: SubMiner always tries an English track (ma
}
```
| Option | Type | Description |
| ----------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Option | Type | Description |
| ----------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track for local/Jellyfin sidecar files. Default is empty (`[]`). Not used for YouTube. |
| `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary sidecar track for local files (default: `false`). Not used for YouTube. |
| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
These settings come from `config.jsonc` (or built-in defaults); there are no CLI flags or environment variables for subtitle language selection.
@@ -64,17 +64,15 @@ External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out o
A cue parser extracts both timing and text content from subtitle files for prefetching.
**Parsed cue structure:**
```typescript
interface SubtitleCue {
startTime: number; // seconds
endTime: number; // seconds
text: string; // raw subtitle text
startTime: number; // seconds
endTime: number; // seconds
text: string; // raw subtitle text
}
```
**Supported formats:**
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
- ASS: Parse `[Events]` section, extract `Dialogue:` lines, split on the first 9 commas only (ASS v4+ has 10 fields; the last field is Text which can itself contain commas). Strip ASS override tags (`{\...}`) from the text before storing.
ASS text fields contain inline override tags like `{\b1}`, `{\an8}`, `{\fad(200,300)}`. The cue parser strips these during extraction so the tokenizer receives clean text.
@@ -155,7 +153,6 @@ 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.
@@ -172,14 +169,18 @@ 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 };
});
@@ -220,7 +221,6 @@ Replace `document.createElement('span')` calls in the renderer with `templateSpa
### Current Behavior
In `renderWithTokens` (`subtitle-render.ts`), each render cycle:
1. Clears DOM with `innerHTML = ''`
2. Creates a `DocumentFragment`
3. Calls `document.createElement('span')` for each token (~10-15 per subtitle)
@@ -256,30 +256,27 @@ 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)
+6 -1
View File
@@ -3,7 +3,7 @@
# Domain Ownership
Status: active
Last verified: 2026-07-15
Last verified: 2026-08-02
Owner: Kyle Yasuda
Read when: you need to find the owner module for a behavior or test surface
@@ -27,6 +27,10 @@ Read when: you need to find the owner module for a behavior or test surface
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
- Anime browser: extension bridge client, sidecar, and stream handling in `src/anime-bridge/`;
browser window UI in `src/animeui/` (preload `src/preload-animeui.ts`); runtime wiring in
`src/main/runtime/anime-browser-runtime.ts`, `src/main/runtime/anime-browser-ipc-handlers.ts`,
`src/main/runtime/anime-bridge-installer.ts`, `src/main/runtime/stream-playback-metadata.ts`
- 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/`
@@ -44,6 +48,7 @@ Read when: you need to find the owner module for a behavior or test surface
- 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
+1 -1
View File
@@ -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-05-23 | runtime and feature ownership |
| Domain ownership | `docs/architecture/domains.md` | active | 2026-08-02 | 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 |
+18 -18
View File
@@ -11,27 +11,27 @@ Grades are directional, not ceremonial. The point is to keep gaps visible.
## Product / Runtime Domains
| Area | Grade | Notes |
| --------------------------------------- | ----- | ------------------------------------------------------------------------ |
| Desktop runtime composition | B | strong modularization; still easy for `main` wiring drift to reappear |
| Launcher CLI | B | focused surface; generated/stale artifact hazards need constant guarding |
| mpv plugin | B | modular, but Lua/runtime coupling still specialized |
| Overlay renderer | B | improved modularity; interaction complexity remains |
| Config system | A- | clear defaults/definitions split and good validation surface |
| Immersion / AniList / Jellyfin surfaces | B- | growing product scope; ownership spans multiple services |
| Internal docs system | B | new structure in place; needs habitual maintenance |
| Public docs site | B | strong user docs; must stay separate from internal KB |
| Area | Grade | Notes |
| --- | --- | --- |
| Desktop runtime composition | B | strong modularization; still easy for `main` wiring drift to reappear |
| Launcher CLI | B | focused surface; generated/stale artifact hazards need constant guarding |
| mpv plugin | B | modular, but Lua/runtime coupling still specialized |
| Overlay renderer | B | improved modularity; interaction complexity remains |
| Config system | A- | clear defaults/definitions split and good validation surface |
| Immersion / AniList / Jellyfin surfaces | B- | growing product scope; ownership spans multiple services |
| Internal docs system | B | new structure in place; needs habitual maintenance |
| Public docs site | B | strong user docs; must stay separate from internal KB |
## Architectural Layers
| Layer | Grade | Notes |
| ------------------------------ | ----- | --------------------------------------------------------- |
| `src/main.ts` composition root | B | direction good; still needs vigilance against logic creep |
| `src/main/` runtime adapters | B | mostly clear; can accumulate wiring debt |
| `src/core/services/` | B+ | good extraction pattern; some domains remain broad |
| `src/renderer/` | B | cleaner than before; UI/runtime behavior still dense |
| `launcher/` | B | clear command boundaries |
| `docs/` internal KB | B | structure exists; enforcement now guards core rules |
| Layer | Grade | Notes |
| --- | --- | --- |
| `src/main.ts` composition root | B | direction good; still needs vigilance against logic creep |
| `src/main/` runtime adapters | B | mostly clear; can accumulate wiring debt |
| `src/core/services/` | B+ | good extraction pattern; some domains remain broad |
| `src/renderer/` | B | cleaner than before; UI/runtime behavior still dense |
| `launcher/` | B | clear command boundaries |
| `docs/` internal KB | B | structure exists; enforcement now guards core rules |
## Current Gaps
@@ -15,16 +15,13 @@
## File Structure
**Backend (`src/core/services/immersion-tracker/`):**
- `query-trends.ts` — add `LibrarySummaryRow` type, `buildLibrarySummary` helper, wire into `getTrendsDashboard`, drop `animePerDay` from `TrendsDashboardQueryResult`, delete now-unused `buildPerAnimeFromSessions` and `buildLookupsPerHundredPerAnime`.
- `__tests__/query.test.ts` — update existing `getTrendsDashboard` test (drop `animePerDay` assertion, add `librarySummary` assertion); add new tests for summary-specific behavior (empty window, multi-title, null lookupsPerHundred).
**Backend test fixtures:**
- `src/core/services/__tests__/stats-server.test.ts` — update `TRENDS_DASHBOARD` fixture (remove `animePerDay`, add `librarySummary`), fix `assert.deepEqual` that references `body.animePerDay.watchTime`.
**Frontend (`stats/src/`):**
- `types/stats.ts` — add `LibrarySummaryRow` interface, add `librarySummary` field to `TrendsDashboardData`, remove `animePerDay` field.
- `lib/api-client.test.ts` — update the two inline fetch-mock fixtures (remove `animePerDay`, add `librarySummary`).
- `components/trends/LibrarySummarySection.tsx` — **new** file. Owns the header content: leaderboard Recharts chart + sortable HTML table. Takes `{ rows, hiddenTitles }` as props.
@@ -32,7 +29,6 @@
- `components/trends/anime-visibility.ts` — unchanged. The existing helpers operate on `PerAnimeDataPoint[]`; we'll adapt by passing a derived `PerAnimeDataPoint[]` built from `librarySummary` (or add an overload — see Task 7 for the final decision).
**Changelog:**
- `changes/stats-library-summary.md` — **new** changelog fragment.
---
@@ -40,7 +36,6 @@
## Task 1: Backend — Add `LibrarySummaryRow` type and empty stub field
**Files:**
- Modify: `src/core/services/immersion-tracker/query-trends.ts`
- [ ] **Step 1: Add the row type and add `librarySummary: []` to the returned object**
@@ -87,7 +82,6 @@ git commit -m "feat(stats): scaffold LibrarySummaryRow type and empty field"
## Task 2: Backend — TDD the `buildLibrarySummary` helper
**Files:**
- Modify: `src/core/services/immersion-tracker/query-trends.ts`
- Modify: `src/core/services/immersion-tracker/__tests__/query.test.ts`
@@ -166,7 +160,16 @@ test('getTrendsDashboard builds librarySummary with per-title aggregates', () =>
lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ?
WHERE session_id = ?
`,
).run(`${startedAtMs + activeMs}`, activeMs, activeMs, 10, tokens, cards, lookups, sessionId);
).run(
`${startedAtMs + activeMs}`,
activeMs,
activeMs,
10,
tokens,
cards,
lookups,
sessionId,
);
}
for (const [day, active, tokens, cards] of [
@@ -286,7 +289,8 @@ function buildLibrarySummary(
cards: acc.cards,
words: acc.words,
lookups: acc.lookups,
lookupsPerHundred: acc.words > 0 ? +((acc.lookups / acc.words) * 100).toFixed(1) : null,
lookupsPerHundred:
acc.words > 0 ? +((acc.lookups / acc.words) * 100).toFixed(1) : null,
firstWatched: acc.firstWatched,
lastWatched: acc.lastWatched,
});
@@ -330,7 +334,6 @@ git commit -m "feat(stats): build per-title librarySummary from daily rollups an
## Task 3: Backend — Add null-lookupsPerHundred and empty-window tests
**Files:**
- Modify: `src/core/services/immersion-tracker/__tests__/query.test.ts`
- [ ] **Step 1: Write a failing test for `lookupsPerHundred: null` when words == 0**
@@ -399,7 +402,16 @@ test('getTrendsDashboard librarySummary returns null lookupsPerHundred when word
lines_seen = ?, tokens_seen = ?, cards_mined = ?, yomitan_lookup_count = ?
WHERE session_id = ?
`,
).run(`${startMs + 20 * 60_000}`, 20 * 60_000, 20 * 60_000, 5, 0, 0, 0, session.sessionId);
).run(
`${startMs + 20 * 60_000}`,
20 * 60_000,
20 * 60_000,
5,
0,
0,
0,
session.sessionId,
);
db.prepare(
`
@@ -452,7 +464,6 @@ git commit -m "test(stats): cover librarySummary null-lookups and empty-window c
## Task 4: Backend — Drop `animePerDay` from the response type and clean up dead helpers
**Files:**
- Modify: `src/core/services/immersion-tracker/query-trends.ts`
- Modify: `src/core/services/immersion-tracker/__tests__/query.test.ts`
- Modify: `src/core/services/__tests__/stats-server.test.ts`
@@ -478,61 +489,61 @@ animePerDay: {
In `getTrendsDashboard` (around lines 649-668 and 694-699), keep the internal `animePerDay` construction (it's still used by `animeCumulative`) but do NOT include it in the returned object. Also drop the now-unused `lookups` and `lookupsPerHundred` fields from the internal `animePerDay` object. Replace the block starting with `const animePerDay = {` through the return statement:
```ts
const animePerDay = {
episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId),
watchTime: buildPerAnimeFromDailyRollups(
dailyRollups,
titlesByVideoId,
(rollup) => rollup.totalActiveMin,
),
cards: buildPerAnimeFromDailyRollups(
dailyRollups,
titlesByVideoId,
(rollup) => rollup.totalCards,
),
words: buildPerAnimeFromDailyRollups(
dailyRollups,
titlesByVideoId,
(rollup) => rollup.totalTokensSeen,
),
};
const animePerDay = {
episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId),
watchTime: buildPerAnimeFromDailyRollups(
dailyRollups,
titlesByVideoId,
(rollup) => rollup.totalActiveMin,
),
cards: buildPerAnimeFromDailyRollups(
dailyRollups,
titlesByVideoId,
(rollup) => rollup.totalCards,
),
words: buildPerAnimeFromDailyRollups(
dailyRollups,
titlesByVideoId,
(rollup) => rollup.totalTokensSeen,
),
};
return {
activity,
progress: {
watchTime: accumulatePoints(activity.watchTime),
sessions: accumulatePoints(activity.sessions),
words: accumulatePoints(activity.words),
newWords: accumulatePoints(
useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs),
),
cards: accumulatePoints(activity.cards),
episodes: accumulatePoints(
useMonthlyBuckets
? buildEpisodesPerMonthFromRollups(monthlyRollups)
: buildEpisodesPerDayFromDailyRollups(dailyRollups),
),
lookups: accumulatePoints(
useMonthlyBuckets
? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount)
: buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
),
},
ratios: {
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
},
librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId),
animeCumulative: {
watchTime: buildCumulativePerAnime(animePerDay.watchTime),
episodes: buildCumulativePerAnime(animePerDay.episodes),
cards: buildCumulativePerAnime(animePerDay.cards),
words: buildCumulativePerAnime(animePerDay.words),
},
patterns: {
watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
watchTimeByHour: buildWatchTimeByHour(sessions),
},
};
return {
activity,
progress: {
watchTime: accumulatePoints(activity.watchTime),
sessions: accumulatePoints(activity.sessions),
words: accumulatePoints(activity.words),
newWords: accumulatePoints(
useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs),
),
cards: accumulatePoints(activity.cards),
episodes: accumulatePoints(
useMonthlyBuckets
? buildEpisodesPerMonthFromRollups(monthlyRollups)
: buildEpisodesPerDayFromDailyRollups(dailyRollups),
),
lookups: accumulatePoints(
useMonthlyBuckets
? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount)
: buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
),
},
ratios: {
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
},
librarySummary: buildLibrarySummary(dailyRollups, sessions, titlesByVideoId),
animeCumulative: {
watchTime: buildCumulativePerAnime(animePerDay.watchTime),
episodes: buildCumulativePerAnime(animePerDay.episodes),
cards: buildCumulativePerAnime(animePerDay.cards),
words: buildCumulativePerAnime(animePerDay.words),
},
patterns: {
watchTimeByDayOfWeek: buildWatchTimeByDayOfWeek(sessions),
watchTimeByHour: buildWatchTimeByHour(sessions),
},
};
```
- [ ] **Step 3: Delete now-unused helpers**
@@ -611,7 +622,6 @@ git commit -m "refactor(stats): drop animePerDay from trends response in favor o
## Task 5: Frontend — Update types and api-client test fixtures
**Files:**
- Modify: `stats/src/types/stats.ts`
- Modify: `stats/src/lib/api-client.test.ts`
@@ -700,7 +710,6 @@ git commit -m "refactor(stats): replace animePerDay type with librarySummary"
## Task 6: Frontend — Create `LibrarySummarySection` skeleton with empty state
**Files:**
- Create: `stats/src/components/trends/LibrarySummarySection.tsx`
- [ ] **Step 1: Create the file with the empty state and props plumbing**
@@ -756,7 +765,6 @@ git commit -m "feat(stats): scaffold LibrarySummarySection with empty state"
## Task 7: Frontend — Add the leaderboard bar chart to `LibrarySummarySection`
**Files:**
- Modify: `stats/src/components/trends/LibrarySummarySection.tsx`
- [ ] **Step 1: Replace the skeleton body with the leaderboard chart**
@@ -764,7 +772,15 @@ git commit -m "feat(stats): scaffold LibrarySummarySection with empty state"
Replace the entire contents of `stats/src/components/trends/LibrarySummarySection.tsx` with:
```tsx
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { LibrarySummaryRow } from '../../types/stats';
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
@@ -805,7 +821,9 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
return (
<>
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
<h3 className="text-xs font-semibold text-ctp-text mb-2">Top Titles by Watch Time (min)</h3>
<h3 className="text-xs font-semibold text-ctp-text mb-2">
Top Titles by Watch Time (min)
</h3>
<ResponsiveContainer width="100%" height={LEADERBOARD_HEIGHT}>
<BarChart
data={leaderboard}
@@ -863,7 +881,6 @@ git commit -m "feat(stats): add top-titles leaderboard chart to LibrarySummarySe
## Task 8: Frontend — Add the sortable table to `LibrarySummarySection`
**Files:**
- Modify: `stats/src/components/trends/LibrarySummarySection.tsx`
- [ ] **Step 1: Add sort state, column definitions, and the table markup**
@@ -872,7 +889,15 @@ Replace the entire file with the version below. The change vs. Task 7: imports `
```tsx
import { useMemo, useState } from 'react';
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { LibrarySummaryRow } from '../../types/stats';
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from '../../lib/chart-theme';
import { epochDayToDate, formatDuration, formatNumber } from '../../lib/formatters';
@@ -998,7 +1023,9 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
if (visibleRows.length === 0) {
return (
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
<div className="text-xs text-ctp-overlay2">No library activity in the selected window.</div>
<div className="text-xs text-ctp-overlay2">
No library activity in the selected window.
</div>
</div>
);
}
@@ -1015,7 +1042,9 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
return (
<>
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
<h3 className="text-xs font-semibold text-ctp-text mb-2">Top Titles by Watch Time (min)</h3>
<h3 className="text-xs font-semibold text-ctp-text mb-2">
Top Titles by Watch Time (min)
</h3>
<ResponsiveContainer width="100%" height={LEADERBOARD_HEIGHT}>
<BarChart
data={leaderboard}
@@ -1052,7 +1081,10 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
</div>
<div className="col-span-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4">
<h3 className="text-xs font-semibold text-ctp-text mb-2">Per-Title Summary</h3>
<div className="overflow-auto" style={{ maxHeight: TABLE_MAX_HEIGHT }}>
<div
className="overflow-auto"
style={{ maxHeight: TABLE_MAX_HEIGHT }}
>
<table className="w-full text-xs">
<thead className="sticky top-0 bg-ctp-surface0">
<tr className="border-b border-ctp-surface1 text-ctp-subtext0">
@@ -1106,7 +1138,9 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect
{formatNumber(row.lookups)}
</td>
<td className="px-2 py-2 text-right text-ctp-text tabular-nums">
{row.lookupsPerHundred === null ? '—' : row.lookupsPerHundred.toFixed(1)}
{row.lookupsPerHundred === null
? '—'
: row.lookupsPerHundred.toFixed(1)}
</td>
<td className="px-2 py-2 text-right text-ctp-subtext0 tabular-nums">
{formatDateRange(row.firstWatched, row.lastWatched)}
@@ -1139,7 +1173,6 @@ git commit -m "feat(stats): add sortable per-title table to LibrarySummarySectio
## Task 9: Frontend — Wire `LibrarySummarySection` into `TrendsTab` and remove the per-day block
**Files:**
- Modify: `stats/src/components/trends/TrendsTab.tsx`
- [ ] **Step 1: Delete the per-day filtered locals and imports**
@@ -1159,7 +1192,10 @@ const filteredWatchTimePerAnime = filterHiddenAnimeData(
);
const filteredCardsPerAnime = filterHiddenAnimeData(data.animePerDay.cards, activeHiddenAnime);
const filteredWordsPerAnime = filterHiddenAnimeData(data.animePerDay.words, activeHiddenAnime);
const filteredLookupsPerAnime = filterHiddenAnimeData(data.animePerDay.lookups, activeHiddenAnime);
const filteredLookupsPerAnime = filterHiddenAnimeData(
data.animePerDay.lookups,
activeHiddenAnime,
);
const filteredLookupsPerHundredPerAnime = filterHiddenAnimeData(
data.animePerDay.lookupsPerHundred,
activeHiddenAnime,
@@ -1250,7 +1286,6 @@ git commit -m "feat(stats): replace per-day trends section with library summary"
## Task 10: Add changelog fragment and run the full handoff gate
**Files:**
- Create: `changes/stats-library-summary.md`
- [ ] **Step 1: Check the existing changelog fragment format**
@@ -43,7 +43,6 @@
## Task 1: 365d range — backend type extension
**Files:**
- Modify: `src/core/services/immersion-tracker/query-trends.ts:16` and `src/core/services/immersion-tracker/query-trends.ts:84-88`
- Test: `src/core/services/immersion-tracker/__tests__/query.test.ts`
@@ -102,14 +101,13 @@
## Task 2: 365d range — server route allow-list
**Files:**
- Modify: `src/core/services/stats-server.ts` (search for trends route handler — look for `/api/stats/trends` or `getTrendsDashboard`)
- Test: `src/core/services/__tests__/stats-server.test.ts`
- [ ] **Step 1: Locate the trends route in `stats-server.ts`**
Run: `grep -n 'trends\|TrendRange' src/core/services/stats-server.ts`
Read the surrounding code. If the route delegates straight through to `tracker.getTrendsDashboard(range, groupBy)` without an allow-list, **this entire task is a no-op** — skip ahead to Task 3 and document in the commit message of Task 3 that no server changes were needed. If there _is_ an allow-list (e.g. a `validRanges` array), continue.
Read the surrounding code. If the route delegates straight through to `tracker.getTrendsDashboard(range, groupBy)` without an allow-list, **this entire task is a no-op** — skip ahead to Task 3 and document in the commit message of Task 3 that no server changes were needed. If there *is* an allow-list (e.g. a `validRanges` array), continue.
- [ ] **Step 2: Add a failing test for `range=365d`**
@@ -147,7 +145,6 @@
## Task 3: 365d range — frontend client and selector
**Files:**
- Modify: `stats/src/lib/api-client.ts`
- Modify: `stats/src/lib/api-client.test.ts`
- Modify: `stats/src/hooks/useTrends.ts:5`
@@ -178,13 +175,10 @@
- [ ] **Step 6: Add `365d` to the `DateRangeSelector` segmented control**
In `stats/src/components/trends/DateRangeSelector.tsx:56`, change:
```tsx
options={['7d', '30d', '90d', 'all'] as TimeRange[]}
```
to:
```tsx
options={['7d', '30d', '90d', '365d', 'all'] as TimeRange[]}
```
@@ -212,7 +206,6 @@
## Task 4: Vocabulary Top 50 — collapse word/reading column
**Files:**
- Modify: `stats/src/components/vocabulary/FrequencyRankTable.tsx:110-144`
- Test: create `stats/src/components/vocabulary/FrequencyRankTable.test.tsx` if not present (check first with `ls stats/src/components/vocabulary/`)
@@ -224,7 +217,6 @@
- [ ] **Step 2: Write the failing test**
Create or extend `stats/src/components/vocabulary/FrequencyRankTable.test.tsx` with:
```tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'bun:test';
@@ -279,7 +271,6 @@
Replace the `<th>Reading</th>` header column and the corresponding `<td>` in the body. The new shape:
Header (around line 113-119):
```tsx
<thead>
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
@@ -292,7 +283,6 @@
```
Body row (around line 122-141):
```tsx
<tr
key={w.wordId}
@@ -307,10 +297,16 @@
{(() => {
const reading = fullReading(w.headword, w.reading);
if (!reading || reading === w.headword) return null;
return <span className="text-ctp-subtext0 text-xs ml-1.5">【{reading}】</span>;
return (
<span className="text-ctp-subtext0 text-xs ml-1.5">
【{reading}】
</span>
);
})()}
</td>
<td className="py-1.5 pr-3">{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}</td>
<td className="py-1.5 pr-3">
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
</td>
<td className="py-1.5 text-right font-mono tabular-nums text-ctp-blue text-xs">
{w.frequency}x
</td>
@@ -340,7 +336,6 @@
## Task 5: Episode detail — filter Anki-deleted cards
**Files:**
- Modify: `stats/src/components/anime/EpisodeDetail.tsx:109-147`
- Test: create `stats/src/components/anime/EpisodeDetail.test.tsx` if not present
@@ -415,13 +410,11 @@
Then change the JSX iteration from `cardEvents.map(...)` to `filteredCardEvents.map(...)` (one occurrence around line 113), and after the `</div>` closing the cards-mined section, add:
```tsx
{
hiddenCardCount > 0 && (
<div className="px-3 pb-3 -mt-1 text-[10px] text-ctp-overlay2 italic">
{hiddenCardCount} card{hiddenCardCount === 1 ? '' : 's'} hidden (deleted from Anki)
</div>
);
}
{hiddenCardCount > 0 && (
<div className="px-3 pb-3 -mt-1 text-[10px] text-ctp-overlay2 italic">
{hiddenCardCount} card{hiddenCardCount === 1 ? '' : 's'} hidden (deleted from Anki)
</div>
)}
```
Place that footer immediately before the closing `</div>` of the bordered cards-mined section, so it stays scoped to that block.
@@ -429,14 +422,12 @@
**Important:** the filter only fires once `noteInfos` has been populated. While `noteInfos` is still empty (initial load before the second fetch resolves), every card with noteIds would be filtered out — that's wrong. Guard the filter so that it only runs after the noteInfos fetch has completed. The simplest signal: track `noteInfosLoaded: boolean` next to `noteInfos`, set it `true` in the `.then` callback, and only apply filtering when `noteInfosLoaded || allNoteIds.length === 0`.
Concrete change near line 22:
```tsx
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
const [noteInfosLoaded, setNoteInfosLoaded] = useState(false);
```
Inside the existing `useEffect` (around line 36-46), set the loaded flag:
```tsx
if (allNoteIds.length > 0) {
getStatsClient()
@@ -461,7 +452,6 @@
```
And gate the filter:
```tsx
const filteredCardEvents = noteInfosLoaded
? cardEvents
@@ -506,7 +496,6 @@
## Task 6: Library detail — delete episode action
**Files:**
- Modify: `stats/src/components/library/MediaHeader.tsx`
- Modify: `stats/src/components/library/MediaDetailView.tsx`
- Modify: `stats/src/hooks/useMediaLibrary.ts`
@@ -564,7 +553,9 @@
```tsx
<div className="flex items-start gap-2">
<h2 className="text-lg font-bold text-ctp-text truncate flex-1">{detail.canonicalTitle}</h2>
<h2 className="text-lg font-bold text-ctp-text truncate flex-1">
{detail.canonicalTitle}
</h2>
{onDeleteEpisode && (
<button
type="button"
@@ -727,7 +718,6 @@
## Task 7: Library — collapsible series groups
**Files:**
- Modify: `stats/src/components/library/LibraryTab.tsx`
- Test: create `stats/src/components/library/LibraryTab.test.tsx`
@@ -768,13 +758,11 @@
- [ ] **Step 3: Add collapsible state and toggle to `LibraryTab.tsx`**
Modify imports:
```tsx
import { useState, useMemo, useCallback } from 'react';
```
Inside the component, after the existing `useState` calls:
```tsx
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set());
@@ -792,7 +780,6 @@
```
Actually, the cleanest pattern is **initialize once on first data load via `useEffect`**:
```tsx
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set());
const [hasInitializedCollapsed, setHasInitializedCollapsed] = useState(false);
@@ -827,71 +814,71 @@
Replace the section block (around line 64-115) so the header is a `<button>`:
```tsx
{
grouped.map((group) => {
const isCollapsed = collapsedGroups.has(group.key);
const isSingleVideo = group.items.length === 1;
return (
<section
key={group.key}
className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden"
{grouped.map((group) => {
const isCollapsed = collapsedGroups.has(group.key);
const isSingleVideo = group.items.length === 1;
return (
<section
key={group.key}
className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden"
>
<button
type="button"
onClick={() => !isSingleVideo && toggleGroup(group.key)}
aria-expanded={!isCollapsed}
aria-controls={`group-body-${group.key}`}
disabled={isSingleVideo}
className={`w-full flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40 text-left ${
isSingleVideo ? '' : 'hover:bg-ctp-base/60 transition-colors cursor-pointer'
}`}
>
<button
type="button"
onClick={() => !isSingleVideo && toggleGroup(group.key)}
aria-expanded={!isCollapsed}
aria-controls={`group-body-${group.key}`}
disabled={isSingleVideo}
className={`w-full flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40 text-left ${
isSingleVideo ? '' : 'hover:bg-ctp-base/60 transition-colors cursor-pointer'
}`}
>
{!isSingleVideo && (
<span
aria-hidden="true"
className={`text-xs text-ctp-overlay2 transition-transform shrink-0 ${
isCollapsed ? '' : 'rotate-90'
}`}
>
{'\u25B6'}
</span>
)}
<CoverImage
videoId={group.items[0]!.videoId}
title={group.title}
src={group.imageUrl}
className="w-16 h-16 rounded-2xl shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold text-ctp-text truncate">{group.title}</h3>
</div>
{group.subtitle ? (
<div className="text-xs text-ctp-overlay1 truncate mt-1">{group.subtitle}</div>
) : null}
<div className="text-xs text-ctp-overlay2 mt-2">
{group.items.length} video{group.items.length !== 1 ? 's' : ''} ·{' '}
{formatDuration(group.totalActiveMs)} · {formatNumber(group.totalCards)} cards
</div>
</div>
</button>
{!isCollapsed && (
<div id={`group-body-${group.key}`} className="p-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{group.items.map((item) => (
<MediaCard
key={item.videoId}
item={item}
onClick={() => setSelectedVideoId(item.videoId)}
/>
))}
</div>
</div>
{!isSingleVideo && (
<span
aria-hidden="true"
className={`text-xs text-ctp-overlay2 transition-transform shrink-0 ${
isCollapsed ? '' : 'rotate-90'
}`}
>
{'\u25B6'}
</span>
)}
</section>
);
});
}
<CoverImage
videoId={group.items[0]!.videoId}
title={group.title}
src={group.imageUrl}
className="w-16 h-16 rounded-2xl shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold text-ctp-text truncate">
{group.title}
</h3>
</div>
{group.subtitle ? (
<div className="text-xs text-ctp-overlay1 truncate mt-1">{group.subtitle}</div>
) : null}
<div className="text-xs text-ctp-overlay2 mt-2">
{group.items.length} video{group.items.length !== 1 ? 's' : ''} ·{' '}
{formatDuration(group.totalActiveMs)} · {formatNumber(group.totalCards)} cards
</div>
</div>
</button>
{!isCollapsed && (
<div id={`group-body-${group.key}`} className="p-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{group.items.map((item) => (
<MediaCard
key={item.videoId}
item={item}
onClick={() => setSelectedVideoId(item.videoId)}
/>
))}
</div>
</div>
)}
</section>
);
})}
```
**Watch out:** the previous header had a clickable `<a>` for the channel URL. Wrapping the whole header in a `<button>` makes nested anchors invalid. The simplest fix: drop the channel URL link from inside the header (it's still reachable from the individual `MediaCard`s), or move it to a separate row outside the button. Choose the first — minimum visual disruption.
@@ -919,7 +906,6 @@
## Task 8: Session grouping helper
**Files:**
- Create: `stats/src/lib/session-grouping.ts`
- Create: `stats/src/lib/session-grouping.test.ts`
@@ -1026,9 +1012,7 @@
for (const session of sessions) {
const hasVideoId =
typeof session.videoId === 'number' &&
Number.isFinite(session.videoId) &&
session.videoId > 0;
typeof session.videoId === 'number' && Number.isFinite(session.videoId) && session.videoId > 0;
const key = hasVideoId ? `v-${session.videoId}` : `s-${session.sessionId}`;
const existing = byVideo.get(key);
if (existing) {
@@ -1082,7 +1066,6 @@
## Task 9: Sessions tab — episode rollup UI
**Files:**
- Modify: `stats/src/components/sessions/SessionsTab.tsx`
- Modify: `stats/src/lib/delete-confirm.ts` (add `confirmBucketDelete`)
- Modify: `stats/src/lib/delete-confirm.test.ts`
@@ -1178,120 +1161,114 @@
Skeleton:
```tsx
{
Array.from(groups.entries()).map(([dayLabel, daySessions]) => {
const buckets = groupSessionsByVideo(daySessions);
return (
<div key={dayLabel}>
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
{dayLabel}
</h3>
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
</div>
<div className="space-y-2">
{buckets.map((bucket) => {
if (bucket.sessions.length === 1) {
const s = bucket.sessions[0]!;
const detailsId = `session-details-${s.sessionId}`;
return (
<div key={bucket.key}>
<SessionRow
session={s}
isExpanded={expandedId === s.sessionId}
detailsId={detailsId}
onToggle={() =>
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
onNavigateToMediaDetail={onNavigateToMediaDetail}
/>
{expandedId === s.sessionId && (
<div id={detailsId}>
<SessionDetail session={s} />
</div>
)}
</div>
);
}
const isOpen = expandedBuckets.has(bucket.key);
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => {
const buckets = groupSessionsByVideo(daySessions);
return (
<div key={dayLabel}>
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
{dayLabel}
</h3>
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
</div>
<div className="space-y-2">
{buckets.map((bucket) => {
if (bucket.sessions.length === 1) {
const s = bucket.sessions[0]!;
const detailsId = `session-details-${s.sessionId}`;
return (
<div
key={bucket.key}
className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/40"
>
<button
type="button"
onClick={() => toggleBucket(bucket.key)}
aria-expanded={isOpen}
className="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-ctp-surface0/70 transition-colors"
>
<span
aria-hidden="true"
className={`text-xs text-ctp-overlay2 transition-transform ${isOpen ? 'rotate-90' : ''}`}
>
{'\u25B6'}
</span>
<div className="min-w-0 flex-1">
<div className="text-sm text-ctp-text truncate">
{bucket.representativeSession.canonicalTitle ?? 'Unknown Episode'}
</div>
<div className="text-xs text-ctp-overlay2">
{bucket.sessions.length} sessions · {formatDuration(bucket.totalActiveMs)} ·{' '}
{bucket.totalCardsMined} cards
</div>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleDeleteBucket(bucket);
}}
className="text-[10px] text-ctp-red/70 hover:text-ctp-red px-1.5 py-0.5 rounded hover:bg-ctp-red/10 transition-colors"
title="Delete all sessions in this group"
>
Delete
</button>
</button>
{isOpen && (
<div className="pl-8 pr-2 pb-2 space-y-2">
{bucket.sessions.map((s) => {
const detailsId = `session-details-${s.sessionId}`;
return (
<div key={s.sessionId}>
<SessionRow
session={s}
isExpanded={expandedId === s.sessionId}
detailsId={detailsId}
onToggle={() =>
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
onNavigateToMediaDetail={onNavigateToMediaDetail}
/>
{expandedId === s.sessionId && (
<div id={detailsId}>
<SessionDetail session={s} />
</div>
)}
</div>
);
})}
<div key={bucket.key}>
<SessionRow
session={s}
isExpanded={expandedId === s.sessionId}
detailsId={detailsId}
onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId)}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
onNavigateToMediaDetail={onNavigateToMediaDetail}
/>
{expandedId === s.sessionId && (
<div id={detailsId}>
<SessionDetail session={s} />
</div>
)}
</div>
);
})}
</div>
}
const isOpen = expandedBuckets.has(bucket.key);
return (
<div key={bucket.key} className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/40">
<button
type="button"
onClick={() => toggleBucket(bucket.key)}
aria-expanded={isOpen}
className="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-ctp-surface0/70 transition-colors"
>
<span
aria-hidden="true"
className={`text-xs text-ctp-overlay2 transition-transform ${isOpen ? 'rotate-90' : ''}`}
>
{'\u25B6'}
</span>
<div className="min-w-0 flex-1">
<div className="text-sm text-ctp-text truncate">
{bucket.representativeSession.canonicalTitle ?? 'Unknown Episode'}
</div>
<div className="text-xs text-ctp-overlay2">
{bucket.sessions.length} sessions ·{' '}
{formatDuration(bucket.totalActiveMs)} ·{' '}
{bucket.totalCardsMined} cards
</div>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleDeleteBucket(bucket);
}}
className="text-[10px] text-ctp-red/70 hover:text-ctp-red px-1.5 py-0.5 rounded hover:bg-ctp-red/10 transition-colors"
title="Delete all sessions in this group"
>
Delete
</button>
</button>
{isOpen && (
<div className="pl-8 pr-2 pb-2 space-y-2">
{bucket.sessions.map((s) => {
const detailsId = `session-details-${s.sessionId}`;
return (
<div key={s.sessionId}>
<SessionRow
session={s}
isExpanded={expandedId === s.sessionId}
detailsId={detailsId}
onToggle={() =>
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
onNavigateToMediaDetail={onNavigateToMediaDetail}
/>
{expandedId === s.sessionId && (
<div id={detailsId}>
<SessionDetail session={s} />
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
);
});
}
</div>
);
})}
```
**Note on nested buttons:** the bucket header is a `<button>` and contains a "Delete" `<button>`. HTML disallows nested buttons. Switch the outer element to a `<div role="button" tabIndex={0} onClick={...} onKeyDown={...}>` instead, OR put the delete button in a wrapping flex container _outside_ the toggle button. Pick the second option — it's accessible without role gymnastics:
**Note on nested buttons:** the bucket header is a `<button>` and contains a "Delete" `<button>`. HTML disallows nested buttons. Switch the outer element to a `<div role="button" tabIndex={0} onClick={...} onKeyDown={...}>` instead, OR put the delete button in a wrapping flex container *outside* the toggle button. Pick the second option — it's accessible without role gymnastics:
```tsx
<div className="flex items-center">
@@ -1304,7 +1281,7 @@
</div>
```
Use that pattern in the actual implementation. The skeleton above shows the _intent_; the final code must have sibling buttons, not nested ones.
Use that pattern in the actual implementation. The skeleton above shows the *intent*; the final code must have sibling buttons, not nested ones.
Add `handleDeleteBucket`:
@@ -1359,7 +1336,6 @@
## Task 10: Chart clarity pass
**Files:**
- Modify: `stats/src/lib/chart-theme.ts`
- Modify: `stats/src/components/trends/TrendChart.tsx`
- Modify: `stats/src/components/trends/StackedTrendChart.tsx`
@@ -1552,7 +1528,6 @@
## Task 11: Changelog fragment
**Files:**
- Create: `changes/2026-04-09-stats-dashboard-feedback-pass.md`
- [ ] **Step 1: Read the existing changelog format**
@@ -46,16 +46,16 @@ Add to `stats/src/types/stats.ts` and the backend query module:
```ts
type LibrarySummaryRow = {
title: string; // display title — anime series, YouTube video title, etc.
watchTimeMin: number; // sum(total_active_min) across the window
videos: number; // distinct video_id count
sessions: number; // session count from imm_sessions
cards: number; // sum(total_cards)
words: number; // sum(total_tokens_seen)
lookups: number; // sum(lookup_count) from imm_sessions
title: string; // display title — anime series, YouTube video title, etc.
watchTimeMin: number; // sum(total_active_min) across the window
videos: number; // distinct video_id count
sessions: number; // session count from imm_sessions
cards: number; // sum(total_cards)
words: number; // sum(total_tokens_seen)
lookups: number; // sum(lookup_count) from imm_sessions
lookupsPerHundred: number | null; // lookups / words * 100, null when words == 0
firstWatched: number; // min(rollup_day) as epoch day, within the window
lastWatched: number; // max(rollup_day) as epoch day, within the window
firstWatched: number; // min(rollup_day) as epoch day, within the window
lastWatched: number; // max(rollup_day) as epoch day, within the window
};
```
@@ -21,7 +21,6 @@ Out of scope for this pass: English-token ingestion cleanup and Overview stat-ca
## Files touched (inventory)
Dashboard (`stats/src/`):
- `components/library/LibraryTab.tsx` — collapsible groups (item 1).
- `components/library/MediaDetailView.tsx`, `components/library/MediaHeader.tsx` — delete-episode action (item 4).
- `components/sessions/SessionsTab.tsx`, `components/library/MediaSessionList.tsx` — episode rollup (item 2).
@@ -32,7 +31,6 @@ Dashboard (`stats/src/`):
- New file: `stats/src/lib/session-grouping.ts` + `session-grouping.test.ts`.
Backend (`src/core/services/`):
- `immersion-tracker/query-trends.ts` — extend `TrendRange` and `TREND_DAY_LIMITS` (item 3).
- `immersion-tracker/__tests__/query.test.ts` — 365d coverage (item 3).
- `stats-server.ts` — passthrough if range validation lives here (check before editing).
@@ -95,10 +93,9 @@ Within each day, sessions with the same `videoId` collapse into one parent row s
### Implementation
- New helper in `stats/src/lib/session-grouping.ts`:
```ts
export interface SessionBucket {
key: string; // videoId as string, or `s-${sessionId}` for singletons
key: string; // videoId as string, or `s-${sessionId}` for singletons
videoId: number | null;
sessions: SessionSummary[];
totalActiveMs: number;
@@ -107,7 +104,6 @@ Within each day, sessions with the same `videoId` collapse into one parent row s
}
export function groupSessionsByVideo(sessions: SessionSummary[]): SessionBucket[];
```
Sessions missing a `videoId` become singleton buckets.
- `SessionsTab.tsx`: after day grouping, pipe each `daySessions` through `groupSessionsByVideo`. Render each bucket:
@@ -136,13 +132,11 @@ Within each day, sessions with the same `videoId` collapse into one parent row s
### Backend
`src/core/services/immersion-tracker/query-trends.ts`:
- `type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';`
- Add `'365d': 365` to `TREND_DAY_LIMITS`.
- `getTrendDayLimit` picks up the new key automatically because of the `Exclude<TrendRange, 'all'>` generic.
`src/core/services/stats-server.ts`:
- Search for any hardcoded range validation (e.g. allow-list in the trends route handler) and extend it.
### Frontend
@@ -208,7 +202,11 @@ Merge Word + Reading into a single column titled "Word". Reading sits immediatel
```tsx
<td className="py-1.5 pr-3">
<span className="text-ctp-text font-medium">{w.headword}</span>
{reading && <span className="text-ctp-subtext0 text-xs ml-1.5">【{reading}】</span>}
{reading && (
<span className="text-ctp-subtext0 text-xs ml-1.5">
【{reading}】
</span>
)}
</td>
```
where `reading = fullReading(w.headword, w.reading)` and differs from `headword`.
@@ -232,7 +230,6 @@ Merge Word + Reading into a single column titled "Word". Reading sits immediatel
### Target behavior
After `ankiNotesInfo` resolves:
- Drop `noteId`s that are not in the resolved map.
- Drop `cardEvents` whose `noteIds` list was non-empty but is now empty after filtering.
- Card events with a positive `cardsDelta` but no `noteIds` (legacy rollup path) still render as `+N cards` — we have no way to cross-reference them, so leave them alone.
@@ -258,7 +255,6 @@ After `ankiNotesInfo` resolves:
### Current behavior
`TrendChart.tsx`, `StackedTrendChart.tsx`, and `WatchTimeChart.tsx` render Recharts components with:
- No `CartesianGrid` → no horizontal reference lines.
- 9px axis ticks → borderline unreadable.
- Height 120 → cramped.
@@ -274,7 +270,6 @@ All three charts share a theme, have horizontal gridlines, readable ticks, and s
### Implementation
Extend `stats/src/lib/chart-theme.ts` with the additional shared defaults (keeping the existing `CHART_THEME` export intact so current consumers don't break):
```ts
export const CHART_THEME = {
tick: '#a5adcb',
@@ -304,7 +299,6 @@ export const TOOLTIP_CONTENT_STYLE = {
```
Apply to each chart:
- Import `CartesianGrid` from recharts.
- Insert `<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />` inside each chart container.
- `<XAxis tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} />` and equivalent `YAxis`.
+9 -3
View File
@@ -27,9 +27,11 @@ function copyFile(sourcePath, outputPath) {
fs.copyFileSync(sourcePath, outputPath);
}
function copyAssets(sourceDir, outputDir, label) {
function copyAssets(sourceDir, outputDir, label, stylesheets = ['style.css']) {
copyFile(path.join(sourceDir, 'index.html'), path.join(outputDir, 'index.html'));
copyFile(path.join(sourceDir, 'style.css'), path.join(outputDir, 'style.css'));
for (const stylesheet of stylesheets) {
copyFile(path.join(sourceDir, stylesheet), path.join(outputDir, stylesheet));
}
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(outputDir, 'fonts'), {
recursive: true,
force: true,
@@ -50,7 +52,11 @@ function copySyncUiAssets() {
}
function copyAnimeUiAssets() {
copyAssets(animeUiSourceDir, animeUiOutputDir, 'animeui');
copyAssets(animeUiSourceDir, animeUiOutputDir, 'animeui', [
'style.css',
'detail.css',
'panels.css',
]);
}
function fallbackToMacosSource() {
+17 -1
View File
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import test from 'node:test';
const source = readFileSync('scripts/prepare-build-assets.mjs', 'utf8');
@@ -18,3 +18,19 @@ test('macOS helper build creates dist scripts directory before swiftc output', (
'buildMacosHelper must create dist/scripts before swiftc writes the helper binary',
);
});
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*\]\)/,
);
});
+140
View File
@@ -0,0 +1,140 @@
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');
});
+205
View File
@@ -0,0 +1,205 @@
/**
* 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 {
/** 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 {
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),
};
}
+59 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
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';
@@ -164,6 +164,64 @@ test('the byte limit stops the read instead of buffering the whole body', async
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');
+32 -4
View File
@@ -1,4 +1,5 @@
import { mkdir, rm, writeFile } from 'node:fs/promises';
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';
@@ -19,8 +20,24 @@ export interface InstallExtensionOptions {
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;
@@ -41,6 +58,7 @@ export function looksLikeApk(bytes: Uint8Array): boolean {
*/
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);
@@ -59,9 +77,17 @@ export async function installExtension(options: InstallExtensionOptions): Promis
throw new Error(`${options.extension.name} did not download as an APK.`);
}
await mkdir(options.extensionsDir, { recursive: true });
await fileIo.mkdir(options.extensionsDir);
const target = resolveTarget(options.extensionsDir, options.extension.pkg);
await writeFile(target, bytes);
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;
}
@@ -93,7 +119,9 @@ async function readBounded(
if (done) break;
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel();
try {
await reader.cancel();
} catch {}
throw new Error(`${name} is larger than the ${maxBytes} byte limit.`);
}
chunks.push(value);
+12
View File
@@ -144,6 +144,18 @@ test('fetchRepoIndex surfaces a non-ok response', async () => {
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) => {
+7 -1
View File
@@ -115,8 +115,12 @@ export function parseRepoIndex(indexUrl: string, payload: unknown): RepoExtensio
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,
@@ -126,9 +130,11 @@ export async function fetchRepoIndex(
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' },
...(options.signal ? { signal: options.signal } : {}),
signal,
});
if (!response.ok) {
throw new Error(`Repository returned ${response.status} for ${indexUrl}`);
+23 -4
View File
@@ -87,12 +87,31 @@ test('listExtensionSources flattens every source a factory apk provides', async
const sources = await listExtensionSources(client, extensions);
assert.equal(sources.length, 2);
// Numeric ids are normalized to strings so they can key UI state.
assert.equal(sources[0]?.id, '101');
// 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' },
{ file: '/x/two.apk', fallbackName: 'pkg.two', sha256: 'hash-two' },
];
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' },
@@ -117,8 +136,8 @@ test('toInstalledExtensionViews names an extension after the sources it provides
{ file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a' },
];
const sources: ExtensionSource[] = [
{ id: '1', name: 'One', lang: 'en', pkg: 'multi', file: '/x/multi.apk' },
{ id: '2', name: 'Two', lang: 'ja', pkg: 'multi', file: '/x/multi.apk' },
{ 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, []), [
+7 -4
View File
@@ -26,8 +26,10 @@ export interface InstalledExtension {
}
export interface ExtensionSource {
/** Stable id: the bridge source id, which selects it inside a factory APK. */
/** 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;
@@ -126,10 +128,11 @@ export async function listExtensionSources(
try {
const descriptors = await client.listAnimeSources(toBridgeSource(extension));
for (const descriptor of descriptors) {
const id = descriptor.id === undefined ? null : String(descriptor.id);
if (id === null || id.length === 0) continue;
const bridgeId = descriptor.id === undefined ? null : String(descriptor.id);
if (bridgeId === null || bridgeId.length === 0) continue;
sources.push({
id,
id: `${extension.fallbackName}:${bridgeId}`,
bridgeId,
name: descriptor.name?.trim() || extension.fallbackName,
lang: descriptor.lang ?? 'all',
pkg: extension.fallbackName,
+76 -17
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises';
import { chmod, mkdtemp, readFile, readdir, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { PreferenceStore } from './preference-store';
@@ -12,9 +12,49 @@ async function storeFile(): Promise<string> {
test('values round-trip through the file', async () => {
const file = await storeFile();
await new PreferenceStore(file).set('src-1', [{ key: 'address' }]);
await new PreferenceStore(file).set('pkg', 'src-1', [{ key: 'address' }]);
assert.deepEqual(await new PreferenceStore(file).get('src-1'), [{ key: 'address' }]);
assert.deepEqual(await new PreferenceStore(file).get('pkg', 'src-1'), [{ key: 'address' }]);
});
test('the same bridge source id is isolated between extension packages', async () => {
const file = await storeFile();
const store = new PreferenceStore(file);
await store.set('pkg.one', 'shared-source', [{ key: 'password', value: 'one-secret' }]);
await store.set('pkg.two', 'shared-source', [{ key: 'password', value: 'two-secret' }]);
const reloaded = new PreferenceStore(file);
assert.deepEqual(await reloaded.get('pkg.one', 'shared-source'), [
{ key: 'password', value: 'one-secret' },
]);
assert.deepEqual(await reloaded.get('pkg.two', 'shared-source'), [
{ key: 'password', value: 'two-secret' },
]);
});
test('a legacy bare source id is discarded rather than assigned to an unproven package', async () => {
const file = await storeFile();
await writeFile(file, JSON.stringify({ 'legacy-source': [{ key: 'address', value: 'saved' }] }));
const store = new PreferenceStore(file);
assert.deepEqual(await store.get('pkg.one', 'legacy-source'), []);
const persisted = JSON.parse(await readFile(file, 'utf8')) as Record<string, unknown>;
assert.equal(persisted['legacy-source'], undefined);
assert.equal(persisted['pkg.one:legacy-source'], undefined);
});
test('an ambiguous legacy source id is discarded instead of exposed to either package', async () => {
const file = await storeFile();
await writeFile(file, JSON.stringify({ shared: [{ key: 'password', value: 'old-secret' }] }));
const store = new PreferenceStore(file);
assert.deepEqual(await store.get('pkg.one', 'shared'), []);
assert.deepEqual(await store.get('pkg.two', 'shared'), []);
const persisted = JSON.parse(await readFile(file, 'utf8')) as Record<string, unknown>;
assert.equal(persisted.shared, undefined);
});
test('concurrent writes on a cold cache do not lose an update', async () => {
@@ -23,30 +63,34 @@ test('concurrent writes on a cold cache do not lose an update', async () => {
// Both start before either has loaded; unserialized they would each get their
// own object and the later persist would drop the other's entry.
await Promise.all([store.set('src-1', [{ key: 'a' }]), store.set('src-2', [{ key: 'b' }])]);
await Promise.all([
store.set('pkg', 'src-1', [{ key: 'a' }]),
store.set('pkg', 'src-2', [{ key: 'b' }]),
]);
const reloaded = new PreferenceStore(file);
assert.deepEqual(await reloaded.get('src-1'), [{ key: 'a' }]);
assert.deepEqual(await reloaded.get('src-2'), [{ key: 'b' }]);
assert.deepEqual(await reloaded.get('pkg', 'src-1'), [{ key: 'a' }]);
assert.deepEqual(await reloaded.get('pkg', 'src-2'), [{ key: 'b' }]);
});
test('a clear racing a set is applied in order', async () => {
const file = await storeFile();
const store = new PreferenceStore(file);
await store.set('pkg:src', [{ key: 'password' }]);
await store.set('pkg', 'src', [{ key: 'password' }]);
await Promise.all([store.clear('pkg'), store.set('other:src', [{ key: 'x' }])]);
await Promise.all([store.clear('pkg'), store.set('other', 'src', [{ key: 'x' }])]);
const reloaded = new PreferenceStore(file);
assert.deepEqual(await reloaded.get('pkg:src'), []);
assert.deepEqual(await reloaded.get('other:src'), [{ key: 'x' }]);
assert.deepEqual(await reloaded.get('pkg', 'src'), []);
assert.deepEqual(await reloaded.get('other', 'src'), [{ key: 'x' }]);
});
test('the file is written owner-only and leaves no temporary behind', async () => {
test('the file is written owner-only even when an existing temporary file is permissive', async () => {
const file = await storeFile();
await new PreferenceStore(file).set('src-1', [{ key: 'password' }]);
await writeFile(`${file}.tmp`, 'stale');
await chmod(`${file}.tmp`, 0o666);
await new PreferenceStore(file).set('pkg', 'src-1', [{ key: 'password' }]);
const { stat } = await import('node:fs/promises');
assert.equal((await stat(file)).mode & 0o777, 0o600);
assert.deepEqual(await readdir(path.dirname(file)), [path.basename(file)]);
});
@@ -55,15 +99,30 @@ test('a corrupt file starts empty rather than blocking the browser', async () =>
const file = await storeFile();
await writeFile(file, '{ not json');
assert.deepEqual(await new PreferenceStore(file).get('src-1'), []);
assert.deepEqual(await new PreferenceStore(file).get('pkg', 'src-1'), []);
});
test('malformed persisted values are filtered to preference objects with string keys', async () => {
const file = await storeFile();
await writeFile(
file,
JSON.stringify({
'pkg:source': [null, { key: 42 }, 'bad', { key: 'valid', value: 'kept' }],
'pkg:not-an-array': { key: 'invalid-container' },
}),
);
const store = new PreferenceStore(file);
assert.deepEqual(await store.get('pkg', 'source'), [{ key: 'valid', value: 'kept' }]);
assert.deepEqual(await store.get('pkg', 'not-an-array'), []);
});
test('a write replaces the previous contents wholesale', async () => {
const file = await storeFile();
const store = new PreferenceStore(file);
await store.set('src-1', [{ key: 'first' }]);
await store.set('src-1', [{ key: 'second' }]);
await store.set('pkg', 'src-1', [{ key: 'first' }]);
await store.set('pkg', 'src-1', [{ key: 'second' }]);
const parsed = JSON.parse(await readFile(file, 'utf8')) as Record<string, unknown[]>;
assert.deepEqual(parsed['src-1'], [{ key: 'second' }]);
assert.deepEqual(parsed['pkg:src-1'], [{ key: 'second' }]);
});
+39 -10
View File
@@ -1,9 +1,27 @@
import { readFile, writeFile, rename, rm, mkdir } from 'node:fs/promises';
import { chmod, readFile, writeFile, rename, rm, mkdir } from 'node:fs/promises';
import path from 'node:path';
import type { BridgePreference } from './types';
function parseStoredPreferences(value: unknown): Record<string, BridgePreference[]> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {};
const parsed: Record<string, BridgePreference[]> = {};
for (const [key, entries] of Object.entries(value)) {
if (!Array.isArray(entries)) continue;
parsed[key] = entries.filter(
(entry): entry is BridgePreference =>
entry !== null &&
typeof entry === 'object' &&
!Array.isArray(entry) &&
typeof (entry as Record<string, unknown>).key === 'string',
);
}
return parsed;
}
/**
* Persists each source's preference array verbatim, keyed by bridge source id.
* Persists each source's preference array verbatim, keyed by extension package
* and bridge source id.
*
* Extensions keep credentials in here (the Jellyfin source stores a password),
* so the file is written with owner-only permissions.
@@ -33,10 +51,7 @@ export class PreferenceStore {
if (this.cache !== null) return this.cache;
try {
const parsed = JSON.parse(await readFile(this.file, 'utf8')) as unknown;
this.cache =
parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, BridgePreference[]>)
: {};
this.cache = parseStoredPreferences(parsed);
} catch {
// Missing or corrupt file starts empty rather than blocking the browser.
this.cache = {};
@@ -44,17 +59,27 @@ export class PreferenceStore {
return this.cache;
}
async get(sourceId: string): Promise<BridgePreference[]> {
async get(pkg: string, sourceId: string): Promise<BridgePreference[]> {
return this.enqueue(async () => {
const all = await this.load();
return all[sourceId] ?? [];
const key = `${pkg}:${sourceId}`;
if (all[key]) return all[key];
// Bare source IDs predate package scoping and have no trustworthy owner.
// Never assign their credentials to whichever package happens to ask first.
const legacy = all[sourceId];
if (legacy) {
delete all[sourceId];
await this.persist(all);
}
return [];
});
}
async set(sourceId: string, preferences: BridgePreference[]): Promise<void> {
async set(pkg: string, sourceId: string, preferences: BridgePreference[]): Promise<void> {
await this.enqueue(async () => {
const all = await this.load();
all[sourceId] = preferences;
all[`${pkg}:${sourceId}`] = preferences;
await this.persist(all);
});
}
@@ -91,7 +116,11 @@ export class PreferenceStore {
await mkdir(path.dirname(this.file), { recursive: true });
const temporary = `${this.file}.tmp`;
try {
await chmod(temporary, 0o600).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
});
await writeFile(temporary, JSON.stringify(all, null, 2), { mode: 0o600 });
await chmod(temporary, 0o600);
await rename(temporary, this.file);
} catch (error) {
await rm(temporary, { force: true }).catch(() => undefined);
+45
View File
@@ -120,6 +120,51 @@ test('a failed download keeps its url so the episode still plays', async () => {
assert.deepEqual(io.removed, []);
});
test('an oversized streamed subtitle stops early and falls back to its remote url', async () => {
const io = fakeIo({});
const logged: string[] = [];
let chunksRead = 0;
let buffered = false;
const chunk = new Uint8Array(20 * 1024 * 1024);
const body = new ReadableStream<Uint8Array>(
{
pull(controller) {
chunksRead += 1;
controller.enqueue(chunk);
},
},
{ highWaterMark: 0 },
);
io.fetch = async () => ({
ok: true,
status: 200,
body,
async arrayBuffer() {
buffered = true;
throw new Error('stream should not be buffered');
},
});
const result = await cacheSubtitleTracks({
tracks: [{ url: 'http://bridge/sub/oversized', lang: 'Japanese' }],
io,
log: (message) => logged.push(message),
});
assert.equal(buffered, false);
assert.equal(chunksRead, 2);
assert.equal(result.dir, null);
assert.deepEqual(result.tracks, [
{
url: 'http://bridge/sub/oversized',
lang: 'Japanese',
sourceUrl: 'http://bridge/sub/oversized',
local: false,
},
]);
assert.ok(logged.some((message) => message.includes('response too large')));
});
test('a directory with nothing in it is removed and not reported', async () => {
const io = fakeIo({ 'http://bridge/sub/ja': { status: 500 } });
const result = await cacheSubtitleTracks({
+36 -1
View File
@@ -58,6 +58,7 @@ export interface SubtitleCacheResult {
interface FetchResponseLike {
ok: boolean;
status: number;
body?: ReadableStream<Uint8Array> | null;
arrayBuffer: () => Promise<ArrayBuffer>;
}
@@ -150,7 +151,7 @@ async function downloadTrack(
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
bytes = new Uint8Array(await response.arrayBuffer());
bytes = await readBounded(response, MAX_SUBTITLE_BYTES);
} finally {
clearTimeout(timeoutId);
}
@@ -171,6 +172,40 @@ async function downloadTrack(
return filePath;
}
async function readBounded(response: FetchResponseLike, maxBytes: number): Promise<Uint8Array> {
const reader = response.body?.getReader();
if (!reader) {
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > maxBytes) {
throw new Error(`response too large (${bytes.byteLength} bytes)`);
}
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(`response too large (${total} bytes)`);
}
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;
}
/**
* Download every subtitle track to a fresh temp directory.
*
+103 -184
View File
@@ -2,13 +2,23 @@ import { describe, el } from './dom';
import { sourceOptionLabel, summarizeSearch } from './format';
import { applySearchUpdate, idleSearchProgress, summarizeProgress } from './search-progress';
import { createExtensionsPanel } from './extensions-panel';
import { createDetailPanel } from './detail-panel';
import { renderPreferences, renderPreferencesUnavailable } from './preferences-fields';
import {
beginBrowse,
beginNextPage,
createBrowseState,
failBrowse,
finishBrowse,
soleBrowseRequest,
takeUnseenEntries,
} from './browse-state';
import type { BrowseRequest } from './browse-state';
import { ALL_SOURCES_ID } from '../types/anime-browser';
import type {
AnimeBrowserAPI,
AnimeBrowserBridgeState,
AnimeBrowserEntry,
AnimeBrowserEpisode,
AnimeBrowserSource,
} from '../types/anime-browser';
@@ -26,15 +36,7 @@ const searchButton = el<HTMLButtonElement>('search-button');
const sourceSelect = el<HTMLSelectElement>('source-select');
const grid = el<HTMLDivElement>('grid');
const gridEmpty = el<HTMLParagraphElement>('grid-empty');
const results = el<HTMLElement>('results');
const detail = el<HTMLElement>('detail');
const detailBack = el<HTMLButtonElement>('detail-back');
const detailCover = el<HTMLImageElement>('detail-cover');
const detailTitle = el<HTMLHeadingElement>('detail-title');
const detailChips = el<HTMLDivElement>('detail-chips');
const detailDescription = el<HTMLParagraphElement>('detail-description');
const episodes = el<HTMLOListElement>('episodes');
const episodesCount = el<HTMLSpanElement>('episodes-count');
const loadMoreButton = el<HTMLButtonElement>('load-more');
const banner = el<HTMLDivElement>('bridge-banner');
const bannerMessage = el<HTMLSpanElement>('bridge-message');
const bannerMeter = el<HTMLSpanElement>('bridge-meter');
@@ -49,11 +51,8 @@ const settingsPanel = el<HTMLElement>('settings');
const settingsFields = el<HTMLDivElement>('settings-fields');
const settingsTitle = el<HTMLHeadingElement>('settings-title');
/** The anime the detail page is showing, with the source that produced it. */
let selectedAnime: { url: string; title: string; sourceId: string } | null = null;
/** Where the results grid was scrolled to before the detail page covered it. */
let resultsScrollTop = 0;
/** Last source accepted by the main process, used to roll back a rejected change. */
let selectedSourceId: string | null = null;
/* ---------- tabs ---------- */
@@ -81,6 +80,8 @@ function setStatus(message: string, tone: 'info' | 'ok' | 'error' = 'info'): voi
statusMessage.parentElement?.setAttribute('data-tone', tone);
}
const detailPanel = createDetailPanel({ api, setStatus });
const BRIDGE_LABELS: Record<AnimeBrowserBridgeState['stage'], string> = {
idle: 'Starting the extension bridge',
locating: 'Looking up the extension bridge release',
@@ -150,6 +151,7 @@ function renderSources(sources: AnimeBrowserSource[], selectedId: string | null)
sourceSelect.replaceChildren(...options);
sourceSelect.disabled = options.length <= 1;
selectedSourceId = selectedId;
}
function searchingAllSources(): boolean {
@@ -195,17 +197,24 @@ function createCard(entry: AnimeBrowserEntry, showSource: boolean): HTMLButtonEl
card.append(art, title);
card.title = showSource ? `${entry.title} — ${entry.sourceName}` : entry.title;
card.addEventListener('click', () => {
void openDetail(entry);
void detailPanel.open(entry);
});
return card;
}
/** Drops every card and the empty-state message so a fresh page can build up. */
function resetGrid(): void {
seenEntries.clear();
grid.replaceChildren();
gridEmpty.classList.add('hidden');
}
function renderEntries(entries: AnimeBrowserEntry[], emptyMessage: string): void {
// Which source a cover came from only matters when they are mixed together.
const showSource = searchingAllSources();
grid.replaceChildren(...entries.map((entry) => createCard(entry, showSource)));
resetGrid();
appendEntries(entries);
const empty = entries.length === 0;
const empty = grid.childElementCount === 0;
gridEmpty.classList.toggle('hidden', !empty);
gridEmpty.textContent = emptyMessage;
}
@@ -213,138 +222,9 @@ function renderEntries(entries: AnimeBrowserEntry[], emptyMessage: string): void
/** Streamed results land at the end of the grid, in arrival order. */
function appendEntries(entries: AnimeBrowserEntry[]): void {
const showSource = searchingAllSources();
grid.append(...entries.map((entry) => createCard(entry, showSource)));
}
function formatEpisodeIndex(episode: AnimeBrowserEpisode, fallbackIndex: number): string {
const value = episode.number ?? fallbackIndex;
return Number.isInteger(value) ? String(value).padStart(2, '0') : value.toFixed(1);
}
function renderEpisodes(list: AnimeBrowserEpisode[]): void {
episodesCount.textContent = list.length === 0 ? '' : `${list.length}`;
episodes.replaceChildren(
...list.map((episode, index) => {
const item = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.className = 'cue';
const cueIndex = document.createElement('span');
cueIndex.className = 'cue-index';
cueIndex.textContent = formatEpisodeIndex(episode, list.length - index);
const name = document.createElement('span');
name.className = 'cue-name';
name.textContent = episode.name;
if (episode.uploadedAt !== null) {
const sub = document.createElement('span');
sub.className = 'cue-sub';
sub.textContent = new Date(episode.uploadedAt).toISOString().slice(0, 10);
name.append(sub);
}
button.append(cueIndex, name);
button.addEventListener('click', () => {
void playEpisode(button, episode);
});
item.append(button);
return item;
}),
);
}
/**
* The detail page replaces the results grid rather than squeezing in beside
* it. The grid stays in the DOM with its scroll position remembered, so Back
* returns to the same results without re-running the search.
*/
async function openDetail(entry: AnimeBrowserEntry): Promise<void> {
selectedAnime = { url: entry.url, title: entry.title, sourceId: entry.sourceId };
resultsScrollTop = results.scrollTop;
results.classList.add('hidden');
detail.classList.remove('hidden');
detail.scrollTop = 0;
detailTitle.textContent = entry.title;
detailDescription.textContent = 'Loading…';
detailChips.replaceChildren();
episodes.replaceChildren();
episodesCount.textContent = '';
detailCover.src = entry.thumbnailUrl ?? '';
try {
// Always ask the entry's own source: after an all-sources search the
// picker's selection says nothing about where this cover came from.
const [details, episodeList] = await Promise.all([
api.getDetails(entry.url, entry.sourceId),
api.getEpisodes(entry.url, entry.sourceId),
]);
detailTitle.textContent = details.title;
detailDescription.textContent = details.description ?? 'No description from this source.';
if (details.thumbnailUrl) detailCover.src = details.thumbnailUrl;
const chips: HTMLSpanElement[] = [];
const source = document.createElement('span');
source.className = 'chip source';
source.textContent = entry.sourceName;
chips.push(source);
if (details.status !== 'unknown') {
const status = document.createElement('span');
status.className = 'chip status';
status.textContent = details.status.replace(/-/g, ' ');
chips.push(status);
}
for (const genre of details.genres.slice(0, 6)) {
const chip = document.createElement('span');
chip.className = 'chip';
chip.textContent = genre;
chips.push(chip);
}
detailChips.replaceChildren(...chips);
renderEpisodes(episodeList);
setStatus(`${details.title} · ${episodeList.length} episodes`);
} catch (error) {
detailDescription.textContent = '';
setStatus(describe(error), 'error');
}
}
function closeDetail(): void {
detail.classList.add('hidden');
results.classList.remove('hidden');
results.scrollTop = resultsScrollTop;
selectedAnime = null;
}
async function playEpisode(button: HTMLButtonElement, episode: AnimeBrowserEpisode): Promise<void> {
if (!selectedAnime) return;
for (const other of episodes.querySelectorAll<HTMLButtonElement>('.cue')) {
other.removeAttribute('data-state');
}
button.dataset.state = 'loading';
setStatus(`Resolving ${episode.name}…`);
const result = await api.playEpisode({
sourceId: selectedAnime.sourceId,
animeUrl: selectedAnime.url,
animeTitle: selectedAnime.title,
episodeUrl: episode.url,
episodeName: episode.name,
});
if (result.ok) {
button.dataset.state = 'playing';
setStatus(
result.quality ? `Playing ${episode.name} · ${result.quality}` : `Playing ${episode.name}`,
'ok',
);
} else {
button.removeAttribute('data-state');
setStatus(result.error ?? 'Could not play that episode.', 'error');
}
const unseen = takeUnseenEntries(entries, seenEntries);
grid.append(...unseen.map((entry) => createCard(entry, showSource)));
if (unseen.length > 0) gridEmpty.classList.add('hidden');
}
/* ---------- streamed search ---------- */
@@ -357,8 +237,17 @@ async function playEpisode(button: HTMLButtonElement, episode: AnimeBrowserEpiso
*/
let progress = idleSearchProgress();
/** Orders runSearch calls so a slow search cannot finish over a newer one. */
let searchRequest = 0;
let browseState = createBrowseState();
const inFlightBrowses = new Map<number, BrowseRequest>();
let activeStreamRequestId = 0;
const seenEntries = new Set<string>();
function renderLoadMore(): void {
const loadingNextPage = browseState.loading && browseState.page > 1;
loadMoreButton.classList.toggle('hidden', !browseState.hasNextPage && !loadingNextPage);
loadMoreButton.disabled = browseState.loading;
loadMoreButton.textContent = loadingNextPage ? 'Loading…' : 'Load more';
}
api.onSearchUpdate((update) => {
const applied = applySearchUpdate(progress, update);
@@ -366,53 +255,82 @@ api.onSearchUpdate((update) => {
progress = applied.progress;
if (applied.started) {
grid.replaceChildren();
gridEmpty.classList.add('hidden');
// The runtime token does not carry the renderer request id. When calls
// overlap their starts may arrive in either order, so stream only when the
// association is unambiguous; the final response still backfills the grid.
const request = soleBrowseRequest(inFlightBrowses);
activeStreamRequestId = request?.id ?? 0;
const append = request?.append === true;
if (!append) resetGrid();
return;
}
if (activeStreamRequestId !== browseState.requestId) return;
if (applied.entries.length > 0) appendEntries(applied.entries);
if (!progress.done) {
setStatus(summarizeProgress(progress), progress.failures.length > 0 ? 'error' : 'info');
}
});
async function runSearch(query: string): Promise<void> {
const request = ++searchRequest;
// A new search means new results; leave the detail page for them.
if (!detail.classList.contains('hidden')) closeDetail();
setStatus(query ? `Searching for “${query}”…` : 'Loading popular…');
grid.replaceChildren();
gridEmpty.classList.add('hidden');
async function runBrowse(request: BrowseRequest): Promise<void> {
inFlightBrowses.set(request.id, request);
renderLoadMore();
try {
const result = query ? await api.search(query) : await api.getPopular();
// A newer search owns the grid now; this one's result is history.
if (request !== searchRequest) return;
const result = request.query
? await api.search(request.query, request.page)
: await api.getPopular(request.page);
if (request.id !== browseState.requestId) return;
browseState = finishBrowse(browseState, request.id, result.hasNextPage);
renderLoadMore();
// Every source failing is an error, not an empty result set.
if (result.entries.length === 0 && result.failures.length > 0) {
const first = result.failures[0];
renderEntries([], `${first?.sourceName}: ${first?.error}`);
if (!request.append) renderEntries([], `${first?.sourceName}: ${first?.error}`);
setStatus(summarizeSearch(result), 'error');
return;
}
// The stream already filled the grid; only render from the result when no
// update arrived (covers a host without streaming wired up).
if (grid.childElementCount === 0 || result.entries.length === 0) {
// The final response backfills a host without streaming. Entries already
// pushed by the stream are filtered out, including sources that repeat a
// final page while another source still has more.
appendEntries(result.entries);
if (!request.append && grid.childElementCount === 0) {
renderEntries(
result.entries,
query ? `Nothing found for “${query}”.` : 'This source returned nothing.',
[],
request.query ? `Nothing found for “${request.query}”.` : 'This source returned nothing.',
);
}
setStatus(summarizeSearch(result), result.failures.length > 0 ? 'error' : 'info');
} catch (error) {
if (request !== searchRequest) return;
renderEntries([], describe(error));
if (request.id !== browseState.requestId) return;
browseState = failBrowse(browseState, request);
renderLoadMore();
if (!request.append) renderEntries([], describe(error));
setStatus(describe(error), 'error');
} finally {
inFlightBrowses.delete(request.id);
}
}
async function runSearch(query: string): Promise<void> {
const started = beginBrowse(browseState, query);
browseState = started.state;
// A new search means new results; leave the detail page for them.
if (detailPanel.isOpen()) detailPanel.close();
setStatus(query ? `Searching for “${query}”…` : 'Loading popular…');
resetGrid();
await runBrowse(started.request);
}
async function loadNextPage(): Promise<void> {
const started = beginNextPage(browseState);
if (!started) return;
browseState = started.state;
setStatus(`Loading page ${started.request.page}…`);
await runBrowse(started.request);
}
/* ---------- source settings ---------- */
async function openSettings(): Promise<void> {
@@ -469,20 +387,21 @@ searchForm.addEventListener('submit', (event) => {
sourceSelect.addEventListener('change', () => {
void (async () => {
await api.selectSource(sourceSelect.value);
// Settings belong to the source, so reload them rather than showing stale fields.
if (currentView === 'settings') await openSettings();
await runSearch(searchInput.value.trim());
const requestedSourceId = sourceSelect.value;
try {
await api.selectSource(requestedSourceId);
selectedSourceId = requestedSourceId;
// Settings belong to the source, so reload them rather than showing stale fields.
if (currentView === 'settings') await openSettings();
await runSearch(searchInput.value.trim());
} catch (error) {
if (selectedSourceId !== null) sourceSelect.value = selectedSourceId;
setStatus(describe(error), 'error');
}
})();
});
detailBack.addEventListener('click', closeDetail);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !detail.classList.contains('hidden')) {
closeDetail();
}
});
loadMoreButton.addEventListener('click', () => void loadNextPage());
api.onBridgeState(renderBridgeState);
+114
View File
@@ -0,0 +1,114 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
beginBrowse,
beginNextPage,
capture,
createBrowseState,
failBrowse,
finishBrowse,
LatestRequest,
safeUploadDate,
soleBrowseRequest,
takeUnseenEntries,
} from './browse-state';
import type { AnimeBrowserEntry } from '../types/anime-browser';
test('a new browse replaces results and starts from page one', () => {
const started = beginBrowse(createBrowseState(), 'frieren');
assert.deepEqual(started.request, {
id: 1,
query: 'frieren',
page: 1,
append: false,
});
assert.equal(started.state.loading, true);
});
test('the next page appends only when the current result has another page', () => {
const first = beginBrowse(createBrowseState(), '');
const ready = finishBrowse(first.state, first.request.id, true);
const next = beginNextPage(ready);
assert.ok(next);
assert.deepEqual(next.request, {
id: 2,
query: '',
page: 2,
append: true,
});
assert.equal(beginNextPage(next.state), null, 'cannot overlap page requests');
assert.equal(beginNextPage(finishBrowse(next.state, next.request.id, false)), null);
});
test('a stale page completion cannot change the active browse state', () => {
const first = beginBrowse(createBrowseState(), 'old');
const current = beginBrowse(first.state, 'new');
assert.equal(finishBrowse(current.state, first.request.id, true), current.state);
});
test('stream updates are correlated only when one browse request is in flight', () => {
const first = beginBrowse(createBrowseState(), 'old');
const second = beginBrowse(first.state, 'new');
const inFlight = new Map([
[second.request.id, second.request],
[first.request.id, first.request],
]);
assert.equal(soleBrowseRequest(inFlight), null, 'start order is ambiguous while calls overlap');
inFlight.delete(first.request.id);
assert.equal(soleBrowseRequest(inFlight), second.request);
});
test('a failed next page remains retryable', () => {
const first = beginBrowse(createBrowseState(), '');
const ready = finishBrowse(first.state, first.request.id, true);
const next = beginNextPage(ready)!;
const failed = failBrowse(next.state, next.request);
const retry = beginNextPage(failed);
assert.ok(retry);
assert.equal(retry.request.page, 2);
});
test('latest request tokens invalidate closed and superseded details', () => {
const requests = new LatestRequest();
const first = requests.begin();
const second = requests.begin();
assert.equal(requests.isCurrent(first), false);
assert.equal(requests.isCurrent(second), true);
requests.cancel();
assert.equal(requests.isCurrent(second), false);
});
test('safeUploadDate ignores malformed timestamps', () => {
assert.equal(safeUploadDate(Date.UTC(2025, 3, 2)), '2025-04-02');
assert.equal(safeUploadDate(Number.NaN), null);
assert.equal(safeUploadDate(Number.POSITIVE_INFINITY), null);
});
test('capture turns a rejected IPC operation into a displayable failure', async () => {
const failure = new Error('bridge disconnected');
const result = await capture(async () => Promise.reject(failure));
assert.deepEqual(result, { ok: false, error: failure });
});
test('takeUnseenEntries deduplicates streamed and final-page entries', () => {
const entry = (sourceId: string, url: string): AnimeBrowserEntry => ({
sourceId,
sourceName: sourceId,
url,
title: url,
thumbnailUrl: null,
});
const seen = new Set<string>();
assert.deepEqual(takeUnseenEntries([entry('one', '/a')], seen), [entry('one', '/a')]);
assert.deepEqual(takeUnseenEntries([entry('one', '/a'), entry('two', '/a')], seen), [
entry('two', '/a'),
]);
});
+133
View File
@@ -0,0 +1,133 @@
import type { AnimeBrowserEntry } from '../types/anime-browser';
export interface BrowseState {
requestId: number;
query: string;
page: number;
loading: boolean;
hasNextPage: boolean;
}
export interface BrowseRequest {
id: number;
query: string;
page: number;
append: boolean;
}
export interface StartedBrowse {
state: BrowseState;
request: BrowseRequest;
}
export function createBrowseState(): BrowseState {
return { requestId: 0, query: '', page: 0, loading: false, hasNextPage: false };
}
export function beginBrowse(state: BrowseState, query: string): StartedBrowse {
const request: BrowseRequest = {
id: state.requestId + 1,
query,
page: 1,
append: false,
};
return {
state: {
requestId: request.id,
query,
page: request.page,
loading: true,
hasNextPage: false,
},
request,
};
}
export function beginNextPage(state: BrowseState): StartedBrowse | null {
if (state.loading || !state.hasNextPage) return null;
const request: BrowseRequest = {
id: state.requestId + 1,
query: state.query,
page: state.page + 1,
append: true,
};
return {
state: {
...state,
requestId: request.id,
page: request.page,
loading: true,
hasNextPage: false,
},
request,
};
}
export function soleBrowseRequest(
inFlight: ReadonlyMap<number, BrowseRequest>,
): BrowseRequest | null {
if (inFlight.size !== 1) return null;
return inFlight.values().next().value ?? null;
}
export function finishBrowse(
state: BrowseState,
requestId: number,
hasNextPage: boolean,
): BrowseState {
if (requestId !== state.requestId) return state;
return { ...state, loading: false, hasNextPage };
}
export function failBrowse(state: BrowseState, request: BrowseRequest): BrowseState {
if (request.id !== state.requestId) return state;
return {
...state,
page: request.append ? request.page - 1 : request.page,
loading: false,
hasNextPage: request.append,
};
}
export class LatestRequest {
private current = 0;
begin(): number {
return ++this.current;
}
cancel(): void {
this.current += 1;
}
isCurrent(request: number): boolean {
return request === this.current;
}
}
export function safeUploadDate(uploadedAt: number): string | null {
const date = new Date(uploadedAt);
return Number.isFinite(date.getTime()) ? date.toISOString().slice(0, 10) : null;
}
export type Captured<T> = { ok: true; value: T } | { ok: false; error: unknown };
export async function capture<T>(operation: () => Promise<T>): Promise<Captured<T>> {
try {
return { ok: true, value: await operation() };
} catch (error) {
return { ok: false, error };
}
}
export function takeUnseenEntries(
entries: AnimeBrowserEntry[],
seen: Set<string>,
): AnimeBrowserEntry[] {
return entries.filter((entry) => {
const key = `${entry.sourceId}\0${entry.url}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
+189
View File
@@ -0,0 +1,189 @@
import { capture, LatestRequest, safeUploadDate } from './browse-state';
import { describe, el } from './dom';
import type {
AnimeBrowserAPI,
AnimeBrowserEntry,
AnimeBrowserEpisode,
} from '../types/anime-browser';
interface DetailPanelOptions {
api: AnimeBrowserAPI;
setStatus: (message: string, tone?: 'info' | 'ok' | 'error') => void;
}
export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
const results = el<HTMLElement>('results');
const detail = el<HTMLElement>('detail');
const detailBack = el<HTMLButtonElement>('detail-back');
const detailCover = el<HTMLImageElement>('detail-cover');
const detailTitle = el<HTMLHeadingElement>('detail-title');
const detailChips = el<HTMLDivElement>('detail-chips');
const detailDescription = el<HTMLParagraphElement>('detail-description');
const episodes = el<HTMLOListElement>('episodes');
const episodesCount = el<HTMLSpanElement>('episodes-count');
let selectedAnime: { url: string; title: string; sourceId: string } | null = null;
let resultsScrollTop = 0;
const requests = new LatestRequest();
const playbacks = new LatestRequest();
function formatEpisodeIndex(episode: AnimeBrowserEpisode, fallbackIndex: number): string {
const value = episode.number ?? fallbackIndex;
return Number.isInteger(value) ? String(value).padStart(2, '0') : value.toFixed(1);
}
async function playEpisode(
button: HTMLButtonElement,
episode: AnimeBrowserEpisode,
): Promise<void> {
const anime = selectedAnime;
if (!anime) return;
// Only the newest click owns the button states and the status line; an
// earlier episode resolving late must not overwrite them.
const playback = playbacks.begin();
for (const other of episodes.querySelectorAll<HTMLButtonElement>('.cue')) {
other.removeAttribute('data-state');
}
button.dataset.state = 'loading';
setStatus(`Resolving ${episode.name}…`);
const attempt = await capture(() =>
api.playEpisode({
sourceId: anime.sourceId,
animeUrl: anime.url,
animeTitle: anime.title,
episodeUrl: episode.url,
episodeName: episode.name,
episodeNumber: episode.number,
}),
);
if (!playbacks.isCurrent(playback)) return;
if (!attempt.ok) {
button.removeAttribute('data-state');
setStatus(describe(attempt.error), 'error');
return;
}
const result = attempt.value;
if (result.ok) {
button.dataset.state = 'playing';
setStatus(
result.quality ? `Playing ${episode.name} · ${result.quality}` : `Playing ${episode.name}`,
'ok',
);
} else {
button.removeAttribute('data-state');
setStatus(result.error ?? 'Could not play that episode.', 'error');
}
}
function renderEpisodes(list: AnimeBrowserEpisode[]): void {
episodesCount.textContent = list.length === 0 ? '' : `${list.length}`;
episodes.replaceChildren(
...list.map((episode, index) => {
const item = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.className = 'cue';
const cueIndex = document.createElement('span');
cueIndex.className = 'cue-index';
cueIndex.textContent = formatEpisodeIndex(episode, list.length - index);
const name = document.createElement('span');
name.className = 'cue-name';
name.textContent = episode.name;
if (episode.uploadedAt !== null) {
const uploaded = safeUploadDate(episode.uploadedAt);
if (uploaded) {
const sub = document.createElement('span');
sub.className = 'cue-sub';
sub.textContent = uploaded;
name.append(sub);
}
}
button.append(cueIndex, name);
button.addEventListener('click', () => void playEpisode(button, episode));
item.append(button);
return item;
}),
);
}
async function open(entry: AnimeBrowserEntry): Promise<void> {
const request = requests.begin();
selectedAnime = { url: entry.url, title: entry.title, sourceId: entry.sourceId };
resultsScrollTop = results.scrollTop;
results.classList.add('hidden');
detail.classList.remove('hidden');
detail.scrollTop = 0;
detailTitle.textContent = entry.title;
detailDescription.textContent = 'Loading…';
detailChips.replaceChildren();
episodes.replaceChildren();
episodesCount.textContent = '';
detailCover.src = entry.thumbnailUrl ?? '';
try {
const [details, episodeList] = await Promise.all([
api.getDetails(entry.url, entry.sourceId),
api.getEpisodes(entry.url, entry.sourceId),
]);
if (!requests.isCurrent(request)) return;
detailTitle.textContent = details.title;
detailDescription.textContent = details.description ?? 'No description from this source.';
if (details.thumbnailUrl) detailCover.src = details.thumbnailUrl;
const chips: HTMLSpanElement[] = [];
const source = document.createElement('span');
source.className = 'chip source';
source.textContent = entry.sourceName;
chips.push(source);
if (details.status !== 'unknown') {
const status = document.createElement('span');
status.className = 'chip status';
status.textContent = details.status.replace(/-/g, ' ');
chips.push(status);
}
for (const genre of details.genres.slice(0, 6)) {
const chip = document.createElement('span');
chip.className = 'chip';
chip.textContent = genre;
chips.push(chip);
}
detailChips.replaceChildren(...chips);
renderEpisodes(episodeList);
setStatus(`${details.title} · ${episodeList.length} episodes`);
} catch (error) {
if (!requests.isCurrent(request)) return;
detailDescription.textContent = '';
setStatus(describe(error), 'error');
}
}
function close(): void {
requests.cancel();
playbacks.cancel();
detail.classList.add('hidden');
results.classList.remove('hidden');
results.scrollTop = resultsScrollTop;
selectedAnime = null;
}
detailBack.addEventListener('click', close);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !detail.classList.contains('hidden')) close();
});
return {
open,
close,
isOpen: (): boolean => !detail.classList.contains('hidden'),
};
}
+272
View File
@@ -0,0 +1,272 @@
/* ---------- detail page ---------- */
/*
* The detail view is a page, not a sidebar: it takes over the whole content
* region while the results grid waits, hidden, behind the Back button.
*/
.detail {
flex: 1 1 auto;
min-width: 0;
background: var(--panel);
overflow-y: auto;
padding: 20px clamp(20px, 5vw, 56px) 32px;
animation: detail-in 0.28s ease both;
}
@keyframes detail-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.detail-body {
max-width: 1040px;
margin: 0 auto;
}
.detail-back {
padding: 6px 14px;
font-size: 12px;
margin-bottom: 20px;
}
.detail-head {
display: flex;
gap: clamp(18px, 3vw, 36px);
margin-bottom: 28px;
}
.detail-cover {
flex: none;
align-self: flex-start;
width: clamp(140px, 18vw, 232px);
height: auto;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--ctp-crust);
box-shadow: 0 18px 40px -24px var(--shadow);
}
.detail-title {
margin: 0 0 10px;
font-size: clamp(19px, 2.4vw, 27px);
font-weight: 600;
line-height: 1.25;
}
.detail-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.chip {
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--line);
font-size: 11px;
color: var(--muted);
}
.chip.status {
border-color: rgba(166, 218, 149, 0.4);
color: var(--ok);
}
/* Which extension answered — the one chip that is always present. */
.chip.source {
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
color: var(--accent);
}
.detail-description {
margin: 0;
font-size: 13px;
max-width: 72ch;
color: var(--muted);
white-space: pre-wrap;
}
.episodes-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--line);
}
.episodes-title {
margin: 0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--faint);
}
.episodes-count {
font-family: var(--mono);
font-size: 11px;
color: var(--faint);
}
/*
* The cue rail: episodes read as subtitle cues on a timeline, because that is
* what they are about to become. The rail is the spine, the index is the cue
* number, and the title is the cue text.
*/
.cue-rail {
list-style: none;
margin: 0;
padding: 0 0 0 2px;
position: relative;
}
.cue-rail::before {
content: '';
position: absolute;
left: 48px;
top: 6px;
bottom: 6px;
width: 1px;
background: var(--line);
}
.cue {
position: relative;
display: grid;
grid-template-columns: 34px 1fr;
gap: 26px;
align-items: baseline;
width: 100%;
padding: 9px 10px 9px 0;
border: none;
border-radius: 8px;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background 0.14s ease;
}
.cue::after {
content: '';
position: absolute;
left: 43px;
top: 15px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--ctp-surface0);
border: 1px solid var(--line);
transition:
background 0.14s ease,
border-color 0.14s ease;
}
.cue:hover,
.cue:focus-visible {
background: var(--panel-elevated);
}
.cue:hover::after,
.cue:focus-visible::after {
background: var(--mine);
border-color: var(--mine);
}
.cue-index {
font-family: var(--mono);
font-size: 12px;
color: var(--faint);
text-align: right;
font-variant-numeric: tabular-nums;
}
.cue-name {
font-size: 13px;
line-height: 1.4;
}
.cue-sub {
display: block;
font-family: var(--mono);
font-size: 11px;
color: var(--faint);
margin-top: 2px;
}
.cue[data-state='loading'] {
background: var(--panel-elevated);
}
.cue[data-state='loading']::after {
background: var(--accent);
border-color: var(--accent);
animation: pulse 1.1s ease-in-out infinite;
}
.cue[data-state='playing']::after {
background: var(--mine);
border-color: var(--mine);
}
/* ---------- status bar ---------- */
.statusbar {
flex: none;
padding: 7px 20px;
border-top: 1px solid var(--line);
background: var(--ctp-mantle);
font-size: 12px;
color: var(--faint);
min-height: 30px;
}
.statusbar[data-tone='error'] {
color: var(--danger);
}
.statusbar[data-tone='ok'] {
color: var(--ok);
}
/* ---------- scrollbars ---------- */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--ctp-surface0);
border-radius: 6px;
border: 2px solid var(--bg);
}
::-webkit-scrollbar-thumb:hover {
background: var(--faint);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+8 -2
View File
@@ -186,8 +186,14 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
{
label: 'Remove',
onClick: async () => {
await api.removeRepo(repoUrl);
await refresh();
setStatus('Removing repository…');
try {
await api.removeRepo(repoUrl);
await refresh();
setStatus('Repository removed', 'ok');
} catch (error) {
setStatus(describe(error), 'error');
}
},
},
],
+5
View File
@@ -8,6 +8,8 @@
/>
<title>SubMiner Anime</title>
<link rel="stylesheet" href="./style.css" />
<link rel="stylesheet" href="./detail.css" />
<link rel="stylesheet" href="./panels.css" />
</head>
<body>
<header class="topbar">
@@ -126,6 +128,9 @@
<section class="results" id="results" aria-label="Results">
<div class="grid" id="grid"></div>
<p class="empty hidden" id="grid-empty"></p>
<button class="ghost-button load-more hidden" id="load-more" type="button">
Load more
</button>
</section>
<section class="detail hidden" id="detail" aria-label="Details">
+272
View File
@@ -0,0 +1,272 @@
/* ---------- source settings ---------- */
.settings {
/* A tab panel owns the whole content region; only one is ever visible. */
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
padding: 16px 20px 18px;
background: var(--panel-elevated);
}
.settings-head {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 12px;
}
.settings-title {
margin: 0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--faint);
}
.settings-note {
font-size: 12px;
color: var(--faint);
}
.settings-fields {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px 20px;
}
.field {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.field-label {
font-size: 12px;
font-weight: 600;
}
.field-summary {
font-size: 11px;
color: var(--faint);
line-height: 1.4;
}
.field-row {
display: flex;
align-items: center;
gap: 8px;
}
.field-row .text-input {
flex: 1 1 auto;
min-width: 0;
}
.field-multi {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.field-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--muted);
cursor: pointer;
}
.field-state {
font-size: 11px;
color: var(--faint);
min-height: 14px;
}
.field-state[data-tone='ok'] {
color: var(--ok);
}
.field-state[data-tone='error'] {
color: var(--danger);
}
/* ---------- extensions panel ---------- */
.repo-add {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.repo-add .text-input {
flex: 1 1 auto;
}
.repo-hint {
margin: 0 0 14px;
font-size: 12px;
color: var(--faint);
max-width: 78ch;
}
.repo-hint code {
font-family: var(--mono);
font-size: 11px;
}
.ext-group-title {
display: flex;
align-items: baseline;
gap: 8px;
margin: 0 0 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--faint);
}
/* A rule between the groups, but not above the first one. */
.ext-list + .ext-group-title {
margin-top: 6px;
padding-top: 14px;
border-top: 1px solid var(--line);
}
.ext-group-count {
font-size: 11px;
letter-spacing: 0.06em;
color: var(--muted);
}
.ext-group-count:empty {
display: none;
}
.lang-filter {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.lang-filter:not(:empty) {
margin-bottom: 10px;
}
.lang-chip {
padding: 4px 11px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--ctp-crust);
color: var(--muted);
font: inherit;
font-size: 11px;
letter-spacing: 0.04em;
cursor: pointer;
transition:
color 120ms ease,
border-color 120ms ease;
}
.lang-chip:hover {
color: var(--text);
border-color: var(--accent);
}
.lang-chip.is-active {
border-color: var(--accent);
color: var(--accent);
}
.lang-chip:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.ext-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.ext-list:not(:empty) {
margin-bottom: 14px;
}
.ext-row {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--ctp-crust);
}
.ext-row.is-error {
border-color: rgba(237, 135, 150, 0.45);
}
.ext-main {
flex: 1 1 auto;
min-width: 0;
}
.ext-name {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ext-sub {
font-family: var(--mono);
font-size: 11px;
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ext-row.is-error .ext-sub {
color: var(--danger);
white-space: normal;
}
.ext-tag {
flex: none;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--line);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--faint);
}
.ext-tag.installed {
border-color: rgba(166, 218, 149, 0.4);
color: var(--ok);
}
.ext-tag.nsfw {
border-color: rgba(237, 135, 150, 0.4);
color: var(--danger);
}
.ext-row .ghost-button,
.ext-row .primary-button {
flex: none;
padding: 5px 12px;
font-size: 12px;
}
.ext-empty {
padding: 14px 2px;
font-size: 13px;
color: var(--muted);
}
+9 -546
View File
@@ -315,6 +315,15 @@ body {
color: var(--muted);
}
.load-more {
display: block;
margin: 24px auto 4px;
}
.load-more.hidden {
display: none;
}
/* ---------- cover cards ---------- */
.card {
@@ -406,549 +415,3 @@ body {
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ---------- detail page ---------- */
/*
* The detail view is a page, not a sidebar: it takes over the whole content
* region while the results grid waits, hidden, behind the Back button.
*/
.detail {
flex: 1 1 auto;
min-width: 0;
background: var(--panel);
overflow-y: auto;
padding: 20px clamp(20px, 5vw, 56px) 32px;
animation: detail-in 0.28s ease both;
}
@keyframes detail-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.detail-body {
max-width: 1040px;
margin: 0 auto;
}
.detail-back {
padding: 6px 14px;
font-size: 12px;
margin-bottom: 20px;
}
.detail-head {
display: flex;
gap: clamp(18px, 3vw, 36px);
margin-bottom: 28px;
}
.detail-cover {
flex: none;
align-self: flex-start;
width: clamp(140px, 18vw, 232px);
height: auto;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--ctp-crust);
box-shadow: 0 18px 40px -24px var(--shadow);
}
.detail-title {
margin: 0 0 10px;
font-size: clamp(19px, 2.4vw, 27px);
font-weight: 600;
line-height: 1.25;
}
.detail-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.chip {
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--line);
font-size: 11px;
color: var(--muted);
}
.chip.status {
border-color: rgba(166, 218, 149, 0.4);
color: var(--ok);
}
/* Which extension answered — the one chip that is always present. */
.chip.source {
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
color: var(--accent);
}
.detail-description {
margin: 0;
font-size: 13px;
max-width: 72ch;
color: var(--muted);
white-space: pre-wrap;
}
.episodes-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--line);
}
.episodes-title {
margin: 0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--faint);
}
.episodes-count {
font-family: var(--mono);
font-size: 11px;
color: var(--faint);
}
/*
* The cue rail: episodes read as subtitle cues on a timeline, because that is
* what they are about to become. The rail is the spine, the index is the cue
* number, and the title is the cue text.
*/
.cue-rail {
list-style: none;
margin: 0;
padding: 0 0 0 2px;
position: relative;
}
.cue-rail::before {
content: '';
position: absolute;
left: 48px;
top: 6px;
bottom: 6px;
width: 1px;
background: var(--line);
}
.cue {
position: relative;
display: grid;
grid-template-columns: 34px 1fr;
gap: 26px;
align-items: baseline;
width: 100%;
padding: 9px 10px 9px 0;
border: none;
border-radius: 8px;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background 0.14s ease;
}
.cue::after {
content: '';
position: absolute;
left: 43px;
top: 15px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--ctp-surface0);
border: 1px solid var(--line);
transition:
background 0.14s ease,
border-color 0.14s ease;
}
.cue:hover,
.cue:focus-visible {
background: var(--panel-elevated);
}
.cue:hover::after,
.cue:focus-visible::after {
background: var(--mine);
border-color: var(--mine);
}
.cue-index {
font-family: var(--mono);
font-size: 12px;
color: var(--faint);
text-align: right;
font-variant-numeric: tabular-nums;
}
.cue-name {
font-size: 13px;
line-height: 1.4;
}
.cue-sub {
display: block;
font-family: var(--mono);
font-size: 11px;
color: var(--faint);
margin-top: 2px;
}
.cue[data-state='loading'] {
background: var(--panel-elevated);
}
.cue[data-state='loading']::after {
background: var(--accent);
border-color: var(--accent);
animation: pulse 1.1s ease-in-out infinite;
}
.cue[data-state='playing']::after {
background: var(--mine);
border-color: var(--mine);
}
/* ---------- status bar ---------- */
.statusbar {
flex: none;
padding: 7px 20px;
border-top: 1px solid var(--line);
background: var(--ctp-mantle);
font-size: 12px;
color: var(--faint);
min-height: 30px;
}
.statusbar[data-tone='error'] {
color: var(--danger);
}
.statusbar[data-tone='ok'] {
color: var(--ok);
}
/* ---------- scrollbars ---------- */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--ctp-surface0);
border-radius: 6px;
border: 2px solid var(--bg);
}
::-webkit-scrollbar-thumb:hover {
background: var(--faint);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ---------- source settings ---------- */
.settings {
/* A tab panel owns the whole content region; only one is ever visible. */
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
padding: 16px 20px 18px;
background: var(--panel-elevated);
}
.settings-head {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 12px;
}
.settings-title {
margin: 0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--faint);
}
.settings-note {
font-size: 12px;
color: var(--faint);
}
.settings-fields {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px 20px;
}
.field {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.field-label {
font-size: 12px;
font-weight: 600;
}
.field-summary {
font-size: 11px;
color: var(--faint);
line-height: 1.4;
}
.field-row {
display: flex;
align-items: center;
gap: 8px;
}
.field-row .text-input {
flex: 1 1 auto;
min-width: 0;
}
.field-multi {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.field-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--muted);
cursor: pointer;
}
.field-state {
font-size: 11px;
color: var(--faint);
min-height: 14px;
}
.field-state[data-tone='ok'] {
color: var(--ok);
}
.field-state[data-tone='error'] {
color: var(--danger);
}
/* ---------- extensions panel ---------- */
.repo-add {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.repo-add .text-input {
flex: 1 1 auto;
}
.repo-hint {
margin: 0 0 14px;
font-size: 12px;
color: var(--faint);
max-width: 78ch;
}
.repo-hint code {
font-family: var(--mono);
font-size: 11px;
}
.ext-group-title {
display: flex;
align-items: baseline;
gap: 8px;
margin: 0 0 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--faint);
}
/* A rule between the groups, but not above the first one. */
.ext-list + .ext-group-title {
margin-top: 6px;
padding-top: 14px;
border-top: 1px solid var(--line);
}
.ext-group-count {
font-size: 11px;
letter-spacing: 0.06em;
color: var(--muted);
}
.ext-group-count:empty {
display: none;
}
.lang-filter {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.lang-filter:not(:empty) {
margin-bottom: 10px;
}
.lang-chip {
padding: 4px 11px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--ctp-crust);
color: var(--muted);
font: inherit;
font-size: 11px;
letter-spacing: 0.04em;
cursor: pointer;
transition:
color 120ms ease,
border-color 120ms ease;
}
.lang-chip:hover {
color: var(--text);
border-color: var(--accent);
}
.lang-chip.is-active {
border-color: var(--accent);
color: var(--accent);
}
.lang-chip:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.ext-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.ext-list:not(:empty) {
margin-bottom: 14px;
}
.ext-row {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--ctp-crust);
}
.ext-row.is-error {
border-color: rgba(237, 135, 150, 0.45);
}
.ext-main {
flex: 1 1 auto;
min-width: 0;
}
.ext-name {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ext-sub {
font-family: var(--mono);
font-size: 11px;
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ext-row.is-error .ext-sub {
color: var(--danger);
white-space: normal;
}
.ext-tag {
flex: none;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--line);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--faint);
}
.ext-tag.installed {
border-color: rgba(166, 218, 149, 0.4);
color: var(--ok);
}
.ext-tag.nsfw {
border-color: rgba(237, 135, 150, 0.4);
color: var(--danger);
}
.ext-row .ghost-button,
.ext-row .primary-button {
flex: none;
padding: 5px 12px;
font-size: 12px;
}
.ext-empty {
padding: 14px 2px;
font-size: 13px;
color: var(--muted);
}
+2 -1
View File
@@ -13,7 +13,8 @@ export interface AnilistMediaGuess {
year?: number;
season: number | null;
episode: number | null;
source: 'guessit' | 'fallback';
/** `stream` means the player was handed these fields, not that we parsed them. */
source: 'guessit' | 'fallback' | 'stream';
}
export interface AnilistPostWatchUpdateResult {
@@ -9,6 +9,7 @@ import {
import {
guessAnilistMediaInfo,
runGuessit,
type AnilistMediaGuess,
type GuessAnilistMediaInfoDeps,
} from './anilist-updater';
import {
@@ -32,7 +33,7 @@ interface Logger {
interface CoverArtCandidate {
title: string;
source: 'guessit' | 'fallback';
source: AnilistMediaGuess['source'];
season: number | null;
episode: number | null;
}
@@ -6,6 +6,7 @@ import {
ANILIST_SEASON_SEARCH_QUERY,
pickByAirOrder,
resolveAnilistSeasonMedia,
splitPartMarker,
stripSeasonSuffix,
type AnilistSeasonMedia,
} from './season-resolver';
@@ -305,3 +306,329 @@ test('air-order fallback declines when a franchise entry has no air year', async
assert.equal(result?.seasonResolved, false);
assert.equal(result?.via, 'anchor');
});
/**
* Real AniList payloads for Mushoku Tensei, whose sequel chain interleaves
* split-cour continuations with real seasons:
* S1 -> "Cour 2" -> S2 -> "Season 2 Part 2" -> S3.
*/
const MUSHOKU_SEARCH: AnilistSeasonMedia[] = [
{
id: 108465,
episodes: 11,
format: 'TV',
seasonYear: 2021,
title: {
romaji: 'Mushoku Tensei: Isekai Ittara Honki Dasu',
english: 'Mushoku Tensei: Jobless Reincarnation',
native: null,
},
},
{
id: 127720,
episodes: 12,
format: 'TV',
seasonYear: 2021,
title: {
romaji: 'Mushoku Tensei: Isekai Ittara Honki Dasu Part 2',
english: 'Mushoku Tensei: Jobless Reincarnation Cour 2',
native: null,
},
},
{
id: 146065,
episodes: 13,
format: 'TV',
seasonYear: 2023,
title: {
romaji: 'Mushoku Tensei II: Isekai Ittara Honki Dasu',
english: 'Mushoku Tensei: Jobless Reincarnation Season 2',
native: null,
},
},
{
id: 166873,
episodes: 12,
format: 'TV',
seasonYear: 2024,
title: {
romaji: 'Mushoku Tensei II: Isekai Ittara Honki Dasu Part 2',
english: 'Mushoku Tensei: Jobless Reincarnation Season 2 Part 2',
native: null,
},
},
{
id: 178789,
episodes: 14,
format: 'TV',
seasonYear: 2026,
title: {
romaji: 'Mushoku Tensei III: Isekai Ittara Honki Dasu',
english: 'Mushoku Tensei: Jobless Reincarnation Season 3',
native: null,
},
},
];
const MUSHOKU_RELATIONS: Record<
number,
Array<{ relationType: string; node: AnilistSeasonMedia }>
> = {
108465: [{ relationType: 'SEQUEL', node: MUSHOKU_SEARCH[1]! }],
127720: [{ relationType: 'SEQUEL', node: MUSHOKU_SEARCH[2]! }],
146065: [{ relationType: 'SEQUEL', node: MUSHOKU_SEARCH[3]! }],
166873: [{ relationType: 'SEQUEL', node: MUSHOKU_SEARCH[4]! }],
};
test('splitPartMarker separates a split-cour marker from the season title', () => {
assert.deepEqual(splitPartMarker('Mushoku Tensei: Jobless Reincarnation Season 2 Part 2'), {
base: 'mushoku tensei: jobless reincarnation season 2',
hasPart: true,
});
assert.deepEqual(splitPartMarker('Mushoku Tensei: Jobless Reincarnation Cour 2'), {
base: 'mushoku tensei: jobless reincarnation',
hasPart: true,
});
assert.deepEqual(splitPartMarker('Attack on Titan Final Season Part 3'), {
base: 'attack on titan final season',
hasPart: true,
});
assert.deepEqual(splitPartMarker('進撃の巨人 第2部'), { base: '進撃の巨人', hasPart: true });
});
test('splitPartMarker leaves a numbered work alone', () => {
// The number is followed by a subtitle, so it names the work, not a cour.
assert.deepEqual(splitPartMarker('JoJo no Kimyou na Bouken Part 5: Ougon no Kaze'), {
base: 'jojo no kimyou na bouken part 5: ougon no kaze',
hasPart: false,
});
assert.deepEqual(splitPartMarker('Mushoku Tensei: Jobless Reincarnation Season 3'), {
base: 'mushoku tensei: jobless reincarnation season 3',
hasPart: false,
});
});
test('a split-cour continuation does not consume a season step', async () => {
const { execute, relationLookups } = createExecutor(MUSHOKU_SEARCH, MUSHOKU_RELATIONS);
const result = await resolveAnilistSeasonMedia(
{ title: 'Mushoku Tensei: Jobless Reincarnation', season: 3, episode: 4 },
{ execute },
);
assert.equal(result?.id, 178789);
assert.equal(result?.title, 'Mushoku Tensei: Jobless Reincarnation Season 3');
assert.equal(result?.seasonResolved, true);
assert.equal(result?.via, 'sequel-chain');
// Four hops for three seasons: two of them were cour continuations.
assert.deepEqual(relationLookups, [108465, 127720, 146065, 166873]);
});
test('season 2 stops at the front half rather than its second cour', async () => {
const { execute } = createExecutor(MUSHOKU_SEARCH, MUSHOKU_RELATIONS);
const result = await resolveAnilistSeasonMedia(
{ title: 'Mushoku Tensei: Jobless Reincarnation', season: 2, episode: 1 },
{ execute },
);
assert.equal(result?.id, 146065);
assert.equal(result?.seasonResolved, true);
});
test('air-order fallback skips split-cour entries too', () => {
const anchor = MUSHOKU_SEARCH[0]!;
assert.equal(pickByAirOrder(anchor, 2, MUSHOKU_SEARCH)?.id, 146065);
assert.equal(pickByAirOrder(anchor, 3, MUSHOKU_SEARCH)?.id, 178789);
// Only three real seasons exist, so a fourth must not fall out of the list.
assert.equal(pickByAirOrder(anchor, 4, MUSHOKU_SEARCH), null);
});
test('a sequel that repeats its predecessor name is still a new season', async () => {
// No part marker, so the identical title must not be read as a continuation.
const search: AnilistSeasonMedia[] = [
{
id: 1,
episodes: 12,
format: 'TV',
seasonYear: 2020,
title: { romaji: 'Same Name Show', english: 'Same Name Show', native: null },
},
{
id: 2,
episodes: 12,
format: 'TV',
seasonYear: 2022,
title: { romaji: 'Same Name Show', english: 'Same Name Show', native: null },
},
];
const { execute } = createExecutor(search, {
1: [{ relationType: 'SEQUEL', node: search[1]! }],
});
const result = await resolveAnilistSeasonMedia(
{ title: 'Same Name Show', season: 2, episode: 3 },
{ execute },
);
assert.equal(result?.id, 2);
assert.equal(result?.seasonResolved, true);
});
/**
* Real AniList payloads for Bleach TYBW, whose cours are titled with their own
* arc names — the part markers live only in localized synonyms, and the English
* title hyphenates "Thousand-Year" while those synonyms do not.
*/
const BLEACH_SEARCH: AnilistSeasonMedia[] = [
{
id: 116674,
episodes: 13,
format: 'TV',
seasonYear: 2022,
synonyms: ['BLEACH TYBW'],
title: {
romaji: 'BLEACH: Sennen Kessen-hen',
english: 'BLEACH: Thousand-Year Blood War',
native: 'BLEACH 千年血戦篇',
},
},
{
id: 159322,
episodes: 13,
format: 'TV',
seasonYear: 2023,
synonyms: [
'BLEACH: Thousand Year Blood War Part 2',
'BLEACH 千年血戦篇 第2クール',
'BLEACH TYBW',
],
title: {
romaji: 'BLEACH: Sennen Kessen-hen - Ketsubetsu-tan',
english: 'BLEACH: Thousand-Year Blood War - The Separation',
native: 'BLEACH 千年血戦篇-訣別譚-',
},
},
];
test('a cour marked only in a synonym, and spelled without the hyphen, still counts as one', async () => {
const { execute } = createExecutor(BLEACH_SEARCH, {
116674: [{ relationType: 'SEQUEL', node: BLEACH_SEARCH[1]! }],
});
const result = await resolveAnilistSeasonMedia(
{ title: 'BLEACH: Thousand-Year Blood War', season: 2, episode: 1 },
{ execute },
);
// The only sequel is cour 2 of the same season, so there is no season 2 to
// find — reporting that beats confidently returning the wrong arc.
assert.equal(result?.seasonResolved, false);
assert.notEqual(result?.id, 159322);
});
test('splitPartMarker reads the Japanese cour marker', () => {
assert.deepEqual(splitPartMarker('BLEACH 千年血戦篇 第2クール'), {
base: 'bleach 千年血戦篇',
hasPart: true,
});
});
/** Dr. STONE routes its sequel chain through a one-episode special. */
const DR_STONE_SEARCH: AnilistSeasonMedia[] = [
{
id: 105333,
episodes: 24,
format: 'TV',
seasonYear: 2019,
title: { romaji: 'Dr. STONE', english: 'Dr. STONE', native: null },
},
{
id: 113936,
episodes: 11,
format: 'TV',
seasonYear: 2021,
title: { romaji: 'Dr. STONE: STONE WARS', english: 'Dr. STONE: STONE WARS', native: null },
},
{
id: 142876,
episodes: 1,
format: 'SPECIAL',
seasonYear: 2022,
title: {
romaji: 'Dr. STONE: Ryuusui',
english: 'Dr. STONE Special Episode – RYUSUI',
native: null,
},
},
{
id: 131518,
episodes: 11,
format: 'TV',
seasonYear: 2023,
title: { romaji: 'Dr. STONE: NEW WORLD', english: 'Dr. STONE New World', native: null },
},
];
test('a special in the sequel chain is walked through but does not count as a season', async () => {
const { execute } = createExecutor(DR_STONE_SEARCH, {
105333: [{ relationType: 'SEQUEL', node: DR_STONE_SEARCH[1]! }],
113936: [{ relationType: 'SEQUEL', node: DR_STONE_SEARCH[2]! }],
142876: [{ relationType: 'SEQUEL', node: DR_STONE_SEARCH[3]! }],
});
const result = await resolveAnilistSeasonMedia(
{ title: 'Dr. STONE', season: 3, episode: 1 },
{ execute },
);
assert.equal(result?.id, 131518);
assert.equal(result?.title, 'Dr. STONE New World');
assert.equal(result?.seasonResolved, true);
});
test('air-order fallback keeps a season whose own synonym spells it as a part', () => {
// AniList carries "…Season 3 Part 1" as a localized synonym *of season 3*.
const search: AnilistSeasonMedia[] = [
{
id: 1,
episodes: 25,
format: 'TV',
seasonYear: 2013,
title: { romaji: 'Some Titans', english: 'Some Titans', native: null },
},
{
id: 2,
episodes: 12,
format: 'TV',
seasonYear: 2017,
title: { romaji: 'Some Titans Season 2', english: 'Some Titans Season 2', native: null },
},
{
id: 3,
episodes: 12,
format: 'TV',
seasonYear: 2018,
synonyms: ['Some Titans Season 3 Part 1'],
title: { romaji: 'Some Titans Season 3', english: 'Some Titans Season 3', native: null },
},
];
assert.equal(pickByAirOrder(search[0]!, 3, search)?.id, 3);
});
test('a transport failure mid-walk is surfaced instead of guessed around', async () => {
const { execute } = createExecutor(MUSHOKU_SEARCH, MUSHOKU_RELATIONS);
const failing = async <T>(query: string, variables: Record<string, unknown>): Promise<T> => {
if (query === ANILIST_SEASON_RELATIONS_QUERY) {
throw new Error('Too Many Requests.');
}
return execute<T>(query, variables);
};
// Air order could answer here, but it would be answering from a franchise
// picture the failed walk never confirmed.
await assert.rejects(
() =>
resolveAnilistSeasonMedia(
{ title: 'Mushoku Tensei: Jobless Reincarnation', season: 3, episode: 4 },
{ execute: failing },
),
/Too Many Requests/,
);
});
+133 -15
View File
@@ -115,10 +115,45 @@ const SEASONAL_FORMAT_PRIORITY = ['TV', 'TV_SHORT', 'ONA'];
const MAX_SEQUEL_HOPS = 12;
/**
* A split-cour continuation adds sequel hops without adding a season, so the
* walk needs room for more hops than the season number alone implies.
*/
const MAX_SEQUEL_STEPS = 24;
/**
* Markers AniList appends when one broadcast season is listed as several
* entries. Anchored to the end so a numbered *work* ("JoJo no Kimyou na Bouken
* Part 5: Ougon no Kaze") is not mistaken for a continuation — those carry a
* subtitle after the number and really are separate seasons.
*/
const PART_MARKER_PATTERNS: RegExp[] = [
/\bpart\s*(?:\d{1,2}|i{1,3}|iv|v)\s*$/i,
/\bcour\s*\d{1,2}\s*$/i,
/\b\d{1,2}(?:st|nd|rd|th)\s+(?:part|cour)\s*$/i,
/\b(?:second|final)\s+(?:part|cour)\s*$/i,
/\s*第\s*\d{1,2}\s*(?:部|クール)\s*$/,
];
function normalizeTitle(value: string): string {
return value.trim().toLowerCase().replace(/\s+/g, ' ');
}
/**
* Comparison key that ignores punctuation.
*
* AniList spells the same name differently across a franchise's own entries —
* "BLEACH: Thousand-Year Blood War" against the synonym "BLEACH: Thousand Year
* Blood War Part 2" — so an exact string compare misses the continuation.
*/
function titleMatchKey(value: string): string {
return value
.normalize('NFKC')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim();
}
/**
* Drops season markers a release name carries but AniList titles never do,
* so "Some Show Season 3" and "Some Show S3" both search as "Some Show".
@@ -132,6 +167,50 @@ export function stripSeasonSuffix(title: string): string {
.trim();
}
/**
* Splits a trailing part/cour marker off a title.
*
* "Mushoku Tensei: Jobless Reincarnation Season 2 Part 2" is the back half of
* season 2, not season 3 — AniList lists it as its own SEQUEL of the front
* half, which is what used to make every split-cour franchise resolve one
* season short.
*/
export function splitPartMarker(title: string): { base: string; hasPart: boolean } {
const trimmed = title.trim();
for (const pattern of PART_MARKER_PATTERNS) {
const match = trimmed.match(pattern);
if (!match || match.index === undefined) continue;
const base = normalizeTitle(trimmed.slice(0, match.index));
if (!base) continue;
return { base, hasPart: true };
}
return { base: normalizeTitle(trimmed), hasPart: false };
}
/**
* Whether `next` continues `current`'s season rather than starting a new one.
*
* The marker is required, not just a matching base title: a sequel that repeats
* its predecessor's name exactly is a new season that AniList simply did not
* number, and skipping it would undercount in the other direction.
*/
function isSeasonContinuation(current: AnilistSeasonMedia, next: AnilistSeasonMedia): boolean {
const parts = mediaTitles(next)
.map((title) => splitPartMarker(title))
.filter((part) => part.hasPart);
if (parts.length === 0) return false;
// Both the full title and its own stripped base, so a "Part 3" can be matched
// against a season the walk only ever saw as its "Part 2".
const currentKeys = new Set(
mediaTitles(current).flatMap((title) => [
titleMatchKey(title),
titleMatchKey(splitPartMarker(title).base),
]),
);
return parts.some((part) => currentKeys.has(titleMatchKey(part.base)));
}
function mediaTitles(media: AnilistSeasonMedia): string[] {
const synonyms = Array.isArray(media.synonyms) ? media.synonyms : [];
return [media.title?.english, media.title?.romaji, media.title?.native, ...synonyms]
@@ -250,16 +329,21 @@ async function walkSequelChain(
season: number,
deps: ResolveAnilistSeasonMediaDeps,
): Promise<AnilistSeasonMedia | null> {
// Walking fewer hops than requested would land on the wrong season and report it as
// Advancing fewer seasons than requested would land on the wrong one and report it as
// resolved, so refuse instead and let the caller fall through to its guarded fallback.
const hops = season - 1;
if (hops > MAX_SEQUEL_HOPS) {
const targetAdvances = season - 1;
if (targetAdvances > MAX_SEQUEL_HOPS) {
return null;
}
const visited = new Set<number>([anchor.id]);
let current = anchor;
// The last entry that counted as a season. Continuations are compared against
// this rather than against `current`, so a special sitting between a season
// and its second cour cannot hide the link between them.
let seasonStart = anchor;
let advances = 0;
for (let hop = 0; hop < hops; hop += 1) {
for (let step = 0; step < MAX_SEQUEL_STEPS && advances < targetAdvances; step += 1) {
// Transport errors propagate: a failed hop must not be mistaken for "no sequel exists".
const response = await deps.execute<AnilistSeasonRelationsResponse>(
ANILIST_SEASON_RELATIONS_QUERY,
@@ -296,11 +380,25 @@ async function walkSequelChain(
return a.id - b.id;
});
current = sequels[0]!;
const next = sequels[0]!;
// Two kinds of hop move along the chain without moving to the next season:
// the back half of a split cour, which is its own SEQUEL entry, and a
// special or OVA, which is not a season at all. Both are still traversed,
// because the rest of the franchise hangs off them.
if (isSeasonalFormat(next) && !isSeasonContinuation(seasonStart, next)) {
advances += 1;
seasonStart = next;
}
current = next;
visited.add(current.id);
}
return current.id === anchor.id ? null : current;
if (advances < targetAdvances) {
return null;
}
// seasonStart, not current: it is the entry the season count landed on, and
// it is always a seasonal format because only those advance the count.
return seasonStart.id === anchor.id ? null : seasonStart;
}
/**
@@ -323,13 +421,31 @@ export function pickByAirOrder(
stripSeasonSuffix(candidate).includes(anchorBase),
);
});
if (franchise.length < season) return null;
// Drop the back halves of split-cour seasons, which are separate entries here
// but not separate seasons — counting them shifts every later season index.
// The comparison excludes the entry's own titles: a localized synonym often
// spells a season as "<season> Part 1", which would otherwise delete the very
// season it names.
const seasons = franchise.filter((entry) => {
if (entry.id === anchor.id) return true;
const otherKeys = new Set(
franchise
.filter((other) => other.id !== entry.id)
.flatMap((other) => mediaTitles(other).map((title) => titleMatchKey(title))),
);
return !mediaTitles(entry).some((title) => {
const part = splitPartMarker(title);
return part.hasPart && otherKeys.has(titleMatchKey(part.base));
});
});
if (seasons.length < season) return null;
// Ordering by air date is only meaningful when every entry has one: an unknown year
// would sort last and shift the season index while still reporting a resolved match.
if (franchise.some((entry) => airYear(entry) === null)) return null;
if (seasons.some((entry) => airYear(entry) === null)) return null;
const ordered = [...franchise].sort((a, b) => {
const ordered = [...seasons].sort((a, b) => {
const yearDelta = (airYear(a) ?? 0) - (airYear(b) ?? 0);
if (yearDelta !== 0) return yearDelta;
return a.id - b.id;
@@ -386,6 +502,14 @@ export async function resolveAnilistSeasonMedia(
return toResolution(viaChain, searchTitle, season, 'sequel-chain', true);
}
// The chain failed for transport reasons rather than because the season is absent, so
// nothing was learned about the franchise's shape. Surface it and let the caller retry:
// the air-order fallback is for a chain that answered and had no edge to follow, and
// running it here would turn a rate limit into a confident wrong season.
if (chainError) {
throw chainError;
}
const viaAirOrder = pickByAirOrder(anchor, season, media);
if (viaAirOrder) {
deps.logInfo?.(
@@ -394,12 +518,6 @@ export async function resolveAnilistSeasonMedia(
return toResolution(viaAirOrder, searchTitle, season, 'air-order', true);
}
// The chain failed for transport reasons rather than because the season is absent;
// surface that so callers retry instead of reporting an unresolvable season.
if (chainError) {
throw chainError;
}
deps.logInfo?.(
`[anilist] could not resolve season ${season} of "${searchTitle}"; falling back to ${displayTitle(anchor, searchTitle)} (${anchor.id})`,
);
@@ -4313,3 +4313,116 @@ test('ensureAnimeCoverArt fetches art via the latest video of the anime', async
cleanupDbPath(dbPath);
}
});
test('anime browser streams group by series instead of by the proxy file extension', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
// Two different shows, each behind the strip proxy. Without the metadata
// recorded up front the only readable part of either URL is ".m3u8", which
// is what used to collapse them into one series.
tracker.recordStreamPlaybackMetadata({
mediaPath: 'http://127.0.0.1:41234/video/9f2c1b7a.m3u8',
statsPath: 'animebrowser://9001/%2Fanime%2Fmushoku/%2Fwatch%2Fep-4',
displayTitle: 'Mushoku Tensei: Jobless Reincarnation S03E04',
seriesTitle: 'Mushoku Tensei: Jobless Reincarnation',
seasonNumber: 3,
episodeNumber: 4,
});
tracker.handleMediaChange(
'http://127.0.0.1:41234/video/9f2c1b7a.m3u8',
'Mushoku Tensei: Jobless Reincarnation S03E04',
);
await waitForPendingAnimeMetadata(tracker);
tracker.handleMediaChange(null, null);
tracker.recordStreamPlaybackMetadata({
mediaPath: 'http://127.0.0.1:41234/video/33ab90ff.m3u8',
statsPath: 'animebrowser://9001/%2Fanime%2Fsnafu/%2Fwatch%2Fep-10',
displayTitle: 'My Teen Romantic Comedy SNAFU Climax! E10 - Gallantly',
seriesTitle: 'My Teen Romantic Comedy SNAFU Climax!',
seasonNumber: null,
episodeNumber: 10,
});
tracker.handleMediaChange(
'http://127.0.0.1:41234/video/33ab90ff.m3u8',
'My Teen Romantic Comedy SNAFU Climax! E10 - Gallantly',
);
await waitForPendingAnimeMetadata(tracker);
tracker.handleMediaChange(null, null);
// The same episode again: a fresh proxy port and token, which must not mint
// a second video row.
tracker.recordStreamPlaybackMetadata({
mediaPath: 'http://127.0.0.1:55555/video/ffff0000.m3u8',
statsPath: 'animebrowser://9001/%2Fanime%2Fmushoku/%2Fwatch%2Fep-4',
displayTitle: 'Mushoku Tensei: Jobless Reincarnation S03E04',
seriesTitle: 'Mushoku Tensei: Jobless Reincarnation',
seasonNumber: 3,
episodeNumber: 4,
});
tracker.handleMediaChange(
'http://127.0.0.1:55555/video/ffff0000.m3u8',
'Mushoku Tensei: Jobless Reincarnation S03E04',
);
await waitForPendingAnimeMetadata(tracker);
const privateApi = tracker as unknown as { db: DatabaseSync };
const rows = privateApi.db
.prepare(
`
SELECT
v.source_url,
v.canonical_title AS video_title,
v.parsed_title,
v.parsed_season,
v.parsed_episode,
v.parser_source,
a.canonical_title AS anime_title
FROM imm_videos v
JOIN imm_anime a ON a.anime_id = v.anime_id
ORDER BY v.video_id
`,
)
.all() as Array<{
source_url: string | null;
video_title: string;
parsed_title: string | null;
parsed_season: number | null;
parsed_episode: number | null;
parser_source: string | null;
anime_title: string;
}>;
// Two episodes, three playbacks: the rewatch reused its row.
assert.equal(rows.length, 2);
assert.equal(new Set(rows.map((row) => row.anime_title)).size, 2);
assert.equal(
rows.some((row) => row.anime_title.toLowerCase().includes('m3u8')),
false,
);
const mushoku = rows.find((row) => row.parsed_title?.startsWith('Mushoku'));
assert.ok(mushoku);
assert.equal(mushoku.source_url, 'animebrowser://9001/%2Fanime%2Fmushoku/%2Fwatch%2Fep-4');
assert.equal(mushoku.video_title, 'Mushoku Tensei: Jobless Reincarnation S03E04');
assert.equal(mushoku.parsed_title, 'Mushoku Tensei: Jobless Reincarnation');
assert.equal(mushoku.parsed_season, 3);
assert.equal(mushoku.parsed_episode, 4);
assert.equal(mushoku.parser_source, 'anime-browser');
assert.equal(mushoku.anime_title, 'Mushoku Tensei: Jobless Reincarnation Season 3');
const snafu = rows.find((row) => row.parsed_title?.startsWith('My Teen'));
assert.ok(snafu);
assert.equal(snafu.parsed_episode, 10);
assert.equal(snafu.parsed_season, null);
assert.equal(snafu.anime_title, 'My Teen Romantic Comedy SNAFU Climax!');
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
+116 -32
View File
@@ -320,6 +320,31 @@ export interface JellyfinPlaybackMetadataInput {
itemId: string;
}
/**
* An episode the anime browser is about to stream.
*
* Reported up front for the same reason the Jellyfin variant is: the stream URL
* mpv sees is a per-playback proxy address ending in `.m3u8`, so a session
* started from it alone lands in a series named after the file extension.
*/
export interface StreamPlaybackMetadataInput {
/** The URL handed to mpv; recorded as an alias of `statsPath`. */
mediaPath: string;
/** Stable per-episode identity. Survives the proxy port and token changing. */
statsPath: string;
displayTitle: string;
seriesTitle: string;
seasonNumber: number | null;
episodeNumber: number | null;
}
/**
* Parser sources that are recorded before playback starts. A video carrying one
* already has better metadata than filename guessing could produce, so the
* guess is skipped rather than allowed to overwrite it.
*/
const PREPLAYBACK_PARSER_SOURCES = new Set(['jellyfin', 'anime-browser']);
function normalizeMetadataInt(value: number | null | undefined): number | null {
return typeof value === 'number' && Number.isSafeInteger(value) ? value : null;
}
@@ -353,7 +378,7 @@ function deleteSearchParamsCaseInsensitive(searchParams: URLSearchParams, names:
}
}
function buildJellyfinMediaPathAliasCandidates(mediaPath: string): string[] {
function buildMediaPathAliasCandidates(mediaPath: string): string[] {
const candidates = new Set<string>([mediaPath]);
try {
const parsed = new URL(mediaPath);
@@ -1251,15 +1276,11 @@ export class ImmersionTrackerService {
if (!rawPath) {
return;
}
const normalizedPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId);
for (const alias of buildJellyfinMediaPathAliasCandidates(rawPath)) {
this.mediaPathAliases.set(alias, normalizedPath);
}
const statsPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId);
const displayTitle =
normalizeText(metadata.displayTitle) ||
normalizeText(metadata.itemTitle) ||
deriveCanonicalTitle(normalizedPath);
deriveCanonicalTitle(statsPath);
const itemTitle = normalizeText(metadata.itemTitle) || displayTitle;
const seriesTitle = normalizeText(metadata.seriesTitle);
const libraryTitle = seriesTitle || itemTitle;
@@ -1269,47 +1290,110 @@ export class ImmersionTrackerService {
return;
}
this.recordPrePlaybackMetadata({
statsPath,
aliases: buildMediaPathAliasCandidates(rawPath),
displayTitle,
libraryTitle,
seasonNumber,
episodeNumber,
parserSource: 'jellyfin',
metadataJson: JSON.stringify({
source: 'jellyfin',
itemId: normalizeText(metadata.itemId) || null,
itemTitle,
seriesTitle: seriesTitle || null,
displayTitle,
seasonNumber,
episodeNumber,
}),
});
}
recordStreamPlaybackMetadata(metadata: StreamPlaybackMetadataInput): void {
const rawPath = normalizeMediaPath(metadata.mediaPath);
const statsPath = normalizeMediaPath(metadata.statsPath) || rawPath;
if (!statsPath) {
return;
}
const seriesTitle = normalizeText(metadata.seriesTitle);
const displayTitle =
normalizeText(metadata.displayTitle) || seriesTitle || deriveCanonicalTitle(statsPath);
const libraryTitle = seriesTitle || displayTitle;
if (!libraryTitle) {
return;
}
const seasonNumber = normalizeMetadataInt(metadata.seasonNumber);
const episodeNumber = normalizeMetadataInt(metadata.episodeNumber);
this.recordPrePlaybackMetadata({
statsPath,
aliases: rawPath ? buildMediaPathAliasCandidates(rawPath) : [],
displayTitle,
libraryTitle,
seasonNumber,
episodeNumber,
parserSource: 'anime-browser',
metadataJson: JSON.stringify({
source: 'anime-browser',
seriesTitle: seriesTitle || null,
displayTitle,
seasonNumber,
episodeNumber,
}),
});
}
/**
* Creates the video row and its series link ahead of playback, so the session
* mpv's path change starts already belongs to the right anime.
*/
private recordPrePlaybackMetadata(params: {
statsPath: string;
aliases: string[];
displayTitle: string;
libraryTitle: string;
seasonNumber: number | null;
episodeNumber: number | null;
parserSource: string;
metadataJson: string;
}): void {
for (const alias of params.aliases) {
this.mediaPathAliases.set(alias, params.statsPath);
}
const videoId = getOrCreateVideoRecord(
this.db,
buildVideoKey(normalizedPath, SOURCE_TYPE_REMOTE),
buildVideoKey(params.statsPath, SOURCE_TYPE_REMOTE),
{
canonicalTitle: displayTitle,
canonicalTitle: params.displayTitle,
sourcePath: null,
sourceUrl: normalizedPath,
sourceUrl: params.statsPath,
sourceType: SOURCE_TYPE_REMOTE,
},
);
const previousLink = this.db
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
.get(videoId) as { animeId: number | null } | null;
const metadataJson = JSON.stringify({
source: 'jellyfin',
itemId: normalizeText(metadata.itemId) || null,
itemTitle,
seriesTitle: seriesTitle || null,
displayTitle,
seasonNumber,
episodeNumber,
});
const animeId = getOrCreateAnimeRecord(this.db, {
parsedTitle: libraryTitle,
canonicalTitle: libraryTitle,
seasonScope: seasonNumber,
parsedTitle: params.libraryTitle,
canonicalTitle: params.libraryTitle,
seasonScope: params.seasonNumber,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson,
metadataJson: params.metadataJson,
});
linkVideoToAnimeRecord(this.db, videoId, {
animeId,
parsedBasename: null,
parsedTitle: libraryTitle,
parsedSeason: seasonNumber,
parsedEpisode: episodeNumber,
parserSource: 'jellyfin',
parsedTitle: params.libraryTitle,
parsedSeason: params.seasonNumber,
parsedEpisode: params.episodeNumber,
parserSource: params.parserSource,
parserConfidence: 1,
parseMetadataJson: metadataJson,
parseMetadataJson: params.metadataJson,
});
const hasLifetimeMedia = Boolean(
@@ -1320,17 +1404,17 @@ export class ImmersionTrackerService {
}
}
private hasJellyfinMetadata(videoId: number): boolean {
private hasPrePlaybackMetadata(videoId: number): boolean {
const row = this.db
.prepare('SELECT parser_source AS parserSource FROM imm_videos WHERE video_id = ?')
.get(videoId) as { parserSource: string | null } | null;
return row?.parserSource === 'jellyfin';
return row?.parserSource !== null && PREPLAYBACK_PARSER_SOURCES.has(row?.parserSource ?? '');
}
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
const rawPath = normalizeMediaPath(mediaPath);
const normalizedPath =
buildJellyfinMediaPathAliasCandidates(rawPath)
buildMediaPathAliasCandidates(rawPath)
.map((alias) => this.mediaPathAliases.get(alias))
.find((alias): alias is string => Boolean(alias)) ?? rawPath;
const normalizedTitle = normalizeText(mediaTitle);
@@ -1380,7 +1464,7 @@ export class ImmersionTrackerService {
if (youtubeVideoId) {
void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId);
this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath);
} else if (!this.hasJellyfinMetadata(sessionInfo.videoId)) {
} else if (!this.hasPrePlaybackMetadata(sessionInfo.videoId)) {
this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
}
if (!youtubeVideoId) {
+2 -1
View File
@@ -93,7 +93,8 @@ export function scheduleOverlayContentReadyFallback(deps: {
delayMs?: number;
}): void {
const setTimeoutFn =
deps.setTimeoutFn ?? ((callback: () => void, delayMs: number): unknown => setTimeout(callback, delayMs));
deps.setTimeoutFn ??
((callback: () => void, delayMs: number): unknown => setTimeout(callback, delayMs));
setTimeoutFn(() => {
if (deps.isContentReady() || deps.isDestroyed()) {
return;
@@ -0,0 +1,113 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
convertSubtitleForAlass,
formatCuesAsSrt,
needsAlassConversion,
parseTimedCues,
} from './subsync-alass-input';
const VTT = [
'WEBVTT',
'',
'NOTE this comment is not a cue',
'',
'cue-1',
'00:00:01.500 --> 00:00:03.250 line:90% align:center',
'<v Geese>しっかし',
'まさかお前が',
'',
'00:01:02.000 --> 00:01:04.000',
'ゼニスも きっと幸せじゃろう',
'',
].join('\n');
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'alass-input-test-'));
}
test('parseTimedCues keeps cue payloads verbatim and skips headers', () => {
const cues = parseTimedCues(VTT);
assert.equal(cues.length, 2);
assert.equal(cues[0]!.start, 1.5);
assert.equal(cues[0]!.end, 3.25);
assert.equal(cues[0]!.text, '<v Geese>しっかし\nまさかお前が');
assert.equal(cues[1]!.start, 62);
assert.equal(cues[1]!.text, 'ゼニスも きっと幸せじゃろう');
});
test('parseTimedCues reads VTT timestamps without an hours field', () => {
const cues = parseTimedCues('WEBVTT\n\n01:02.000 --> 01:03.000\nhello\n');
assert.equal(cues.length, 1);
assert.equal(cues[0]!.start, 62);
assert.equal(cues[0]!.end, 63);
});
test('formatCuesAsSrt writes numbered SubRip blocks', () => {
assert.equal(
formatCuesAsSrt(parseTimedCues(VTT)),
'1\n00:00:01,500 --> 00:00:03,250\n<v Geese>しっかし\nまさかお前が\n\n' +
'2\n00:01:02,000 --> 00:01:04,000\nゼニスも きっと幸せじゃろう\n',
);
});
test('needsAlassConversion targets VTT by extension and by content', () => {
assert.equal(needsAlassConversion('/tmp/track-0.vtt', 'WEBVTT\n'), true);
assert.equal(needsAlassConversion('/tmp/track-0.srt', 'WEBVTT\n'), true);
assert.equal(needsAlassConversion('/tmp/track-0.ttml', '<?xml version="1.0"?>'), true);
assert.equal(
needsAlassConversion('/tmp/track-0.srt', '1\n00:00:01,000 --> 00:00:02,000\n'),
false,
);
assert.equal(needsAlassConversion('/tmp/track-0.ass', '[Script Info]\n'), false);
});
test('convertSubtitleForAlass rewrites a VTT track as SRT', () => {
const dir = makeTempDir();
const source = path.join(dir, 'track-0.vtt');
fs.writeFileSync(source, VTT);
const converted = convertSubtitleForAlass(source);
assert.notEqual(converted, null);
assert.equal(converted!.temporary, true);
assert.equal(path.extname(converted!.path), '.srt');
assert.match(fs.readFileSync(converted!.path, 'utf8'), /00:00:01,500 --> 00:00:03,250/);
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(path.dirname(converted!.path), { recursive: true, force: true });
});
test('convertSubtitleForAlass converts a VTT payload hiding behind an .srt name', () => {
const dir = makeTempDir();
const source = path.join(dir, 'remote_track.srt');
fs.writeFileSync(source, VTT);
const converted = convertSubtitleForAlass(source);
assert.notEqual(converted, null);
assert.notEqual(converted!.path, source);
assert.equal(fs.readFileSync(converted!.path, 'utf8').startsWith('1\n'), true);
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(path.dirname(converted!.path), { recursive: true, force: true });
});
test('convertSubtitleForAlass leaves an SRT file alone', () => {
const dir = makeTempDir();
const source = path.join(dir, 'track-0.srt');
fs.writeFileSync(source, '1\n00:00:01,000 --> 00:00:02,000\nhello\n');
assert.equal(convertSubtitleForAlass(source), null);
fs.rmSync(dir, { recursive: true, force: true });
});
test('convertSubtitleForAlass reports a file with no readable timings', () => {
const dir = makeTempDir();
const source = path.join(dir, 'track-0.vtt');
fs.writeFileSync(source, 'WEBVTT\n\nnot a cue at all\n');
assert.throws(() => convertSubtitleForAlass(source), /Could not read subtitle timings/);
fs.rmSync(dir, { recursive: true, force: true });
});
+155
View File
@@ -0,0 +1,155 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { FileExtractionResult } from './subsync-extract';
import { createLogger } from '../../logger';
const logger = createLogger('subsync');
/**
* alass picks its parser purely from the file extension, and WebVTT is not one
* of the formats it knows. A `.vtt` reference is therefore treated as a *video*
* ("no audio stream in file"), and the same file renamed to `.srt` dies in the
* SubRip parser on the `WEBVTT` header. Extension-backed anime streams hand out
* VTT almost exclusively, so every alass input is converted to SRT first.
*/
const ALASS_SUBTITLE_EXTENSIONS = new Set(['srt', 'ass', 'ssa', 'sub', 'idx']);
/** How much of the file is decoded to recognise a WebVTT header. */
const SNIFF_BYTES = 1024;
const CUE_TIMING_PATTERN =
/^\s*(?:(\d{1,3}):)?(\d{1,2}):(\d{2})[.,](\d{1,3})\s*-->\s*(?:(\d{1,3}):)?(\d{1,2}):(\d{2})[.,](\d{1,3})/;
interface RawCue {
start: number;
end: number;
text: string;
}
function toSeconds(
hours: string | undefined,
minutes: string,
seconds: string,
millis: string,
): number {
return (
Number(hours ?? 0) * 3600 +
Number(minutes) * 60 +
Number(seconds) +
Number(millis.padEnd(3, '0')) / 1000
);
}
function formatTimestamp(totalSeconds: number): string {
const clamped = Math.max(0, totalSeconds);
const millis = Math.round(clamped * 1000);
const hours = Math.floor(millis / 3_600_000);
const minutes = Math.floor((millis % 3_600_000) / 60_000);
const seconds = Math.floor((millis % 60_000) / 1000);
const remainder = millis % 1000;
const pad = (value: number, width: number): string => String(value).padStart(width, '0');
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)},${pad(remainder, 3)}`;
}
/**
* Read timed cues out of a WebVTT (or SRT-shaped) file.
*
* The payload is kept verbatim rather than sanitised: this same file is what
* alass retimes and mpv then loads, so stripping inline tags here would show up
* on screen. Cue identifiers, `NOTE`/`STYLE`/`REGION` blocks and the `WEBVTT`
* header all fall out for free — only lines that follow a timing line are kept.
*/
export function parseTimedCues(content: string): RawCue[] {
const lines = content.replace(/^/, '').split(/\r?\n/);
const cues: RawCue[] = [];
for (let index = 0; index < lines.length; index += 1) {
const timing = CUE_TIMING_PATTERN.exec(lines[index]!);
if (!timing) continue;
const start = toSeconds(timing[1], timing[2]!, timing[3]!, timing[4]!);
const end = toSeconds(timing[5], timing[6]!, timing[7]!, timing[8]!);
const textLines: string[] = [];
index += 1;
while (index < lines.length && lines[index]!.trim().length > 0) {
textLines.push(lines[index]!);
index += 1;
}
const text = textLines.join('\n').trim();
if (text.length > 0) {
cues.push({ start, end, text });
}
}
return cues;
}
export function formatCuesAsSrt(cues: RawCue[]): string {
return cues
.map(
(cue, index) =>
`${index + 1}\n${formatTimestamp(cue.start)} --> ${formatTimestamp(cue.end)}\n${cue.text}\n`,
)
.join('\n');
}
function readHead(filePath: string): string {
const handle = fs.openSync(filePath, 'r');
try {
const buffer = Buffer.alloc(SNIFF_BYTES);
const bytesRead = fs.readSync(handle, buffer, 0, SNIFF_BYTES, 0);
return buffer.subarray(0, bytesRead).toString('utf8');
} finally {
fs.closeSync(handle);
}
}
function isWebVttContent(head: string): boolean {
return /^WEBVTT(\s|$)/.test(head.replace(/^/, '').trimStart());
}
/**
* Decide whether alass can read the file as-is.
*
* Content wins over the extension in one direction only: a file alass would
* accept by extension still needs converting when it turns out to hold VTT,
* while an unrecognised extension is always converted. Anything else is passed
* through untouched, which also keeps its original charset intact — alass
* detects encodings that a UTF-8 round trip here would mangle.
*/
export function needsAlassConversion(filePath: string, head: string): boolean {
const extension = path.extname(filePath).slice(1).toLowerCase();
if (!ALASS_SUBTITLE_EXTENSIONS.has(extension)) return true;
return isWebVttContent(head);
}
/**
* Rewrite a subtitle file as SRT for alass, or return null when alass can
* already read it. The result is a temporary file in its own directory, so
* `cleanupTemporaryFile` can remove it (and keep the retimed output beside it).
*/
export function convertSubtitleForAlass(filePath: string): FileExtractionResult | null {
if (!needsAlassConversion(filePath, readHead(filePath))) return null;
const cues = parseTimedCues(fs.readFileSync(filePath, 'utf8'));
if (cues.length === 0) {
throw new Error(`Could not read subtitle timings for alass from ${path.basename(filePath)}`);
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-alass-'));
const outputPath = path.join(tempDir, `${path.parse(filePath).name}.srt`);
try {
fs.writeFileSync(outputPath, formatCuesAsSrt(cues), 'utf8');
} catch (error) {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {}
throw error;
}
logger.info(`Converted ${filePath} to SRT for alass: ${outputPath} (${cues.length} cues)`);
return { path: outputPath, temporary: true };
}
+29
View File
@@ -163,6 +163,35 @@ test('extractSubtitleTrackToFile rejects a missing local external track', async
);
});
test('internal WebVTT extraction uses ffmpeg webvtt muxer with a vtt output file', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-extract-'));
const ffmpegPath = path.join(dir, 'ffmpeg-stub');
const argsPath = path.join(dir, 'args.txt');
fs.writeFileSync(
ffmpegPath,
`#!/bin/sh\nprintf '%s\\n' "$@" > '${argsPath}'\nfor arg in "$@"; do output="$arg"; done\n: > "$output"\n`,
{ mode: 0o755 },
);
try {
const result = await extractSubtitleTrackToFile({
resolveFfmpegPath: () => ffmpegPath,
videoPath: path.join(dir, 'video.mkv'),
track: { id: 2, type: 'sub', codec: 'webvtt', 'ff-index': 3 },
httpHeaders: null,
});
const args = fs.readFileSync(argsPath, 'utf8').trimEnd().split('\n');
const formatIndex = args.indexOf('-f');
assert.equal(args[formatIndex + 1], 'webvtt');
assert.equal(path.extname(result.path), '.vtt');
assert.ok(fs.existsSync(result.path), 'the extracted file was not written');
cleanupTemporaryFile(result);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('cleanupTemporaryFile preserves the retimed output sharing the temp directory', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-extract-'));
const sourcePath = path.join(dir, 'remote_track.srt');
+2 -1
View File
@@ -100,6 +100,7 @@ async function extractInternalTrack(input: SubtitleExtractionInput): Promise<Fil
if (!extension) {
throw new Error(`Unsupported subtitle codec: ${input.track.codec ?? 'unknown'}`);
}
const ffmpegMuxer = extension === 'vtt' ? 'webvtt' : extension;
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-subsync-'));
try {
@@ -123,7 +124,7 @@ async function extractInternalTrack(input: SubtitleExtractionInput): Promise<Fil
'-map',
`0:${ffIndex}`,
'-f',
extension,
ffmpegMuxer,
outputPath,
]);
+79
View File
@@ -528,6 +528,85 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
assert.equal(alassArgs[1], toShellPath(primaryPath));
});
test('runSubsyncManual converts VTT stream tracks to SRT before running alass', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-vtt-'));
const alassLogPath = path.join(tmpDir, 'alass-args.log');
const alassPath = path.join(tmpDir, 'alass.sh');
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
const videoPath = 'https://example.com/stream.m3u8';
const primaryPath = path.join(tmpDir, 'track-1.vtt');
const sourcePath = path.join(tmpDir, 'track-0.vtt');
fs.writeFileSync(primaryPath, 'WEBVTT\n\n00:00:05.000 --> 00:00:06.000\nしっかし\n');
fs.writeFileSync(sourcePath, 'WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nWell then\n');
writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(ffsubsyncPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(
alassPath,
`#!/bin/sh\n: > "${toShellPath(alassLogPath)}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${toShellPath(alassLogPath)}"; done\ncp "$2" "$3"\nexit 0\n`,
);
const sentCommands: Array<Array<string | number>> = [];
const deps = makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: (payload) => {
sentCommands.push(payload.command);
},
requestProperty: async (name: string) => {
if (name === 'path') return videoPath;
if (name === 'sid') return 1;
if (name === 'secondary-sid') return null;
if (name === 'track-list') {
return [
{
id: 1,
type: 'sub',
selected: true,
external: true,
'external-filename': primaryPath,
},
{
id: 2,
type: 'sub',
selected: false,
external: true,
'external-filename': sourcePath,
},
];
}
return null;
},
}),
getResolvedConfig: () => ({
alassPath,
ffsubsyncPath,
ffmpegPath,
}),
});
const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: 2 }, deps);
assert.equal(result.ok, true);
const alassArgs = fs.readFileSync(alassLogPath, 'utf8').trim().split('\n');
assert.equal(alassArgs.length, 3);
for (const arg of alassArgs) {
assert.equal(path.extname(arg), '.srt');
}
// The originals stay put; only the alass copies are rewritten.
assert.equal(fs.existsSync(primaryPath), true);
assert.equal(fs.existsSync(sourcePath), true);
const loadedPath = sentCommands[0]?.[1];
assert.equal(sentCommands[0]?.[0], 'sub_add');
assert.equal(typeof loadedPath, 'string');
assert.match(fs.readFileSync(fromShellPath(String(loadedPath)), 'utf8'), /しっかし/);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('runSubsyncManual keeps internal alass source file alive until sync finishes', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-internal-source-'));
const alassPath = path.join(tmpDir, 'alass.sh');
+28 -5
View File
@@ -21,6 +21,7 @@ import {
extractSubtitleTrackToFile,
FileExtractionResult,
} from './subsync-extract';
import { convertSubtitleForAlass } from './subsync-alass-input';
import { resolveMpvHttpHeaders, ResolvedMpvHttpHeaders } from './mpv-http-headers';
import { isRemoteMediaPath } from '../../jimaku/utils';
import { createLogger } from '../../logger';
@@ -218,23 +219,43 @@ async function subsyncToReference(
track: context.primaryTrack,
httpHeaders,
});
const replacePrimary = resolved.replace !== false && !primaryExtraction.temporary;
const outputPath = buildRetimedPath(primaryExtraction.path, replacePrimary);
// alass reads neither the target nor the reference as WebVTT, so both are
// rewritten as SRT first; ffsubsync parses VTT itself.
let convertedPrimary: FileExtractionResult | null = null;
let convertedReference: FileExtractionResult | null = null;
if (engine === 'alass') {
try {
convertedPrimary = convertSubtitleForAlass(primaryExtraction.path);
convertedReference = convertSubtitleForAlass(referenceFilePath);
} catch (error) {
if (convertedPrimary) cleanupTemporaryFile(convertedPrimary);
cleanupTemporaryFile(primaryExtraction);
const message = `alass synchronization failed: ${(error as Error).message}`;
logger.error(message);
return { ok: false, message };
}
}
const target = convertedPrimary ?? primaryExtraction;
const referencePath = convertedReference?.path ?? referenceFilePath;
const replacePrimary = resolved.replace !== false && !target.temporary;
const outputPath = buildRetimedPath(target.path, replacePrimary);
logger.info(
`Running ${engine}: target=${primaryExtraction.path} reference=${referenceFilePath} output=${outputPath}`,
`Running ${engine}: target=${target.path} reference=${referencePath} output=${outputPath}`,
);
try {
let result: CommandResult;
if (engine === 'alass') {
const alassPath = resolveSubsyncExecutable(resolved.alassPath, 'alass');
result = await runAlassSync(alassPath, referenceFilePath, primaryExtraction.path, outputPath);
result = await runAlassSync(alassPath, referencePath, target.path, outputPath);
} else {
const ffsubsyncPath = resolveSubsyncExecutable(resolved.ffsubsyncPath, 'ffsubsync');
result = await runFfsubsyncSync(
ffsubsyncPath,
context.videoPath,
primaryExtraction.path,
target.path,
outputPath,
context.audioStreamIndex,
);
@@ -256,6 +277,8 @@ async function subsyncToReference(
message: `Subtitle synchronized with ${engine}`,
};
} finally {
if (convertedReference) cleanupTemporaryFile(convertedReference);
if (convertedPrimary) cleanupTemporaryFile(convertedPrimary, outputPath);
cleanupTemporaryFile(primaryExtraction, outputPath);
}
}
+29 -4
View File
@@ -4,6 +4,7 @@ import * as path from 'path';
import * as fs from 'fs';
import * as childProcess from 'child_process';
import { createLogger } from '../logger';
import { splitSeasonFromTitle } from '../anime-bridge/episode-metadata';
import {
JimakuApiResponse,
JimakuConfig,
@@ -163,6 +164,28 @@ function matchEpisodeFromName(name: string): {
};
}
// Spelled-out labels. Streaming sources name episodes this way ("Episode 4"),
// and the abbreviated `E04` pattern below cannot see them.
const worded = name.match(/(?:^|[\s._-])episode\s*[.#]?\s*(\d{1,3})(?:\D|$)/i);
if (worded && worded.index !== undefined) {
return {
season: null,
episode: Number.parseInt(worded[1]!, 10),
index: worded.index,
confidence: 'medium',
};
}
const japanese = name.match(/第\s*(\d{1,3})\s*話/);
if (japanese && japanese.index !== undefined) {
return {
season: null,
episode: Number.parseInt(japanese[1]!, 10),
index: japanese.index,
confidence: 'medium',
};
}
const epOnly = name.match(/(?:^|[\s._-])E(?:P)?(\d{1,3})(?:\b|[\s._-])/i);
if (epOnly && epOnly.index !== undefined) {
return {
@@ -229,12 +252,14 @@ export function parseMediaInfo(mediaPath: string | null): JimakuMediaInfo {
titlePart = name.slice(0, parsed.index);
}
const seasonFromDir = parsed.season ?? detectSeasonFromDir(normalizedMediaPath);
const title = cleanupTitle(titlePart || name);
// A season named in the title ("… Season 3") belongs in the season field, not
// glued to the title — otherwise it is searched for as part of the name.
const titleSeason = splitSeasonFromTitle(cleanupTitle(titlePart || name));
const season = parsed.season ?? titleSeason.season ?? detectSeasonFromDir(normalizedMediaPath);
return {
title,
season: seasonFromDir,
title: titleSeason.title,
season,
episode: parsed.episode,
confidence: parsed.confidence,
filename,
+63 -4
View File
@@ -419,6 +419,7 @@ import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update
import {
guessAnilistMediaInfo,
updateAnilistPostWatchProgress,
type AnilistMediaGuess,
} from './core/services/anilist/anilist-updater';
import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher';
import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
@@ -483,6 +484,12 @@ import {
getJlptDictionarySearchPaths,
} from './main/jlpt-runtime';
import { createMediaRuntimeService } from './main/media-runtime';
import {
createStreamPlaybackMetadataStore,
matchRequestedStreamPlaybackMetadata,
toAnilistMediaGuess,
toJimakuMediaInfo,
} from './main/runtime/stream-playback-metadata';
import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime';
import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime';
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
@@ -2385,6 +2392,35 @@ const createFieldGroupingCallback = fieldGroupingOverlayRuntime.createFieldGroup
const SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, 'subtitle-positions');
const JELLYFIN_SUBTITLE_DELAYS_PATH = path.join(CONFIG_DIR, 'jellyfin-subtitle-delays.json');
/**
* What the anime browser resolved for the stream that is playing. Consulted by
* everything that would otherwise have to parse the mpv title, or — worse — the
* stream URL, which names only the proxy and the container format.
*/
const streamPlaybackMetadata = createStreamPlaybackMetadataStore();
/** Stream metadata for the requested path, or current media when none was supplied. */
function getActiveStreamMetadata(mediaPath: string | null = null) {
return matchRequestedStreamPlaybackMetadata(
streamPlaybackMetadata,
mediaPath,
appState.currentMediaPath,
);
}
/**
* The AniList guess for what is playing. A stream answers from the fields its
* source reported; everything else goes through the usual title parsers.
*/
async function guessAnilistMediaInfoForCurrentMedia(
mediaPath: string | null,
mediaTitle: string | null,
): Promise<AnilistMediaGuess | null> {
const stream = getActiveStreamMetadata(mediaPath);
const streamGuess = stream ? toAnilistMediaGuess(stream) : null;
return streamGuess ?? guessAnilistMediaInfo(mediaPath, mediaTitle);
}
const mediaRuntime = createMediaRuntimeService(
createBuildMediaRuntimeMainDepsHandler({
isRemoteMediaPath: (mediaPath) => isRemoteMediaPath(mediaPath),
@@ -2417,7 +2453,8 @@ const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
getCurrentVideoPath: () => appState.mpvClient?.currentVideoPath,
getCurrentMediaTitle: () => appState.currentMediaTitle,
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
guessAnilistMediaInfoForCurrentMedia(mediaPath, mediaTitle),
getNameMatchImagesEnabled: () => configService.getConfig().subtitleStyle.nameMatchImagesEnabled,
getCollapsibleSectionOpenState: (section) =>
configService.getConfig().anilist.characterDictionary.collapsibleSections[section],
@@ -3252,6 +3289,22 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({
},
showMpvOsd: (text) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(text, { title: 'Anime' }),
onPlaybackMetadata: (metadata) => {
streamPlaybackMetadata.set(metadata);
// Set before mpv reports the path change, so the session that change starts
// is titled and grouped from the source's own listing rather than from the
// proxy URL, whose only readable part is the `.m3u8` extension.
mediaRuntime.updateCurrentMediaTitle(metadata.displayTitle);
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordStreamPlaybackMetadata({
mediaPath: metadata.mediaPath,
statsPath: metadata.statsPath,
displayTitle: metadata.displayTitle,
seriesTitle: metadata.seriesTitle,
seasonNumber: metadata.seasonNumber,
episodeNumber: metadata.episodeNumber,
});
},
onBridgeState: (state) => {
const window = appState.animeBrowserWindow;
if (window && !window.isDestroyed()) {
@@ -3708,7 +3761,8 @@ const {
mediaRuntime.resolveMediaPathForJimaku(currentMediaPath),
getCurrentMediaPath: () => appState.currentMediaPath,
getCurrentMediaTitle: () => appState.currentMediaTitle,
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
guessAnilistMediaInfoForCurrentMedia(mediaPath, mediaTitle),
},
processNextRetryUpdateMainDeps: {
nextReady: () => anilistUpdateQueue.nextReady(),
@@ -5860,8 +5914,13 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getFieldGroupingResolver: () => getFieldGroupingResolverHandler(),
setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) =>
setFieldGroupingResolverHandler(resolver),
parseMediaInfo: (mediaPath: string | null) =>
parseMediaInfo(mediaRuntime.resolveMediaPathForJimaku(mediaPath)),
parseMediaInfo: (mediaPath: string | null) => {
// A stream already knows its series, season and episode; parsing the
// title back out of a string would only lose what the source told us.
const stream = getActiveStreamMetadata();
if (stream) return toJimakuMediaInfo(stream);
return parseMediaInfo(mediaRuntime.resolveMediaPathForJimaku(mediaPath));
},
getCurrentMediaPath: () => appState.currentMediaPath,
jimakuFetchJson: <T>(
endpoint: string,
@@ -0,0 +1,87 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { AnimeBridgeClient, BridgeSource } from '../../anime-bridge/bridge-client';
import type { AnimeBrowserPlayRequest } from '../../types/anime-browser';
import { createAnimeBrowserPlayback } from './anime-browser-playback';
const request: AnimeBrowserPlayRequest = {
sourceId: 'source',
animeUrl: '/anime',
animeTitle: 'Anime',
episodeUrl: '/episode-1',
episodeName: 'Episode 1',
episodeNumber: 1,
};
test('playback returns the established error when an extension has no playable stream', async () => {
let mpvConnections = 0;
const client = { getVideoList: async () => [] } as unknown as AnimeBridgeClient;
const playback = createAnimeBrowserPlayback({
deps: {
sendMpvCommand: () => undefined,
ensureMpvConnected: async () => {
mpvConnections += 1;
return true;
},
log: () => undefined,
},
bridge: async () => ({ client, baseUrl: 'http://127.0.0.1:1234' }),
sourceFor: async () => ({}) as BridgeSource,
stripProxy: () => null,
});
assert.deepEqual(await playback.playEpisode(request), {
ok: false,
error: 'That source returned no playable video.',
quality: null,
});
assert.equal(mpvConnections, 0);
await playback.dispose();
});
test('a superseded episode stops instead of driving mpv behind the newer one', async () => {
let releaseFirst = (): void => undefined;
const firstResolved = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
let calls = 0;
const client = {
async getVideoList() {
calls += 1;
if (calls === 1) await firstResolved;
return [{ videoUrl: `http://stream/${calls}.m3u8`, quality: '1080p' }];
},
} as unknown as AnimeBridgeClient;
const commands: Array<Array<string | number>> = [];
let overlays = 0;
const playback = createAnimeBrowserPlayback({
deps: {
sendMpvCommand: (command) => void commands.push(command),
ensureMpvConnected: async () => true,
log: () => undefined,
showVisibleOverlay: () => {
overlays += 1;
},
},
bridge: async () => ({ client, baseUrl: 'http://127.0.0.1:1234' }),
sourceFor: async () => ({}) as BridgeSource,
stripProxy: () => null,
});
const stale = playback.playEpisode(request);
const fresh = await playback.playEpisode({ ...request, episodeUrl: '/episode-2' });
const commandsAfterFresh = commands.length;
releaseFirst();
assert.equal(fresh.ok, true);
assert.equal(overlays, 1);
assert.deepEqual(await stale, {
ok: false,
error: 'A newer episode replaced this playback.',
quality: null,
});
assert.equal(commands.length, commandsAfterFresh);
assert.equal(overlays, 1);
await playback.dispose();
});
+200
View File
@@ -0,0 +1,200 @@
import type { AnimeBridgeClient, BridgeSource } from '../../anime-bridge/bridge-client';
import { buildAnimeStreamMetadata } from '../../anime-bridge/episode-metadata';
import { resolveStream } from '../../anime-bridge/headers';
import { resolveBridgeMediaUrl, routeHlsThroughProxy } from '../../anime-bridge/media-url';
import {
buildPlaybackCommands,
buildTrackCommands,
selectPreferredStream,
} from '../../anime-bridge/mpv-playback';
import { watchPlaybackOutcome } from '../../anime-bridge/playback-outcome';
import { cacheSubtitleTracks, removeSubtitleCache } from '../../anime-bridge/subtitle-cache';
import type { StreamStripProxyHandle } from '../../anime-bridge/stream-strip-proxy';
import type { AnimeBrowserPlayRequest, AnimeBrowserPlayResult } from '../../types/anime-browser';
import type { AnimeBrowserPlaybackDeps } from './anime-browser-runtime-deps';
const TRACK_ATTACH_DELAY_MS = 300;
interface AnimeBrowserPlaybackOptions {
deps: AnimeBrowserPlaybackDeps;
bridge: () => Promise<{ client: AnimeBridgeClient; baseUrl: string }>;
sourceFor: (sourceId: string) => Promise<BridgeSource>;
stripProxy: () => StreamStripProxyHandle | null;
}
export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions) {
const { deps, bridge, sourceFor, stripProxy } = options;
const wait =
deps.wait ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
let subtitleCacheDir: string | null = null;
// Overlapping playEpisode calls share mpv and subtitleCacheDir, so each call
// carries a generation and only acts while it is still the newest one.
let playbackGeneration = 0;
async function clearSubtitleCache(generation: number): Promise<void> {
// A stale call must not delete the directory a newer playback now owns.
if (generation !== playbackGeneration) return;
const previousDir = subtitleCacheDir;
subtitleCacheDir = null;
await removeSubtitleCache(previousDir, deps.subtitleCacheIo);
}
async function cacheStreamSubtitles(
stream: {
headers: Record<string, string>;
subtitles: Array<{ url: string; lang: string }>;
},
generation: number,
): Promise<Array<{ url: string; lang: string }>> {
const cached = await cacheSubtitleTracks({
tracks: stream.subtitles,
headers: stream.headers,
io: deps.subtitleCacheIo,
log: deps.log,
});
if (generation === playbackGeneration) {
subtitleCacheDir = cached.dir;
} else {
await removeSubtitleCache(cached.dir, deps.subtitleCacheIo);
}
const localCount = cached.tracks.filter((track) => track.local).length;
if (cached.tracks.length > 0) {
deps.log(
`[anime-browser] cached ${localCount}/${cached.tracks.length} subtitle track(s) to disk` +
(cached.dir ? ` in ${cached.dir}` : ''),
);
}
return cached.tracks.map((track) => ({ url: track.url, lang: track.lang }));
}
async function playEpisode(request: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> {
const generation = ++playbackGeneration;
const isCurrent = (): boolean => generation === playbackGeneration;
try {
const { client, baseUrl } = await bridge();
const videos = await client.getVideoList(
await sourceFor(request.sourceId),
request.episodeUrl,
);
const streams = videos
.map((video) => resolveStream(video))
.filter((stream): stream is NonNullable<typeof stream> => stream !== null)
.map((stream) => ({
...stream,
url: resolveBridgeMediaUrl(baseUrl, stream.url),
audios: stream.audios.map((track) => ({
...track,
url: resolveBridgeMediaUrl(baseUrl, track.url),
})),
subtitles: stream.subtitles.map((track) => ({
...track,
url: resolveBridgeMediaUrl(baseUrl, track.url),
})),
}));
const selected = selectPreferredStream(streams, deps.preferredQuality?.());
if (!selected) {
return { ok: false, error: 'That source returned no playable video.', quality: null };
}
const proxy = stripProxy();
const stream = proxy
? { ...selected, url: routeHlsThroughProxy(selected.url, baseUrl, proxy.origin) }
: selected;
if (!(await deps.ensureMpvConnected())) {
return { ok: false, error: 'mpv is not running and could not be started.', quality: null };
}
// Resolving the stream can outlast a newer click; from here on every step
// touches mpv or the overlay, so a superseded call stops instead.
if (!isCurrent()) return superseded();
const watch =
deps.onPlaybackEndFile && deps.readMpvProperty
? watchPlaybackOutcome({
onEndFile: deps.onPlaybackEndFile,
readProperty: deps.readMpvProperty,
wait,
})
: null;
try {
const metadata = buildAnimeStreamMetadata({
sourceId: request.sourceId,
animeUrl: request.animeUrl,
animeTitle: request.animeTitle,
episodeUrl: request.episodeUrl,
episodeName: request.episodeName,
episodeNumber: request.episodeNumber ?? null,
mediaPath: stream.url,
});
const title = metadata.displayTitle;
deps.onPlaybackMetadata?.(metadata);
for (const command of buildPlaybackCommands({ stream, title })) {
deps.sendMpvCommand(command);
}
// The file is already loading; a subtitle cache or track attach failure
// costs extra tracks, not the episode, so it must not fail playback.
try {
await clearSubtitleCache(generation);
if (stream.audios.length > 0 || stream.subtitles.length > 0) {
deps.log(
`[anime-browser] ${stream.audios.length} external audio, ` +
`${stream.subtitles.length} external subtitle track(s)`,
);
const [subtitles] = await Promise.all([
cacheStreamSubtitles(stream, generation),
wait(TRACK_ATTACH_DELAY_MS),
]);
if (!isCurrent()) return superseded();
for (const command of buildTrackCommands({ ...stream, subtitles })) {
deps.sendMpvCommand(command);
}
}
} catch (error) {
deps.log(`[anime-browser] external track setup failed: ${String(error)}`);
}
if (!isCurrent()) return superseded();
if (watch) {
const outcome = await watch.wait();
// The outcome belongs to whichever file mpv is playing now, so a
// superseded call must not read it as its own.
if (!isCurrent()) return superseded();
if (!outcome.ok) {
deps.log(`[anime-browser] playback failed to start: ${outcome.error}`);
return { ok: false, error: outcome.error, quality: null };
}
}
deps.showVisibleOverlay?.();
deps.showMpvOsd?.(title);
return { ok: true, error: null, quality: stream.quality || null };
} finally {
watch?.dispose();
}
} catch (error) {
deps.log(`[anime-browser] playback failed: ${String(error)}`);
return { ok: false, error: describeError(error), quality: null };
}
}
async function dispose(): Promise<void> {
const cacheDir = subtitleCacheDir;
subtitleCacheDir = null;
await removeSubtitleCache(cacheDir, deps.subtitleCacheIo);
}
return { playEpisode, dispose };
}
function superseded(): AnimeBrowserPlayResult {
return { ok: false, error: 'A newer episode replaced this playback.', quality: null };
}
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -0,0 +1,60 @@
import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata';
import type { PlaybackEndFileEvent } from '../../anime-bridge/playback-outcome';
import type { SubtitleCacheIo } from '../../anime-bridge/subtitle-cache';
import type { BundleBinaries } from '../../anime-bridge/sidecar-bundle';
import { startSidecar } from '../../anime-bridge/sidecar-process';
import { startStreamStripProxy } from '../../anime-bridge/stream-strip-proxy';
import type { AnimeBrowserBridgeState, AnimeBrowserSearchUpdate } from '../../types/anime-browser';
import type { InstallProgress } from './anime-bridge-installer';
export interface AnimeBrowserRuntimeDeps {
/** Where user-supplied Aniyomi extension APKs live. Read lazily so config edits apply. */
extensionsDir: () => string;
/** Configured repository index URLs. Empty unless the user added one. */
repos: () => string[];
/** Persists the repository list. Config stays the source of truth. */
setRepos: (repos: string[]) => void;
/** JSON file holding each source's saved preference values. */
preferencesFile: string;
ensureBinaries: (onProgress: (progress: InstallProgress) => void) => Promise<BundleBinaries>;
/** Sends mpv an IPC command; same transport the Jellyfin path uses. */
sendMpvCommand: (command: Array<string | number>) => void;
/** Brings mpv up if it is not already connected. Resolves false on failure. */
ensureMpvConnected: () => Promise<boolean>;
/** Subscribe to mpv end-file events so playback startup can be confirmed. */
onPlaybackEndFile?: (listener: (event: PlaybackEndFileEvent) => void) => () => void;
/** One-shot mpv property read; rejects while the property is unavailable. */
readMpvProperty?: (name: string) => Promise<unknown>;
showMpvOsd?: (message: string) => void;
showVisibleOverlay?: () => void;
/** Publishes stream identity before loadfile starts the stats session. */
onPlaybackMetadata?: (metadata: AnimeStreamMetadata) => void;
/** Lets tests drive the pause between loadfile and track attachment. */
wait?: (ms: number) => Promise<void>;
/** Overrides the filesystem/network the subtitle cache uses. Tests only. */
subtitleCacheIo?: SubtitleCacheIo;
onBridgeState: (state: AnimeBrowserBridgeState) => void;
/** Streams per-source progress while a search invoke is pending. */
onSearchUpdate?: (update: AnimeBrowserSearchUpdate) => void;
preferredQuality?: () => string | undefined;
log: (message: string) => void;
/** Overrides process startup in focused runtime tests. */
startSidecar?: typeof startSidecar;
/** Overrides proxy startup in focused runtime tests. */
startStreamStripProxy?: typeof startStreamStripProxy;
}
export type AnimeBrowserPlaybackDeps = Pick<
AnimeBrowserRuntimeDeps,
| 'sendMpvCommand'
| 'ensureMpvConnected'
| 'onPlaybackEndFile'
| 'readMpvProperty'
| 'showMpvOsd'
| 'showVisibleOverlay'
| 'onPlaybackMetadata'
| 'wait'
| 'subtitleCacheIo'
| 'preferredQuality'
| 'log'
>;
@@ -0,0 +1,138 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type { BridgePreference } from '../../anime-bridge/types';
import { createAnimeBrowserRuntime } from './anime-browser-runtime';
import type { AnimeBridgeClient } from '../../anime-bridge/bridge-client';
function textPreference(key: string, value = ''): BridgePreference {
return { key, editTextPreference: { title: key, value, text: value } };
}
async function setupRuntime(
client: Record<string, unknown>,
packages: Record<string, string>,
storedPreferences?: Record<string, BridgePreference[]>,
) {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-anime-runtime-'));
for (const [pkg, contents] of Object.entries(packages)) {
await writeFile(path.join(dir, `${pkg}.apk`), contents);
}
const preferencesFile = path.join(dir, 'preferences.json');
if (storedPreferences) await writeFile(preferencesFile, JSON.stringify(storedPreferences));
const runtime = createAnimeBrowserRuntime({
extensionsDir: () => dir,
repos: () => [],
setRepos: () => undefined,
preferencesFile,
ensureBinaries: async () => ({}) as never,
sendMpvCommand: () => undefined,
ensureMpvConnected: async () => true,
onBridgeState: () => undefined,
log: () => undefined,
startSidecar: async () => ({
client: client as unknown as AnimeBridgeClient,
baseUrl: 'http://127.0.0.1:12345',
port: 12345,
stop: async () => undefined,
onExit: () => undefined,
}),
startStreamStripProxy: async () => ({
origin: 'http://127.0.0.1:12346',
port: 12346,
close: async () => undefined,
}),
});
await runtime.ensureBridge();
return { runtime, preferencesFile };
}
test('setPreference overlays saved values onto the extension current schema', async () => {
const saved = textPreference('address', 'saved-address');
const current = [textPreference('address'), textPreference('new-setting')];
let received: BridgePreference[] = [];
let schemaCalls = 0;
const pkgBytes = 'one';
const client = {
listAnimeSources: async () => [{ id: 'shared', name: 'One', lang: 'en' }],
getSourcePreferences: async () => {
schemaCalls += 1;
return current;
},
setSourcePreference: async (source: { preferences?: BridgePreference[] }) => {
received = source.preferences ?? [];
return received;
},
};
const { runtime } = await setupRuntime(
client,
{ 'pkg.one': pkgBytes },
{ 'pkg.one:shared': [saved] },
);
assert.equal(runtime.getSnapshot().sources[0]?.id, 'pkg.one:shared');
await runtime.setPreference('pkg.one:shared', 'new-setting', 'chosen');
assert.equal(schemaCalls, 1);
const address = received.find((entry) => entry.key === 'address')?.editTextPreference as
| Record<string, unknown>
| undefined;
const added = received.find((entry) => entry.key === 'new-setting')?.editTextPreference as
| Record<string, unknown>
| undefined;
assert.equal(address?.value, 'saved-address');
assert.equal(added?.value, 'chosen');
await runtime.dispose();
});
test('colliding bridge ids keep package preferences isolated and uninstall clears only its package', async () => {
const seen: Array<{ sourceId?: string; preferences?: BridgePreference[]; fingerprint: string }> =
[];
const client = {
listAnimeSources: async () => [{ id: 'shared', name: 'Source', lang: 'en' }],
getSourcePreferences: async (source: {
sourceId?: string;
preferences?: BridgePreference[];
fingerprint: string;
}) => {
seen.push(source);
return [textPreference('password')];
},
};
const one = textPreference('password', 'one-secret');
const two = textPreference('password', 'two-secret');
const { runtime, preferencesFile } = await setupRuntime(
client,
{ 'pkg.one': 'one', 'pkg.two': 'two' },
{ 'pkg.one:shared': [one], 'pkg.two:shared': [two] },
);
const sourceIds = runtime.getSnapshot().sources.map((source) => source.id);
assert.deepEqual(sourceIds, ['pkg.one:shared', 'pkg.two:shared']);
const oneView = await runtime.getPreferences('pkg.one:shared');
const twoView = await runtime.getPreferences('pkg.two:shared');
assert.equal(oneView[0]?.value, 'one-secret');
assert.equal(twoView[0]?.value, 'two-secret');
assert.deepEqual(
seen.map((source) => source.sourceId),
['shared', 'shared'],
);
assert.notEqual(seen[0]?.fingerprint, seen[1]?.fingerprint);
assert.equal(
(seen[0]?.preferences?.[0]?.editTextPreference as Record<string, unknown>)?.value,
'one-secret',
);
assert.equal(
(seen[1]?.preferences?.[0]?.editTextPreference as Record<string, unknown>)?.value,
'two-secret',
);
await runtime.removeExtension('pkg.one');
const persisted = JSON.parse(await readFile(preferencesFile, 'utf8')) as Record<string, unknown>;
assert.equal(persisted['pkg.one:shared'], undefined);
assert.deepEqual(persisted['pkg.two:shared'], [two]);
await runtime.dispose();
});
+40 -226
View File
@@ -1,23 +1,9 @@
import { AnimeBridgeClient } from '../../anime-bridge/bridge-client';
import { resolveStream } from '../../anime-bridge/headers';
import {
parseAnimeStatus,
resolveBridgeMediaUrl,
routeHlsThroughProxy,
} from '../../anime-bridge/media-url';
import {
watchPlaybackOutcome,
type PlaybackEndFileEvent,
} from '../../anime-bridge/playback-outcome';
import { parseAnimeStatus, resolveBridgeMediaUrl } from '../../anime-bridge/media-url';
import {
startStreamStripProxy,
type StreamStripProxyHandle,
} from '../../anime-bridge/stream-strip-proxy';
import {
buildPlaybackCommands,
buildTrackCommands,
selectPreferredStream,
} from '../../anime-bridge/mpv-playback';
import {
listExtensionSources,
readInstalledExtensions,
@@ -26,11 +12,6 @@ import {
type ExtensionSource,
type InstalledExtension,
} from '../../anime-bridge/extension-store';
import {
cacheSubtitleTracks,
removeSubtitleCache,
type SubtitleCacheIo,
} from '../../anime-bridge/subtitle-cache';
import { interleave, mapSourcesConcurrently } from '../../anime-bridge/multi-source-search';
import { startSidecar, type SidecarHandle } from '../../anime-bridge/sidecar-process';
import {
@@ -45,67 +26,25 @@ import {
import { PreferenceStore } from '../../anime-bridge/preference-store';
import { applyPreferenceValue, parsePreferences } from '../../anime-bridge/preferences';
import type { SourcePreferenceView } from '../../anime-bridge/preferences';
import type { BundleBinaries } from '../../anime-bridge/sidecar-bundle';
import type { InstallProgress } from './anime-bridge-installer';
import { ALL_SOURCES_ID } from '../../types/anime-browser';
import type {
AnimeBrowserBridgeState,
AnimeBrowserDetails,
AnimeBrowserEntry,
AnimeBrowserEpisode,
AnimeBrowserPlayRequest,
AnimeBrowserPlayResult,
AnimeBrowserSearchResult,
AnimeBrowserSearchUpdate,
AnimeBrowserSnapshot,
AvailableExtensionsResult,
ExtensionLoadFailure,
} from '../../types/anime-browser';
import type { BridgeAnimePage } from '../../anime-bridge/types';
export interface AnimeBrowserRuntimeDeps {
/** Where user-supplied Aniyomi extension APKs live. Read lazily so config edits apply. */
extensionsDir: () => string;
/** Configured repository index URLs. Empty unless the user added one. */
repos: () => string[];
/** Persists the repository list. Config stays the source of truth. */
setRepos: (repos: string[]) => void;
/** JSON file holding each source's saved preference values. */
preferencesFile: string;
ensureBinaries: (onProgress: (progress: InstallProgress) => void) => Promise<BundleBinaries>;
/** Sends mpv an IPC command; same transport the Jellyfin path uses. */
sendMpvCommand: (command: Array<string | number>) => void;
/** Brings mpv up if it is not already connected. Resolves false on failure. */
ensureMpvConnected: () => Promise<boolean>;
/**
* Subscribe to mpv end-file events; returns the unsubscribe. Together with
* readMpvProperty this lets playEpisode confirm playback really started
* instead of reporting success the moment the commands were written.
*/
onPlaybackEndFile?: (listener: (event: PlaybackEndFileEvent) => void) => () => void;
/** One-shot mpv property read; rejects while the property is unavailable. */
readMpvProperty?: (name: string) => Promise<unknown>;
showMpvOsd?: (message: string) => void;
showVisibleOverlay?: () => void;
/** Lets tests drive the pause between `loadfile` and the track commands. */
wait?: (ms: number) => Promise<void>;
/** Overrides the filesystem/network the subtitle cache uses. Tests only. */
subtitleCacheIo?: SubtitleCacheIo;
onBridgeState: (state: AnimeBrowserBridgeState) => void;
/**
* Streams per-source progress while a search invoke is pending. Optional so
* a host that has no window to push to can leave it out.
*/
onSearchUpdate?: (update: AnimeBrowserSearchUpdate) => void;
preferredQuality?: () => string | undefined;
log: (message: string) => void;
}
import type { BridgeAnimePage, BridgePreference } from '../../anime-bridge/types';
import { createAnimeBrowserPlayback } from './anime-browser-playback';
import type { AnimeBrowserRuntimeDeps } from './anime-browser-runtime-deps';
export type { AnimeBrowserRuntimeDeps } from './anime-browser-runtime-deps';
const IDLE_STATE: AnimeBrowserBridgeState = { stage: 'idle', progress: null, message: null };
/** How long to let `loadfile` settle before adding external tracks. */
const TRACK_ATTACH_DELAY_MS = 300;
export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
let bridgeState: AnimeBrowserBridgeState = IDLE_STATE;
let sidecar: SidecarHandle | null = null;
@@ -117,53 +56,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
let loadFailures: ExtensionLoadFailure[] = [];
// Monotonic; identifies the newest browse so stale ones stop emitting.
let searchToken = 0;
// Temp directory holding the playing episode's downloaded subtitles. Kept
// until the next episode replaces it, because alass reads it mid-playback.
let subtitleCacheDir: string | null = null;
const preferenceStore = new PreferenceStore(deps.preferencesFile);
const wait =
deps.wait ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
/**
* Drop the previous episode's downloaded subtitles.
*
* Deliberately not done at end-file: alass reads those files for as long as
* the episode is up, so they only go once another one replaces them.
*/
async function clearSubtitleCache(): Promise<void> {
const previousDir = subtitleCacheDir;
subtitleCacheDir = null;
await removeSubtitleCache(previousDir, deps.subtitleCacheIo);
}
/**
* Download the stream's subtitle tracks so mpv loads files instead of URLs.
*
* alass needs the reference track on disk, and the subsync picker rejects an
* external track whose `external-filename` is not a real file, so a streamed
* track cannot be used to retime anything.
*/
async function cacheStreamSubtitles(stream: {
headers: Record<string, string>;
subtitles: Array<{ url: string; lang: string }>;
}): Promise<Array<{ url: string; lang: string }>> {
const cached = await cacheSubtitleTracks({
tracks: stream.subtitles,
headers: stream.headers,
io: deps.subtitleCacheIo,
log: deps.log,
});
subtitleCacheDir = cached.dir;
const localCount = cached.tracks.filter((track) => track.local).length;
if (cached.tracks.length > 0) {
deps.log(
`[anime-browser] cached ${localCount}/${cached.tracks.length} subtitle track(s) to disk` +
(cached.dir ? ` in ${cached.dir}` : ''),
);
}
return cached.tracks.map((track) => ({ url: track.url, lang: track.lang }));
}
function setState(state: AnimeBrowserBridgeState): void {
bridgeState = state;
@@ -176,8 +69,19 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
const extension = extensions.find((candidate) => candidate.file === source.file);
if (!extension) throw new Error(`Extension file missing for ${source.name}.`);
// Saved values ride along on every call; the extension is stateless per request.
const saved = await preferenceStore.get(source.id);
return { ...toBridgeSource(extension, source.id), preferences: saved };
const saved = await preferenceStore.get(source.pkg, source.bridgeId);
return { ...toBridgeSource(extension, source.bridgeId), preferences: saved };
}
function overlaySavedPreferences(
schema: BridgePreference[],
saved: BridgePreference[],
): BridgePreference[] {
let merged = schema;
for (const preference of parsePreferences(saved)) {
merged = applyPreferenceValue(merged, preference.key, preference.value);
}
return merged;
}
/**
@@ -200,7 +104,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
);
setState({ stage: 'starting', progress: null, message: null });
const handle = await startSidecar({
const handle = await (deps.startSidecar ?? startSidecar)({
binaries,
onLog: (line) => deps.log(`[anime-bridge] ${line}`),
});
@@ -228,7 +132,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
});
try {
const proxy = await startStreamStripProxy({
const proxy = await (deps.startStreamStripProxy ?? startStreamStripProxy)({
upstreamOrigin: () => sidecar?.baseUrl ?? handle.baseUrl,
log: deps.log,
});
@@ -386,6 +290,13 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
};
}
const playback = createAnimeBrowserPlayback({
deps,
bridge,
sourceFor,
stripProxy: () => stripProxy,
});
return {
getSnapshot(): AnimeBrowserSnapshot {
return {
@@ -490,8 +401,9 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
async getPreferences(sourceId: string): Promise<SourcePreferenceView[]> {
const { client } = await bridge();
const source = requireSource(sourceId);
const schema = await client.getSourcePreferences(await sourceFor(source.id));
return parsePreferences(schema);
const bridgeSource = await sourceFor(source.id);
const schema = await client.getSourcePreferences(bridgeSource);
return parsePreferences(overlaySavedPreferences(schema, bridgeSource.preferences ?? []));
},
/**
@@ -504,20 +416,21 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
value: string | string[] | boolean,
): Promise<SourcePreferenceView[]> {
const { client } = await bridge();
const source = await sourceFor(sourceId);
const extensionSource = requireSource(sourceId);
const source = await sourceFor(extensionSource.id);
// Start from the extension's own schema so saved values never go stale
// against an updated extension.
const current =
source.preferences && source.preferences.length > 0
? source.preferences
: await client.getSourcePreferences(source);
const schema = await client.getSourcePreferences(source);
const current = overlaySavedPreferences(schema, source.preferences ?? []);
const updated = applyPreferenceValue(current, key, value);
await preferenceStore.set(sourceId, updated);
await preferenceStore.set(extensionSource.pkg, extensionSource.bridgeId, updated);
const refreshed = await client.setSourcePreference({ ...source, preferences: updated }, key);
if (refreshed.length > 0) await preferenceStore.set(sourceId, refreshed);
if (refreshed.length > 0) {
await preferenceStore.set(extensionSource.pkg, extensionSource.bridgeId, refreshed);
}
return parsePreferences(refreshed.length > 0 ? refreshed : updated);
},
@@ -562,115 +475,16 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
}));
},
async playEpisode(request: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> {
try {
const { client, baseUrl } = await bridge();
const videos = await client.getVideoList(
await sourceFor(request.sourceId),
request.episodeUrl,
);
const streams = videos
.map((video) => resolveStream(video))
.filter((stream): stream is NonNullable<typeof stream> => stream !== null)
// External tracks come off the same loopback proxy as the video, so
// they need the same rebase onto the port the bridge really uses.
.map((stream) => ({
...stream,
url: resolveBridgeMediaUrl(baseUrl, stream.url),
audios: stream.audios.map((track) => ({
...track,
url: resolveBridgeMediaUrl(baseUrl, track.url),
})),
subtitles: stream.subtitles.map((track) => ({
...track,
url: resolveBridgeMediaUrl(baseUrl, track.url),
})),
}));
const selected = selectPreferredStream(streams, deps.preferredQuality?.());
if (!selected) {
return { ok: false, error: 'That source returned no playable video.', quality: null };
}
// HLS goes through the local strip proxy, which undoes fake-image
// segment disguises mpv cannot decode around.
const stream = stripProxy
? { ...selected, url: routeHlsThroughProxy(selected.url, baseUrl, stripProxy.origin) }
: selected;
if (!(await deps.ensureMpvConnected())) {
return {
ok: false,
error: 'mpv is not running and could not be started.',
quality: null,
};
}
// Subscribed before loadfile so a fast failure cannot slip past it.
const watch =
deps.onPlaybackEndFile && deps.readMpvProperty
? watchPlaybackOutcome({
onEndFile: deps.onPlaybackEndFile,
readProperty: deps.readMpvProperty,
wait,
})
: null;
try {
const title = `${request.animeTitle} — ${request.episodeName}`;
for (const command of buildPlaybackCommands({ stream, title })) {
deps.sendMpvCommand(command);
}
// The old episode's subtitles are dead the moment this one loads,
// whether or not the new one brings any of its own.
await clearSubtitleCache();
if (stream.audios.length > 0 || stream.subtitles.length > 0) {
deps.log(
`[anime-browser] ${stream.audios.length} external audio, ` +
`${stream.subtitles.length} external subtitle track(s)`,
);
// mpv attaches added tracks to the file that is loading, so give the
// loadfile a moment to take effect first. Same pause the Jellyfin
// subtitle preload uses. The download runs inside that pause.
const [subtitles] = await Promise.all([
cacheStreamSubtitles(stream),
wait(TRACK_ATTACH_DELAY_MS),
]);
for (const command of buildTrackCommands({ ...stream, subtitles })) {
deps.sendMpvCommand(command);
}
}
if (watch) {
const outcome = await watch.wait();
if (!outcome.ok) {
deps.log(`[anime-browser] playback failed to start: ${outcome.error}`);
return { ok: false, error: outcome.error, quality: null };
}
}
deps.showVisibleOverlay?.();
deps.showMpvOsd?.(title);
return { ok: true, error: null, quality: stream.quality || null };
} finally {
watch?.dispose();
}
} catch (error) {
deps.log(`[anime-browser] playback failed: ${String(error)}`);
return { ok: false, error: describeError(error), quality: null };
}
},
playEpisode: playback.playEpisode,
async dispose(): Promise<void> {
const handle = sidecar;
const proxy = stripProxy;
const cacheDir = subtitleCacheDir;
sidecar = null;
stripProxy = null;
starting = null;
subtitleCacheDir = null;
setState(IDLE_STATE);
await removeSubtitleCache(cacheDir, deps.subtitleCacheIo);
await playback.dispose();
await proxy?.close();
await handle?.stop();
},
@@ -0,0 +1,96 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createStreamPlaybackMetadataStore,
matchRequestedStreamPlaybackMetadata,
toAnilistMediaGuess,
toJimakuMediaInfo,
} from './stream-playback-metadata';
import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata';
function metadata(overrides: Partial<AnimeStreamMetadata> = {}): AnimeStreamMetadata {
return {
mediaPath: 'http://127.0.0.1:41234/video/abc.m3u8',
statsPath: 'animebrowser://9001/%2Fanime%2Fmushoku/%2Fwatch%2Fep-4',
seriesTitle: 'Mushoku Tensei: Jobless Reincarnation',
seasonNumber: 3,
episodeNumber: 4,
episodeTitle: null,
displayTitle: 'Mushoku Tensei: Jobless Reincarnation S03E04',
...overrides,
};
}
test('the store answers for the stream URL and for the stats path', () => {
const store = createStreamPlaybackMetadataStore();
const current = metadata();
store.set(current);
assert.equal(store.match(current.mediaPath), current);
assert.equal(store.match(current.statsPath), current);
assert.equal(store.match(` ${current.mediaPath} `), current);
});
test('the store stops answering once the player moves on', () => {
const store = createStreamPlaybackMetadataStore();
store.set(metadata());
assert.equal(store.match('/home/user/Videos/local.mkv'), null);
assert.equal(store.match(null), null);
assert.equal(store.match(''), null);
store.clear();
assert.equal(store.match(metadata().mediaPath), null);
});
test('an explicit target path does not inherit the current stream metadata', () => {
const store = createStreamPlaybackMetadataStore();
const current = metadata();
store.set(current);
assert.equal(
matchRequestedStreamPlaybackMetadata(store, '/home/user/Videos/other.mkv', current.mediaPath),
null,
);
assert.equal(matchRequestedStreamPlaybackMetadata(store, null, current.mediaPath), current);
});
test('toJimakuMediaInfo prefills the modals with the source-reported fields', () => {
assert.deepEqual(toJimakuMediaInfo(metadata()), {
title: 'Mushoku Tensei: Jobless Reincarnation',
season: 3,
episode: 4,
confidence: 'high',
filename: 'Mushoku Tensei: Jobless Reincarnation S03E04',
rawTitle: 'Mushoku Tensei: Jobless Reincarnation S03E04',
});
});
test('toJimakuMediaInfo drops to low confidence when there is no episode', () => {
const info = toJimakuMediaInfo(metadata({ episodeNumber: null, seasonNumber: null }));
assert.equal(info.episode, null);
assert.equal(info.confidence, 'low');
assert.equal(info.title, 'Mushoku Tensei: Jobless Reincarnation');
});
test('toJimakuMediaInfo reports low confidence when a fractional episode is omitted', () => {
const info = toJimakuMediaInfo(metadata({ episodeNumber: 6.5 }));
assert.equal(info.episode, null);
assert.equal(info.confidence, 'low');
});
test('toAnilistMediaGuess reports the stream fields verbatim', () => {
assert.deepEqual(toAnilistMediaGuess(metadata()), {
title: 'Mushoku Tensei: Jobless Reincarnation',
season: 3,
episode: 4,
source: 'stream',
});
});
test('toAnilistMediaGuess declines a special so the caller can fall back', () => {
// AniList progress counts whole episodes; a 6.5 special is not one of them.
assert.equal(toAnilistMediaGuess(metadata({ episodeNumber: 6.5 })), null);
assert.equal(toAnilistMediaGuess(metadata({ episodeNumber: null })), null);
assert.equal(toAnilistMediaGuess(metadata({ seriesTitle: '' })), null);
});
@@ -0,0 +1,91 @@
import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata';
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
import type { JimakuMediaInfo } from '../../types';
/**
* Holds what the anime browser resolved for the episode currently streaming.
*
* Consumers otherwise have to re-derive the series and episode from the mpv
* title, and some of them only ever see the stream URL, which carries no title
* at all. Keeping the resolved fields lets each of them ask instead of guess.
*/
export interface StreamPlaybackMetadataStore {
set: (metadata: AnimeStreamMetadata) => void;
clear: () => void;
/**
* The metadata for `mediaPath`, or null when the player has moved on to
* something else. Matching on the path is what makes this self-expiring —
* there is no teardown hook to miss.
*/
match: (mediaPath: string | null) => AnimeStreamMetadata | null;
}
export function createStreamPlaybackMetadataStore(): StreamPlaybackMetadataStore {
let current: AnimeStreamMetadata | null = null;
return {
set(metadata: AnimeStreamMetadata): void {
current = metadata;
},
clear(): void {
current = null;
},
match(mediaPath: string | null): AnimeStreamMetadata | null {
if (!current) return null;
const trimmed = typeof mediaPath === 'string' ? mediaPath.trim() : '';
if (!trimmed) return null;
// The stats path is accepted too: stats rewrites the volatile stream URL
// to it, so callers reading from there still resolve.
return trimmed === current.mediaPath || trimmed === current.statsPath ? current : null;
},
};
}
/**
* Match metadata for the path a caller requested, falling back to the active
* player path only when the caller has no path of its own.
*/
export function matchRequestedStreamPlaybackMetadata(
store: StreamPlaybackMetadataStore,
requestedMediaPath: string | null,
currentMediaPath: string | null,
): AnimeStreamMetadata | null {
return store.match(requestedMediaPath ?? currentMediaPath);
}
/** AniList counts whole episodes, so a special numbered 6.5 cannot drive it. */
function wholeEpisode(episode: number | null): number | null {
return typeof episode === 'number' && Number.isInteger(episode) && episode > 0 ? episode : null;
}
/**
* The subtitle modals' prefill. `high` confidence is honest here: these fields
* came from the source's own listing rather than from a filename guess, so the
* modals may search on them without waiting for the user to confirm.
*/
export function toJimakuMediaInfo(metadata: AnimeStreamMetadata): JimakuMediaInfo {
const episode = wholeEpisode(metadata.episodeNumber);
return {
title: metadata.seriesTitle,
season: metadata.seasonNumber,
episode,
confidence: episode !== null ? 'high' : 'low',
filename: metadata.displayTitle,
rawTitle: metadata.displayTitle,
};
}
/**
* The AniList guess, or null when there is no episode to report — in which case
* the caller should fall back to parsing the title as usual.
*/
export function toAnilistMediaGuess(metadata: AnimeStreamMetadata): AnilistMediaGuess | null {
const episode = wholeEpisode(metadata.episodeNumber);
if (!metadata.seriesTitle || episode === null) return null;
return {
title: metadata.seriesTitle,
season: metadata.seasonNumber,
episode,
source: 'stream',
};
}
+4
View File
@@ -148,6 +148,10 @@
<span>Title</span>
<input id="tsukihimeTitle" type="text" placeholder="Anime title" />
</label>
<label class="jimaku-field">
<span>Season</span>
<input id="tsukihimeSeason" type="number" min="1" placeholder="1" />
</label>
<label class="jimaku-field">
<span>Episode</span>
<input id="tsukihimeEpisode" type="number" min="1" placeholder="1" />
+1
View File
@@ -188,6 +188,7 @@ function createModalHarness(
setAttribute: () => {},
},
tsukihimeTitleInput: { value: '' },
tsukihimeSeasonInput: { value: '' },
tsukihimeEpisodeInput: { value: '' },
tsukihimeSearchButton: { addEventListener: () => {} },
tsukihimeCloseButton: { addEventListener: () => {} },
+18 -6
View File
@@ -191,16 +191,27 @@ export function createTsukihimeModal(
});
}
function readNumericInput(input: HTMLInputElement): number | null {
if (!input.value) return null;
const parsed = Number.parseInt(input.value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function getSearchQuery(): string {
const title = ctx.dom.tsukihimeTitleInput.value.trim();
if (!title) return '';
const episodeValue = ctx.dom.tsukihimeEpisodeInput.value
? Number.parseInt(ctx.dom.tsukihimeEpisodeInput.value, 10)
: null;
if (episodeValue !== null && Number.isFinite(episodeValue)) {
return `${title} ${String(episodeValue).padStart(2, '0')}`;
const season = readNumericInput(ctx.dom.tsukihimeSeasonInput);
const episode = readNumericInput(ctx.dom.tsukihimeEpisodeInput);
// Season 1 is left out on purpose: releases of a first season almost never
// put "S01" in the name, so adding it narrows the search to nothing.
const parts = [title];
if (season !== null && season > 1) {
parts.push(`S${String(season).padStart(2, '0')}`);
}
return title;
if (episode !== null) {
parts.push(String(episode).padStart(2, '0'));
}
return parts.join(' ');
}
async function performTsukihimeSearch(): Promise<void> {
@@ -369,6 +380,7 @@ export function createTsukihimeModal(
.getJimakuMediaInfo()
.then(async (info: JimakuMediaInfo) => {
ctx.dom.tsukihimeTitleInput.value = info.title || '';
ctx.dom.tsukihimeSeasonInput.value = info.season ? String(info.season) : '';
ctx.dom.tsukihimeEpisodeInput.value = info.episode ? String(info.episode) : '';
if (info.confidence === 'high' && info.title && info.episode) {
+2
View File
@@ -24,6 +24,7 @@ export type RendererDom = {
tsukihimeModal: HTMLDivElement;
tsukihimeTitleInput: HTMLInputElement;
tsukihimeSeasonInput: HTMLInputElement;
tsukihimeEpisodeInput: HTMLInputElement;
tsukihimeSearchButton: HTMLButtonElement;
tsukihimeCloseButton: HTMLButtonElement;
@@ -169,6 +170,7 @@ export function resolveRendererDom(): RendererDom {
tsukihimeModal: getRequiredElement<HTMLDivElement>('tsukihimeModal'),
tsukihimeTitleInput: getRequiredElement<HTMLInputElement>('tsukihimeTitle'),
tsukihimeSeasonInput: getRequiredElement<HTMLInputElement>('tsukihimeSeason'),
tsukihimeEpisodeInput: getRequiredElement<HTMLInputElement>('tsukihimeEpisode'),
tsukihimeSearchButton: getRequiredElement<HTMLButtonElement>('tsukihimeSearch'),
tsukihimeCloseButton: getRequiredElement<HTMLButtonElement>('tsukihimeClose'),
+5
View File
@@ -166,6 +166,11 @@ export interface AnimeBrowserPlayRequest {
animeTitle: string;
episodeUrl: string;
episodeName: string;
/**
* The episode's number as the source reported it. Carried rather than
* re-parsed out of `episodeName`, which is free-form and often lacks one.
*/
episodeNumber: number | null;
}
export interface AnimeBrowserPlayResult {