From fbfdea7c64048dc966b902e2b5842bf019672470 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 2 Aug 2026 00:50:14 -0700 Subject: [PATCH] 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 --- CHANGELOG.md | 39 -- changes/anime-browser.md | 6 +- docs-site/.vitepress/theme/mermaid-modal.css | 8 +- .../.vitepress/theme/status-line.test.ts | 4 +- docs-site/.vitepress/theme/tui-theme.css | 97 ++- docs-site/anime-browser.md | 10 +- docs-site/aniskip-integration.md | 6 +- docs-site/changelog.md | 14 - docs-site/ipc-contracts.md | 20 +- docs-site/mpv-plugin.md | 28 +- docs-site/shortcuts.md | 36 +- docs-site/subtitle-sidebar.md | 30 +- docs-site/websocket-texthooker-api.md | 64 +- docs-site/youtube-integration.md | 10 +- .../2026-03-15-renderer-performance-design.md | 31 +- docs/knowledge-base/quality.md | 36 +- ...-04-09-library-summary-replaces-per-day.md | 193 +++--- ...026-04-09-stats-dashboard-feedback-pass.md | 389 ++++++------ ...library-summary-replaces-per-day-design.md | 18 +- ...09-stats-dashboard-feedback-pass-design.md | 18 +- scripts/prepare-build-assets.mjs | 12 +- scripts/prepare-build-assets.test.ts | 18 +- src/anime-bridge/extension-installer.test.ts | 57 +- src/anime-bridge/extension-installer.ts | 36 +- src/anime-bridge/extension-repo.test.ts | 12 + src/anime-bridge/extension-repo.ts | 8 +- src/anime-bridge/extension-store.test.ts | 27 +- src/anime-bridge/extension-store.ts | 11 +- src/anime-bridge/preference-store.test.ts | 93 ++- src/anime-bridge/preference-store.ts | 49 +- src/anime-bridge/subtitle-cache.test.ts | 45 ++ src/anime-bridge/subtitle-cache.ts | 37 +- src/animeui/animeui.ts | 288 ++++----- src/animeui/browse-state.test.ts | 114 ++++ src/animeui/browse-state.ts | 133 +++++ src/animeui/detail-panel.ts | 182 ++++++ src/animeui/detail.css | 272 +++++++++ src/animeui/extensions-panel.ts | 10 +- src/animeui/index.html | 5 + src/animeui/panels.css | 272 +++++++++ src/animeui/style.css | 555 +----------------- src/core/services/overlay-window.ts | 3 +- src/core/services/subsync-extract.test.ts | 28 + src/core/services/subsync-extract.ts | 3 +- src/main.ts | 13 +- .../runtime/anime-browser-playback.test.ts | 40 ++ src/main/runtime/anime-browser-playback.ts | 166 ++++++ .../runtime/anime-browser-runtime-deps.ts | 60 ++ .../anime-browser-runtime-preferences.test.ts | 138 +++++ src/main/runtime/anime-browser-runtime.ts | 289 ++------- .../runtime/stream-playback-metadata.test.ts | 19 + src/main/runtime/stream-playback-metadata.ts | 17 +- 52 files changed, 2471 insertions(+), 1598 deletions(-) create mode 100644 src/animeui/browse-state.test.ts create mode 100644 src/animeui/browse-state.ts create mode 100644 src/animeui/detail-panel.ts create mode 100644 src/animeui/detail.css create mode 100644 src/animeui/panels.css create mode 100644 src/main/runtime/anime-browser-playback.test.ts create mode 100644 src/main/runtime/anime-browser-playback.ts create mode 100644 src/main/runtime/anime-browser-runtime-deps.ts create mode 100644 src/main/runtime/anime-browser-runtime-preferences.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b20b6d4..5416cdc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,34 +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 - -- Split-Cour Season Resolution: 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, since AniList spells the same name inconsistently across a franchise's own entries, and the marker is looked for in every title and synonym rather than just the display title. Note that a wrong match cached before this fix is remembered in `character-dictionaries/anilist-resolution-cache.json` and needs to be removed (or overridden from the character dictionary picker) for the affected series. -- Specials In The Season Chain: A special or OVA sitting in a franchise's sequel chain is now 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. -- Season Resolution Under Rate Limiting: An AniList rate limit or network failure while walking sequel relations is now 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. -- Anime Browser Titles: An episode launched from the anime browser now carries its series, season and episode number through the app instead of a single joined string. Stats groups streams by series (every stream previously landed in one entry named `m3u8`, because the only readable part of the strip-proxy URL was its file extension), rewatching an episode reuses its entry rather than creating a new one, the Jimaku and TsukiHime modals prefill Title/Season/Episode from the source's own listing instead of leaving them blank, and AniList updates use those fields directly. The mpv title now reads `Series S03E04 - Episode Name`. -- TsukiHime Season: The TsukiHime modal has a Season field, matching Jimaku. It is filled in automatically for anime browser streams, and seasons after the first are included in the search query so a later season's releases are actually found. -- Episode Parsing: The shared title parser now recognizes spelled-out episode labels (`Episode 4`, `第4話`) and a season named in the title (`… Season 3`, `… 2nd Season`, `… S3`), which are put in the Season field instead of being searched for as part of the series name. -- 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 With WebVTT Tracks: 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" (and a VTT saved under an `.srt` name died in its SubRip parser instead). Both the sync target and the reference are now rewritten as SRT for alass, keeping the cue text as-is, and the originals are left untouched. -- 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/`, 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. @@ -36,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. @@ -56,7 +28,6 @@ Internal changes ### Internal - - Added a golden-file regression test corpus for the tokenizer/annotation pipeline, plus scripts to record new fixtures and diff against stock Yomitan. - Consolidated renderer modal state handling into a descriptor registry. - Consolidated CI quality checks (PR, stable, and prerelease) into one reusable workflow with mpv plugin tests and dependency audits. @@ -68,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. @@ -81,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. @@ -95,7 +63,6 @@ Internal changes ### Internal - - Test lanes moved to `scripts/test-lanes.ts` with per-directory discovery and isolated per-file timeouts; CI now covers previously orphaned stats, scripts, plugin process-retry, and runtime-compat suites, plus a new stats lane in the change-verification workflow. @@ -103,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. @@ -195,7 +159,6 @@ Internal changes ### Internal - - **Build**: `make deps` now initializes git submodules before installing dependencies on a fresh source checkout. - **Release Tooling**: Release notes now credit contributors and first-time authors resolved from changelog fragments via git and the GitHub API. - **Changelog Guidance**: PR fragment guidance updated to preserve separate-outcome fragments while directing contributors to consolidate same-PR follow-up notes before adding churn. @@ -205,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. diff --git a/changes/anime-browser.md b/changes/anime-browser.md index 21ffa747..0dcde23c 100644 --- a/changes/anime-browser.md +++ b/changes/anime-browser.md @@ -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 `/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. diff --git a/docs-site/.vitepress/theme/mermaid-modal.css b/docs-site/.vitepress/theme/mermaid-modal.css index 225dda05..d54e8d19 100644 --- a/docs-site/.vitepress/theme/mermaid-modal.css +++ b/docs-site/.vitepress/theme/mermaid-modal.css @@ -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 { diff --git a/docs-site/.vitepress/theme/status-line.test.ts b/docs-site/.vitepress/theme/status-line.test.ts index ab095282..61317fd4 100644 --- a/docs-site/.vitepress/theme/status-line.test.ts +++ b/docs-site/.vitepress/theme/status-line.test.ts @@ -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', + ); }); diff --git a/docs-site/.vitepress/theme/tui-theme.css b/docs-site/.vitepress/theme/tui-theme.css index f0dcf96c..e9026ff3 100644 --- a/docs-site/.vitepress/theme/tui-theme.css +++ b/docs-site/.vitepress/theme/tui-theme.css @@ -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; } diff --git a/docs-site/anime-browser.md b/docs-site/anime-browser.md index ca5433a2..f7bb7c0c 100644 --- a/docs-site/anime-browser.md +++ b/docs-site/anime-browser.md @@ -111,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. @@ -132,7 +136,11 @@ persist across restarts in `/anime-source-preferences.json`. Each save is handed back to the extension, so it can react: the Jellyfin source logs in when the address and password land, then fills in its media-library picker. Password-like fields are masked. Because that file can hold -credentials, it is written with owner-only permissions. +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 diff --git a/docs-site/aniskip-integration.md b/docs-site/aniskip-integration.md index a8115b08..fc9bc230 100644 --- a/docs-site/aniskip-integration.md +++ b/docs-site/aniskip-integration.md @@ -45,9 +45,9 @@ Results are cached per file for the app session; only definitive "no intro found You can trigger AniSkip actions from mpv script-messages: -| Command | Effect | -| ----------------------------------------- | ----------------------------------------------------------------------- | -| `script-message subminer-skip-intro` | Skip to the intro end immediately (same as pressing the key) | +| Command | Effect | +| ------- | ------ | +| `script-message subminer-skip-intro` | Skip to the intro end immediately (same as pressing the key) | | `script-message subminer-aniskip-refresh` | Force a fresh lookup for the current file, discarding any cached result | These are handled by the SubMiner app over the IPC socket. diff --git a/docs-site/changelog.md b/docs-site/changelog.md index c5fcff5e..bfb75ba0 100644 --- a/docs-site/changelog.md +++ b/docs-site/changelog.md @@ -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 @@ Internal changes **Internal** - - Added a golden-file regression test corpus for the tokenizer/annotation pipeline, plus scripts to record new fixtures and diff against stock Yomitan. - Consolidated renderer modal state handling into a descriptor registry. - Consolidated CI quality checks (PR, stable, and prerelease) into one reusable workflow with mpv plugin tests and dependency audits. @@ -48,12 +44,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. @@ -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 @@ Internal changes **Internal** - - Test lanes moved to `scripts/test-lanes.ts` with per-directory discovery and isolated per-file timeouts; CI now covers previously orphaned stats, scripts, plugin process-retry, and runtime-compat suites, plus a new stats lane in the change-verification workflow. @@ -88,18 +80,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. @@ -185,7 +174,6 @@ Internal changes **Internal** - - **Build**: `make deps` now initializes git submodules before installing dependencies on a fresh source checkout. - **Release Tooling**: Release notes now credit contributors and first-time authors resolved from changelog fragments via git and the GitHub API. - **Changelog Guidance**: PR fragment guidance updated to preserve separate-outcome fragments while directing contributors to consolidate same-PR follow-up notes before adding churn. @@ -200,11 +188,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. diff --git a/docs-site/ipc-contracts.md b/docs-site/ipc-contracts.md index 9054dacc..44ce3a62 100644 --- a/docs-site/ipc-contracts.md +++ b/docs-site/ipc-contracts.md @@ -38,7 +38,7 @@ flowchart TB ## Runtime Sockets -The renderer↔main bridge above lives _inside_ the Electron app. A separate set of OS sockets connects the app to the other runtimes - mpv and the launcher/plugin. These carry no renderer payloads and bypass the contract/validator layer; they are command and property channels between processes. +The renderer↔main bridge above lives *inside* the Electron app. A separate set of OS sockets connects the app to the other runtimes - mpv and the launcher/plugin. These carry no renderer payloads and bypass the contract/validator layer; they are command and property channels between processes. - **mpv IPC socket** (`/tmp/subminer-socket`, or `\\.\pipe\subminer-socket` on Windows): the `MpvIpcClient` in the main process connects here to send JSON commands and subscribe to playback/subtitle properties via `observe_property`. Created by mpv's `--input-ipc-server`. - **App control socket** (`/tmp/subminer-control--.sock`, or a named pipe on Windows): the launcher and the mpv plugin send CLI-style commands (`--start`, `--show-visible-overlay`, `--texthooker`) to a running app here. It also dedupes a second `subminer` invocation into the existing instance instead of launching twice. @@ -69,15 +69,15 @@ How these sockets are established during launch is covered in [Playback Startup ## Core Surfaces -| File | Role | -| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `src/shared/ipc/contracts.ts` | Canonical channel names and payload type contracts. Single source of truth for both processes. | -| `src/shared/ipc/validators.ts` | Runtime payload parsers and type guards. Every `invoke` payload is validated here before the handler runs. | -| `src/preload.ts` | Renderer-side bridge. Exposes a typed API surface to the renderer - only approved channels are accessible. | -| `src/main/ipc-runtime.ts` | Main-process handler registration and routing. Wires validated channels to domain handlers. | -| `src/core/services/ipc.ts` | Service-level invoke handling. Applies guardrails (validation, error wrapping) before calling domain logic. | -| `src/core/services/anki-jimaku-ipc.ts` | Integration-specific IPC boundary for Anki and Jimaku operations. | -| `src/main/cli-runtime.ts` | CLI/runtime command boundary. Handles commands that originate from the launcher or mpv plugin rather than the renderer. | +| File | Role | +| --- | --- | +| `src/shared/ipc/contracts.ts` | Canonical channel names and payload type contracts. Single source of truth for both processes. | +| `src/shared/ipc/validators.ts` | Runtime payload parsers and type guards. Every `invoke` payload is validated here before the handler runs. | +| `src/preload.ts` | Renderer-side bridge. Exposes a typed API surface to the renderer - only approved channels are accessible. | +| `src/main/ipc-runtime.ts` | Main-process handler registration and routing. Wires validated channels to domain handlers. | +| `src/core/services/ipc.ts` | Service-level invoke handling. Applies guardrails (validation, error wrapping) before calling domain logic. | +| `src/core/services/anki-jimaku-ipc.ts` | Integration-specific IPC boundary for Anki and Jimaku operations. | +| `src/main/cli-runtime.ts` | CLI/runtime command boundary. Handles commands that originate from the launcher or mpv plugin rather than the renderer. | ## Contract Rules diff --git a/docs-site/mpv-plugin.md b/docs-site/mpv-plugin.md index 7be12870..01112d8c 100644 --- a/docs-site/mpv-plugin.md +++ b/docs-site/mpv-plugin.md @@ -31,20 +31,20 @@ input-ipc-server=\\.\pipe\subminer-socket The plugin reads options from `script-opts` with the `subminer-` prefix (for example `--script-opts=subminer-backend=hyprland`). Managed launches inject these automatically from your SubMiner config; the shipped `subminer.conf` is intentionally empty so command-line opts always win. -| Option | Default | Description | -| ---------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------- | -| `binary_path` | `""` | Path to the SubMiner binary; empty enables [auto-detection](#binary-auto-detection) | -| `socket_path` | platform default | mpv IPC socket path (`/tmp/subminer-socket`, or `\\.\pipe\subminer-socket` on Windows) | -| `texthooker_enabled` | `no` | Start the texthooker server with the overlay | -| `texthooker_port` | `5174` | Texthooker server port | -| `backend` | `auto` | Window backend (`auto`, `hyprland`, `sway`, `x11`, `macos`) | -| `auto_start` | `no` | Start the overlay app on `file-loaded` (managed launches set this from `mpv.autoStartSubMiner`) | -| `auto_start_visible_overlay` | `no` | Show the visible overlay on auto-start (from `auto_start_overlay` in config) | -| `overlay_loading_osd` | `no` | Show an OSD loading spinner while the overlay starts | -| `auto_start_pause_until_ready` | `yes` | Keep mpv paused until the overlay reports tokenization-ready | -| `auto_start_pause_until_ready_timeout_seconds` | `30` | Timeout before resuming playback anyway | -| `osd_messages` | `yes` | Show plugin OSD status messages | -| `log_level` | `info` | Plugin log verbosity | +| Option | Default | Description | +| -------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------- | +| `binary_path` | `""` | Path to the SubMiner binary; empty enables [auto-detection](#binary-auto-detection) | +| `socket_path` | platform default | mpv IPC socket path (`/tmp/subminer-socket`, or `\\.\pipe\subminer-socket` on Windows) | +| `texthooker_enabled` | `no` | Start the texthooker server with the overlay | +| `texthooker_port` | `5174` | Texthooker server port | +| `backend` | `auto` | Window backend (`auto`, `hyprland`, `sway`, `x11`, `macos`) | +| `auto_start` | `no` | Start the overlay app on `file-loaded` (managed launches set this from `mpv.autoStartSubMiner`) | +| `auto_start_visible_overlay` | `no` | Show the visible overlay on auto-start (from `auto_start_overlay` in config) | +| `overlay_loading_osd` | `no` | Show an OSD loading spinner while the overlay starts | +| `auto_start_pause_until_ready` | `yes` | Keep mpv paused until the overlay reports tokenization-ready | +| `auto_start_pause_until_ready_timeout_seconds` | `30` | Timeout before resuming playback anyway | +| `osd_messages` | `yes` | Show plugin OSD status messages | +| `log_level` | `info` | Plugin log verbosity | ## Keybindings diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index 0599313e..645cd144 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -12,10 +12,10 @@ All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindi ## App-Wide Shortcuts -| Shortcut | Action | Scope | Configurable | -| ------------- | ---------------------- | ---------------------------------------- | -------------------------------------- | -| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus | `shortcuts.toggleVisibleOverlayGlobal` | -| `Alt+Shift+Y` | Open Yomitan settings | OS-global (registered with the OS) | Fixed (not configurable) | +| Shortcut | Action | Scope | Configurable | +| ------------- | ---------------------- | -------------------------------------------- | -------------------------------------- | +| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus | `shortcuts.toggleVisibleOverlayGlobal` | +| `Alt+Shift+Y` | Open Yomitan settings | OS-global (registered with the OS) | Fixed (not configurable) | ::: tip `Alt+Shift+O` is dispatched by the overlay window and the mpv plugin, so it works from either surface without OS registration. Only `Alt+Shift+Y` is registered with the OS; if it conflicts with another application, that binding cannot be changed. All `shortcuts.*` keys hot-reload - no restart needed. @@ -75,21 +75,21 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle ## Subtitle & Feature Shortcuts -| Shortcut | Action | Config key | -| ------------------ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` | -| `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` | -| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | -| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` | -| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | -| `Ctrl+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` | -| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` | -| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | -| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | -| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` | +| Shortcut | Action | Config key | +| ------------------ | -------------------------------------------------------- | ------------------------------------------ | +| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` | +| `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` | +| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | +| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` | +| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | +| `Ctrl+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` | +| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` | +| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | +| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | +| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` | | `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` (overlay) / `shortcuts.toggleSubtitleSidebar` (mpv session binding) | -| `` ` `` | Toggle stats overlay | `stats.toggleKey` | -| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` | +| `` ` `` | Toggle stats overlay | `stats.toggleKey` | +| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` | `shortcuts.openAnimetosho` remains accepted as a deprecated alias for `shortcuts.openTsukihime`. The current name takes precedence when both are configured. diff --git a/docs-site/subtitle-sidebar.md b/docs-site/subtitle-sidebar.md index d342e103..e5203085 100644 --- a/docs-site/subtitle-sidebar.md +++ b/docs-site/subtitle-sidebar.md @@ -62,23 +62,23 @@ Styling lives under the `css` object, using CSS property names and CSS custom pr | `pauseVideoOnHover` | boolean | `true` | Pause playback while hovering the cue list | | `autoScroll` | boolean | `true` | Keep the active cue in view during playback | -| `css` property | Default | Description | -| -------------------------------------------- | --------------------------------------------------------------- | ----------------------------------- | -| `font-family` | `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP` | Cue text font family | -| `color` | `#cad3f5` | Default cue text color | -| `background-color` | `rgba(73, 77, 100, 0.9)` | Sidebar shell background color | -| `font-size` | `16px` | Base cue font size | -| `opacity` | `0.95` | Sidebar opacity between `0` and `1` | -| `--subtitle-sidebar-max-width` | `420px` | Maximum sidebar width | -| `--subtitle-sidebar-timestamp-color` | `#a5adcb` | Cue timestamp color | -| `--subtitle-sidebar-active-line-color` | `#f5bde6` | Active cue text color | -| `--subtitle-sidebar-active-background-color` | `rgba(138, 173, 244, 0.22)` | Active cue background color | -| `--subtitle-sidebar-hover-background-color` | `rgba(54, 58, 79, 0.84)` | Hovered cue background color | +| `css` property | Default | Description | +| ------------------------------------------- | --------------------------- | ---------------------------- | +| `font-family` | `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP` | Cue text font family | +| `color` | `#cad3f5` | Default cue text color | +| `background-color` | `rgba(73, 77, 100, 0.9)` | Sidebar shell background color | +| `font-size` | `16px` | Base cue font size | +| `opacity` | `0.95` | Sidebar opacity between `0` and `1` | +| `--subtitle-sidebar-max-width` | `420px` | Maximum sidebar width | +| `--subtitle-sidebar-timestamp-color` | `#a5adcb` | Cue timestamp color | +| `--subtitle-sidebar-active-line-color` | `#f5bde6` | Active cue text color | +| `--subtitle-sidebar-active-background-color`| `rgba(138, 173, 244, 0.22)` | Active cue background color | +| `--subtitle-sidebar-hover-background-color` | `rgba(54, 58, 79, 0.84)` | Hovered cue background color | ## Keyboard Shortcut -| Key | Action | Config key | -| --- | ----------------------- | --------------------------- | -| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` | +| Key | Action | Config key | +| --- | ----------------------- | ------------------------------ | +| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` | The toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source. See [Keyboard Shortcuts](/shortcuts) for the full shortcut reference. diff --git a/docs-site/websocket-texthooker-api.md b/docs-site/websocket-texthooker-api.md index 89dc1f0b..ed0b3e08 100644 --- a/docs-site/websocket-texthooker-api.md +++ b/docs-site/websocket-texthooker-api.md @@ -2,7 +2,7 @@ **Who this page is for:** developers and tinkerers who want to consume SubMiner's live subtitle stream from their own tools - a browser tab, an automation script, or another mpv plugin. If you just want subtitles in a browser tab for Yomitan, skip to [Texthooker Integration Guide](#texthooker-integration-guide); the rest is reference for building custom clients. -A _texthooker_ is a page/tool that receives the text currently on screen so a dictionary extension (like Yomitan) can look words up. SubMiner ships its own texthooker UI and also broadcasts subtitle text over local WebSockets that any client can connect to. +A *texthooker* is a page/tool that receives the text currently on screen so a dictionary extension (like Yomitan) can look words up. SubMiner ships its own texthooker UI and also broadcasts subtitle text over local WebSockets that any client can connect to. SubMiner exposes a small set of local integration surfaces for browser tools, automation helpers, and mpv-driven workflows: @@ -15,12 +15,12 @@ This page documents those integration points and shows how to build custom consu ## Quick Reference -| Surface | Default | Purpose | -| --------------------- | --------------------------- | -------------------------------------------------- | -| `websocket` | `ws://127.0.0.1:6677` | Basic subtitle broadcast stream | -| `annotationWebsocket` | `ws://127.0.0.1:6678` | Structured stream with token metadata | -| `texthooker` | `http://127.0.0.1:5174` | Local texthooker UI with injected websocket config | -| mpv plugin | `script-message subminer-*` | Start/stop/toggle/status automation inside mpv | +| Surface | Default | Purpose | +| --- | --- | --- | +| `websocket` | `ws://127.0.0.1:6677` | Basic subtitle broadcast stream | +| `annotationWebsocket` | `ws://127.0.0.1:6678` | Structured stream with token metadata | +| `texthooker` | `http://127.0.0.1:5174` | Local texthooker UI with injected websocket config | +| mpv plugin | `script-message subminer-*` | Start/stop/toggle/status automation inside mpv | ## Enable and Configure the Services @@ -30,16 +30,16 @@ SubMiner's integration ports are configured in `config.jsonc`. All three service { "websocket": { "enabled": "auto", - "port": 6677, + "port": 6677 }, "annotationWebsocket": { "enabled": true, - "port": 6678, + "port": 6678 }, "texthooker": { "launchAtStartup": true, - "openBrowser": false, - }, + "openBrowser": false + } } ``` @@ -79,12 +79,12 @@ When a client connects, SubMiner immediately sends the latest subtitle payload i #### Field reference -| Field | Type | Notes | -| ---------- | ------ | ---------------------------------------------------------------------------------------------- | -| `version` | number | Current websocket payload version. Today this is `1`. | -| `text` | string | Raw subtitle text. | +| Field | Type | Notes | +| --- | --- | --- | +| `version` | number | Current websocket payload version. Today this is `1`. | +| `text` | string | Raw subtitle text. | | `sentence` | string | Plain subtitle text with line breaks represented as `
`. No annotation spans or attributes. | -| `tokens` | array | Always empty on the basic subtitle websocket. | +| `tokens` | array | Always empty on the basic subtitle websocket. | ### 2. Annotation WebSocket @@ -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 diff --git a/docs-site/youtube-integration.md b/docs-site/youtube-integration.md index 4611642d..cdc2ae0d 100644 --- a/docs-site/youtube-integration.md +++ b/docs-site/youtube-integration.md @@ -110,8 +110,8 @@ Background cache downloads are capped at 720p by default (`youtube.mediaCache.ma } ``` -| Option | Type | Description | -| --------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Option | Type | Description | +| --------------------- | ---------- | ------------------------------------------------------------------------------------- | | `primarySubLanguages` | `string[]` | Languages that count as a satisfactory primary subtitle (default `["ja", "jpn"]`). Used by the "primary subtitle missing" notification and by managed local/playlist subtitle selection. | YouTube auto-selection itself always picks a Japanese track first (manual over auto), then falls back to any manual track — `primarySubLanguages` does not change which YouTube track is auto-picked. @@ -130,11 +130,11 @@ YouTube secondary selection is fixed: SubMiner always tries an English track (ma } ``` -| Option | Type | Description | -| ----------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Option | Type | Description | +| ----------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track for local/Jellyfin sidecar files. Default is empty (`[]`). Not used for YouTube. | | `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary sidecar track for local files (default: `false`). Not used for YouTube. | -| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) | +| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) | These settings come from `config.jsonc` (or built-in defaults); there are no CLI flags or environment variables for subtitle language selection. diff --git a/docs/architecture/2026-03-15-renderer-performance-design.md b/docs/architecture/2026-03-15-renderer-performance-design.md index ec1daf4d..8b01ff9a 100644 --- a/docs/architecture/2026-03-15-renderer-performance-design.md +++ b/docs/architecture/2026-03-15-renderer-performance-design.md @@ -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) diff --git a/docs/knowledge-base/quality.md b/docs/knowledge-base/quality.md index 9fa5c1ab..df7d4ffe 100644 --- a/docs/knowledge-base/quality.md +++ b/docs/knowledge-base/quality.md @@ -11,27 +11,27 @@ Grades are directional, not ceremonial. The point is to keep gaps visible. ## Product / Runtime Domains -| Area | Grade | Notes | -| --------------------------------------- | ----- | ------------------------------------------------------------------------ | -| Desktop runtime composition | B | strong modularization; still easy for `main` wiring drift to reappear | -| Launcher CLI | B | focused surface; generated/stale artifact hazards need constant guarding | -| mpv plugin | B | modular, but Lua/runtime coupling still specialized | -| Overlay renderer | B | improved modularity; interaction complexity remains | -| Config system | A- | clear defaults/definitions split and good validation surface | -| Immersion / AniList / Jellyfin surfaces | B- | growing product scope; ownership spans multiple services | -| Internal docs system | B | new structure in place; needs habitual maintenance | -| Public docs site | B | strong user docs; must stay separate from internal KB | +| Area | Grade | Notes | +| --- | --- | --- | +| Desktop runtime composition | B | strong modularization; still easy for `main` wiring drift to reappear | +| Launcher CLI | B | focused surface; generated/stale artifact hazards need constant guarding | +| mpv plugin | B | modular, but Lua/runtime coupling still specialized | +| Overlay renderer | B | improved modularity; interaction complexity remains | +| Config system | A- | clear defaults/definitions split and good validation surface | +| Immersion / AniList / Jellyfin surfaces | B- | growing product scope; ownership spans multiple services | +| Internal docs system | B | new structure in place; needs habitual maintenance | +| Public docs site | B | strong user docs; must stay separate from internal KB | ## Architectural Layers -| Layer | Grade | Notes | -| ------------------------------ | ----- | --------------------------------------------------------- | -| `src/main.ts` composition root | B | direction good; still needs vigilance against logic creep | -| `src/main/` runtime adapters | B | mostly clear; can accumulate wiring debt | -| `src/core/services/` | B+ | good extraction pattern; some domains remain broad | -| `src/renderer/` | B | cleaner than before; UI/runtime behavior still dense | -| `launcher/` | B | clear command boundaries | -| `docs/` internal KB | B | structure exists; enforcement now guards core rules | +| Layer | Grade | Notes | +| --- | --- | --- | +| `src/main.ts` composition root | B | direction good; still needs vigilance against logic creep | +| `src/main/` runtime adapters | B | mostly clear; can accumulate wiring debt | +| `src/core/services/` | B+ | good extraction pattern; some domains remain broad | +| `src/renderer/` | B | cleaner than before; UI/runtime behavior still dense | +| `launcher/` | B | clear command boundaries | +| `docs/` internal KB | B | structure exists; enforcement now guards core rules | ## Current Gaps diff --git a/docs/superpowers/plans/2026-04-09-library-summary-replaces-per-day.md b/docs/superpowers/plans/2026-04-09-library-summary-replaces-per-day.md index cd5f9476..7fb37fe4 100644 --- a/docs/superpowers/plans/2026-04-09-library-summary-replaces-per-day.md +++ b/docs/superpowers/plans/2026-04-09-library-summary-replaces-per-day.md @@ -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 ( <>
-

Top Titles by Watch Time (min)

+

+ Top Titles by Watch Time (min) +

-
No library activity in the selected window.
+
+ No library activity in the selected window. +
); } @@ -1015,7 +1042,9 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect return ( <>
-

Top Titles by Watch Time (min)

+

+ Top Titles by Watch Time (min) +

Per-Title Summary

-
+
@@ -1106,7 +1138,9 @@ export function LibrarySummarySection({ rows, hiddenTitles }: LibrarySummarySect {formatNumber(row.lookups)} ` header column and the corresponding ` @@ -292,7 +283,6 @@ ``` Body row (around line 122-141): - ```tsx { const reading = fullReading(w.headword, w.reading); if (!reading || reading === w.headword) return null; - return 【{reading}】; + return ( + + 【{reading}】 + + ); })()} - + @@ -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 `` closing the cards-mined section, add: ```tsx - { - hiddenCardCount > 0 && ( -
- {hiddenCardCount} card{hiddenCardCount === 1 ? '' : 's'} hidden (deleted from Anki) -
- ); - } + {hiddenCardCount > 0 && ( +
+ {hiddenCardCount} card{hiddenCardCount === 1 ? '' : 's'} hidden (deleted from Anki) +
+ )} ``` Place that footer immediately before the closing `` 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>(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
-

{detail.canonicalTitle}

+

+ {detail.canonicalTitle} +

{onDeleteEpisode && ( - {!isCollapsed && ( -
-
- {group.items.map((item) => ( - setSelectedVideoId(item.videoId)} - /> - ))} -
-
+ {!isSingleVideo && ( + )} - - ); - }); - } + +
+
+

+ {group.title} +

+
+ {group.subtitle ? ( +
{group.subtitle}
+ ) : null} +
+ {group.items.length} video{group.items.length !== 1 ? 's' : ''} ·{' '} + {formatDuration(group.totalActiveMs)} · {formatNumber(group.totalCards)} cards +
+
+ + {!isCollapsed && ( +
+
+ {group.items.map((item) => ( + setSelectedVideoId(item.videoId)} + /> + ))} +
+
+ )} + + ); + })} ``` **Watch out:** the previous header had a clickable `` for the channel URL. Wrapping the whole header in a ` - - {isOpen && ( -
- {bucket.sessions.map((s) => { - const detailsId = `session-details-${s.sessionId}`; - return ( -
- - setExpandedId(expandedId === s.sessionId ? null : s.sessionId) - } - onDelete={() => void handleDeleteSession(s)} - deleteDisabled={deletingSessionId === s.sessionId} - onNavigateToMediaDetail={onNavigateToMediaDetail} - /> - {expandedId === s.sessionId && ( -
- -
- )} -
- ); - })} +
+ setExpandedId(expandedId === s.sessionId ? null : s.sessionId)} + onDelete={() => void handleDeleteSession(s)} + deleteDisabled={deletingSessionId === s.sessionId} + onNavigateToMediaDetail={onNavigateToMediaDetail} + /> + {expandedId === s.sessionId && ( +
+
)}
); - })} -
+ } + const isOpen = expandedBuckets.has(bucket.key); + return ( +
+ + + {isOpen && ( +
+ {bucket.sessions.map((s) => { + const detailsId = `session-details-${s.sessionId}`; + return ( +
+ + setExpandedId(expandedId === s.sessionId ? null : s.sessionId) + } + onDelete={() => void handleDeleteSession(s)} + deleteDisabled={deletingSessionId === s.sessionId} + onNavigateToMediaDetail={onNavigateToMediaDetail} + /> + {expandedId === s.sessionId && ( +
+ +
+ )} +
+ ); + })} +
+ )} +
+ ); + })}
- ); - }); - } + + ); + })} ``` - **Note on nested buttons:** the bucket header is a `
``` 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 `` inside each chart container. - `` and equivalent `YAxis`. diff --git a/scripts/prepare-build-assets.mjs b/scripts/prepare-build-assets.mjs index 0457d026..3ba2b9b9 100644 --- a/scripts/prepare-build-assets.mjs +++ b/scripts/prepare-build-assets.mjs @@ -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() { diff --git a/scripts/prepare-build-assets.test.ts b/scripts/prepare-build-assets.test.ts index 13f64fe2..c72d8213 100644 --- a/scripts/prepare-build-assets.test.ts +++ b/scripts/prepare-build-assets.test.ts @@ -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(/ 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*\]\)/, + ); +}); diff --git a/src/anime-bridge/extension-installer.test.ts b/src/anime-bridge/extension-installer.test.ts index 6f421386..afc56274 100644 --- a/src/anime-bridge/extension-installer.test.ts +++ b/src/anime-bridge/extension-installer.test.ts @@ -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,61 @@ 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-')); + const body = new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array(1025)); + }, + async cancel() { + 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/, + ); +}); + +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'); diff --git a/src/anime-bridge/extension-installer.ts b/src/anime-bridge/extension-installer.ts index 55c12b5d..1740f719 100644 --- a/src/anime-bridge/extension-installer.ts +++ b/src/anime-bridge/extension-installer.ts @@ -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; + writeFile: (filePath: string, bytes: Uint8Array) => Promise; + rename: (from: string, to: string) => Promise; + removeFile: (filePath: string) => Promise; +} + +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 { 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); diff --git a/src/anime-bridge/extension-repo.test.ts b/src/anime-bridge/extension-repo.test.ts index c139b8ce..4c9f60f3 100644 --- a/src/anime-bridge/extension-repo.test.ts +++ b/src/anime-bridge/extension-repo.test.ts @@ -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) => { diff --git a/src/anime-bridge/extension-repo.ts b/src/anime-bridge/extension-repo.ts index 0f07a024..f0e07af4 100644 --- a/src/anime-bridge/extension-repo.ts +++ b/src/anime-bridge/extension-repo.ts @@ -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}`); diff --git a/src/anime-bridge/extension-store.test.ts b/src/anime-bridge/extension-store.test.ts index be2cb328..6ab6b9c3 100644 --- a/src/anime-bridge/extension-store.test.ts +++ b/src/anime-bridge/extension-store.test.ts @@ -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, []), [ diff --git a/src/anime-bridge/extension-store.ts b/src/anime-bridge/extension-store.ts index 4757dccb..1b807324 100644 --- a/src/anime-bridge/extension-store.ts +++ b/src/anime-bridge/extension-store.ts @@ -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, diff --git a/src/anime-bridge/preference-store.test.ts b/src/anime-bridge/preference-store.test.ts index ca36b3d8..510c0885 100644 --- a/src/anime-bridge/preference-store.test.ts +++ b/src/anime-bridge/preference-store.test.ts @@ -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 { 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; + 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; + 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; - assert.deepEqual(parsed['src-1'], [{ key: 'second' }]); + assert.deepEqual(parsed['pkg:src-1'], [{ key: 'second' }]); }); diff --git a/src/anime-bridge/preference-store.ts b/src/anime-bridge/preference-store.ts index 23a416d2..3b0a0f4b 100644 --- a/src/anime-bridge/preference-store.ts +++ b/src/anime-bridge/preference-store.ts @@ -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 { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}; + + const parsed: Record = {}; + 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).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) - : {}; + 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 { + async get(pkg: string, sourceId: string): Promise { 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 { + async set(pkg: string, sourceId: string, preferences: BridgePreference[]): Promise { 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); diff --git a/src/anime-bridge/subtitle-cache.test.ts b/src/anime-bridge/subtitle-cache.test.ts index 148917d8..34ef1cdf 100644 --- a/src/anime-bridge/subtitle-cache.test.ts +++ b/src/anime-bridge/subtitle-cache.test.ts @@ -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( + { + 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({ diff --git a/src/anime-bridge/subtitle-cache.ts b/src/anime-bridge/subtitle-cache.ts index 76af1732..b50d90ec 100644 --- a/src/anime-bridge/subtitle-cache.ts +++ b/src/anime-bridge/subtitle-cache.ts @@ -58,6 +58,7 @@ export interface SubtitleCacheResult { interface FetchResponseLike { ok: boolean; status: number; + body?: ReadableStream | null; arrayBuffer: () => Promise; } @@ -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 { + 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. * diff --git a/src/animeui/animeui.ts b/src/animeui/animeui.ts index fc6c82be..868c5c1f 100644 --- a/src/animeui/animeui.ts +++ b/src/animeui/animeui.ts @@ -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('search-button'); const sourceSelect = el('source-select'); const grid = el('grid'); const gridEmpty = el('grid-empty'); -const results = el('results'); -const detail = el('detail'); -const detailBack = el('detail-back'); -const detailCover = el('detail-cover'); -const detailTitle = el('detail-title'); -const detailChips = el('detail-chips'); -const detailDescription = el('detail-description'); -const episodes = el('episodes'); -const episodesCount = el('episodes-count'); +const loadMoreButton = el('load-more'); const banner = el('bridge-banner'); const bannerMessage = el('bridge-message'); const bannerMeter = el('bridge-meter'); @@ -49,11 +51,8 @@ const settingsPanel = el('settings'); const settingsFields = el('settings-fields'); const settingsTitle = el('settings-title'); -/** The anime the detail page is showing, with the source that produced it. */ -let selectedAnime: { url: string; title: string; sourceId: string } | null = null; - -/** Where the results grid was scrolled to before the detail page covered it. */ -let resultsScrollTop = 0; +/** 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 = { 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,18 @@ 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; } 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))); + seenEntries.clear(); + grid.replaceChildren(); + appendEntries(entries); - const empty = entries.length === 0; + const empty = grid.childElementCount === 0; gridEmpty.classList.toggle('hidden', !empty); gridEmpty.textContent = emptyMessage; } @@ -213,139 +216,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 { - selectedAnime = { url: entry.url, title: entry.title, sourceId: entry.sourceId }; - resultsScrollTop = results.scrollTop; - results.classList.add('hidden'); - detail.classList.remove('hidden'); - detail.scrollTop = 0; - detailTitle.textContent = entry.title; - detailDescription.textContent = 'Loading…'; - detailChips.replaceChildren(); - episodes.replaceChildren(); - episodesCount.textContent = ''; - detailCover.src = entry.thumbnailUrl ?? ''; - - try { - // Always ask the entry's own source: after an all-sources search the - // picker's selection says nothing about where this cover came from. - const [details, episodeList] = await Promise.all([ - api.getDetails(entry.url, entry.sourceId), - api.getEpisodes(entry.url, entry.sourceId), - ]); - - detailTitle.textContent = details.title; - detailDescription.textContent = details.description ?? 'No description from this source.'; - if (details.thumbnailUrl) detailCover.src = details.thumbnailUrl; - - const chips: HTMLSpanElement[] = []; - const source = document.createElement('span'); - source.className = 'chip source'; - source.textContent = entry.sourceName; - chips.push(source); - if (details.status !== 'unknown') { - const status = document.createElement('span'); - status.className = 'chip status'; - status.textContent = details.status.replace(/-/g, ' '); - chips.push(status); - } - for (const genre of details.genres.slice(0, 6)) { - const chip = document.createElement('span'); - chip.className = 'chip'; - chip.textContent = genre; - chips.push(chip); - } - detailChips.replaceChildren(...chips); - - renderEpisodes(episodeList); - setStatus(`${details.title} · ${episodeList.length} episodes`); - } catch (error) { - detailDescription.textContent = ''; - setStatus(describe(error), 'error'); - } -} - -function closeDetail(): void { - detail.classList.add('hidden'); - results.classList.remove('hidden'); - results.scrollTop = resultsScrollTop; - selectedAnime = null; -} - -async function playEpisode(button: HTMLButtonElement, episode: AnimeBrowserEpisode): Promise { - if (!selectedAnime) return; - - for (const other of episodes.querySelectorAll('.cue')) { - other.removeAttribute('data-state'); - } - button.dataset.state = 'loading'; - setStatus(`Resolving ${episode.name}…`); - - const result = await api.playEpisode({ - sourceId: selectedAnime.sourceId, - animeUrl: selectedAnime.url, - animeTitle: selectedAnime.title, - episodeUrl: episode.url, - episodeName: episode.name, - episodeNumber: episode.number, - }); - - 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 ---------- */ @@ -358,8 +231,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(); +let activeStreamRequestId = 0; +const seenEntries = new Set(); + +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); @@ -367,53 +249,88 @@ 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) { + seenEntries.clear(); + grid.replaceChildren(); + gridEmpty.classList.add('hidden'); + } 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 { - 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 { + 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 { + 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…'); + seenEntries.clear(); + grid.replaceChildren(); + gridEmpty.classList.add('hidden'); + await runBrowse(started.request); +} + +async function loadNextPage(): Promise { + 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 { @@ -470,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); diff --git a/src/animeui/browse-state.test.ts b/src/animeui/browse-state.test.ts new file mode 100644 index 00000000..9e792e13 --- /dev/null +++ b/src/animeui/browse-state.test.ts @@ -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(); + + assert.deepEqual(takeUnseenEntries([entry('one', '/a')], seen), [entry('one', '/a')]); + assert.deepEqual(takeUnseenEntries([entry('one', '/a'), entry('two', '/a')], seen), [ + entry('two', '/a'), + ]); +}); diff --git a/src/animeui/browse-state.ts b/src/animeui/browse-state.ts new file mode 100644 index 00000000..05be77f8 --- /dev/null +++ b/src/animeui/browse-state.ts @@ -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, +): 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 = { ok: true; value: T } | { ok: false; error: unknown }; + +export async function capture(operation: () => Promise): Promise> { + try { + return { ok: true, value: await operation() }; + } catch (error) { + return { ok: false, error }; + } +} + +export function takeUnseenEntries( + entries: AnimeBrowserEntry[], + seen: Set, +): AnimeBrowserEntry[] { + return entries.filter((entry) => { + const key = `${entry.sourceId}\0${entry.url}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} diff --git a/src/animeui/detail-panel.ts b/src/animeui/detail-panel.ts new file mode 100644 index 00000000..f8894ed2 --- /dev/null +++ b/src/animeui/detail-panel.ts @@ -0,0 +1,182 @@ +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('results'); + const detail = el('detail'); + const detailBack = el('detail-back'); + const detailCover = el('detail-cover'); + const detailTitle = el('detail-title'); + const detailChips = el('detail-chips'); + const detailDescription = el('detail-description'); + const episodes = el('episodes'); + const episodesCount = el('episodes-count'); + + let selectedAnime: { url: string; title: string; sourceId: string } | null = null; + let resultsScrollTop = 0; + const requests = 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 { + const anime = selectedAnime; + if (!anime) return; + + for (const other of episodes.querySelectorAll('.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 (!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 { + 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(); + 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'), + }; +} diff --git a/src/animeui/detail.css b/src/animeui/detail.css new file mode 100644 index 00000000..6065145f --- /dev/null +++ b/src/animeui/detail.css @@ -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; + } +} diff --git a/src/animeui/extensions-panel.ts b/src/animeui/extensions-panel.ts index a094434d..2280285f 100644 --- a/src/animeui/extensions-panel.ts +++ b/src/animeui/extensions-panel.ts @@ -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'); + } }, }, ], diff --git a/src/animeui/index.html b/src/animeui/index.html index e2488e16..b21486ed 100644 --- a/src/animeui/index.html +++ b/src/animeui/index.html @@ -8,6 +8,8 @@ /> SubMiner Anime + +
@@ -126,6 +128,9 @@
+
- {row.lookupsPerHundred === null ? '—' : row.lookupsPerHundred.toFixed(1)} + {row.lookupsPerHundred === null + ? '—' + : row.lookupsPerHundred.toFixed(1)} {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** diff --git a/docs/superpowers/plans/2026-04-09-stats-dashboard-feedback-pass.md b/docs/superpowers/plans/2026-04-09-stats-dashboard-feedback-pass.md index 7261a141..9e754a59 100644 --- a/docs/superpowers/plans/2026-04-09-stats-dashboard-feedback-pass.md +++ b/docs/superpowers/plans/2026-04-09-stats-dashboard-feedback-pass.md @@ -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 `Reading` in the body. The new shape: Header (around line 113-119): - ```tsx
{w.partOfSpeech && } + {w.partOfSpeech && } + {w.frequency}x {w.headword} - {reading && 【{reading}】} + {reading && ( + + 【{reading}】 + + )}