Compare commits

...

21 Commits

Author SHA1 Message Date
sudacode 7b94adafbd chore(release): prepare 0.18.0-beta.1 2026-07-07 02:40:05 -07:00
sudacode 61f39d1e09 fix(overlay): resolve unspaced Japanese name splits and scan recovery (#146) 2026-07-07 02:33:46 -07:00
sudacode e7739de51c fix(tokenizer): keep unparsed Yomitan tokens hoverable (#145) 2026-07-07 02:01:04 -07:00
sudacode ad1d240f20 refactor(tests): centralize lane definitions and add per-file isolation (#143) 2026-07-07 01:51:05 -07:00
sudacode 8b0ef662bc chore: remove shipped changelog entries and demo asset
- Drop changes/kiku-field-grouping-modal-wiring.md (landed in #138)
- Drop changes/known-word-partial-reading-highlight.md (landed in #142)
- Remove assets/minecard.mkv demo video
2026-07-07 00:18:48 -07:00
sudacode 0a58c20ad7 fix(stats): start stats server on background app launch (#144) 2026-07-07 00:16:57 -07:00
sudacode 38ddb29aa0 feat(anki): reading-aware known-word matching (cache v3) (#142) 2026-07-07 00:13:10 -07:00
sudacode 8b9a70c5a6 feat(stats): Trends dashboard overhaul — title visibility, ranking modes, calendar-accurate windows, tooltips (#140) 2026-07-06 23:52:43 -07:00
sudacode 48a084914a feat(branding): replace app icon with contributed pixel-art set (#141) 2026-07-06 23:31:19 -07:00
sudacode a042b04357 fix(overlay): prevent field grouping modal from freezing overlay on Hyprland (#138) 2026-07-06 22:13:14 -07:00
sudacode 35ca2afc6f fix(overlay): collapse karaoke syllable spam in secondary subtitles (#139) 2026-07-06 01:09:06 -07:00
sudacode b14f977e33 fix(tokenizer): stop partial furigana readings from marking words known (#137) 2026-07-05 17:02:44 -07:00
sudacode eef4500599 feat(launcher): add -H/--history command to browse local watch history (#136) 2026-07-05 16:46:05 -07:00
sudacode 73af1451b7 fix(anki): lower audio normalization target to -23 LUFS (EBU R128) 2026-07-05 01:13:54 -07:00
sudacode 36a3704815 fix(youtube): parse mpv EDL stream URLs with byte-length guards (#134) 2026-07-02 01:37:37 -07:00
sudacode 359cb0a301 Normalize generated Anki audio by default (#135) 2026-07-02 00:34:16 -07:00
sudacode 4b10e85053 chore(release): prepare v0.17.2 2026-06-28 23:03:18 -07:00
sudacode c942a2cf2d fFix(youtube): recover source URL for background media cache on direct mpv open (#132) 2026-06-28 22:46:11 -07:00
sudacode f65afa6046 feat(youtube): notify on manual picker open and show success after track load (#133) 2026-06-28 22:43:16 -07:00
sudacode 389d8e06e0 chore: remove stale npm artifacts 2026-06-28 20:18:08 -07:00
sudacode 0008b55b70 chore(release): prepare v0.17.1 2026-06-27 22:59:28 -07:00
183 changed files with 6771 additions and 5011 deletions
+3 -3
View File
@@ -55,9 +55,6 @@ jobs:
- name: Verify generated config examples - name: Verify generated config examples
run: bun run verify:config-example run: bun run verify:config-example
- name: Internal docs knowledge-base checks
run: bun run test:docs:kb
- name: Test suite (source) - name: Test suite (source)
run: bun run test:fast run: bun run test:fast
@@ -71,6 +68,9 @@ jobs:
path: coverage/test-src/lcov.info path: coverage/test-src/lcov.info
if-no-files-found: error if-no-files-found: error
- name: Stats UI tests
run: bun run test:stats
- name: Launcher smoke suite (source) - name: Launcher smoke suite (source)
run: bun run test:launcher:smoke:src run: bun run test:launcher:smoke:src
+3
View File
@@ -70,6 +70,9 @@ jobs:
path: coverage/test-src/lcov.info path: coverage/test-src/lcov.info
if-no-files-found: error if-no-files-found: error
- name: Stats UI tests
run: bun run test:stats
- name: Launcher smoke suite (source) - name: Launcher smoke suite (source)
run: bun run test:launcher:smoke:src run: bun run test:launcher:smoke:src
+3
View File
@@ -61,6 +61,9 @@ jobs:
path: coverage/test-src/lcov.info path: coverage/test-src/lcov.info
if-no-files-found: error if-no-files-found: error
- name: Stats UI tests
run: bun run test:stats
- name: Launcher smoke suite (source) - name: Launcher smoke suite (source)
run: bun run test:launcher:smoke:src run: bun run test:launcher:smoke:src
+3
View File
@@ -1,5 +1,6 @@
# Dependencies # Dependencies
node_modules/ node_modules/
/package-lock.json
# Superpowers brainstorming # Superpowers brainstorming
.superpowers/ .superpowers/
@@ -16,6 +17,8 @@ coverage/
# Launcher build artifact (produced by make build-launcher) # Launcher build artifact (produced by make build-launcher)
/subminer /subminer
/main-entry.js
/main-entry.js.map
# Logs # Logs
*.log *.log
+3
View File
@@ -42,7 +42,10 @@ Start here, then leave this file.
- Config/schema/defaults: `bun run test:config`; if template/defaults changed, `bun run generate:config-example` - Config/schema/defaults: `bun run test:config`; if template/defaults changed, `bun run generate:config-example`
- Launcher/plugin: `bun run test:launcher` or `bun run test:env` - Launcher/plugin: `bun run test:launcher` or `bun run test:env`
- Runtime-compat / dist-sensitive: `bun run test:runtime:compat` - Runtime-compat / dist-sensitive: `bun run test:runtime:compat`
- Stats dashboard UI (`stats/`): `bun run test:stats`
- Build/release scripts (`scripts/**`): `bun run test:scripts`
- Docs-only: `bun run docs:test`, then `bun run docs:build` - Docs-only: `bun run docs:test`, then `bun run docs:build`
- Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json`
## Docs Upkeep ## Docs Upkeep
+15
View File
@@ -1,5 +1,20 @@
# Changelog # Changelog
## 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.
## v0.17.0 (2026-06-15) ## v0.17.0 (2026-06-15)
### Changed ### Changed
+1
View File
@@ -213,6 +213,7 @@ On **Windows**, just run `SubMiner.exe` and the setup will open automatically on
subminer video.mkv # launch mpv with SubMiner subminer video.mkv # launch mpv with SubMiner
subminer /path/to/dir # pick a file with fzf subminer /path/to/dir # pick a file with fzf
subminer -R /path/to/dir # pick a file with rofi (Linux only) subminer -R /path/to/dir # pick a file with rofi (Linux only)
subminer -H # browse local watch history (replay / next episode / browse)
``` ```
On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it. On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 436 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 580 B

After

Width:  |  Height:  |  Size: 549 B

Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: mining
- Normalized generated card audio by default during media extraction, with `ankiConnect.media.normalizeAudio` available to keep raw source loudness when needed.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- `subminer app` background launches now start the stats server automatically when `stats.autoStartServer` is enabled, and skip startup when a background stats server is already running.
@@ -0,0 +1,6 @@
type: fixed
area: overlay
- Fixed character-dictionary surname/given-name splitting for unspaced AniList native names where romanized reading length misled the boundary guess (東紫乃 split as 東紫+乃, 渡辺真奈美 as 渡辺真+奈美), so surnames like 東 and 渡辺 were never highlighted with character portraits and unmatched leading kanji (渡) lost hover lookup entirely. Name parts are now resolved with MeCab person-name POS tags and reading validation when MeCab is available; without MeCab, terms are generated for both plausible boundaries so the real surname still matches. Existing snapshots regenerate automatically (format version bump), and snapshots built without MeCab are upgraded to exact MeCab splits once MeCab is detected.
- Fixed subtitle tokenization dropping a character entirely when Yomitan's greedy scan consumed trailing punctuation/whitespace past the term (e.g. 平 in 「(東)平 (平)え?」 matched across the paren with no exact-source headword). The scanner now retries shorter windows so the prefix term still tokenizes with its name match and portrait.
- Fixed character-name highlights being stripped when MeCab's context-dependent POS tag put the token in the annotation noise filter (e.g. 平 tagged as prefix 接頭詞 in 「あっ 平 これ…」 lost its highlight and portrait even though hover lookup showed the character). Confirmed name matches now survive the POS exclusion filter, and name matching alone keeps the annotation stage active when all other annotations are disabled.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: launcher
- Show cover art icons in the rofi watch-history picker, reusing AniList covers already stored in the stats database (extracted to `~/.cache/subminer/covers`).
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Fixed cancelling the Kiku field grouping dialog showing two "Field grouping cancelled" notifications when grouping was started via the trigger shortcut: the manual workflow already notifies about its outcome (cancelled, UI unavailable, failed), and the trigger path re-notified on top of it. The workflow now owns all outcome notifications, and a previously silent failure (the original card no longer loadable) gets its own message.
@@ -0,0 +1,6 @@
type: fixed
area: overlay
- Fixed Kiku manual field grouping freezing the overlay after adding a duplicate card: the field grouping modal now reliably appears above fullscreen mpv on Hyprland/Wayland by re-asserting window placement until the compositor maps the modal window, instead of a single post-show attempt that raced the async map and left the dialog invisible.
- Fixed manual field grouping staying broken after the first attempt: the request resolver is now always cleared once a choice is made or the request is abandoned, so later grouping attempts no longer short-circuit to an instant "Field grouping cancelled".
- Fixed a timed-out or failed field grouping request leaving an orphaned, invisible modal window covering mpv: abandoned requests now tear down the modal window and close the dialog so the overlay recovers immediately.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed words being highlighted green as known when a same-spelled Anki card taught a different reading (e.g. とこ parsed as 床 "bed" matching a known 床/ゆか "floor" card). The known-word cache now stores each card's word together with its reading and only matches when the token's reading agrees; cards without a reading field keep matching in any reading as before.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: launcher
- Added `subminer -H` / `--history` to browse local watch history, replay the last watched episode, continue to the next episode, or browse episodes with fzf/rofi.
-5
View File
@@ -1,5 +0,0 @@
type: fixed
area: logs
- Fixed log filenames to use the user's local date so log export includes the current logs instead of stale prior-day logs around UTC midnight.
- Expanded log export redaction to mask common PII and secrets, including IP addresses, emails, auth/cookie headers, yt-dlp cookie arguments, URL credentials, compound token/key/password fields, and signed YouTube media URL query strings.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: branding
- Replaced the SubMiner app icon with new pixel-art submarine artwork contributed by an anonymous community member, used across the app icon, tray, notifications, README, docs site, and stats page.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed secondary subtitles stacking dozens of one-syllable lines down the screen during karaoke-typeset openings/endings, which made the hover-pause band cover the whole video: karaoke-like event spam is now collapsed into a single deduped line, and the secondary subtitle area is height-capped so it always stays a strip at the top.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Show all trend chart titles by default, persist hidden-title choices, and add a per-chart top-title limit selector.
+7
View File
@@ -0,0 +1,7 @@
type: internal
area: testing
- Test lanes are now defined once in `scripts/test-lanes.ts` and discovered by directory instead of hand-maintained file lists in `package.json`; the unused `test:core:*`/`test:config:dist`/`test:full` scripts were removed.
- `scripts/run-test-lane.mjs` runs each test file in an isolated `bun test` process with a wall timeout, so a hanging test or leaked global can no longer cascade failures across the lane.
- Previously orphaned suites now run in CI: the stats dashboard tests (`bun run test:stats`), the `scripts/**` tests (`bun run test:scripts`, including the change-verification skill tests), the `test-plugin-process-start-retries.lua` plugin test, and the runtime-compat dist slice (now part of `bun run test:fast`).
- The change-verification skill gained a `stats` lane for `stats/` edits.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed subtitle text that Yomitan's parser cannot match (e.g. the truncated volitional in とこ戻ろ…) being rendered as plain, non-interactive text: it was invisible to hover/lookup and excluded from the n+1 word count, which could wrongly mark a sentence as n+1. Unparsed runs are now kept as hoverable tokens matching Yomitan's own segmentation; bracketed SFX/speaker captions and punctuation-only runs are still skipped.
@@ -1,7 +0,0 @@
type: fixed
area: youtube
- Improved YouTube card media generation by sending safer ffmpeg request options for resolved streams and skipping stale stream maps, including cached YouTube files.
- Added `youtube.mediaCache.mode` with `direct` and `background` modes so YouTube card audio/image extraction can optionally use a background yt-dlp media cache when direct stream extraction is unreliable; background mode now announces cache download start/readiness through queued overlay/OSD notifications, creates text-only cards while the cache downloads, queues media updates for the mined note IDs, fills audio/image fields once the cached file is ready, and caps background downloads at `youtube.mediaCache.maxHeight` 720p by default while honoring height changes between downloads.
- Cleaned stale YouTube background media cache files on startup and before new background downloads, and stopped in-flight background downloads when switching back to direct mode.
- Hardened YouTube media cache downloads with IPv4/extractor retry flags and made failed background downloads notify users and clear queued media updates instead of leaving them pending silently.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: youtube
- Fixed direct YouTube stream media extraction by parsing mpv EDL stream URLs with their byte-length guards, preventing trailing EDL segment options from corrupting signed googlevideo URLs and causing ffmpeg 403 errors.
+2 -1
View File
@@ -559,6 +559,7 @@
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio. "animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files. "animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false "syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Values: true | false
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips. "audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable. "fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. "maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
@@ -568,7 +569,7 @@
"refreshMinutes": 1440, // Minutes between known-word cache refreshes. "refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. "decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.
}, // Known words setting. }, // Known words setting.
"behavior": { "behavior": {
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false "overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
+3 -1
View File
@@ -161,13 +161,14 @@ Audio is extracted from the video file using the subtitle's start and end timest
"ankiConnect": { "ankiConnect": {
"media": { "media": {
"generateAudio": true, "generateAudio": true,
"normalizeAudio": true, // normalize generated clip loudness
"audioPadding": 0, // optional seconds before and after subtitle timing "audioPadding": 0, // optional seconds before and after subtitle timing
"maxMediaDuration": 30 // cap total duration in seconds "maxMediaDuration": 30 // cap total duration in seconds
} }
} }
``` ```
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream. Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream. Generated sentence audio is loudness-normalized by default during extraction; set `normalizeAudio` to `false` to keep raw source loudness.
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`. The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
@@ -347,6 +348,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
"imageType": "static", "imageType": "static",
"imageFormat": "jpg", "imageFormat": "jpg",
"imageQuality": 92, "imageQuality": 92,
"normalizeAudio": true,
"audioPadding": 0, "audioPadding": 0,
"maxMediaDuration": 30, "maxMediaDuration": 30,
}, },
+15
View File
@@ -1,5 +1,20 @@
# Changelog # Changelog
## 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.
## v0.17.0 (2026-06-15) ## v0.17.0 (2026-06-15)
**Changed** **Changed**
+2
View File
@@ -56,6 +56,8 @@ A single character produces many searchable terms so that names are recognized r
- Family name alone: 須々木 - Family name alone: 須々木
- Given name alone: 心一 - Given name alone: 心一
Unspaced native names (AniList often stores 渡辺真奈美 without a separator) are split into family/given parts with MeCab when it is available: person-name POS tags (姓/名) decide the boundary, validated against AniList's romanized first/last name readings. Without MeCab, a length heuristic based on the romanized readings guesses the boundary — and because that guess can be ambiguous (東紫乃 could be 東+紫乃 or 東紫+乃), terms are generated for the top two candidate boundaries so the real surname still matches. Snapshots built without MeCab are regenerated automatically once MeCab becomes available, upgrading them to the exact splits.
**Middle-dot removal** (common in katakana foreign names): **Middle-dot removal** (common in katakana foreign names):
- ア・リ・ス → アリス (combined), plus individual segments - ア・リ・ス → アリス (combined), plus individual segments
+3 -1
View File
@@ -951,6 +951,7 @@ Enable automatic Anki card creation and updates with media generation:
"animatedMaxWidth": 640, "animatedMaxWidth": 640,
"animatedMaxHeight": 0, "animatedMaxHeight": 0,
"animatedCrf": 35, "animatedCrf": 35,
"normalizeAudio": true,
"audioPadding": 0, "audioPadding": 0,
"fallbackDuration": 3, "fallbackDuration": 3,
"maxMediaDuration": 30 "maxMediaDuration": 30
@@ -1001,6 +1002,7 @@ This example is intentionally compact. The option table below documents availabl
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. | | `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. | | `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) | | `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. |
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) | | `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) | | `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) | | `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
@@ -1538,7 +1540,7 @@ Set defaults used by managed subtitle auto-selection and the `subminer` launcher
| `mediaCache.mode` | `direct` \| `background` | YouTube card audio/image extraction mode (default `direct`) | | `mediaCache.mode` | `direct` \| `background` | YouTube card audio/image extraction mode (default `direct`) |
| `mediaCache.maxHeight` | number | Maximum background cache download height. Set `0` for unlimited (default `720`) | | `mediaCache.maxHeight` | number | Maximum background cache download height. Set `0` for unlimited (default `720`) |
`mediaCache.mode: "direct"` extracts card media from the active YouTube stream URL. `mediaCache.mode: "background"` starts a separate yt-dlp media download after YouTube playback has loaded. Playback and subtitle loading do not wait for that download. Use background mode if direct card media generation hits YouTube `403` errors from expiring stream URLs. `mediaCache.mode: "direct"` extracts card media from the active YouTube stream URL. `mediaCache.mode: "background"` starts a separate yt-dlp media download after YouTube playback has loaded, including YouTube URLs opened directly in mpv and resolved stream URLs when mpv still exposes the original YouTube playlist entry. Playback and subtitle loading do not wait for that download. Use background mode if direct card media generation hits YouTube `403` errors from expiring stream URLs.
Background cache downloads are capped by `mediaCache.maxHeight`, which defaults to 720p; set it to `0` to let yt-dlp choose the best available height. Downloads use IPv4 and yt-dlp retry flags to reduce YouTube throttling failures. SubMiner announces when the background cache download starts and when the cache is ready, using the configured notification surface; overlay and OSD messages queue until the overlay or mpv is ready. If you mine cards before the cache is ready, SubMiner creates the text fields immediately, queues the audio/image work for those note IDs, shows a status notification, and fills the media fields once the cached file is ready. If the cache download fails, SubMiner shows a failure notification, shows queued-card failure notifications, and clears the pending updates. Background cache downloads are capped by `mediaCache.maxHeight`, which defaults to 720p; set it to `0` to let yt-dlp choose the best available height. Downloads use IPv4 and yt-dlp retry flags to reduce YouTube throttling failures. SubMiner announces when the background cache download starts and when the cache is ready, using the configured notification surface; overlay and OSD messages queue until the overlay or mpv is ready. If you mine cards before the cache is ready, SubMiner creates the text fields immediately, queues the audio/image work for those note IDs, shows a status notification, and fills the media fields once the cached file is ready. If the cache download fails, SubMiner shows a failure notification, shows queued-card failure notifications, and clears the pending updates.
+11 -7
View File
@@ -80,18 +80,24 @@ Default lanes:
```bash ```bash
bun run test # alias for test:fast bun run test # alias for test:fast
bun run test:fast # default fast lane bun run test:fast # full source lanes: src + launcher-unit + scripts + runtime compat
bun run test:full # maintained source + launcher-unit + runtime compat surface
bun run test:runtime:compat # compiled/runtime compatibility slice only bun run test:runtime:compat # compiled/runtime compatibility slice only
bun run test:env # launcher/plugin + env-sensitive verification bun run test:env # launcher/plugin + env-sensitive verification
bun run test:stats # stats dashboard UI suite
bun run test:immersion:sqlite # SQLite persistence lane bun run test:immersion:sqlite # SQLite persistence lane
bun run test:subtitle # maintained alass/ffsubsync subtitle surface bun run test:subtitle # maintained alass/ffsubsync subtitle surface
``` ```
- `bun run test` and `bun run test:fast` cover config/core suites plus representative entry/runtime, Anki integration, release-workflow coverage, typecheck, and runtime-registry checks. Test lane membership is defined once in `scripts/test-lanes.ts` and discovered by
- `bun run test:full` is the maintained full surface: Bun-compatible `src/**` discovery, Bun-compatible launcher unit discovery, and the compiled/runtime compatibility lane for suites routed through `dist/**`. directory, so new test files join their lane automatically. `scripts/run-test-lane.mjs`
runs each test file in its own `bun test` process (per-file isolation) so a hanging
test or leaked global in one file cannot cascade into the rest of the lane; pass
`--jobs N` to parallelize or `--single-process` for one shared process.
- `bun run test` and `bun run test:fast` cover the full discovered `src/**` suite, launcher unit tests, `scripts/**` tests, and the compiled/runtime compatibility lane.
- `bun run test:runtime:compat` covers the compiled/runtime slice directly: `ipc`, `anki-jimaku-ipc`, `overlay-manager`, `config-validation`, `startup-config`, and `registry`. - `bun run test:runtime:compat` covers the compiled/runtime slice directly: `ipc`, `anki-jimaku-ipc`, `overlay-manager`, `config-validation`, `startup-config`, and `registry`.
- `bun run test:env` covers environment-sensitive checks: launcher smoke/plugin verification plus the Bun source SQLite lane. - `bun run test:env` covers environment-sensitive checks: launcher smoke/plugin verification plus the Bun source SQLite lane.
- `bun run test:stats` runs the stats dashboard suite under `stats/src/**`.
- `bun run test:immersion:sqlite` is the reproducible persistence lane when you need real DB-backed SQLite coverage under Bun. - `bun run test:immersion:sqlite` is the reproducible persistence lane when you need real DB-backed SQLite coverage under Bun.
The Bun-managed discovery lanes intentionally exclude a small compiled/runtime-focused set: `src/core/services/ipc.test.ts`, `src/core/services/anki-jimaku-ipc.test.ts`, `src/core/services/overlay-manager.test.ts`, `src/main/config-validation.test.ts`, `src/main/runtime/startup-config.test.ts`, and `src/main/runtime/registry.test.ts`. `bun run test:runtime:compat` keeps them in the standard workflow via `dist/**`. The Bun-managed discovery lanes intentionally exclude a small compiled/runtime-focused set: `src/core/services/ipc.test.ts`, `src/core/services/anki-jimaku-ipc.test.ts`, `src/core/services/overlay-manager.test.ts`, `src/main/config-validation.test.ts`, `src/main/runtime/startup-config.test.ts`, and `src/main/runtime/registry.test.ts`. `bun run test:runtime:compat` keeps them in the standard workflow via `dist/**`.
@@ -126,11 +132,11 @@ Focused commands:
```bash ```bash
bun run test:config # Source-level config schema/validation tests bun run test:config # Source-level config schema/validation tests
bun run test:launcher # Launcher regression tests (config discovery + command routing) bun run test:launcher # Launcher regression tests (config discovery + command routing)
bun run test:core # Source-level core regression tests (default lane)
bun run test:launcher:smoke:src # Launcher e2e smoke: launcher -> mpv IPC -> overlay start/stop wiring bun run test:launcher:smoke:src # Launcher e2e smoke: launcher -> mpv IPC -> overlay start/stop wiring
bun run test:launcher:env:src # Launcher smoke + Lua plugin gate bun run test:launcher:env:src # Launcher smoke + Lua plugin gate
bun run test:src # Bun-managed maintained src/** discovery lane bun run test:src # Bun-managed maintained src/** discovery lane
bun run test:launcher:unit:src # Bun-managed maintained launcher unit lane bun run test:launcher:unit:src # Bun-managed maintained launcher unit lane
bun run test:scripts # Bun-managed scripts/** test lane
bun run test:immersion:sqlite:src # Bun source lane bun run test:immersion:sqlite:src # Bun source lane
``` ```
@@ -144,8 +150,6 @@ Smoke and optional deep dist commands:
bun run build # compile dist artifacts bun run build # compile dist artifacts
bun run test:immersion:sqlite # compile + run SQLite-backed immersion tests under Bun bun run test:immersion:sqlite # compile + run SQLite-backed immersion tests under Bun
bun run test:smoke:dist # explicit smoke scope for compiled runtime bun run test:smoke:dist # explicit smoke scope for compiled runtime
bun run test:config:dist # optional full dist config suite
bun run test:core:dist # optional full dist core suite
``` ```
Use `bun run test:immersion:sqlite` when you need real DB-backed coverage for the immersion tracker. Use `bun run test:immersion:sqlite` when you need real DB-backed coverage for the immersion tracker.
+1 -1
View File
@@ -95,7 +95,7 @@ Stats server config lives under `stats`:
- `toggleKey` is overlay-local, not a system-wide shortcut. - `toggleKey` is overlay-local, not a system-wide shortcut.
- `serverPort` controls the localhost dashboard URL. - `serverPort` controls the localhost dashboard URL.
- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running. - `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running. Background app launches (`subminer app`) start the stats server immediately, registering it so later launches reuse it instead of starting another one.
- `autoOpenBrowser` controls whether `subminer stats` launches the dashboard URL in your browser after ensuring the server is running. - `autoOpenBrowser` controls whether `subminer stats` launches the dashboard URL in your browser after ensuring the server is running.
- `subminer stats` forces the dashboard server to start even when `autoStartServer` is `false`. - `subminer stats` forces the dashboard server to start even when `autoStartServer` is `false`.
- `subminer stats -b` starts or reuses the dedicated background stats daemon and exits after startup acknowledgement. - `subminer stats -b` starts or reuses the dedicated background stats daemon and exits after startup acknowledgement.
+18
View File
@@ -61,6 +61,23 @@ Override with the `SUBMINER_ROFI_THEME` environment variable:
SUBMINER_ROFI_THEME=/path/to/custom-theme.rasi subminer -R SUBMINER_ROFI_THEME=/path/to/custom-theme.rasi subminer -R
``` ```
## Watch History
`subminer -H` (or `--history`) browses your local watch history, sourced from the immersion tracker database. It works with both pickers: fzf by default, rofi with `-R -H`.
```bash
subminer -H # fzf history browser
subminer -R -H # rofi history browser
```
The first menu lists every locally watched series, most recently watched first, using the parsed media title (e.g. the anime title) when available and the directory name otherwise. Selecting a series opens an action menu:
- **Replay last watched** — replays the most recently watched episode
- **Next episode** — plays the episode after the last watched one (continues into the next season directory when the season ends)
- **Browse episodes** — lists the video files in the series directory in episode order, using the same fzf/rofi episode picker as directory browsing; if the series has multiple season directories, a season menu is shown first
Series whose directories are not currently accessible (e.g. an unmounted network share) are hidden from the list. Watch history requires the immersion tracker database (`immersionTracking.dbPath`, default `<config dir>/immersion.sqlite`), which SubMiner populates during playback.
## Common Commands ## Common Commands
```bash ```bash
@@ -105,6 +122,7 @@ Use `subminer <subcommand> -h` for command-specific help.
| `-d, --directory` | Video search directory (default: cwd) | | `-d, --directory` | Video search directory (default: cwd) |
| `-r, --recursive` | Search directories recursively | | `-r, --recursive` | Search directories recursively |
| `-R, --rofi` | Use rofi instead of fzf | | `-R, --rofi` | Use rofi instead of fzf |
| `-H, --history` | Browse local watch history (see [Watch History](#watch-history)) |
| `--setup` | Open first-run setup popup manually | | `--setup` | Open first-run setup popup manually |
| `-v, --version` | Print installed SubMiner version | | `-v, --version` | Print installed SubMiner version |
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible | | `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 15 KiB

+2 -1
View File
@@ -559,6 +559,7 @@
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio. "animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files. "animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false "syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Values: true | false
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips. "audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable. "fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. "maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
@@ -568,7 +569,7 @@
"refreshMinutes": 1440, // Minutes between known-word cache refreshes. "refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. "decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.
}, // Known words setting. }, // Known words setting.
"behavior": { "behavior": {
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false "overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+20 -16
View File
@@ -74,23 +74,27 @@ Press **Ctrl+Alt+C** during YouTube playback to open the subtitle picker overlay
- Select different primary and secondary tracks - Select different primary and secondary tracks
- Retry track loading if the auto-load failed or picked the wrong track - Retry track loading if the auto-load failed or picked the wrong track
SubMiner shows an "Opening YouTube subtitle picker..." status through your configured notification
surface while it probes tracks and prepares the modal, then updates the subtitle download progress
card to a success notification after the selected tracks load.
The picker displays each track with its language, kind (manual/auto), and title when available. The picker displays each track with its language, kind (manual/auto), and title when available.
## Subtitle Format Handling ## Subtitle Format Handling
SubMiner handles several YouTube subtitle formats transparently: SubMiner handles several YouTube subtitle formats transparently:
| Format | Handling | | Format | Handling |
| ------ | -------- | | ---------------------- | -------------------------------------------------------- |
| `srt`, `vtt` | Used directly (preferred for manual tracks) | | `srt`, `vtt` | Used directly (preferred for manual tracks) |
| `srv1`, `srv2`, `srv3` | YouTube TimedText XML --- converted to VTT automatically | | `srv1`, `srv2`, `srv3` | YouTube TimedText XML --- converted to VTT automatically |
| Auto-generated VTT | Normalized to remove rolling-caption text duplication | | Auto-generated VTT | Normalized to remove rolling-caption text duplication |
For auto-generated tracks, SubMiner prefers `srv3` > `srv2` > `srv1` > `vtt` (TimedText XML produces cleaner output). For manual tracks, `srt` > `vtt` is preferred. For auto-generated tracks, SubMiner prefers `srv3` > `srv2` > `srv1` > `vtt` (TimedText XML produces cleaner output). For manual tracks, `srt` > `vtt` is preferred.
## Card Media Cache ## Card Media Cache
By default, YouTube card audio and screenshots are extracted directly from mpv's active stream URLs. If generated card media fails with YouTube `403` errors, set `youtube.mediaCache.mode` to `"background"`. Background mode starts a separate `yt-dlp` media download after playback loads, creates text fields immediately, queues audio/image work for mined notes, and fills those fields once the local cache file is ready. By default, YouTube card audio and screenshots are extracted directly from mpv's active stream URLs. If generated card media fails with YouTube `403` errors, set `youtube.mediaCache.mode` to `"background"`. Background mode starts a separate `yt-dlp` media download after playback loads, including YouTube URLs opened directly in mpv and resolved stream URLs when mpv still exposes the original YouTube playlist entry. It creates text fields immediately, queues audio/image work for mined notes, and fills those fields once the local cache file is ready.
Background cache downloads use IPv4 and retry flags to reduce YouTube throttling failures. If the background download still fails, SubMiner shows a cache failure notification, shows queued-card failure notifications, and clears those pending updates so cards are not left waiting silently. Background cache downloads use IPv4 and retry flags to reduce YouTube throttling failures. If the background download still fails, SubMiner shows a cache failure notification, shows queued-card failure notifications, and clears those pending updates so cards are not left waiting silently.
@@ -101,13 +105,13 @@ Background cache downloads use IPv4 and retry flags to reduce YouTube throttling
```jsonc ```jsonc
{ {
"youtube": { "youtube": {
"primarySubLanguages": ["ja", "jpn"] "primarySubLanguages": ["ja", "jpn"],
} },
} }
``` ```
| Option | Type | Description | | Option | Type | Description |
| ------ | ---- | ----------- | | --------------------- | ---------- | ------------------------------------------------------------------------------------- |
| `primarySubLanguages` | `string[]` | Language priority for YouTube primary subtitle auto-loading (default `["ja", "jpn"]`) | | `primarySubLanguages` | `string[]` | Language priority for YouTube primary subtitle auto-loading (default `["ja", "jpn"]`) |
### Secondary Subtitle Languages ### Secondary Subtitle Languages
@@ -119,16 +123,16 @@ Secondary track selection uses the shared `secondarySub` config:
"secondarySub": { "secondarySub": {
"secondarySubLanguages": [], "secondarySubLanguages": [],
"autoLoadSecondarySub": false, "autoLoadSecondarySub": false,
"defaultMode": "hover" "defaultMode": "hover",
} },
} }
``` ```
| Option | Type | Description | | Option | Type | Description |
| ------ | ---- | ----------- | | ----------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track. Default is empty (`[]`). For YouTube, SubMiner always tries an English track first regardless of this list. | | `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track. Default is empty (`[]`). For YouTube, SubMiner always tries an English track first regardless of this list. |
| `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary track (default: `false`) | | `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary track (default: `false`) |
| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) | | `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
Precedence: CLI flag > environment variable > `config.jsonc` > built-in default. Precedence: CLI flag > environment variable > `config.jsonc` > built-in default.
+15 -1
View File
@@ -3,10 +3,22 @@
# Verification # Verification
Status: active Status: active
Last verified: 2026-05-23 Last verified: 2026-07-06
Owner: Kyle Yasuda Owner: Kyle Yasuda
Read when: selecting the right verification lane for a change Read when: selecting the right verification lane for a change
## Lane Infrastructure
- Lane membership is defined once in `scripts/test-lanes.ts` and discovered by
directory — new test files join their lane automatically; never hand-list test
files in `package.json`.
- `scripts/run-test-lane.mjs` runs each test file in its own `bun test` process
(per-file isolation with a wall timeout) so a hanging test or leaked global in
one file cannot cascade into the rest of the lane. `--jobs N` parallelizes;
`--single-process` restores the shared-process mode for debugging.
- `bun run test:fast` is the full source gate: discovered `src/**`, launcher
unit, `scripts/**`, and the compiled runtime-compat slice.
## Default Handoff Gate ## Default Handoff Gate
```bash ```bash
@@ -31,6 +43,8 @@ bun run docs:build
- Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed - Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed
- Launcher/plugin: `bun run test:launcher` or `bun run test:env` - Launcher/plugin: `bun run test:launcher` or `bun run test:env`
- Runtime-compat / compiled behavior: `bun run test:runtime:compat` - Runtime-compat / compiled behavior: `bun run test:runtime:compat`
- Stats dashboard UI: `bun run test:stats`
- Build/release scripts (`scripts/**`): `bun run test:scripts`
- Coverage for the maintained source lane: `bun run test:coverage:src` - Coverage for the maintained source lane: `bun run test:coverage:src`
- Deep/local full gate: default handoff gate above - Deep/local full gate: default handoff gate above
+30
View File
@@ -0,0 +1,30 @@
// Minimal ambient typing for bun:sqlite. The launcher always runs under bun
// (see the build banner in package.json), but the repo typechecks with plain
// tsc which has no bun type definitions.
declare module 'bun:sqlite' {
export interface RunResult {
changes: number;
lastInsertRowid: number | bigint;
}
export interface Statement<ReturnType = unknown, ParamsType extends unknown[] = unknown[]> {
all(...params: ParamsType): ReturnType[];
get(...params: ParamsType): ReturnType | undefined;
run(...params: ParamsType): RunResult;
}
export class Database {
constructor(
filename: string,
options?: { readonly?: boolean; readwrite?: boolean; create?: boolean },
);
query<ReturnType = unknown, ParamsType extends unknown[] = unknown[]>(
sql: string,
): Statement<ReturnType, ParamsType>;
prepare<ReturnType = unknown, ParamsType extends unknown[] = unknown[]>(
sql: string,
): Statement<ReturnType, ParamsType>;
run(sql: string, ...params: unknown[]): RunResult;
close(throwOnError?: boolean): void;
}
}
+228
View File
@@ -0,0 +1,228 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fail, log } from '../log.js';
import { commandExists } from '../util.js';
import {
collectVideos,
findRofiTheme,
formatPickerLaunchError,
showFzfMenu,
showRofiMenu,
} from '../picker.js';
import {
findNextEpisode,
groupHistoryBySeries,
listSeasonDirs,
materializeCoverArt,
queryLocalWatchHistory,
resolveImmersionDbPath,
sortVideosByEpisode,
type HistorySeriesEntry,
} from '../history.js';
import type { Args } from '../types.js';
import type { LauncherCommandContext } from './context.js';
function checkPickerDependencies(args: Args): void {
if (args.useRofi) {
if (!commandExists('rofi')) fail('Missing dependency: rofi');
return;
}
if (!commandExists('fzf')) fail('Missing dependency: fzf');
}
function showRofiIndexMenu(
labels: string[],
prompt: string,
themePath: string | null,
icons: Array<string | null> = [],
): number {
const rofiArgs = ['-dmenu', '-i', '-matching', 'fuzzy', '-format', 'i', '-p', prompt];
const hasIcons = icons.some(Boolean);
if (hasIcons) rofiArgs.push('-show-icons');
if (themePath) {
rofiArgs.push('-theme', themePath);
} else {
rofiArgs.push('-theme-str', 'configuration { font: "Noto Sans CJK JP Regular 8";}');
}
if (hasIcons) {
rofiArgs.push('-theme-str', 'configuration { show-icons: true; }');
rofiArgs.push('-theme-str', 'element-icon { enabled: true; size: 3em; }');
}
const lines = labels.map((label, index) =>
icons[index] ? `${label}\u0000icon\u001f${icons[index]}` : label,
);
const result = spawnSync('rofi', rofiArgs, {
input: `${lines.join('\n')}\n`,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
});
if (result.error) {
fail(formatPickerLaunchError('rofi', result.error as NodeJS.ErrnoException));
}
const out = (result.stdout || '').trim();
if (!out) return -1;
const idx = Number.parseInt(out, 10);
return Number.isInteger(idx) && idx >= 0 && idx < labels.length ? idx : -1;
}
function showFzfIndexMenu(labels: string[], prompt: string): number {
const lines = labels.map((label, index) => `${index}\t${label}`);
const result = spawnSync(
'fzf',
[
'--ansi',
'--reverse',
'--ignore-case',
`--prompt=${prompt}: `,
'--delimiter=\t',
'--with-nth=2..',
],
{
input: `${lines.join('\n')}\n`,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'inherit'],
},
);
if (result.error) {
fail(formatPickerLaunchError('fzf', result.error as NodeJS.ErrnoException));
}
const picked = (result.stdout || '').trim();
const tab = picked.indexOf('\t');
if (tab === -1) return -1;
const idx = Number.parseInt(picked.slice(0, tab), 10);
return Number.isInteger(idx) && idx >= 0 && idx < labels.length ? idx : -1;
}
function pickIndex(
labels: string[],
prompt: string,
useRofi: boolean,
themePath: string | null,
icons: Array<string | null> = [],
): number {
if (labels.length === 0) return -1;
return useRofi
? showRofiIndexMenu(labels, prompt, themePath, icons)
: showFzfIndexMenu(labels, prompt);
}
function formatEpisodeLabel(entry: HistorySeriesEntry): string {
const { parsedSeason, parsedEpisode } = entry.lastWatched;
if (parsedEpisode === null) return '';
return parsedSeason !== null ? `S${parsedSeason}E${parsedEpisode}` : `E${parsedEpisode}`;
}
function formatSeriesLabel(entry: HistorySeriesEntry): string {
const episodeLabel = formatEpisodeLabel(entry);
return episodeLabel ? `${entry.displayName} [last: ${episodeLabel}]` : entry.displayName;
}
function pickEpisodeFromDir(dir: string, context: LauncherCommandContext): string | null {
const { args, scriptPath } = context;
const videos = sortVideosByEpisode(collectVideos(dir, false));
if (videos.length === 0) {
fail(`No video files found in: ${dir}`);
}
const selected = args.useRofi
? showRofiMenu(videos, dir, false, scriptPath, args.logLevel)
: showFzfMenu(videos);
return selected || null;
}
function browseEpisodes(
entry: HistorySeriesEntry,
context: LauncherCommandContext,
themePath: string | null,
): string | null {
const { args } = context;
const seasons = listSeasonDirs(entry.seriesRoot);
let dir = entry.seriesRoot;
if (seasons.length > 1) {
const idx = pickIndex(
seasons.map((season) => season.name),
`${entry.displayName} — Season`,
args.useRofi,
themePath,
);
if (idx < 0) return null;
dir = seasons[idx]!.path;
} else if (seasons.length === 1 && collectVideos(dir, false).length === 0) {
dir = seasons[0]!.path;
}
return pickEpisodeFromDir(dir, context);
}
export async function runHistoryCommand(context: LauncherCommandContext): Promise<string | null> {
const { args, scriptPath } = context;
checkPickerDependencies(args);
const themePath = args.useRofi ? findRofiTheme(scriptPath) : null;
const dbPath = resolveImmersionDbPath();
if (!fs.existsSync(dbPath)) {
fail(`Watch history database not found: ${dbPath}`);
}
const rows = queryLocalWatchHistory(dbPath);
const series = groupHistoryBySeries(rows);
if (series.length === 0) {
fail('No local watch history found (or watched directories are not accessible).');
}
log('info', args.logLevel, `Watch history: ${series.length} series found in ${dbPath}`);
const coverPaths = args.useRofi
? materializeCoverArt(
dbPath,
series.map((seriesEntry) => seriesEntry.coverBlobHash),
)
: new Map<string, string>();
const seriesIcons = series.map((seriesEntry) =>
seriesEntry.coverBlobHash ? (coverPaths.get(seriesEntry.coverBlobHash) ?? null) : null,
);
const seriesIdx = pickIndex(
series.map(formatSeriesLabel),
'Watch History',
args.useRofi,
themePath,
seriesIcons,
);
if (seriesIdx < 0) return null;
const entry = series[seriesIdx]!;
const lastPath = path.resolve(entry.lastWatched.sourcePath);
const lastExists = fs.existsSync(lastPath);
const nextEpisode = findNextEpisode(lastPath);
const actions: Array<{ kind: 'replay' | 'next' | 'browse'; label: string }> = [];
if (lastExists) {
actions.push({ kind: 'replay', label: `Replay last watched — ${path.basename(lastPath)}` });
}
if (nextEpisode) {
actions.push({ kind: 'next', label: `Next episode — ${path.basename(nextEpisode)}` });
}
actions.push({ kind: 'browse', label: 'Browse episodes' });
const entryIcon = seriesIcons[seriesIdx] ?? null;
const actionIdx = pickIndex(
actions.map((action) => action.label),
entry.displayName,
args.useRofi,
themePath,
actions.map(() => entryIcon),
);
if (actionIdx < 0) return null;
switch (actions[actionIdx]!.kind) {
case 'replay':
return lastPath;
case 'next':
return nextEpisode;
case 'browse':
return browseEpisodes(entry, context, themePath);
}
}
@@ -35,6 +35,7 @@ function createContext(): LauncherCommandContext {
texthookerOnly: false, texthookerOnly: false,
texthookerOpenBrowser: false, texthookerOpenBrowser: false,
useRofi: false, useRofi: false,
history: false,
logLevel: 'info', logLevel: 'info',
logRotation: 7, logRotation: 7,
passwordStore: '', passwordStore: '',
+2
View File
@@ -198,6 +198,7 @@ export function createDefaultArgs(
texthookerOnly: false, texthookerOnly: false,
texthookerOpenBrowser: false, texthookerOpenBrowser: false,
useRofi: false, useRofi: false,
history: false,
logLevel: loggingConfig.level ?? 'warn', logLevel: loggingConfig.level ?? 'warn',
logRotation: loggingConfig.rotation ?? 7, logRotation: loggingConfig.rotation ?? 7,
passwordStore: '', passwordStore: '',
@@ -231,6 +232,7 @@ export function applyRootOptionsToArgs(
if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel); if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel);
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore; if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
if (options.rofi === true) parsed.useRofi = true; if (options.rofi === true) parsed.useRofi = true;
if (options.history === true) parsed.history = true;
if (options.update === true) parsed.update = true; if (options.update === true) parsed.update = true;
if (options.version === true) parsed.version = true; if (options.version === true) parsed.version = true;
if (options.settings === true) parsed.settings = true; if (options.settings === true) parsed.settings = true;
+1
View File
@@ -64,6 +64,7 @@ function applyRootOptions(program: Command): void {
.option('--settings', 'Open settings window') .option('--settings', 'Open settings window')
.option('-u, --update', 'Check for updates') .option('-u, --update', 'Check for updates')
.option('-R, --rofi', 'Use rofi picker') .option('-R, --rofi', 'Use rofi picker')
.option('-H, --history', 'Browse local watch history')
.option('-S, --start-overlay', 'Auto-start overlay') .option('-S, --start-overlay', 'Auto-start overlay')
.option('-T, --no-texthooker', 'Disable texthooker-ui server'); .option('-T, --no-texthooker', 'Disable texthooker-ui server');
} }
+126
View File
@@ -0,0 +1,126 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Database } from 'bun:sqlite';
import { withReadonlyWalRetry } from './history-db.js';
const COVER_EXTENSIONS = ['.jpg', '.png', '.webp', '.gif'] as const;
const SAFE_COVER_HASH_PATTERN = /^[a-z0-9_-]+$/i;
export function getDefaultCoverCacheDir(): string {
return path.join(os.homedir(), '.cache', 'subminer', 'covers');
}
export function detectImageExtension(blob: Buffer): string {
if (blob.length >= 8 && blob.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) {
return '.png';
}
if (blob.length >= 3 && blob[0] === 0xff && blob[1] === 0xd8 && blob[2] === 0xff) {
return '.jpg';
}
if (
blob.length >= 12 &&
blob.subarray(0, 4).toString('ascii') === 'RIFF' &&
blob.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return '.webp';
}
if (blob.length >= 4 && blob.subarray(0, 3).toString('ascii') === 'GIF') {
return '.gif';
}
return '.jpg';
}
function findCachedCover(cacheDir: string, hash: string): string | null {
for (const ext of COVER_EXTENSIONS) {
const candidate = path.join(cacheDir, `${hash}${ext}`);
try {
if (fs.statSync(candidate).size > 0) return candidate;
} catch {
// not cached with this extension
}
}
return null;
}
function queryCoverBlobs(
dbPath: string,
hashes: string[],
options: { readonly?: boolean; readwrite?: boolean; create?: boolean },
): Map<string, Buffer> {
const blobs = new Map<string, Buffer>();
const db = new Database(dbPath, options);
try {
const hasBlobTable = db
.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'imm_cover_art_blobs'`)
.get();
if (!hasBlobTable) return blobs;
const stmt = db.query<{ cover_blob: Uint8Array | null }>(
'SELECT cover_blob FROM imm_cover_art_blobs WHERE blob_hash = ?',
);
for (const hash of hashes) {
const row = stmt.get(hash);
if (row?.cover_blob && row.cover_blob.length > 0) {
blobs.set(hash, Buffer.from(row.cover_blob));
}
}
return blobs;
} finally {
db.close();
}
}
function isSafeCoverHash(hash: string | null | undefined): hash is string {
return typeof hash === 'string' && SAFE_COVER_HASH_PATTERN.test(hash);
}
/**
* Ensures cover art blobs referenced by hash exist as image files in the cache
* directory, extracting missing ones from the stats database. Returns a map of
* blob hash to on-disk image path for every cover that could be materialized.
*/
export function materializeCoverArt(
dbPath: string,
hashes: Array<string | null | undefined>,
cacheDir: string = getDefaultCoverCacheDir(),
): Map<string, string> {
const wanted = Array.from(new Set(hashes.filter(isSafeCoverHash)));
const resolved = new Map<string, string>();
if (wanted.length === 0) return resolved;
const missing: string[] = [];
for (const hash of wanted) {
const cached = findCachedCover(cacheDir, hash);
if (cached) {
resolved.set(hash, cached);
} else {
missing.push(hash);
}
}
if (missing.length === 0) return resolved;
let blobs: Map<string, Buffer>;
try {
blobs = withReadonlyWalRetry(dbPath, (options) => queryCoverBlobs(dbPath, missing, options));
} catch {
return resolved;
}
if (blobs.size === 0) return resolved;
try {
fs.mkdirSync(cacheDir, { recursive: true });
} catch {
return resolved;
}
for (const [hash, blob] of blobs) {
const target = path.join(cacheDir, `${hash}${detectImageExtension(blob)}`);
try {
fs.writeFileSync(target, blob);
resolved.set(hash, target);
} catch {
// cache write failure just means no icon for this entry
}
}
return resolved;
}
+151
View File
@@ -0,0 +1,151 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Database } from 'bun:sqlite';
import { resolveConfigDir } from '../src/config/path-resolution.js';
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
import type { HistoryVideoRow } from './history-types.js';
import { resolvePathMaybe } from './util.js';
export function resolveImmersionDbPath(): string {
const root = readLauncherMainConfigObject();
const tracking =
root?.immersionTracking &&
typeof root.immersionTracking === 'object' &&
!Array.isArray(root.immersionTracking)
? (root.immersionTracking as Record<string, unknown>)
: null;
const configured = typeof tracking?.dbPath === 'string' ? tracking.dbPath.trim() : '';
if (configured) return resolvePathMaybe(configured);
const configDir = resolveConfigDir({
platform: process.platform,
appDataDir: process.env.APPDATA,
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
existsSync: fs.existsSync,
});
return path.join(configDir, 'immersion.sqlite');
}
interface RawHistoryRow {
video_id: number;
source_path: string | null;
parsed_title: string | null;
parsed_season: number | null;
parsed_episode: number | null;
anime_title: string | null;
last_watched_ms: number | bigint | null;
cover_blob_hash: string | null;
}
export function queryLocalWatchHistory(dbPath: string): HistoryVideoRow[] {
return withReadonlyWalRetry(dbPath, (options) => readHistoryRows(dbPath, options));
}
export function withReadonlyWalRetry<T>(
dbPath: string,
query: (options: { readonly?: boolean; readwrite?: boolean; create?: boolean }) => T,
): T {
try {
return query({ readonly: true });
} catch (error) {
if (!isReadonlyWalRetryError(error, dbPath)) throw error;
return query({ readwrite: true, create: false });
}
}
export function isReadonlyWalRetryError(error: unknown, dbPath: string): boolean {
if (!isWalModeSqliteDatabase(dbPath)) return false;
const code =
typeof error === 'object' && error !== null && 'code' in error
? String((error as { code?: unknown }).code ?? '')
: '';
const message = error instanceof Error ? error.message : String(error);
const text = `${code} ${message}`.toLowerCase();
return (
text.includes('readonly') ||
text.includes('read-only') ||
text.includes('attempt to write a readonly database') ||
text.includes('sqlite_cantopen') ||
text.includes('unable to open database file')
);
}
function isWalModeSqliteDatabase(dbPath: string): boolean {
const header = Buffer.alloc(20);
let fd: number | null = null;
try {
fd = fs.openSync(dbPath, 'r');
if (fs.readSync(fd, header, 0, header.length, 0) < header.length) return false;
} catch {
return false;
} finally {
if (fd !== null) fs.closeSync(fd);
}
return header.subarray(0, 16).toString('ascii') === 'SQLite format 3\0' && header[18] === 2;
}
function tableExists(db: Database, tableName: string): boolean {
return Boolean(
db.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(tableName),
);
}
function readHistoryRows(
dbPath: string,
options: { readonly?: boolean; readwrite?: boolean; create?: boolean },
): HistoryVideoRow[] {
const db = new Database(dbPath, options);
try {
const hasMediaArt = tableExists(db, 'imm_media_art');
const coverSelect = hasMediaArt
? `COALESCE(
ma.cover_blob_hash,
(SELECT ma2.cover_blob_hash
FROM imm_media_art ma2
JOIN imm_videos v2 ON v2.video_id = ma2.video_id
WHERE v2.anime_id = v.anime_id AND ma2.cover_blob_hash IS NOT NULL
LIMIT 1)
) AS cover_blob_hash`
: 'NULL AS cover_blob_hash';
const coverJoin = hasMediaArt ? 'LEFT JOIN imm_media_art ma ON ma.video_id = v.video_id' : '';
const rows = db
.query<RawHistoryRow>(
`
SELECT
v.video_id,
v.source_path,
v.parsed_title,
v.parsed_season,
v.parsed_episode,
COALESCE(a.title_romaji, a.canonical_title) AS anime_title,
MAX(CAST(s.started_at_ms AS INTEGER)) AS last_watched_ms,
${coverSelect}
FROM imm_sessions s
JOIN imm_videos v ON v.video_id = s.video_id
LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
${coverJoin}
WHERE v.source_type = 1 AND v.source_path IS NOT NULL AND v.source_path != ''
GROUP BY v.video_id
ORDER BY last_watched_ms DESC
`,
)
.all();
return rows
.filter((row) => typeof row.source_path === 'string' && row.source_path.length > 0)
.map((row) => ({
videoId: row.video_id,
sourcePath: row.source_path!,
parsedTitle: row.parsed_title,
parsedSeason: row.parsed_season,
parsedEpisode: row.parsed_episode,
animeTitle: row.anime_title,
lastWatchedMs: Number(row.last_watched_ms ?? 0),
coverBlobHash: row.cover_blob_hash,
}));
} finally {
db.close();
}
}
+132
View File
@@ -0,0 +1,132 @@
import fs from 'node:fs';
import path from 'node:path';
import { parseMediaInfo } from '../src/jimaku/utils.js';
import { collectVideos } from './picker.js';
import type { HistorySeriesEntry, HistoryVideoRow, SeasonDirEntry } from './history-types.js';
const SEASON_DIR_PATTERN = /^(?:season|s)[\s._-]*(\d{1,3})\b/i;
export function seasonNumberFromDirName(name: string): number | null {
const match = name.trim().match(SEASON_DIR_PATTERN);
if (!match) return null;
const parsed = Number.parseInt(match[1]!, 10);
return Number.isFinite(parsed) ? parsed : null;
}
export function resolveSeriesRoot(filePath: string): string {
const parent = path.dirname(filePath);
if (seasonNumberFromDirName(path.basename(parent)) !== null) {
return path.dirname(parent);
}
return parent;
}
export function groupHistoryBySeries(
rows: HistoryVideoRow[],
existsFn: (candidate: string) => boolean = fs.existsSync,
): HistorySeriesEntry[] {
const byRoot = new Map<string, HistorySeriesEntry>();
const sorted = [...rows].sort((a, b) => b.lastWatchedMs - a.lastWatchedMs);
for (const row of sorted) {
const seriesRoot = resolveSeriesRoot(row.sourcePath);
const existing = byRoot.get(seriesRoot);
if (existing) {
if (existing.coverBlobHash === null && row.coverBlobHash !== null) {
existing.coverBlobHash = row.coverBlobHash;
}
continue;
}
if (!existsFn(seriesRoot)) continue;
const displayName =
row.parsedTitle?.trim() || row.animeTitle?.trim() || path.basename(seriesRoot);
byRoot.set(seriesRoot, {
seriesRoot,
displayName,
lastWatched: row,
coverBlobHash: row.coverBlobHash,
});
}
return Array.from(byRoot.values());
}
function compareNatural(a: string, b: string): number {
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
}
export function sortVideosByEpisode(videos: string[]): string[] {
const parsed = videos.map((video) => ({ video, info: parseMediaInfo(video) }));
parsed.sort((a, b) => {
if (a.info.episode !== null && b.info.episode !== null) {
const seasonA = a.info.season ?? 0;
const seasonB = b.info.season ?? 0;
if (seasonA !== seasonB) return seasonA - seasonB;
if (a.info.episode !== b.info.episode) return a.info.episode - b.info.episode;
}
return compareNatural(a.video, b.video);
});
return parsed.map((entry) => entry.video);
}
function dirContainsVideo(dir: string): boolean {
return collectVideos(dir, true).length > 0;
}
export function listSeasonDirs(seriesRoot: string): SeasonDirEntry[] {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(seriesRoot, { withFileTypes: true });
} catch {
return [];
}
const dirs = entries
.filter((entry) => entry.isDirectory())
.map((entry) => ({
name: entry.name,
path: path.join(seriesRoot, entry.name),
season: seasonNumberFromDirName(entry.name),
}))
.filter((entry) => dirContainsVideo(entry.path));
dirs.sort((a, b) => {
if (a.season !== null && b.season !== null && a.season !== b.season) {
return a.season - b.season;
}
return compareNatural(a.name, b.name);
});
return dirs;
}
function findFirstEpisodeInNextSeason(resolvedLast: string, dir: string): string | null {
const seriesRoot = resolveSeriesRoot(resolvedLast);
if (seriesRoot === dir) return null;
const seasons = listSeasonDirs(seriesRoot);
const currentIdx = seasons.findIndex((season) => path.resolve(season.path) === dir);
if (currentIdx < 0 || currentIdx + 1 >= seasons.length) return null;
const nextSeason = sortVideosByEpisode(collectVideos(seasons[currentIdx + 1]!.path, false));
return nextSeason[0] ?? null;
}
export function findNextEpisode(lastPath: string): string | null {
const resolvedLast = path.resolve(lastPath);
const dir = path.dirname(resolvedLast);
const episodes = sortVideosByEpisode(collectVideos(dir, false));
const idx = episodes.indexOf(resolvedLast);
if (idx >= 0) {
if (idx + 1 < episodes.length) return episodes[idx + 1]!;
} else {
const lastInfo = parseMediaInfo(resolvedLast);
if (lastInfo.episode !== null) {
const candidate = episodes.find((episode) => {
const info = parseMediaInfo(episode);
return info.episode !== null && info.episode > lastInfo.episode!;
});
if (candidate) return candidate;
}
}
return findFirstEpisodeInNextSeason(resolvedLast, dir);
}
+23
View File
@@ -0,0 +1,23 @@
export interface HistoryVideoRow {
videoId: number;
sourcePath: string;
parsedTitle: string | null;
parsedSeason: number | null;
parsedEpisode: number | null;
animeTitle: string | null;
lastWatchedMs: number;
coverBlobHash: string | null;
}
export interface HistorySeriesEntry {
seriesRoot: string;
displayName: string;
lastWatched: HistoryVideoRow;
coverBlobHash: string | null;
}
export interface SeasonDirEntry {
name: string;
path: string;
season: number | null;
}
+441
View File
@@ -0,0 +1,441 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Database } from 'bun:sqlite';
import {
detectImageExtension,
findNextEpisode,
groupHistoryBySeries,
isReadonlyWalRetryError,
listSeasonDirs,
materializeCoverArt,
queryLocalWatchHistory,
resolveSeriesRoot,
seasonNumberFromDirName,
sortVideosByEpisode,
type HistoryVideoRow,
} from './history.js';
function makeRow(overrides: Partial<HistoryVideoRow> = {}): HistoryVideoRow {
return {
videoId: 1,
sourcePath: '/media/anime/Show/Season-1/Show - S01E01.mkv',
parsedTitle: 'Show',
parsedSeason: 1,
parsedEpisode: 1,
animeTitle: null,
lastWatchedMs: 1000,
coverBlobHash: null,
...overrides,
};
}
test('seasonNumberFromDirName detects common season directory names', () => {
assert.equal(seasonNumberFromDirName('Season-1'), 1);
assert.equal(seasonNumberFromDirName('Season 2'), 2);
assert.equal(seasonNumberFromDirName('S03'), 3);
assert.equal(seasonNumberFromDirName('season_04'), 4);
assert.equal(seasonNumberFromDirName('Specials'), null);
assert.equal(seasonNumberFromDirName('Show Name'), null);
});
test('resolveSeriesRoot skips season directories', () => {
assert.equal(
resolveSeriesRoot('/media/anime/Show/Season-1/Show - S01E01.mkv'),
'/media/anime/Show',
);
assert.equal(resolveSeriesRoot('/media/anime/Show/Show - 01.mkv'), '/media/anime/Show');
});
test('groupHistoryBySeries keeps most recent entry per series root', () => {
const rows = [
makeRow({ videoId: 1, parsedEpisode: 1, lastWatchedMs: 1000 }),
makeRow({
videoId: 2,
sourcePath: '/media/anime/Show/Season-1/Show - S01E02.mkv',
parsedEpisode: 2,
lastWatchedMs: 3000,
}),
makeRow({
videoId: 3,
sourcePath: '/media/anime/Other/Other - 05.mkv',
parsedTitle: 'Other',
parsedSeason: null,
parsedEpisode: 5,
lastWatchedMs: 2000,
}),
];
const series = groupHistoryBySeries(rows, () => true);
assert.equal(series.length, 2);
assert.equal(series[0]?.displayName, 'Show');
assert.equal(series[0]?.seriesRoot, '/media/anime/Show');
assert.equal(series[0]?.lastWatched.parsedEpisode, 2);
assert.equal(series[1]?.displayName, 'Other');
});
test('groupHistoryBySeries filters series roots that no longer exist', () => {
const rows = [
makeRow({ videoId: 1 }),
makeRow({
videoId: 2,
sourcePath: '/gone/anime/Missing/Season-1/Missing - S01E01.mkv',
parsedTitle: 'Missing',
lastWatchedMs: 5000,
}),
];
const series = groupHistoryBySeries(rows, (candidate) => !candidate.startsWith('/gone/'));
assert.equal(series.length, 1);
assert.equal(series[0]?.displayName, 'Show');
});
test('groupHistoryBySeries falls back to directory name for display', () => {
const rows = [
makeRow({
sourcePath: '/media/anime/Some Show Dir/video.mkv',
parsedTitle: null,
animeTitle: null,
}),
];
const series = groupHistoryBySeries(rows, () => true);
assert.equal(series[0]?.displayName, 'Some Show Dir');
});
test('sortVideosByEpisode orders by parsed episode with natural fallback', () => {
const videos = [
'/media/Show/Show - S01E10 - Ten.mkv',
'/media/Show/Show - S01E02 - Two.mkv',
'/media/Show/Show - S01E01 - One.mkv',
];
assert.deepEqual(sortVideosByEpisode(videos), [
'/media/Show/Show - S01E01 - One.mkv',
'/media/Show/Show - S01E02 - Two.mkv',
'/media/Show/Show - S01E10 - Ten.mkv',
]);
});
function createSeriesTree(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-'));
const seriesRoot = path.join(root, 'Show');
const season1 = path.join(seriesRoot, 'Season-1');
const season2 = path.join(seriesRoot, 'Season-2');
fs.mkdirSync(season1, { recursive: true });
fs.mkdirSync(season2, { recursive: true });
fs.mkdirSync(path.join(seriesRoot, 'extras-empty'), { recursive: true });
for (const name of ['Show - S01E01.mkv', 'Show - S01E02.mkv', 'Show - S01E03.mkv']) {
fs.writeFileSync(path.join(season1, name), '');
}
fs.writeFileSync(path.join(season2, 'Show - S02E01.mkv'), '');
fs.writeFileSync(path.join(season1, 'notes.txt'), '');
return seriesRoot;
}
test('listSeasonDirs returns only video-bearing directories in season order', () => {
const seriesRoot = createSeriesTree();
try {
const seasons = listSeasonDirs(seriesRoot);
assert.deepEqual(
seasons.map((entry) => entry.name),
['Season-1', 'Season-2'],
);
assert.deepEqual(
seasons.map((entry) => entry.season),
[1, 2],
);
} finally {
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
}
});
test('findNextEpisode advances within a season and across seasons', () => {
const seriesRoot = createSeriesTree();
try {
const season1 = path.join(seriesRoot, 'Season-1');
const season2 = path.join(seriesRoot, 'Season-2');
assert.equal(
findNextEpisode(path.join(season1, 'Show - S01E02.mkv')),
path.join(season1, 'Show - S01E03.mkv'),
);
assert.equal(
findNextEpisode(path.join(season1, 'Show - S01E03.mkv')),
path.join(season2, 'Show - S02E01.mkv'),
);
assert.equal(findNextEpisode(path.join(season2, 'Show - S02E01.mkv')), null);
} finally {
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
}
});
test('findNextEpisode falls back to episode numbers when file was removed', () => {
const seriesRoot = createSeriesTree();
try {
const season1 = path.join(seriesRoot, 'Season-1');
const missing = path.join(season1, 'Show - S01E02 - Deleted Cut.mkv');
assert.equal(findNextEpisode(missing), path.join(season1, 'Show - S01E03.mkv'));
} finally {
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
}
});
test('findNextEpisode advances seasons when a deleted file was the last episode', () => {
const seriesRoot = createSeriesTree();
try {
const season1 = path.join(seriesRoot, 'Season-1');
const season2 = path.join(seriesRoot, 'Season-2');
const missing = path.join(season1, 'Show - S01E03 - Deleted Cut.mkv');
assert.equal(findNextEpisode(missing), path.join(season2, 'Show - S02E01.mkv'));
} finally {
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
}
});
const PNG_MAGIC = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
function createHistoryDb(
dbPath: string,
options: { wal?: boolean; coverArt?: boolean } = {},
): void {
const db = new Database(dbPath);
try {
if (options.wal) db.run('PRAGMA journal_mode = WAL;');
db.run(`
CREATE TABLE imm_anime(
anime_id INTEGER PRIMARY KEY,
canonical_title TEXT,
title_romaji TEXT
);
`);
db.run(`
CREATE TABLE imm_videos(
video_id INTEGER PRIMARY KEY,
anime_id INTEGER,
source_type INTEGER,
source_path TEXT,
parsed_title TEXT,
parsed_season INTEGER,
parsed_episode INTEGER
);
`);
db.run(`
CREATE TABLE imm_sessions(
session_id INTEGER PRIMARY KEY,
video_id INTEGER,
started_at_ms TEXT
);
`);
db.run(`INSERT INTO imm_anime VALUES (1, 'Show Season 1', 'Show Romaji');`);
db.run(`
INSERT INTO imm_videos VALUES
(1, 1, 1, '/media/Show/Season-1/Show - S01E01.mkv', 'Show', 1, 1),
(2, 1, 1, '/media/Show/Season-1/Show - S01E02.mkv', 'Show', 1, 2),
(3, NULL, 2, NULL, 'Remote Show', NULL, NULL),
(4, NULL, 1, '', 'Empty Path', NULL, NULL);
`);
db.run(`
INSERT INTO imm_sessions VALUES
(1, 1, '1000'),
(2, 1, '5000'),
(3, 2, '3000'),
(4, 3, '9000');
`);
if (options.coverArt) {
db.run(`
CREATE TABLE imm_media_art(
video_id INTEGER PRIMARY KEY,
cover_blob_hash TEXT
);
`);
db.run(`
CREATE TABLE imm_cover_art_blobs(
blob_hash TEXT PRIMARY KEY,
cover_blob BLOB NOT NULL
);
`);
// Art only on video 1; video 2 resolves it through the shared anime_id.
db.run(`INSERT INTO imm_media_art VALUES (1, 'hash-1');`);
db.query('INSERT INTO imm_cover_art_blobs VALUES (?, ?)').run('hash-1', PNG_MAGIC);
}
if (options.wal) db.run('PRAGMA wal_checkpoint(TRUNCATE);');
} finally {
db.close();
}
}
function assertHistoryRows(dbPath: string): void {
const rows = queryLocalWatchHistory(dbPath);
assert.equal(rows.length, 2);
assert.equal(rows[0]?.videoId, 1);
assert.equal(rows[0]?.lastWatchedMs, 5000);
assert.equal(rows[0]?.animeTitle, 'Show Romaji');
assert.equal(rows[1]?.videoId, 2);
assert.equal(rows[1]?.lastWatchedMs, 3000);
}
test('queryLocalWatchHistory returns local files ordered by most recent session', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-db-'));
const dbPath = path.join(dir, 'immersion.sqlite');
try {
createHistoryDb(dbPath);
assertHistoryRows(dbPath);
const rows = queryLocalWatchHistory(dbPath);
assert.equal(rows[0]?.coverBlobHash, null);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('queryLocalWatchHistory resolves cover hashes directly and via shared anime', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-art-'));
const dbPath = path.join(dir, 'immersion.sqlite');
try {
createHistoryDb(dbPath, { coverArt: true });
const rows = queryLocalWatchHistory(dbPath);
assert.equal(rows[0]?.videoId, 1);
assert.equal(rows[0]?.coverBlobHash, 'hash-1');
assert.equal(rows[1]?.videoId, 2);
assert.equal(rows[1]?.coverBlobHash, 'hash-1');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('materializeCoverArt extracts blobs to the cache dir and reuses cached files', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-covers-'));
const dbPath = path.join(dir, 'immersion.sqlite');
const cacheDir = path.join(dir, 'covers');
try {
createHistoryDb(dbPath, { coverArt: true });
const covers = materializeCoverArt(
dbPath,
['hash-1', 'hash-1', null, 'hash-missing'],
cacheDir,
);
const coverPath = covers.get('hash-1');
assert.ok(coverPath);
assert.equal(path.extname(coverPath!), '.png');
assert.ok(fs.statSync(coverPath!).size > 0);
assert.equal(covers.has('hash-missing'), false);
// Cached file is reused even when the database has disappeared.
fs.rmSync(dbPath);
const cachedCovers = materializeCoverArt(dbPath, ['hash-1'], cacheDir);
assert.equal(cachedCovers.get('hash-1'), coverPath);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('materializeCoverArt rejects cover hashes that escape the cache dir', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-cover-safety-'));
const dbPath = path.join(dir, 'immersion.sqlite');
const cacheDir = path.join(dir, 'covers');
const unsafeHash = '../escape';
try {
createHistoryDb(dbPath, { coverArt: true });
const db = new Database(dbPath);
try {
db.query('INSERT INTO imm_cover_art_blobs VALUES (?, ?)').run(unsafeHash, PNG_MAGIC);
} finally {
db.close();
}
const covers = materializeCoverArt(dbPath, [unsafeHash], cacheDir);
assert.equal(covers.has(unsafeHash), false);
assert.equal(fs.existsSync(path.join(dir, 'escape.png')), false);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('detectImageExtension identifies common cover formats', () => {
assert.equal(detectImageExtension(PNG_MAGIC), '.png');
assert.equal(detectImageExtension(Buffer.from([0xff, 0xd8, 0xff, 0xe0])), '.jpg');
assert.equal(detectImageExtension(Buffer.from('RIFF0000WEBPVP8 ', 'ascii')), '.webp');
assert.equal(detectImageExtension(Buffer.from('GIF89a', 'ascii')), '.gif');
assert.equal(detectImageExtension(Buffer.from('unknown', 'ascii')), '.jpg');
});
test('groupHistoryBySeries backfills cover hash from older rows of the same series', () => {
const rows = [
makeRow({ videoId: 2, parsedEpisode: 2, lastWatchedMs: 3000, coverBlobHash: null }),
makeRow({ videoId: 1, parsedEpisode: 1, lastWatchedMs: 1000, coverBlobHash: 'hash-1' }),
];
const series = groupHistoryBySeries(rows, () => true);
assert.equal(series.length, 1);
assert.equal(series[0]?.lastWatched.videoId, 2);
assert.equal(series[0]?.coverBlobHash, 'hash-1');
});
test('queryLocalWatchHistory reads a cleanly-closed WAL database', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-wal-'));
const dbPath = path.join(dir, 'immersion.sqlite');
try {
createHistoryDb(dbPath, { wal: true });
// Reproduce the state after the app shuts down cleanly: WAL journal mode
// with no -wal/-shm sidecar files on disk. A read-only connection then
// fails at query time because it cannot recreate them.
fs.rmSync(`${dbPath}-wal`, { force: true });
fs.rmSync(`${dbPath}-shm`, { force: true });
assertHistoryRows(dbPath);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('isReadonlyWalRetryError only accepts readonly errors from WAL-mode databases', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-retry-'));
const walDbPath = path.join(dir, 'wal.sqlite');
const rollbackDbPath = path.join(dir, 'rollback.sqlite');
try {
createHistoryDb(walDbPath, { wal: true });
createHistoryDb(rollbackDbPath);
assert.equal(
isReadonlyWalRetryError(
Object.assign(new Error('attempt to write a readonly database'), {
code: 'SQLITE_READONLY',
}),
walDbPath,
),
true,
);
assert.equal(
isReadonlyWalRetryError(
Object.assign(new Error('unable to open database file'), {
code: 'SQLITE_CANTOPEN',
}),
walDbPath,
),
true,
);
assert.equal(
isReadonlyWalRetryError(new Error('no such table: imm_sessions'), walDbPath),
false,
);
assert.equal(
isReadonlyWalRetryError(
Object.assign(new Error('attempt to write a readonly database'), {
code: 'SQLITE_READONLY',
}),
rollbackDbPath,
),
false,
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
+4
View File
@@ -0,0 +1,4 @@
export * from './history-art.js';
export * from './history-db.js';
export * from './history-navigation.js';
export type { HistorySeriesEntry, HistoryVideoRow, SeasonDirEntry } from './history-types.js';
+1
View File
@@ -29,6 +29,7 @@ function createArgs(): Args {
texthookerOnly: false, texthookerOnly: false,
texthookerOpenBrowser: false, texthookerOpenBrowser: false,
useRofi: false, useRofi: false,
history: false,
logLevel: 'info', logLevel: 'info',
logRotation: 7, logRotation: 7,
passwordStore: '', passwordStore: '',
+46 -16
View File
@@ -65,11 +65,36 @@ function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv
APPDATA: xdgConfigHome, APPDATA: xdgConfigHome,
LOCALAPPDATA: path.join(homeDir, 'AppData', 'Local'), LOCALAPPDATA: path.join(homeDir, 'AppData', 'Local'),
XDG_CONFIG_HOME: xdgConfigHome, XDG_CONFIG_HOME: xdgConfigHome,
// Pin the data dir under the temp home so the Linux runtime-plugin preflight
// resolves managed asset paths deterministically (not the CI runner's).
XDG_DATA_HOME: path.join(homeDir, '.local', 'share'),
PATH: pathValue, PATH: pathValue,
Path: pathValue, Path: pathValue,
}; };
} }
// On Linux the playback path runs `ensureLinuxRuntimePluginAvailable`, which —
// when the runtime plugin/theme are missing — spawns the app with
// `--ensure-linux-runtime-plugin-assets` and polls up to 30s
// (RESPONSE_TIMEOUT_MS) for an install response. A fake app that just exits
// never writes that response, so the launcher hangs and the test times out on
// Linux CI (the preflight is a no-op on macOS/Windows). This shell prelude makes
// the fake app install the managed plugin/theme and write the response, matching
// launcher/smoke.e2e.test.ts. Prepend it to each fake app that reaches playback.
const RUNTIME_PLUGIN_PREFLIGHT_SH = `if [ "$1" = "--ensure-linux-runtime-plugin-assets" ]; then
data="\${XDG_DATA_HOME:-$HOME/.local/share}/SubMiner"
mkdir -p "$data/plugin/subminer" "$data/themes"
printf -- '-- test plugin\\n' > "$data/plugin/subminer/main.lua"
printf 'test=true\\n' > "$data/plugin/subminer.conf"
printf '/* test theme */\\n' > "$data/themes/subminer.rasi"
if [ "$2" = "--ensure-linux-runtime-plugin-assets-response-path" ] && [ -n "$3" ]; then
mkdir -p "$(dirname "$3")"
printf '{"ok":true,"status":"installed","path":"%s"}' "$data/plugin/subminer/main.lua" > "$3"
fi
exit 0
fi
`;
test('config path uses XDG_CONFIG_HOME override', () => { test('config path uses XDG_CONFIG_HOME override', () => {
withTempDir((root) => { withTempDir((root) => {
const xdgConfigHome = path.join(root, 'xdg'); const xdgConfigHome = path.join(root, 'xdg');
@@ -237,7 +262,7 @@ test('doctor refresh-known-words forwards app refresh command without requiring
const capturePath = path.join(root, 'captured-args.txt'); const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync( fs.writeFileSync(
appPath, appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n', `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
); );
fs.chmodSync(appPath, 0o755); fs.chmodSync(appPath, 0o755);
@@ -264,7 +289,7 @@ test('launcher settings option forwards app settings window command', () => {
const capturePath = path.join(root, 'captured-args.txt'); const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync( fs.writeFileSync(
appPath, appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n', `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
); );
fs.chmodSync(appPath, 0o755); fs.chmodSync(appPath, 0o755);
@@ -288,7 +313,7 @@ test('launcher settings command forwards app settings window command', () => {
const capturePath = path.join(root, 'captured-args.txt'); const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync( fs.writeFileSync(
appPath, appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n', `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
); );
fs.chmodSync(appPath, 0o755); fs.chmodSync(appPath, 0o755);
@@ -369,7 +394,7 @@ test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, ()
}, },
}), }),
); );
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n'); fs.writeFileSync(appPath, `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}exit 0\n`);
fs.chmodSync(appPath, 0o755); fs.chmodSync(appPath, 0o755);
fs.writeFileSync( fs.writeFileSync(
@@ -460,7 +485,7 @@ test('launcher forwards non-info log level into mpv logging args', { timeout: 15
}, },
}), }),
); );
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n'); fs.writeFileSync(appPath, `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}exit 0\n`);
fs.chmodSync(appPath, 0o755); fs.chmodSync(appPath, 0o755);
fs.writeFileSync( fs.writeFileSync(
@@ -539,7 +564,7 @@ test('launcher routes youtube urls through regular playback startup', { timeout:
); );
fs.writeFileSync( fs.writeFileSync(
appPath, appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n', `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
); );
fs.chmodSync(appPath, 0o755); fs.chmodSync(appPath, 0o755);
@@ -566,17 +591,22 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755); fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755);
fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755); fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755);
// Note: no SUBMINER_TEST_CAPTURE here. When set, the launcher intercepts
// *every* app command — including the Linux runtime-plugin preflight's
// `--ensure-linux-runtime-plugin-assets` install — and returns without
// running the fake app, so the preflight would poll 30s for a response that
// never arrives and time out. This test asserts on the mpv args, not on
// captured app args, so capture isn't needed.
const env = { const env = {
...makeTestEnv(homeDir, xdgConfigHome), ...makeTestEnv(homeDir, xdgConfigHome),
PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`, PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`, Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
DISPLAY: ':99',
XDG_SESSION_TYPE: 'x11',
SUBMINER_APPIMAGE_PATH: appPath, SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_MPV_ARGS: mpvArgsPath, SUBMINER_TEST_MPV_ARGS: mpvArgsPath,
SUBMINER_TEST_CAPTURE: path.join(root, 'captured-args.txt'),
}; };
const result = runLauncher(['https://www.youtube.com/watch?v=abc123'], env); // Pass an explicit backend so overlay startup doesn't probe for a display
// (headless CI has none), matching launcher/smoke.e2e.test.ts.
const result = runLauncher(['--backend', 'x11', 'https://www.youtube.com/watch?v=abc123'], env);
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
const forwardedArgs = fs const forwardedArgs = fs
@@ -597,7 +627,7 @@ test('dictionary command forwards --dictionary and --dictionary-target to app co
const capturePath = path.join(root, 'captured-args.txt'); const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync( fs.writeFileSync(
appPath, appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n', `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
); );
fs.chmodSync(appPath, 0o755); fs.chmodSync(appPath, 0o755);
@@ -626,7 +656,7 @@ test('dictionary command forwards manual AniList selection modes to app command
const capturePath = path.join(root, 'captured-args.txt'); const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync( fs.writeFileSync(
appPath, appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n', `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
); );
fs.chmodSync(appPath, 0o755); fs.chmodSync(appPath, 0o755);
@@ -763,7 +793,7 @@ test('jellyfin discovery routes to app --background and remote announce with log
const capturePath = path.join(root, 'captured-args.txt'); const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync( fs.writeFileSync(
appPath, appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n', `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
); );
fs.chmodSync(appPath, 0o755); fs.chmodSync(appPath, 0o755);
@@ -790,7 +820,7 @@ test('jellyfin discovery via jf alias forwards remote announce for cast visibili
const capturePath = path.join(root, 'captured-args.txt'); const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync( fs.writeFileSync(
appPath, appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n', `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
); );
fs.chmodSync(appPath, 0o755); fs.chmodSync(appPath, 0o755);
@@ -817,7 +847,7 @@ test('jellyfin login routes credentials to app command', () => {
const capturePath = path.join(root, 'captured-args.txt'); const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync( fs.writeFileSync(
appPath, appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n', `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
); );
fs.chmodSync(appPath, 0o755); fs.chmodSync(appPath, 0o755);
@@ -856,7 +886,7 @@ test('jellyfin setup forwards password-store to app command', () => {
const capturePath = path.join(root, 'captured-args.txt'); const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync( fs.writeFileSync(
appPath, appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n', `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
); );
fs.chmodSync(appPath, 0o755); fs.chmodSync(appPath, 0o755);
+11
View File
@@ -21,6 +21,7 @@ import { runDictionaryCommand } from './commands/dictionary-command.js';
import { runLogsCommand } from './commands/logs-command.js'; import { runLogsCommand } from './commands/logs-command.js';
import { runStatsCommand } from './commands/stats-command.js'; import { runStatsCommand } from './commands/stats-command.js';
import { runJellyfinCommand } from './commands/jellyfin-command.js'; import { runJellyfinCommand } from './commands/jellyfin-command.js';
import { runHistoryCommand } from './commands/history-command.js';
import { runPlaybackCommand } from './commands/playback-command.js'; import { runPlaybackCommand } from './commands/playback-command.js';
import { runUpdateCommand } from './commands/update-command.js'; import { runUpdateCommand } from './commands/update-command.js';
@@ -142,6 +143,16 @@ async function main(): Promise<void> {
return; return;
} }
if (appContext.args.history) {
const selected = await runHistoryCommand(appContext);
if (!selected) {
log('info', args.logLevel, 'No watch history selection made, exiting');
return;
}
appContext.args.target = selected;
appContext.args.targetKind = 'file';
}
await runPlaybackCommand(appContext); await runPlaybackCommand(appContext);
} }
+1
View File
@@ -570,6 +570,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
texthookerOnly: false, texthookerOnly: false,
texthookerOpenBrowser: false, texthookerOpenBrowser: false,
useRofi: false, useRofi: false,
history: false,
logLevel: 'error', logLevel: 'error',
logRotation: 7, logRotation: 7,
passwordStore: '', passwordStore: '',
+13
View File
@@ -42,6 +42,19 @@ test('parseArgs maps root settings window option', () => {
assert.equal(parsed.settings, true); assert.equal(parsed.settings, true);
}); });
test('parseArgs maps root watch history flags', () => {
const shortParsed = parseArgs(['-H'], 'subminer', {});
const longParsed = parseArgs(['--history'], 'subminer', {});
const rofiParsed = parseArgs(['-R', '-H'], 'subminer', {});
const defaultParsed = parseArgs([], 'subminer', {});
assert.equal(shortParsed.history, true);
assert.equal(longParsed.history, true);
assert.equal(rofiParsed.history, true);
assert.equal(rofiParsed.useRofi, true);
assert.equal(defaultParsed.history, false);
});
test('parseArgs maps root update flags without conflicting with jellyfin username', () => { test('parseArgs maps root update flags without conflicting with jellyfin username', () => {
const shortParsed = parseArgs(['-u'], 'subminer', {}); const shortParsed = parseArgs(['-u'], 'subminer', {});
const longParsed = parseArgs(['--update'], 'subminer', {}); const longParsed = parseArgs(['--update'], 'subminer', {});
+1
View File
@@ -112,6 +112,7 @@ export interface Args {
texthookerOnly: boolean; texthookerOnly: boolean;
texthookerOpenBrowser: boolean; texthookerOpenBrowser: boolean;
useRofi: boolean; useRofi: boolean;
history: boolean;
logLevel: LogLevel; logLevel: LogLevel;
logRotation: LogRotation; logRotation: LogRotation;
passwordStore: string; passwordStore: string;
-233
View File
@@ -1,233 +0,0 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const node_os_1 = __importDefault(require("node:os"));
const node_child_process_1 = require("node:child_process");
const electron_1 = require("electron");
const help_1 = require("./cli/help");
const main_entry_runtime_1 = require("./main-entry-runtime");
const early_single_instance_1 = require("./main/early-single-instance");
const main_entry_launch_config_1 = require("./main-entry-launch-config");
const app_control_client_1 = require("./shared/app-control-client");
const first_run_setup_plugin_1 = require("./main/runtime/first-run-setup-plugin");
const windows_mpv_launch_1 = require("./main/runtime/windows-mpv-launch");
const stats_daemon_entry_1 = require("./stats-daemon-entry");
const fatal_error_1 = require("./main/fatal-error");
const mpv_logging_args_1 = require("./shared/mpv-logging-args");
const log_files_1 = require("./shared/log-files");
const DEFAULT_TEXTHOOKER_PORT = 5174;
function appendWindowsMpvLaunchLog(message, logRotation) {
if (!(0, log_files_1.isLogFileEnabled)('app')) {
return;
}
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
(0, log_files_1.appendLogLine)(process.env.SUBMINER_APP_LOG?.trim() || (0, log_files_1.resolveDefaultLogFilePath)('app'), `[subminer] - ${timestamp} - INFO - [main:windows-mpv-launch] ${message}`, { rotation: logRotation });
}
function applySanitizedEnv(sanitizedEnv) {
if (sanitizedEnv.NODE_NO_WARNINGS) {
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
}
if (sanitizedEnv.VK_INSTANCE_LAYERS) {
process.env.VK_INSTANCE_LAYERS = sanitizedEnv.VK_INSTANCE_LAYERS;
}
else {
delete process.env.VK_INSTANCE_LAYERS;
}
}
function resolveBundledWindowsMpvPluginEntrypoint() {
return ((0, first_run_setup_plugin_1.resolvePackagedRuntimePluginPath)({
dirname: __dirname,
appPath: electron_1.app.getAppPath(),
resourcesPath: process.resourcesPath,
}) ?? undefined);
}
function buildInstalledWindowsMpvPluginMessage(pathValue, version) {
return [
'SubMiner detected an installed mpv plugin at:',
pathValue,
'',
"This mpv session will use the installed plugin. Remove it to use SubMiner's bundled runtime plugin automatically.",
`Detected plugin version: ${version ?? 'unknown or legacy'}`,
].join('\n');
}
async function promptForWindowsLegacyMpvPluginRemoval(mpvPath, detection) {
const response = await electron_1.dialog.showMessageBox({
type: 'warning',
title: 'SubMiner mpv plugin detected',
message: buildInstalledWindowsMpvPluginMessage(detection.path ?? 'unknown path', detection.version),
detail: 'Remove the legacy SubMiner mpv plugin files from mpv before launching this video? This moves the files to the OS trash. SubMiner-managed playback will then use the bundled runtime plugin.',
buttons: ['Remove legacy plugin', 'Continue with installed plugin', 'Cancel'],
defaultId: 0,
cancelId: 2,
});
if (response.response === 2) {
return 'cancel';
}
if (response.response === 1) {
return 'continue';
}
const candidates = (0, first_run_setup_plugin_1.detectInstalledFirstRunPluginCandidates)({
platform: 'win32',
homeDir: node_os_1.default.homedir(),
appDataDir: electron_1.app.getPath('appData'),
mpvExecutablePath: mpvPath,
});
const result = await (0, first_run_setup_plugin_1.removeLegacyMpvPluginCandidates)({
candidates,
trashItem: (candidatePath) => electron_1.shell.trashItem(candidatePath),
});
if (result.ok) {
await electron_1.dialog.showMessageBox({
type: 'info',
title: 'Legacy mpv plugin removed',
message: 'Legacy mpv plugin removed. SubMiner-managed playback will use the bundled runtime plugin.',
});
return 'removed';
}
await electron_1.dialog.showMessageBox({
type: 'error',
title: 'Could not remove legacy mpv plugin',
message: 'Some legacy SubMiner mpv plugin files could not be moved to the trash.',
detail: result.failedPaths.map((failure) => `${failure.path}: ${failure.message}`).join('\n'),
});
return 'cancel';
}
function createWindowsRuntimePluginPolicy() {
return {
detectInstalledMpvPlugin: (mpvPath) => (0, first_run_setup_plugin_1.detectInstalledMpvPlugin)({
platform: 'win32',
homeDir: node_os_1.default.homedir(),
appDataDir: electron_1.app.getPath('appData'),
mpvExecutablePath: mpvPath,
}),
notifyInstalledPluginDetected: (detection) => {
if (!detection.installed || !detection.path)
return;
electron_1.dialog.showMessageBoxSync({
type: 'warning',
title: 'SubMiner mpv plugin detected',
message: buildInstalledWindowsMpvPluginMessage(detection.path, detection.version),
});
},
resolveInstalledPluginBeforeLaunch: (detection, mpvPath) => promptForWindowsLegacyMpvPluginRemoval(mpvPath, detection),
};
}
process.argv = (0, main_entry_runtime_1.normalizeStartupArgv)(process.argv, process.env);
(0, main_entry_runtime_1.applyEarlyLinuxCommandLineSwitches)(electron_1.app.commandLine, process.argv);
applySanitizedEnv((0, main_entry_runtime_1.sanitizeStartupEnv)(process.env));
const userDataPath = (0, main_entry_runtime_1.configureEarlyAppPaths)(electron_1.app);
const reportFatalError = (0, fatal_error_1.createFatalErrorReporter)({
showErrorBox: (title, details) => electron_1.dialog.showErrorBox(title, details),
consoleError: (message, error) => console.error(message, error),
});
(0, fatal_error_1.registerFatalErrorHandlers)({
reportFatalError,
exit: (code) => electron_1.app.exit(code),
});
function startMainProcess() {
const gotSingleInstanceLock = (0, early_single_instance_1.requestSingleInstanceLockEarly)(electron_1.app);
if (!gotSingleInstanceLock) {
electron_1.app.exit(0);
return;
}
try {
require('./main.js');
}
catch (error) {
reportFatalError(error, {
title: 'SubMiner startup failed',
context: 'SubMiner failed while loading the main process.',
});
electron_1.app.exit(1);
}
}
async function forwardStartupArgvViaAppControlIfAvailable() {
if (!(0, main_entry_runtime_1.shouldForwardStartupArgvViaAppControl)(process.argv, process.env)) {
return false;
}
const result = await (0, app_control_client_1.sendAppControlCommand)(process.argv, {
configDir: userDataPath,
timeoutMs: 500,
});
if (result.ok) {
electron_1.app.exit(0);
return true;
}
if (!result.unavailable) {
console.error(`SubMiner app-control handoff failed: ${result.error ?? 'unknown error'}`);
electron_1.app.exit(1);
return true;
}
return false;
}
async function runEntryProcess() {
if ((0, main_entry_runtime_1.shouldHandleHelpOnlyAtEntry)(process.argv, process.env)) {
const sanitizedEnv = (0, main_entry_runtime_1.sanitizeHelpEnv)(process.env);
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
if (!sanitizedEnv.VK_INSTANCE_LAYERS) {
delete process.env.VK_INSTANCE_LAYERS;
}
(0, help_1.printHelp)(DEFAULT_TEXTHOOKER_PORT);
process.exit(0);
return;
}
if ((0, main_entry_runtime_1.shouldHandleLaunchMpvAtEntry)(process.argv, process.env)) {
const sanitizedEnv = (0, main_entry_runtime_1.sanitizeLaunchMpvEnv)(process.env);
applySanitizedEnv(sanitizedEnv);
await electron_1.app.whenReady();
const configuredMpvLaunch = (0, main_entry_launch_config_1.readConfiguredWindowsMpvLaunch)(userDataPath);
const extraArgs = (0, main_entry_runtime_1.normalizeLaunchMpvExtraArgs)(process.argv);
(0, log_files_1.applyLogFileTogglesToEnv)(configuredMpvLaunch.logFiles);
const mpvLogPath = (0, log_files_1.isLogFileEnabled)('mpv')
? process.env.SUBMINER_MPV_LOG?.trim() || (0, log_files_1.resolveDefaultLogFilePath)('mpv')
: '';
if (mpvLogPath) {
(0, log_files_1.pruneLogDirectoryForPath)(mpvLogPath, configuredMpvLaunch.logRotation);
}
const result = await (0, windows_mpv_launch_1.launchWindowsMpv)((0, main_entry_runtime_1.normalizeLaunchMpvTargets)(process.argv), (0, windows_mpv_launch_1.createWindowsMpvLaunchDeps)({
getEnv: (name) => process.env[name],
isAppControlServerAvailable: () => (0, app_control_client_1.isAppControlServerAvailable)({
configDir: userDataPath,
timeoutMs: 350,
}),
sendAppControlCommand: (argv) => (0, app_control_client_1.sendAppControlCommand)(argv, {
configDir: userDataPath,
timeoutMs: 1000,
}),
showError: (title, content) => {
electron_1.dialog.showErrorBox(title, content);
},
logInfo: (message) => appendWindowsMpvLaunchLog(message, configuredMpvLaunch.logRotation),
}), [...extraArgs, ...(0, mpv_logging_args_1.buildMpvLoggingArgs)(configuredMpvLaunch.logLevel, mpvLogPath, extraArgs)], process.execPath, resolveBundledWindowsMpvPluginEntrypoint(), configuredMpvLaunch.executablePath, configuredMpvLaunch.launchMode, createWindowsRuntimePluginPolicy(), configuredMpvLaunch.pluginRuntimeConfig);
electron_1.app.exit(result.ok ? 0 : 1);
return;
}
if ((0, main_entry_runtime_1.shouldHandleStatsDaemonCommandAtEntry)(process.argv, process.env)) {
await electron_1.app.whenReady();
const exitCode = await (0, stats_daemon_entry_1.runStatsDaemonControlFromProcess)(electron_1.app.getPath('userData'));
electron_1.app.exit(exitCode);
return;
}
if (await forwardStartupArgvViaAppControlIfAvailable()) {
return;
}
if ((0, main_entry_runtime_1.shouldDetachBackgroundLaunch)(process.argv, process.env)) {
const childArgs = (0, main_entry_runtime_1.hasTransportedStartupArgs)(process.env) ? [] : process.argv.slice(1);
const child = (0, node_child_process_1.spawn)(process.execPath, childArgs, {
detached: true,
stdio: 'ignore',
env: (0, main_entry_runtime_1.sanitizeBackgroundEnv)(process.env),
});
child.unref();
process.exit(0);
return;
}
startMainProcess();
}
void runEntryProcess().catch((error) => {
console.error('SubMiner app-control handoff failed:', error);
startMainProcess();
});
//# sourceMappingURL=main-entry.js.map
-4095
View File
File diff suppressed because it is too large Load Diff
+8 -11
View File
File diff suppressed because one or more lines are too long
@@ -105,6 +105,8 @@ bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verif
- For `docs-site/`, `docs/`, and doc-only edits. - For `docs-site/`, `docs/`, and doc-only edits.
- `config` - `config`
- For `src/config/` and config-template-sensitive edits. - For `src/config/` and config-template-sensitive edits.
- `stats`
- For `stats/` dashboard UI edits.
- `core` - `core`
- For general source changes where `typecheck` + `test:fast` is the best cheap signal. - For general source changes where `typecheck` + `test:fast` is the best cheap signal.
- `launcher-plugin` - `launcher-plugin`
@@ -108,6 +108,14 @@ for path in "${PATHS[@]}"; do
;; ;;
esac esac
case "$path" in
stats/*)
add_lane "stats"
add_reason "$path -> stats"
specialized=1
;;
esac
case "$path" in case "$path" in
launcher/*|plugin/subminer/*|plugin/subminer.conf|scripts/test-plugin-*|scripts/get-mpv-window-*|scripts/configure-plugin-binary-path.mjs) launcher/*|plugin/subminer/*|plugin/subminer.conf|scripts/test-plugin-*|scripts/get-mpv-window-*|scripts/configure-plugin-binary-path.mjs)
add_lane "launcher-plugin" add_lane "launcher-plugin"
@@ -255,8 +255,18 @@ write_summary_files() {
local lane_lines local lane_lines
lane_lines=$(printf '%s\n' "${SELECTED_LANES[@]}") lane_lines=$(printf '%s\n' "${SELECTED_LANES[@]}")
printf '%s\n' "$lane_lines" >"$ARTIFACT_DIR/lanes.txt" printf '%s\n' "$lane_lines" >"$ARTIFACT_DIR/lanes.txt"
printf '%s\n' "${BLOCKERS[@]}" >"$ARTIFACT_DIR/blockers.txt" # bash 3.2 raises "unbound variable" under set -u when expanding an empty
printf '%s\n' "${PATH_ARGS[@]}" >"$ARTIFACT_DIR/requested-paths.txt" # array, so guard on length (matching the idiom used elsewhere here).
if [[ ${#BLOCKERS[@]} -gt 0 ]]; then
printf '%s\n' "${BLOCKERS[@]}" >"$ARTIFACT_DIR/blockers.txt"
else
: >"$ARTIFACT_DIR/blockers.txt"
fi
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
printf '%s\n' "${PATH_ARGS[@]}" >"$ARTIFACT_DIR/requested-paths.txt"
else
: >"$ARTIFACT_DIR/requested-paths.txt"
fi
ARTIFACT_DIR_ENV="$ARTIFACT_DIR" \ ARTIFACT_DIR_ENV="$ARTIFACT_DIR" \
SESSION_ID_ENV="$SESSION_ID" \ SESSION_ID_ENV="$SESSION_ID" \
@@ -482,6 +492,9 @@ for lane in "${SELECTED_LANES[@]}"; do
config) config)
run_step "$lane" "config" "bun run test:config" || break run_step "$lane" "config" "bun run test:config" || break
;; ;;
stats)
run_step "$lane" "stats" "bun run test:stats" || break
;;
core) core)
run_step "$lane" "typecheck" "bun run typecheck" || break run_step "$lane" "typecheck" "bun run typecheck" || break
run_step "$lane" "fast-tests" "bun run test:fast" || break run_step "$lane" "fast-tests" "bun run test:fast" || break
+41 -10
View File
@@ -1,24 +1,55 @@
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release. > This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
<!-- prerelease-base-version: 0.17.1 --> <!-- prerelease-base-version: 0.18.0 -->
## Highlights ## Highlights
### Added ### Added
- **Watch History Browser**
- New `subminer -H` / `--history` command to browse your local watch history, replay the last episode, jump to the next one, or pick an episode via fzf/rofi.
- The rofi picker now shows AniList cover art for each show, making it easier to spot the right title at a glance.
- **YouTube Media Cache Mode**: A new `youtube.mediaCache.mode` setting (`direct` or `background`) lets you choose how SubMiner extracts audio and image from YouTube cards. ### Changed
- In background mode, SubMiner creates a text-only card immediately, downloads a yt-dlp media cache (capped at 720p by default), and fills audio and image fields once the file is ready — with overlay and OSD notifications when the download starts and when media is available. - **New App Icon**
- If a background download fails, SubMiner now notifies you and clears any pending media updates rather than leaving cards silently incomplete. - SubMiner now ships pixel-art submarine artwork, contributed by an anonymous community member.
- Applied across the app icon, tray icon, notifications, README, docs site, and stats page.
### Fixed ### Fixed
- **Character Name Highlighting in Subtitles**
- **Log Export**: Log filenames now use your local date, so exporting logs near midnight no longer pulls stale files from the previous UTC day. Export redaction has also been expanded to mask a broader range of sensitive data, including IP addresses, email addresses, authentication and cookie headers, yt-dlp cookie arguments, URL credentials, and signed YouTube media URLs. - Fixed unspaced Japanese names (e.g. 東紫乃, 渡辺真奈美) being split at the wrong point, which left surnames like 東 and 渡辺 without their character portrait or hover lookup.
- Fixed names getting cut off or losing their highlight when caught by the subtitle scanner's punctuation handling or by conflicting grammar tagging.
- **YouTube Card Media Reliability**: Direct stream extraction now uses safer ffmpeg options and skips stale or cached stream map entries to reduce failed media generation. Background cache downloads are hardened with IPv4 and extractor retry flags, stale cache files are cleaned up on startup and before new downloads, and in-flight background downloads are stopped automatically when switching back to direct mode. - No action needed — existing data upgrades automatically the next time a matching name is seen.
- **Known-Word Highlighting**
- Words are no longer marked "known" (green) just because they share spelling with a known Anki card that actually teaches a different reading (e.g. 床 read as とこ no longer falsely matches a known 床/ゆか card).
- **Unparsed Subtitle Text**
- Subtitle text the dictionary can't recognize (like a truncated verb form) is now still hoverable for lookup and correctly counted toward a sentence's difficulty, instead of showing as dead, non-interactive text.
- **Kiku Manual Field Grouping**
- Fixed the field-grouping dialog getting stuck invisible behind fullscreen video on Hyprland/Wayland, and failing silently on repeated attempts after the first use.
- Fixed a duplicate "Field grouping cancelled" notification appearing when grouping was cancelled via the trigger shortcut.
- **Secondary Subtitles**
- Karaoke-style secondary subtitles (common in opening/ending songs) no longer spam dozens of lines down the screen; repeated lines are now collapsed and the subtitle area is capped to a strip at the top.
- **Card Audio Normalization**
- Audio extracted for Anki cards is now volume-normalized by default for more consistent playback loudness.
- If you prefer the original source volume, disable it via the new `ankiConnect.media.normalizeAudio` setting.
- **YouTube Extraction**
- Fixed direct YouTube stream extraction occasionally corrupting the stream URL and causing failed audio/video capture.
- **Background Stats Server**
- Launching SubMiner in the background now correctly auto-starts the stats server when enabled, and won't start a duplicate if one's already running.
- **Stats Trend Charts**
- All trend chart titles now show by default, with the ability to hide specific titles (remembered across sessions) and cap how many top titles a chart displays.
## What's Changed ## What's Changed
- feat(youtube): add mediaCache mode and safer stream media extraction by @ksyasuda in #130 - fix(youtube): parse mpv EDL stream URLs with byte-length guards by @ksyasuda in #134
- fix(logs): use local date for log filenames and expand export redaction by @ksyasuda in #131 - Normalize generated Anki audio by default by @ksyasuda in #135
- feat(launcher): add -H/--history command to browse local watch history by @ksyasuda in #136
- fix(overlay): prevent field grouping modal from freezing overlay on Hyprland by @ksyasuda in #138
- fix(overlay): collapse karaoke syllable spam in secondary subtitles by @ksyasuda in #139
- feat(stats): Trends dashboard overhaul — title visibility, ranking modes, calendar-accurate windows, tooltips by @ksyasuda in #140
- feat(branding): replace app icon with contributed pixel-art set by @ksyasuda in #141
- feat(anki): reading-aware known-word matching (cache v3) by @ksyasuda in #142
- fix(stats): start stats server on background app launch by @ksyasuda in #144
- fix(tokenizer): keep unparsed Yomitan tokens hoverable by @ksyasuda in #145
- fix(overlay): resolve unspaced Japanese name splits and scan recovery by @ksyasuda in #146
## Installation ## Installation
+2 -6
View File
@@ -108,15 +108,11 @@ function renderPrereleaseBaseVersionMarker(version: string): string {
} }
function extractPrereleaseBaseVersionMarker(notes: string): string | null { function extractPrereleaseBaseVersionMarker(notes: string): string | null {
return ( return /<!--\s*prerelease-base-version:\s*(\d+\.\d+\.\d+)\s*-->/u.exec(notes)?.[1] ?? null;
/<!--\s*prerelease-base-version:\s*(\d+\.\d+\.\d+)\s*-->/u.exec(notes)?.[1] ?? null
);
} }
function stripPrereleaseMetadata(notes: string): string { function stripPrereleaseMetadata(notes: string): string {
return notes return notes.replace(/<!--\s*prerelease-base-version:\s*\d+\.\d+\.\d+\s*-->\s*/u, '').trim();
.replace(/<!--\s*prerelease-base-version:\s*\d+\.\d+\.\d+\s*-->\s*/u, '')
.trim();
} }
function resolveReusablePrereleaseNotes(notes: string, version: string): string | undefined { function resolveReusablePrereleaseNotes(notes: string, version: string): string | undefined {
+9 -66
View File
@@ -1,12 +1,7 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { isAbsolute, join, relative, resolve } from 'node:path'; import { isAbsolute, join, relative, resolve } from 'node:path';
import { collectLaneFiles } from './test-lanes';
type LaneConfig = {
roots: string[];
include: string[];
exclude: Set<string>;
};
type LcovRecord = { type LcovRecord = {
sourceFile: string; sourceFile: string;
@@ -18,64 +13,6 @@ type LcovRecord = {
const repoRoot = resolve(__dirname, '..'); const repoRoot = resolve(__dirname, '..');
const lanes: Record<string, LaneConfig> = {
'bun-src-full': {
roots: ['src'],
include: ['.test.ts', '.type-test.ts'],
exclude: new Set([
'src/core/services/anki-jimaku-ipc.test.ts',
'src/core/services/ipc.test.ts',
'src/core/services/overlay-manager.test.ts',
'src/main/config-validation.test.ts',
'src/main/runtime/registry.test.ts',
'src/main/runtime/startup-config.test.ts',
]),
},
'bun-launcher-unit': {
roots: ['launcher'],
include: ['.test.ts'],
exclude: new Set(['launcher/smoke.e2e.test.ts']),
},
};
function collectFiles(
rootDir: string,
includeSuffixes: string[],
excludeSet: Set<string>,
): string[] {
const out: string[] = [];
const visit = (currentDir: string) => {
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
const fullPath = resolve(currentDir, entry.name);
if (entry.isDirectory()) {
visit(fullPath);
continue;
}
const relPath = relative(repoRoot, fullPath).replaceAll('\\', '/');
if (excludeSet.has(relPath)) continue;
if (includeSuffixes.some((suffix) => relPath.endsWith(suffix))) {
out.push(relPath);
}
}
};
visit(resolve(repoRoot, rootDir));
out.sort();
return out;
}
function getLaneFiles(laneName: string): string[] {
const lane = lanes[laneName];
if (!lane) {
throw new Error(`Unknown coverage lane: ${laneName}`);
}
const files = lane.roots.flatMap((rootDir) => collectFiles(rootDir, lane.include, lane.exclude));
if (files.length === 0) {
throw new Error(`No test files found for coverage lane: ${laneName}`);
}
return files;
}
function parseCoverageDirArg(argv: string[]): string { function parseCoverageDirArg(argv: string[]): string {
for (let index = 0; index < argv.length; index += 1) { for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === '--coverage-dir') { if (argv[index] === '--coverage-dir') {
@@ -277,7 +214,13 @@ function runCoverageLane(): number {
rmSync(shardRoot, { recursive: true, force: true }); rmSync(shardRoot, { recursive: true, force: true });
mkdirSync(shardRoot, { recursive: true }); mkdirSync(shardRoot, { recursive: true });
const files = getLaneFiles(laneName); let files: string[];
try {
files = collectLaneFiles(repoRoot, laneName);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
return 1;
}
const reports: string[] = []; const reports: string[] = [];
try { try {
+133 -53
View File
@@ -1,73 +1,153 @@
import { readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { relative, resolve } from 'node:path'; import { resolve } from 'node:path';
import { spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import { collectLaneFiles } from './test-lanes.ts';
// Runs a test lane with per-file process isolation: one `bun test` process per
// test file so a hanging test or leaked global in one file cannot poison the
// rest of the lane. Use --single-process for the old all-in-one-process mode.
//
// Usage: bun scripts/run-test-lane.mjs <lane> [--jobs N] [--timeout-secs N] [--single-process]
const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url))); const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
const lanes = { // Cap per-file buffered output so a long or noisy test cannot grow the string
'bun-src-full': { // without bound and exhaust memory.
roots: ['src'], const MAX_OUTPUT_BYTES = 1024 * 1024;
include: ['.test.ts', '.type-test.ts'],
exclude: new Set([
'src/core/services/anki-jimaku-ipc.test.ts',
'src/core/services/ipc.test.ts',
'src/core/services/overlay-manager.test.ts',
'src/main/config-validation.test.ts',
'src/main/runtime/registry.test.ts',
'src/main/runtime/startup-config.test.ts',
]),
},
'bun-launcher-unit': {
roots: ['launcher'],
include: ['.test.ts'],
exclude: new Set(['launcher/smoke.e2e.test.ts']),
},
};
function collectFiles(rootDir, includeSuffixes, excludeSet) { // Track spawned `bun test` children so we can kill them if the runner is
const out = []; // interrupted, avoiding orphaned in-flight test processes.
const visit = (currentDir) => { const activeChildren = new Set();
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
const fullPath = resolve(currentDir, entry.name); function terminateChildren() {
if (entry.isDirectory()) { for (const child of activeChildren) {
visit(fullPath); child.kill('SIGKILL');
continue; }
activeChildren.clear();
}
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => {
terminateChildren();
process.exit(130);
});
}
function parseArgs(argv) {
const options = { lane: undefined, jobs: 1, timeoutSecs: 300, singleProcess: false };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--jobs') {
options.jobs = Math.max(1, Number(argv[(index += 1)]) || 1);
} else if (arg === '--timeout-secs') {
options.timeoutSecs = Math.max(1, Number(argv[(index += 1)]) || 300);
} else if (arg === '--single-process') {
options.singleProcess = true;
} else if (!arg.startsWith('--') && options.lane === undefined) {
options.lane = arg;
} else {
process.stderr.write(`Unknown argument: ${arg}\n`);
process.exit(1);
}
}
return options;
}
function runFile(file, timeoutSecs) {
return new Promise((resolvePromise) => {
const child = spawn('bun', ['test', `./${file}`], { cwd: repoRoot });
activeChildren.add(child);
let output = '';
let truncated = false;
let timedOut = false;
const append = (chunk) => {
if (truncated) return;
output += chunk;
if (output.length > MAX_OUTPUT_BYTES) {
output = `${output.slice(0, MAX_OUTPUT_BYTES)}\n[output truncated at ${MAX_OUTPUT_BYTES} bytes]\n`;
truncated = true;
} }
const relPath = relative(repoRoot, fullPath).replaceAll('\\', '/'); };
if (excludeSet.has(relPath)) continue; child.stdout.on('data', append);
if (includeSuffixes.some((suffix) => relPath.endsWith(suffix))) { child.stderr.on('data', append);
out.push(relPath); const timer = setTimeout(() => {
timedOut = true;
child.kill('SIGKILL');
}, timeoutSecs * 1000);
child.on('close', (code) => {
clearTimeout(timer);
activeChildren.delete(child);
resolvePromise({ file, code: timedOut ? 124 : (code ?? 1), output, timedOut });
});
child.on('error', (error) => {
clearTimeout(timer);
activeChildren.delete(child);
resolvePromise({ file, code: 1, output: String(error), timedOut: false });
});
});
}
async function runIsolated(files, options) {
const failures = [];
let nextIndex = 0;
let completed = 0;
async function worker() {
while (nextIndex < files.length) {
const file = files[nextIndex];
nextIndex += 1;
const result = await runFile(file, options.timeoutSecs);
completed += 1;
if (result.code !== 0) {
failures.push(result);
const reason = result.timedOut ? `timed out after ${options.timeoutSecs}s` : 'failed';
process.stderr.write(`\n[${completed}/${files.length}] ${file} ${reason}\n`);
process.stderr.write(result.output);
} }
} }
}; }
visit(resolve(repoRoot, rootDir)); await Promise.all(Array.from({ length: Math.min(options.jobs, files.length) }, worker));
out.sort();
return out; if (failures.length > 0) {
process.stderr.write(`\n${failures.length} of ${files.length} test files failed:\n`);
for (const failure of failures) {
process.stderr.write(` ${failure.file}${failure.timedOut ? ' (timeout)' : ''}\n`);
}
return 1;
}
process.stdout.write(`All ${files.length} test files passed.\n`);
return 0;
} }
const lane = lanes[process.argv[2]]; function runSingleProcess(files) {
const result = spawnSync('bun', ['test', ...files.map((file) => `./${file}`)], {
cwd: repoRoot,
stdio: 'inherit',
});
if (result.error) {
throw result.error;
}
return result.status ?? 1;
}
if (!lane) { const options = parseArgs(process.argv.slice(2));
process.stderr.write(`Unknown test lane: ${process.argv[2] ?? '(missing)'}\n`);
if (!options.lane) {
process.stderr.write('Missing test lane name\n');
process.exit(1); process.exit(1);
} }
const files = lane.roots.flatMap((rootDir) => collectFiles(rootDir, lane.include, lane.exclude)); let files;
try {
if (files.length === 0) { files = collectLaneFiles(repoRoot, options.lane);
process.stderr.write(`No test files found for lane: ${process.argv[2]}\n`); } catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
process.exit(1); process.exit(1);
} }
const result = spawnSync('bun', ['test', ...files.map((file) => `./${file}`)], { if (options.singleProcess) {
cwd: repoRoot, process.exit(runSingleProcess(files));
stdio: 'inherit',
});
if (result.error) {
throw result.error;
} }
process.exit(result.status ?? 1); process.exit(await runIsolated(files, options));
+97
View File
@@ -0,0 +1,97 @@
import { readdirSync } from 'node:fs';
import { relative, resolve } from 'node:path';
export type TestLane = {
roots: string[];
include: string[];
exclude?: string[];
extraFiles?: string[];
};
// Single source of truth for test-lane membership. Consumed by
// scripts/run-test-lane.mjs (plain runs) and scripts/run-coverage-lane.ts
// (per-file coverage shards). Lanes discover files by directory so new test
// files join their lane automatically.
export const testLanes: Record<string, TestLane> = {
'bun-src-full': {
roots: ['src'],
include: ['.test.ts', '.type-test.ts'],
// Node-compat suites; their dist builds run via test:runtime:compat.
exclude: [
'src/core/services/anki-jimaku-ipc.test.ts',
'src/core/services/ipc.test.ts',
'src/core/services/overlay-manager.test.ts',
'src/main/config-validation.test.ts',
'src/main/runtime/registry.test.ts',
'src/main/runtime/startup-config.test.ts',
],
},
config: {
roots: ['src/config'],
include: ['.test.ts'],
extraFiles: ['src/generate-config-example.test.ts', 'src/verify-config-example.test.ts'],
},
launcher: {
roots: ['launcher'],
include: ['.test.ts'],
},
'bun-launcher-unit': {
roots: ['launcher'],
include: ['.test.ts'],
exclude: ['launcher/smoke.e2e.test.ts'],
},
scripts: {
roots: ['scripts'],
include: ['.test.ts'],
},
stats: {
roots: ['stats/src'],
include: ['.test.ts', '.test.tsx'],
},
};
function collectFiles(
repoRoot: string,
rootDir: string,
includeSuffixes: string[],
excludeSet: Set<string>,
): string[] {
const out: string[] = [];
const visit = (currentDir: string): void => {
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
const fullPath = resolve(currentDir, entry.name);
if (entry.isDirectory()) {
visit(fullPath);
continue;
}
const relPath = relative(repoRoot, fullPath).replaceAll('\\', '/');
if (excludeSet.has(relPath)) continue;
if (includeSuffixes.some((suffix) => relPath.endsWith(suffix))) {
out.push(relPath);
}
}
};
visit(resolve(repoRoot, rootDir));
out.sort();
return out;
}
export function collectLaneFiles(repoRoot: string, laneName: string): string[] {
const lane = testLanes[laneName];
if (!lane) {
throw new Error(`Unknown test lane: ${laneName}`);
}
const excludeSet = new Set(lane.exclude ?? []);
const files = lane.roots.flatMap((rootDir) =>
collectFiles(repoRoot, rootDir, lane.include, excludeSet),
);
for (const extra of lane.extraFiles ?? []) {
if (!files.includes(extra)) files.push(extra);
}
files.sort();
if (files.length === 0) {
throw new Error(`No test files found for lane: ${laneName}`);
}
return files;
}
+110 -4
View File
@@ -768,13 +768,110 @@ test('AnkiIntegration reports partial queued YouTube media updates separately fr
); );
}); });
test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube audio', async () => { test('AnkiIntegration queues YouTube media updates against recovered source URLs', async () => {
const audioCalls: Array<{ path: string; audioStreamIndex?: number }> = []; const updatedNotes: Array<{ noteId: number; fields: Record<string, string> }> = [];
const storedMedia: string[] = [];
const integration = new AnkiIntegration(
{
fields: {
image: 'Picture',
},
media: {
imageFormat: 'jpg',
},
},
{} as never,
{
currentVideoPath: 'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777',
currentSubStart: 10,
currentSubEnd: 12,
currentTimePos: 11,
} as never,
() => undefined,
undefined,
undefined,
undefined,
{},
undefined,
undefined,
async () => null,
() => true,
() => 'https://www.youtube.com/watch?v=abc123',
);
const internals = integration as unknown as {
client: {
notesInfo: (noteIds: number[]) => Promise<unknown[]>;
updateNoteFields: (noteId: number, fields: Record<string, string>) => Promise<void>;
storeMediaFile: (filename: string) => Promise<void>;
};
mediaGenerator: {
generateAudio: () => Promise<Buffer>;
generateScreenshot: () => Promise<Buffer>;
};
queuePendingYoutubeMediaUpdateForNote: (job: {
noteId: number;
noteInfo: { noteId: number; fields: Record<string, { value: string }> };
label: string | number;
}) => Promise<boolean>;
showNotification: () => Promise<void>;
};
internals.client = {
notesInfo: async (noteIds) =>
noteIds.map((noteId) => ({
noteId,
fields: {
SentenceAudio: { value: '' },
Picture: { value: '' },
},
})),
updateNoteFields: async (noteId, fields) => {
updatedNotes.push({ noteId, fields });
},
storeMediaFile: async (filename) => {
storedMedia.push(filename);
},
};
internals.mediaGenerator = {
generateAudio: async () => Buffer.from('audio'),
generateScreenshot: async () => Buffer.from('image'),
};
internals.showNotification = async () => undefined;
const queued = await internals.queuePendingYoutubeMediaUpdateForNote({
noteId: 404,
noteInfo: {
noteId: 404,
fields: {
SentenceAudio: { value: '' },
Picture: { value: '' },
},
},
label: 'resolved source',
});
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
assert.equal(queued, true);
assert.equal(updatedNotes.length, 1);
assert.equal(updatedNotes[0]?.noteId, 404);
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio_/);
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
assert.equal(storedMedia.length, 2);
});
test('AnkiIntegration passes audio normalization config for ready cached YouTube audio', async () => {
const audioCalls: Array<{
path: string;
audioStreamIndex?: number;
normalizeAudio?: boolean;
}> = [];
const integration = new AnkiIntegration( const integration = new AnkiIntegration(
{ {
media: { media: {
audioPadding: 0, audioPadding: 0,
normalizeAudio: false,
}, },
}, },
{} as never, {} as never,
@@ -804,13 +901,21 @@ test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube a
endTime: number, endTime: number,
audioPadding?: number, audioPadding?: number,
audioStreamIndex?: number, audioStreamIndex?: number,
normalizeAudio?: boolean,
) => Promise<Buffer>; ) => Promise<Buffer>;
}; };
generateAudio: () => Promise<Buffer | null>; generateAudio: () => Promise<Buffer | null>;
}; };
internals.mediaGenerator = { internals.mediaGenerator = {
generateAudio: async (path, _startTime, _endTime, _audioPadding, audioStreamIndex) => { generateAudio: async (
audioCalls.push({ path: path.path, audioStreamIndex }); path,
_startTime,
_endTime,
_audioPadding,
audioStreamIndex,
normalizeAudio,
) => {
audioCalls.push({ path: path.path, audioStreamIndex, normalizeAudio });
return Buffer.from('audio'); return Buffer.from('audio');
}, },
}; };
@@ -821,6 +926,7 @@ test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube a
{ {
path: '/tmp/subminer-youtube-media-cache/media.mkv', path: '/tmp/subminer-youtube-media-cache/media.mkv',
audioStreamIndex: undefined, audioStreamIndex: undefined,
normalizeAudio: false,
}, },
]); ]);
}); });
+20 -3
View File
@@ -239,6 +239,9 @@ export class AnkiIntegration {
private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null = private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null =
null; null;
private shouldRequireRemoteMediaCache: (() => boolean) | null = null; private shouldRequireRemoteMediaCache: (() => boolean) | null = null;
private getYoutubeMediaSourceUrl:
| (() => Promise<string | null | undefined> | string | null | undefined)
| null = null;
private pendingYoutubeMediaQueue: PendingYoutubeMediaQueue; private pendingYoutubeMediaQueue: PendingYoutubeMediaQueue;
constructor( constructor(
@@ -257,6 +260,7 @@ export class AnkiIntegration {
overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void, overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void,
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'], getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'],
shouldRequireRemoteMediaCache?: () => boolean, shouldRequireRemoteMediaCache?: () => boolean,
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined,
) { ) {
this.config = normalizeAnkiIntegrationConfig(config); this.config = normalizeAnkiIntegrationConfig(config);
this.aiConfig = { ...aiConfig }; this.aiConfig = { ...aiConfig };
@@ -271,6 +275,7 @@ export class AnkiIntegration {
this.recordCardsMinedCallback = recordCardsMined ?? null; this.recordCardsMinedCallback = recordCardsMined ?? null;
this.getCachedMediaPath = getCachedMediaPath ?? null; this.getCachedMediaPath = getCachedMediaPath ?? null;
this.shouldRequireRemoteMediaCache = shouldRequireRemoteMediaCache ?? null; this.shouldRequireRemoteMediaCache = shouldRequireRemoteMediaCache ?? null;
this.getYoutubeMediaSourceUrl = getYoutubeMediaSourceUrl ?? null;
this.pendingYoutubeMediaQueue = this.createPendingYoutubeMediaQueue(); this.pendingYoutubeMediaQueue = this.createPendingYoutubeMediaQueue();
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath); this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
this.pollingRunner = this.createPollingRunner(); this.pollingRunner = this.createPollingRunner();
@@ -343,6 +348,7 @@ export class AnkiIntegration {
endTime, endTime,
audioPadding, audioPadding,
audioStreamIndex, audioStreamIndex,
this.config.media?.normalizeAudio !== false,
), ),
generateScreenshot: (videoPath, timestamp, options) => generateScreenshot: (videoPath, timestamp, options) =>
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options), this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
@@ -356,7 +362,7 @@ export class AnkiIntegration {
), ),
}, },
getConfig: () => this.config, getConfig: () => this.config,
getCurrentVideoPath: () => this.mpvClient.currentVideoPath, getCurrentVideoPath: () => this.getCurrentYoutubeMediaSourceUrl(),
getCachedMediaPath: this.getCachedMediaPath, getCachedMediaPath: this.getCachedMediaPath,
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true, shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
getSubtitleMediaRange: (context) => this.getSubtitleMediaRange(context), getSubtitleMediaRange: (context) => this.getSubtitleMediaRange(context),
@@ -474,6 +480,7 @@ export class AnkiIntegration {
getMpvClient: () => this.mpvClient, getMpvClient: () => this.mpvClient,
...(this.getCachedMediaPath ? { getCachedMediaPath: this.getCachedMediaPath } : {}), ...(this.getCachedMediaPath ? { getCachedMediaPath: this.getCachedMediaPath } : {}),
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true, shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
getYoutubeMediaSourceUrl: () => this.getCurrentYoutubeMediaSourceUrl(),
queuePendingYoutubeMediaUpdate: (job) => this.queuePendingYoutubeMediaUpdate(job), queuePendingYoutubeMediaUpdate: (job) => this.queuePendingYoutubeMediaUpdate(job),
getDeck: () => this.config.deck, getDeck: () => this.config.deck,
client: { client: {
@@ -496,6 +503,7 @@ export class AnkiIntegration {
endTime, endTime,
audioPadding, audioPadding,
audioStreamIndex, audioStreamIndex,
this.config.media?.normalizeAudio !== false,
), ),
generateScreenshot: (videoPath, timestamp, options) => generateScreenshot: (videoPath, timestamp, options) =>
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options), this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
@@ -695,8 +703,8 @@ export class AnkiIntegration {
}); });
} }
isKnownWord(text: string): boolean { isKnownWord(text: string, reading?: string): boolean {
return this.knownWordCache.isKnownWord(text); return this.knownWordCache.isKnownWord(text, reading);
} }
getKnownWordMatchMode(): NPlusOneMatchMode { getKnownWordMatchMode(): NPlusOneMatchMode {
@@ -945,6 +953,14 @@ export class AnkiIntegration {
return this.pendingYoutubeMediaQueue.queueFromNote(job); return this.pendingYoutubeMediaQueue.queueFromNote(job);
} }
private async getCurrentYoutubeMediaSourceUrl(): Promise<string> {
return (
trimToNonEmptyString(await this.getYoutubeMediaSourceUrl?.()) ??
trimToNonEmptyString(this.mpvClient.currentVideoPath) ??
''
);
}
async handleYoutubeMediaCacheReady( async handleYoutubeMediaCacheReady(
sourceUrl: string, sourceUrl: string,
cachedPath: string, cachedPath: string,
@@ -982,6 +998,7 @@ export class AnkiIntegration {
endTime, endTime,
this.config.media?.audioPadding, this.config.media?.audioPadding,
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex), resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
this.config.media?.normalizeAudio !== false,
); );
} }
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { CardCreationService } from './card-creation'; import { CardCreationService } from './card-creation';
import { toMpvEdlValue } from './mpv-edl-test-utils';
import type { MediaInput } from '../media-generator'; import type { MediaInput } from '../media-generator';
import type { AnkiConnectConfig } from '../types/anki'; import type { AnkiConnectConfig } from '../types/anki';
@@ -269,9 +270,11 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
const imagePaths: string[] = []; const imagePaths: string[] = [];
const recordMediaPath = (mediaInput: MediaInput): string => const recordMediaPath = (mediaInput: MediaInput): string =>
typeof mediaInput === 'string' ? mediaInput : mediaInput.path; typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
const edlSource = [ const edlSource = [
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm', `edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4', `!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
'!global_tags,title=test', '!global_tags,title=test',
].join(';'); ].join(';');
@@ -354,8 +357,8 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
await service.updateLastAddedFromClipboard('一行目\n\n二行目'); await service.updateLastAddedFromClipboard('一行目\n\n二行目');
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']); assert.deepEqual(audioPaths, [audioUrl]);
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']); assert.deepEqual(imagePaths, [videoUrl]);
assert.equal(storedMedia.length, 2); assert.equal(storedMedia.length, 2);
assert.equal(updatedFields.length, 1); assert.equal(updatedFields.length, 1);
assert.equal(updatedFields[0]?.Sentence, '一行目 二行目'); assert.equal(updatedFields[0]?.Sentence, '一行目 二行目');
+7 -4
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { CardCreationService } from './card-creation'; import { CardCreationService } from './card-creation';
import { toMpvEdlValue } from './mpv-edl-test-utils';
import type { MediaInput } from '../media-generator'; import type { MediaInput } from '../media-generator';
import type { AnkiConnectConfig } from '../types/anki'; import type { AnkiConnectConfig } from '../types/anki';
@@ -290,9 +291,11 @@ test('CardCreationService uses stream-open-filename for remote media generation'
const imagePaths: string[] = []; const imagePaths: string[] = [];
const recordMediaPath = (mediaInput: MediaInput): string => const recordMediaPath = (mediaInput: MediaInput): string =>
typeof mediaInput === 'string' ? mediaInput : mediaInput.path; typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
const edlSource = [ const edlSource = [
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm', `edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4', `!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
'!global_tags,title=test', '!global_tags,title=test',
].join(';'); ].join(';');
@@ -397,8 +400,8 @@ test('CardCreationService uses stream-open-filename for remote media generation'
const created = await service.createSentenceCard('テスト', 0, 1); const created = await service.createSentenceCard('テスト', 0, 1);
assert.equal(created, true); assert.equal(created, true);
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']); assert.deepEqual(audioPaths, [audioUrl]);
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']); assert.deepEqual(imagePaths, [videoUrl]);
}); });
test('CardCreationService does not use mpv stream indexes for ready cached YouTube media', async () => { test('CardCreationService does not use mpv stream indexes for ready cached YouTube media', async () => {
+14 -1
View File
@@ -28,6 +28,14 @@ function shouldGenerateImage(config: AnkiConnectConfig): boolean {
return config.media?.generateImage !== false; return config.media?.generateImage !== false;
} }
function trimToNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export interface CardCreationNoteInfo { export interface CardCreationNoteInfo {
noteId: number; noteId: number;
fields: Record<string, { value: string }>; fields: Record<string, { value: string }>;
@@ -57,6 +65,7 @@ interface CardCreationMediaGenerator {
endTime: number, endTime: number,
audioPadding?: number, audioPadding?: number,
audioStreamIndex?: number, audioStreamIndex?: number,
normalizeAudio?: boolean,
): Promise<Buffer | null>; ): Promise<Buffer | null>;
generateScreenshot( generateScreenshot(
path: MediaInput, path: MediaInput,
@@ -90,6 +99,7 @@ interface CardCreationDeps {
getMpvClient: () => MpvClient; getMpvClient: () => MpvClient;
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath']; getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'];
shouldRequireRemoteMediaCache?: () => boolean; shouldRequireRemoteMediaCache?: () => boolean;
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
queuePendingYoutubeMediaUpdate?: (job: PendingYoutubeMediaUpdate) => void; queuePendingYoutubeMediaUpdate?: (job: PendingYoutubeMediaUpdate) => void;
getDeck?: () => string | undefined; getDeck?: () => string | undefined;
client: CardCreationClient; client: CardCreationClient;
@@ -705,7 +715,9 @@ export class CardCreationService {
const label = sentence.length > 30 ? sentence.substring(0, 30) + '...' : sentence; const label = sentence.length > 30 ? sentence.substring(0, 30) + '...' : sentence;
if (shouldQueuePendingYoutubeMedia) { if (shouldQueuePendingYoutubeMedia) {
this.deps.queuePendingYoutubeMediaUpdate?.({ this.deps.queuePendingYoutubeMediaUpdate?.({
sourceUrl: mpvClient.currentVideoPath, sourceUrl:
trimToNonEmptyString(await this.deps.getYoutubeMediaSourceUrl?.()) ??
mpvClient.currentVideoPath,
noteId, noteId,
startTime, startTime,
endTime, endTime,
@@ -831,6 +843,7 @@ export class CardCreationService {
videoPath, videoPath,
mpvClient.currentAudioStreamIndex ?? undefined, mpvClient.currentAudioStreamIndex ?? undefined,
), ),
this.deps.getConfig().media?.normalizeAudio !== false,
); );
} }
@@ -26,6 +26,7 @@ function createWorkflowHarness() {
const deleted: number[][] = []; const deleted: number[][] = [];
const addedTags: Array<{ noteIds: number[]; tags: string[] }> = []; const addedTags: Array<{ noteIds: number[]; tags: string[] }> = [];
const statuses: string[] = []; const statuses: string[] = [];
const osdMessages: string[] = [];
const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = []; const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = [];
const mergeCalls: Array<{ const mergeCalls: Array<{
keepNoteId: number; keepNoteId: number;
@@ -112,7 +113,9 @@ function createWorkflowHarness() {
statuses.push(message); statuses.push(message);
}, },
showNotification: async () => undefined, showNotification: async () => undefined,
showOsdNotification: () => undefined, showOsdNotification: (message: string) => {
osdMessages.push(message);
},
logError: () => undefined, logError: () => undefined,
logInfo: () => undefined, logInfo: () => undefined,
truncateSentence: (value: string) => value, truncateSentence: (value: string) => value,
@@ -125,6 +128,7 @@ function createWorkflowHarness() {
addedTags, addedTags,
rememberedMerges, rememberedMerges,
statuses, statuses,
osdMessages,
mergeCalls, mergeCalls,
setManualChoice: (choice: typeof manualChoice) => { setManualChoice: (choice: typeof manualChoice) => {
manualChoice = choice; manualChoice = choice;
@@ -191,6 +195,50 @@ test('FieldGroupingWorkflow manual mode returns false when callback unavailable'
assert.equal(harness.updates.length, 0); assert.equal(harness.updates.length, 0);
}); });
test('FieldGroupingWorkflow manual cancel notifies exactly once', async () => {
const harness = createWorkflowHarness();
harness.setManualChoice({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
const handled = await harness.workflow.handleManual(1, 2, {
noteId: 2,
fields: {
Expression: { value: 'word-2' },
Sentence: { value: 'line-2' },
},
});
assert.equal(handled, false);
assert.deepEqual(harness.osdMessages, ['Field grouping cancelled']);
assert.equal(harness.updates.length, 0);
});
test('FieldGroupingWorkflow manual mode notifies when the original card cannot be loaded', async () => {
const harness = createWorkflowHarness();
harness.setManualChoice({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: true,
cancelled: false,
});
harness.deps.client.notesInfo = async () => [];
const handled = await harness.workflow.handleManual(1, 2, {
noteId: 2,
fields: {
Expression: { value: 'word-2' },
Sentence: { value: 'line-2' },
},
});
assert.equal(handled, false);
assert.deepEqual(harness.osdMessages, ['Field grouping failed: original card not found']);
});
test('FieldGroupingWorkflow manual keep-new uses new note as merge target and old note as source', async () => { test('FieldGroupingWorkflow manual keep-new uses new note as merge target and old note as source', async () => {
const harness = createWorkflowHarness(); const harness = createWorkflowHarness();
harness.setManualChoice({ harness.setManualChoice({
@@ -98,6 +98,8 @@ export class FieldGroupingWorkflow {
const originalNotesInfoResult = await this.deps.client.notesInfo([originalNoteId]); const originalNotesInfoResult = await this.deps.client.notesInfo([originalNoteId]);
const originalNotesInfo = originalNotesInfoResult as FieldGroupingWorkflowNoteInfo[]; const originalNotesInfo = originalNotesInfoResult as FieldGroupingWorkflowNoteInfo[];
if (!originalNotesInfo || originalNotesInfo.length === 0) { if (!originalNotesInfo || originalNotesInfo.length === 0) {
// handleManual owns all user-facing notifications; callers must not re-notify on false.
this.deps.showOsdNotification('Field grouping failed: original card not found');
return false; return false;
} }
+4 -2
View File
@@ -302,7 +302,7 @@ test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fi
assert.deepEqual(harness.manualCalls, []); assert.deepEqual(harness.manualCalls, []);
}); });
test('triggerFieldGroupingForLastAddedCard shows a cancellation message when manual grouping is declined', async () => { test('triggerFieldGroupingForLastAddedCard does not re-notify when manual grouping is declined', async () => {
const harness = createHarness({ const harness = createHarness({
kikuFieldGrouping: 'manual', kikuFieldGrouping: 'manual',
noteIds: [9], noteIds: [9],
@@ -339,7 +339,9 @@ test('triggerFieldGroupingForLastAddedCard shows a cancellation message when man
expression: 'word-9', expression: 'word-9',
}, },
]); ]);
assert.equal(harness.calls.at(-1), 'osd:Field grouping cancelled'); // The manual workflow already notifies about its outcome (cancelled/unavailable/failed);
// the trigger wrapper re-notifying produced two "Field grouping cancelled" toasts.
assert.equal(harness.calls.filter((call) => call === 'osd:Field grouping cancelled').length, 0);
}); });
test('buildFieldGroupingPreview returns merged compact and full previews', async () => { test('buildFieldGroupingPreview returns merged compact and full previews', async () => {
+3 -4
View File
@@ -156,15 +156,14 @@ export class FieldGroupingService {
); );
return; return;
} }
const handled = await this.deps.handleFieldGroupingManual( // The manual workflow owns all user-facing notifications for its outcomes (cancelled,
// unavailable, failed) — re-notifying on a false return here duplicated them.
await this.deps.handleFieldGroupingManual(
duplicateNoteId, duplicateNoteId,
noteId, noteId,
noteInfo, noteInfo,
expressionText, expressionText,
); );
if (!handled) {
this.deps.showOsdNotification('Field grouping cancelled');
}
}); });
} catch (error) { } catch (error) {
log.error('Error triggering field grouping:', (error as Error).message); log.error('Error triggering field grouping:', (error as Error).message);
+251 -7
View File
@@ -108,6 +108,55 @@ test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without i
assert.equal(manager.isKnownWord('猫'), true); assert.equal(manager.isKnownWord('猫'), true);
assert.equal(calls.findNotes, 0); assert.equal(calls.findNotes, 0);
assert.equal(calls.notesInfo, 0); assert.equal(calls.notesInfo, 0);
// v2 states carry no readings, so they load usable but stale to trigger a
// prompt upgrade refresh.
assert.equal(
(
manager as unknown as {
getMsUntilNextRefresh: () => number;
}
).getMsUntilNextRefresh(),
0,
);
} finally {
Date.now = originalDateNow;
manager.stopLifecycle();
cleanup();
}
});
test('KnownWordCacheManager startLifecycle keeps fresh v3 persisted cache without immediate refresh', async () => {
const config: AnkiConnectConfig = {
knownWords: {
highlightEnabled: true,
refreshMinutes: 60,
},
};
const { manager, calls, statePath, cleanup } = createKnownWordCacheHarness(config);
const originalDateNow = Date.now;
try {
Date.now = () => 120_000;
fs.writeFileSync(
statePath,
JSON.stringify({
version: 3,
refreshedAtMs: 120_000,
scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":""}',
notes: {
'1': [{ word: '猫', reading: 'ねこ' }],
},
}),
'utf-8',
);
manager.startLifecycle();
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(manager.isKnownWord('猫', 'ねこ'), true);
assert.equal(manager.isKnownWord('猫', 'びょう'), false);
assert.equal(calls.findNotes, 0);
assert.equal(calls.notesInfo, 0);
assert.equal( assert.equal(
( (
manager as unknown as { manager as unknown as {
@@ -263,13 +312,11 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as { const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
version: number; version: number;
words: string[]; notes?: Record<string, Array<{ word: string; reading: string | null }>>;
notes?: Record<string, string[]>;
}; };
assert.equal(persisted.version, 2); assert.equal(persisted.version, 3);
assert.deepEqual(persisted.words.sort(), ['鳥']);
assert.deepEqual(persisted.notes, { assert.deepEqual(persisted.notes, {
'1': ['鳥'], '1': [{ word: '鳥', reading: null }],
}); });
} finally { } finally {
cleanup(); cleanup();
@@ -392,10 +439,10 @@ test('KnownWordCacheManager preserves cache state key captured before refresh wo
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as { const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
scope: string; scope: string;
words: string[]; notes: Record<string, Array<{ word: string; reading: string | null }>>;
}; };
assert.equal(persisted.scope, '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}'); assert.equal(persisted.scope, '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}');
assert.deepEqual(persisted.words, ['猫']); assert.deepEqual(persisted.notes, { '1': [{ word: '猫', reading: null }] });
} finally { } finally {
fs.rmSync(stateDir, { recursive: true, force: true }); fs.rmSync(stateDir, { recursive: true, force: true });
} }
@@ -648,3 +695,200 @@ test('KnownWordCacheManager skips immediate append when addMinedWordsImmediately
cleanup(); cleanup();
} }
}); });
test('KnownWordCacheManager disambiguates known words by note reading', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesResult = [1];
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Word: { value: '床' },
'Word Reading': { value: 'ゆか' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('床'), true);
assert.equal(manager.isKnownWord('床', 'ゆか'), true);
assert.equal(manager.isKnownWord('床', 'ユカ'), true);
// Same spelling, different word (床/とこ "bed") must not match.
assert.equal(manager.isKnownWord('床', 'とこ'), false);
// Note readings stay matchable as kana words.
assert.equal(manager.isKnownWord('ゆか'), true);
assert.equal(manager.isKnownWord('とこ'), false);
} finally {
cleanup();
}
});
test('KnownWordCacheManager probes reading fields even with per-deck word fields configured', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Expression',
},
knownWords: {
highlightEnabled: true,
decks: {
'Kaishi 1.5k': ['Word'],
},
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesByQuery.set('deck:"Kaishi 1.5k"', [1]);
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Word: { value: '床' },
'Word Reading': { value: 'ゆか' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('床', 'ゆか'), true);
assert.equal(manager.isKnownWord('床', 'とこ'), false);
} finally {
cleanup();
}
});
test('KnownWordCacheManager matches words without readings in any reading', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesResult = [1];
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Word: { value: '床' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('床'), true);
assert.equal(manager.isKnownWord('床', 'とこ'), true);
} finally {
cleanup();
}
});
test('KnownWordCacheManager extracts word and reading from furigana word fields', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesResult = [1];
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Word: { value: 'お 決[き]まり' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('お決まり'), true);
assert.equal(manager.isKnownWord('お決まり', 'おきまり'), true);
assert.equal(manager.isKnownWord('お決まり', 'おさだまり'), false);
} finally {
cleanup();
}
});
test('KnownWordCacheManager treats non-kana reading fields as words', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesResult = [1];
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Reading: { value: '漢字' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('漢字'), true);
} finally {
cleanup();
}
});
test('KnownWordCacheManager keeps kana-only reading notes matchable', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesResult = [1];
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Reading: { value: 'たべる' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('たべる'), true);
assert.equal(manager.isKnownWord('タベル'), true);
} finally {
cleanup();
}
});
+206 -101
View File
@@ -5,6 +5,16 @@ import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { getConfiguredWordFieldName } from '../anki-field-config'; import { getConfiguredWordFieldName } from '../anki-field-config';
import { AnkiConnectConfig } from '../types/anki'; import { AnkiConnectConfig } from '../types/anki';
import { createLogger } from '../logger'; import { createLogger } from '../logger';
import {
DEFAULT_KNOWN_WORD_READING_FIELDS,
KnownWordEntry,
convertKatakanaToHiragana,
isReadingFieldName,
knownWordEntryListsEqual,
normalizeKnownReadingForLookup,
normalizeKnownWordEntryList,
parseFuriganaAnnotatedText,
} from './known-word-entries';
const log = createLogger('anki').child('integration.known-word-cache'); const log = createLogger('anki').child('integration.known-word-cache');
@@ -79,7 +89,16 @@ interface KnownWordCacheStateV2 {
readonly notes: Record<string, string[]>; readonly notes: Record<string, string[]>;
} }
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2; interface KnownWordCacheStateV3 {
readonly version: 3;
readonly refreshedAtMs: number;
readonly scope: string;
readonly notes: Record<string, KnownWordEntry[]>;
}
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2 | KnownWordCacheStateV3;
const NO_READING_KEY = '';
interface KnownWordCacheClient { interface KnownWordCacheClient {
findNotes: ( findNotes: (
@@ -106,9 +125,12 @@ type KnownWordQueryScope = {
export class KnownWordCacheManager { export class KnownWordCacheManager {
private knownWordsLastRefreshedAtMs = 0; private knownWordsLastRefreshedAtMs = 0;
private knownWordsStateKey = ''; private knownWordsStateKey = '';
private knownWords: Set<string> = new Set(); // word → (hiragana reading | NO_READING_KEY → note count). NO_READING_KEY
private wordReferenceCounts = new Map<string, number>(); // entries fail open: the word matches regardless of the token's reading.
private noteWordsById = new Map<number, string[]>(); private wordReadingCounts = new Map<string, Map<string, number>>();
// hiragana reading → note count, so kana tokens still match by reading alone.
private readingCounts = new Map<string, number>();
private noteEntriesById = new Map<number, KnownWordEntry[]>();
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null; private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null; private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
private isRefreshingKnownWords = false; private isRefreshingKnownWords = false;
@@ -120,13 +142,28 @@ export class KnownWordCacheManager {
); );
} }
isKnownWord(text: string): boolean { isKnownWord(text: string, reading?: string): boolean {
if (!this.isKnownWordCacheEnabled()) { if (!this.isKnownWordCacheEnabled()) {
return false; return false;
} }
const normalized = this.normalizeKnownWordForLookup(text); const normalized = this.normalizeKnownWordForLookup(text);
return normalized.length > 0 ? this.knownWords.has(normalized) : false; if (normalized.length === 0) {
return false;
}
const knownReadings = this.wordReadingCounts.get(normalized);
if (knownReadings && knownReadings.size > 0) {
const normalizedReading =
typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : '';
return (
normalizedReading.length === 0 ||
knownReadings.has(NO_READING_KEY) ||
knownReadings.has(normalizedReading)
);
}
return this.readingCounts.has(convertKatakanaToHiragana(normalized));
} }
refresh(force = false): Promise<void> { refresh(force = false): Promise<void> {
@@ -173,7 +210,7 @@ export class KnownWordCacheManager {
let didMutateCache = false; let didMutateCache = false;
const currentStateKey = this.getKnownWordCacheStateKey(); const currentStateKey = this.getKnownWordCacheStateKey();
if (this.knownWordsStateKey && this.knownWordsStateKey !== currentStateKey) { if (this.knownWordsStateKey && this.knownWordsStateKey !== currentStateKey) {
didMutateCache = this.knownWords.size > 0 || this.noteWordsById.size > 0; didMutateCache = this.wordReadingCounts.size > 0 || this.noteEntriesById.size > 0;
this.clearKnownWordCacheState(); this.clearKnownWordCacheState();
} }
if (!this.knownWordsStateKey) { if (!this.knownWordsStateKey) {
@@ -185,8 +222,8 @@ export class KnownWordCacheManager {
return didMutateCache; return didMutateCache;
} }
const nextWords = this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, preferredFields); const nextEntries = this.extractKnownWordEntriesFromNoteInfo(noteInfo, preferredFields);
const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextWords); const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextEntries);
if (!changed) { if (!changed) {
return didMutateCache; return didMutateCache;
} }
@@ -198,7 +235,7 @@ export class KnownWordCacheManager {
log.info( log.info(
'Known-word cache updated in-session', 'Known-word cache updated in-session',
`noteId=${noteInfo.noteId}`, `noteId=${noteInfo.noteId}`,
`wordCount=${nextWords.length}`, `wordCount=${nextEntries.length}`,
`scope=${getKnownWordCacheScopeForConfig(this.deps.getConfig())}`, `scope=${getKnownWordCacheScopeForConfig(this.deps.getConfig())}`,
); );
return true; return true;
@@ -236,11 +273,11 @@ export class KnownWordCacheManager {
const noteFieldsById = await this.fetchKnownWordNoteFieldsById(); const noteFieldsById = await this.fetchKnownWordNoteFieldsById();
const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b); const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b);
if (this.noteWordsById.size === 0) { if (this.noteEntriesById.size === 0) {
await this.rebuildFromCurrentNotes(currentNoteIds, noteFieldsById); await this.rebuildFromCurrentNotes(currentNoteIds, noteFieldsById);
} else { } else {
const currentNoteIdSet = new Set(currentNoteIds); const currentNoteIdSet = new Set(currentNoteIds);
for (const noteId of Array.from(this.noteWordsById.keys())) { for (const noteId of Array.from(this.noteEntriesById.keys())) {
if (!currentNoteIdSet.has(noteId)) { if (!currentNoteIdSet.has(noteId)) {
this.removeNoteSnapshot(noteId); this.removeNoteSnapshot(noteId);
} }
@@ -251,7 +288,7 @@ export class KnownWordCacheManager {
for (const noteInfo of noteInfos) { for (const noteInfo of noteInfos) {
this.replaceNoteSnapshot( this.replaceNoteSnapshot(
noteInfo.noteId, noteInfo.noteId,
this.extractNormalizedKnownWordsFromNoteInfo( this.extractKnownWordEntriesFromNoteInfo(
noteInfo, noteInfo,
noteFieldsById.get(noteInfo.noteId), noteFieldsById.get(noteInfo.noteId),
), ),
@@ -266,7 +303,7 @@ export class KnownWordCacheManager {
log.info( log.info(
'Known-word cache refreshed', 'Known-word cache refreshed',
`noteCount=${currentNoteIds.length}`, `noteCount=${currentNoteIds.length}`,
`wordCount=${this.knownWords.size}`, `wordCount=${this.wordReadingCounts.size}`,
); );
} catch (error) { } catch (error) {
log.warn('Failed to refresh known-word cache:', (error as Error).message); log.warn('Failed to refresh known-word cache:', (error as Error).message);
@@ -291,7 +328,13 @@ export class KnownWordCacheManager {
private getDefaultKnownWordFields(): string[] { private getDefaultKnownWordFields(): string[] {
const configuredWordField = getConfiguredWordFieldName(this.deps.getConfig()); const configuredWordField = getConfiguredWordFieldName(this.deps.getConfig());
return [...new Set([configuredWordField, 'Word', 'Reading', 'Word Reading'])]; return this.withDefaultReadingFields([configuredWordField, 'Word']);
}
// Reading fields are always probed (even when a deck configures explicit
// word fields) so entries can carry the reading their note teaches.
private withDefaultReadingFields(fields: string[]): string[] {
return [...new Set([...fields, ...DEFAULT_KNOWN_WORD_READING_FIELDS])];
} }
private getKnownWordDecks(): string[] { private getKnownWordDecks(): string[] {
@@ -337,7 +380,9 @@ export class KnownWordCacheManager {
.filter((field) => field.length > 0), .filter((field) => field.length > 0),
), ),
]; ];
return normalizedFields.length > 0 ? normalizedFields : this.getDefaultKnownWordFields(); return normalizedFields.length > 0
? this.withDefaultReadingFields(normalizedFields)
: this.getDefaultKnownWordFields();
} }
const deckFields = selectedDeckEntry[1]; const deckFields = selectedDeckEntry[1];
@@ -351,7 +396,7 @@ export class KnownWordCacheManager {
), ),
]; ];
if (normalizedFields.length > 0) { if (normalizedFields.length > 0) {
return normalizedFields; return this.withDefaultReadingFields(normalizedFields);
} }
} }
@@ -382,7 +427,10 @@ export class KnownWordCacheManager {
: []; : [];
scopes.push({ scopes.push({
query: `deck:"${escapeAnkiSearchValue(trimmedDeckName)}"`, query: `deck:"${escapeAnkiSearchValue(trimmedDeckName)}"`,
fields: normalizedFields.length > 0 ? normalizedFields : this.getDefaultKnownWordFields(), fields:
normalizedFields.length > 0
? this.withDefaultReadingFields(normalizedFields)
: this.getDefaultKnownWordFields(),
}); });
} }
if (scopes.length > 0) { if (scopes.length > 0) {
@@ -490,7 +538,7 @@ export class KnownWordCacheManager {
for (const noteInfo of noteInfos) { for (const noteInfo of noteInfos) {
this.replaceNoteSnapshot( this.replaceNoteSnapshot(
noteInfo.noteId, noteInfo.noteId,
this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, noteFieldsById.get(noteInfo.noteId)), this.extractKnownWordEntriesFromNoteInfo(noteInfo, noteFieldsById.get(noteInfo.noteId)),
); );
} }
} }
@@ -519,56 +567,77 @@ export class KnownWordCacheManager {
return noteInfos; return noteInfos;
} }
private replaceNoteSnapshot(noteId: number, nextWords: string[]): boolean { private replaceNoteSnapshot(noteId: number, nextEntries: KnownWordEntry[]): boolean {
const normalizedWords = normalizeKnownWordList(nextWords); const normalizedEntries = normalizeKnownWordEntryList(nextEntries);
const previousWords = this.noteWordsById.get(noteId) ?? []; const previousEntries = this.noteEntriesById.get(noteId) ?? [];
if (knownWordListsEqual(previousWords, normalizedWords)) { if (knownWordEntryListsEqual(previousEntries, normalizedEntries)) {
return false; return false;
} }
this.removeWordsFromCounts(previousWords); this.removeEntriesFromCounts(previousEntries);
if (normalizedWords.length > 0) { if (normalizedEntries.length > 0) {
this.noteWordsById.set(noteId, normalizedWords); this.noteEntriesById.set(noteId, normalizedEntries);
this.addWordsToCounts(normalizedWords); this.addEntriesToCounts(normalizedEntries);
} else { } else {
this.noteWordsById.delete(noteId); this.noteEntriesById.delete(noteId);
} }
return true; return true;
} }
private removeNoteSnapshot(noteId: number): void { private removeNoteSnapshot(noteId: number): void {
const previousWords = this.noteWordsById.get(noteId); const previousEntries = this.noteEntriesById.get(noteId);
if (!previousWords) { if (!previousEntries) {
return; return;
} }
this.noteWordsById.delete(noteId); this.noteEntriesById.delete(noteId);
this.removeWordsFromCounts(previousWords); this.removeEntriesFromCounts(previousEntries);
} }
private addWordsToCounts(words: string[]): void { private addEntriesToCounts(entries: KnownWordEntry[]): void {
for (const word of words) { for (const entry of entries) {
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) + 1; const readingKey = entry.reading ?? NO_READING_KEY;
this.wordReferenceCounts.set(word, nextCount); let readings = this.wordReadingCounts.get(entry.word);
this.knownWords.add(word); if (!readings) {
readings = new Map();
this.wordReadingCounts.set(entry.word, readings);
}
readings.set(readingKey, (readings.get(readingKey) ?? 0) + 1);
if (entry.reading) {
this.readingCounts.set(entry.reading, (this.readingCounts.get(entry.reading) ?? 0) + 1);
}
} }
} }
private removeWordsFromCounts(words: string[]): void { private removeEntriesFromCounts(entries: KnownWordEntry[]): void {
for (const word of words) { for (const entry of entries) {
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) - 1; const readingKey = entry.reading ?? NO_READING_KEY;
if (nextCount > 0) { const readings = this.wordReadingCounts.get(entry.word);
this.wordReferenceCounts.set(word, nextCount); if (readings) {
} else { const nextCount = (readings.get(readingKey) ?? 0) - 1;
this.wordReferenceCounts.delete(word); if (nextCount > 0) {
this.knownWords.delete(word); readings.set(readingKey, nextCount);
} else {
readings.delete(readingKey);
if (readings.size === 0) {
this.wordReadingCounts.delete(entry.word);
}
}
}
if (entry.reading) {
const nextReadingCount = (this.readingCounts.get(entry.reading) ?? 0) - 1;
if (nextReadingCount > 0) {
this.readingCounts.set(entry.reading, nextReadingCount);
} else {
this.readingCounts.delete(entry.reading);
}
} }
} }
} }
private clearInMemoryState(): void { private clearInMemoryState(): void {
this.knownWords = new Set(); this.wordReadingCounts = new Map();
this.wordReferenceCounts = new Map(); this.readingCounts = new Map();
this.noteWordsById = new Map(); this.noteEntriesById = new Map();
this.knownWordsLastRefreshedAtMs = 0; this.knownWordsLastRefreshedAtMs = 0;
} }
@@ -601,32 +670,48 @@ export class KnownWordCacheManager {
} }
this.clearInMemoryState(); this.clearInMemoryState();
if (parsed.version === 3) {
for (const [noteIdKey, entries] of Object.entries(parsed.notes)) {
const noteId = Number.parseInt(noteIdKey, 10);
if (!Number.isInteger(noteId) || noteId <= 0) {
continue;
}
const normalizedEntries = normalizeKnownWordEntryList(entries);
if (normalizedEntries.length === 0) {
continue;
}
this.noteEntriesById.set(noteId, normalizedEntries);
this.addEntriesToCounts(normalizedEntries);
}
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
this.knownWordsStateKey = parsed.scope;
return;
}
if (parsed.version === 2) { if (parsed.version === 2) {
// Older states have no readings; load them reading-less (fail-open,
// matching the old behavior) but leave the cache marked stale so the
// next refresh upgrades entries with readings from Anki.
for (const [noteIdKey, words] of Object.entries(parsed.notes)) { for (const [noteIdKey, words] of Object.entries(parsed.notes)) {
const noteId = Number.parseInt(noteIdKey, 10); const noteId = Number.parseInt(noteIdKey, 10);
if (!Number.isInteger(noteId) || noteId <= 0) { if (!Number.isInteger(noteId) || noteId <= 0) {
continue; continue;
} }
const normalizedWords = normalizeKnownWordList(words); const normalizedEntries = normalizeKnownWordEntryList(
if (normalizedWords.length === 0) { words.map((word) => ({ word: this.normalizeKnownWordForLookup(word), reading: null })),
);
if (normalizedEntries.length === 0) {
continue; continue;
} }
this.noteWordsById.set(noteId, normalizedWords); this.noteEntriesById.set(noteId, normalizedEntries);
this.addWordsToCounts(normalizedWords); this.addEntriesToCounts(normalizedEntries);
}
} else {
for (const value of parsed.words) {
const normalized = this.normalizeKnownWordForLookup(value);
if (!normalized) {
continue;
}
this.knownWords.add(normalized);
this.wordReferenceCounts.set(normalized, 1);
} }
this.knownWordsStateKey = parsed.scope;
return;
} }
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs; // v1 has no per-note snapshots to convert; refetch from Anki.
this.knownWordsStateKey = parsed.scope; this.knownWordsStateKey = this.getKnownWordCacheStateKey();
} catch (error) { } catch (error) {
log.warn('Failed to load known-word cache state:', (error as Error).message); log.warn('Failed to load known-word cache state:', (error as Error).message);
this.clearInMemoryState(); this.clearInMemoryState();
@@ -636,18 +721,17 @@ export class KnownWordCacheManager {
private persistKnownWordCacheState(): void { private persistKnownWordCacheState(): void {
try { try {
const notes: Record<string, string[]> = {}; const notes: Record<string, KnownWordEntry[]> = {};
for (const [noteId, words] of this.noteWordsById.entries()) { for (const [noteId, entries] of this.noteEntriesById.entries()) {
if (words.length > 0) { if (entries.length > 0) {
notes[String(noteId)] = words; notes[String(noteId)] = entries;
} }
} }
const state: KnownWordCacheStateV2 = { const state: KnownWordCacheStateV3 = {
version: 2, version: 3,
refreshedAtMs: this.knownWordsLastRefreshedAtMs, refreshedAtMs: this.knownWordsLastRefreshedAtMs,
scope: this.knownWordsStateKey, scope: this.knownWordsStateKey,
words: Array.from(this.knownWords),
notes, notes,
}; };
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8'); fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
@@ -659,14 +743,18 @@ export class KnownWordCacheManager {
private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState { private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState {
if (typeof value !== 'object' || value === null) return false; if (typeof value !== 'object' || value === null) return false;
const candidate = value as Record<string, unknown>; const candidate = value as Record<string, unknown>;
if (candidate.version !== 1 && candidate.version !== 2) return false; if (candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3) {
if (typeof candidate.refreshedAtMs !== 'number') return false;
if (typeof candidate.scope !== 'string') return false;
if (!Array.isArray(candidate.words)) return false;
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
return false; return false;
} }
if (candidate.version === 2) { if (typeof candidate.refreshedAtMs !== 'number') return false;
if (typeof candidate.scope !== 'string') return false;
if (candidate.version !== 3) {
if (!Array.isArray(candidate.words)) return false;
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
return false;
}
}
if (candidate.version === 2 || candidate.version === 3) {
if ( if (
typeof candidate.notes !== 'object' || typeof candidate.notes !== 'object' ||
candidate.notes === null || candidate.notes === null ||
@@ -674,10 +762,18 @@ export class KnownWordCacheManager {
) { ) {
return false; return false;
} }
const isValidNoteEntry =
candidate.version === 2
? (entry: unknown): boolean => typeof entry === 'string'
: (entry: unknown): boolean =>
typeof entry === 'object' &&
entry !== null &&
typeof (entry as KnownWordEntry).word === 'string' &&
((entry as KnownWordEntry).reading === null ||
typeof (entry as KnownWordEntry).reading === 'string');
if ( if (
!Object.values(candidate.notes as Record<string, unknown>).every( !Object.values(candidate.notes as Record<string, unknown>).every(
(entry) => (noteEntries) => Array.isArray(noteEntries) && noteEntries.every(isValidNoteEntry),
Array.isArray(entry) && entry.every((word: unknown) => typeof word === 'string'),
) )
) { ) {
return false; return false;
@@ -686,11 +782,12 @@ export class KnownWordCacheManager {
return true; return true;
} }
private extractNormalizedKnownWordsFromNoteInfo( private extractKnownWordEntriesFromNoteInfo(
noteInfo: KnownWordCacheNoteInfo, noteInfo: KnownWordCacheNoteInfo,
preferredFields = this.getConfiguredFields(), preferredFields = this.getConfiguredFields(),
): string[] { ): KnownWordEntry[] {
const words: string[] = []; const wordValues: string[] = [];
let noteReading: string | null = null;
for (const preferredField of preferredFields) { for (const preferredField of preferredFields) {
const fieldName = resolveFieldName(Object.keys(noteInfo.fields), preferredField); const fieldName = resolveFieldName(Object.keys(noteInfo.fields), preferredField);
if (!fieldName) continue; if (!fieldName) continue;
@@ -698,12 +795,36 @@ export class KnownWordCacheManager {
const raw = noteInfo.fields[fieldName]?.value; const raw = noteInfo.fields[fieldName]?.value;
if (!raw) continue; if (!raw) continue;
const normalized = this.normalizeKnownWordForLookup(raw); const cleaned = this.normalizeRawKnownWordValue(raw);
if (normalized) { if (!cleaned) continue;
words.push(normalized);
if (isReadingFieldName(preferredField)) {
const normalizedReading = normalizeKnownReadingForLookup(cleaned);
if (normalizedReading) {
noteReading ??= normalizedReading;
continue;
}
// Non-kana content in a reading field: treat it as a word so decks
// with repurposed reading fields keep matching (fail-open).
} }
wordValues.push(cleaned);
} }
return normalizeKnownWordList(words);
const entries: KnownWordEntry[] = [];
for (const value of wordValues) {
const parsed = parseFuriganaAnnotatedText(value);
const word = parsed.text.trim().toLowerCase();
if (!word) continue;
const inlineReading = parsed.reading ? normalizeKnownReadingForLookup(parsed.reading) : '';
entries.push({ word, reading: inlineReading || noteReading });
}
// Kana-only notes (reading field but no word field) stay matchable.
if (entries.length === 0 && noteReading) {
entries.push({ word: noteReading, reading: noteReading });
}
return normalizeKnownWordEntryList(entries);
} }
private normalizeRawKnownWordValue(value: string): string { private normalizeRawKnownWordValue(value: string): string {
@@ -718,22 +839,6 @@ export class KnownWordCacheManager {
} }
} }
function normalizeKnownWordList(words: string[]): string[] {
return [...new Set(words.map((word) => word.trim()).filter((word) => word.length > 0))].sort();
}
function knownWordListsEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
return false;
}
}
return true;
}
function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null { function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null {
const exact = availableFieldNames.find((name) => name === preferredName); const exact = availableFieldNames.find((name) => name === preferredName);
if (exact) return exact; if (exact) return exact;
+113
View File
@@ -0,0 +1,113 @@
// Known-word cache entries pair a word with the reading its Anki note teaches,
// so spelling collisions across readings (e.g. 床/ゆか vs 床/とこ) don't mark
// unrelated words as known. reading === null means the note carries no usable
// reading and the word matches in any reading (fail-open).
export interface KnownWordEntry {
word: string;
reading: string | null;
}
const KATAKANA_TO_HIRAGANA_OFFSET = 0x60;
const KATAKANA_CODEPOINT_START = 0x30a1;
const KATAKANA_CODEPOINT_END = 0x30f6;
const FURIGANA_SEGMENT_PATTERN = /([^\s \[\]]*)\[([^\]]*)\]/g;
const FURIGANA_BRACKET_PATTERN = /\[[^\]]*\]/g;
const WHITESPACE_PATTERN = /[\s ]+/g;
// Reading-bearing field names probed on every known-word note, in addition to
// any configured word fields (covers Kaishi's "Word Reading" and Lapis's
// "ExpressionReading" note types).
export const DEFAULT_KNOWN_WORD_READING_FIELDS = [
'Reading',
'Word Reading',
'ExpressionReading',
'Expression Reading',
];
export function isReadingFieldName(fieldName: string): boolean {
return /reading/i.test(fieldName);
}
export function convertKatakanaToHiragana(text: string): string {
let converted = '';
for (const char of text) {
const code = char.codePointAt(0);
if (code !== undefined && code >= KATAKANA_CODEPOINT_START && code <= KATAKANA_CODEPOINT_END) {
converted += String.fromCodePoint(code - KATAKANA_TO_HIRAGANA_OFFSET);
continue;
}
converted += char;
}
return converted;
}
function isHiraganaReadingChar(char: string): boolean {
const code = char.codePointAt(0);
if (code === undefined) {
return false;
}
return (code >= 0x3041 && code <= 0x309f) || code === 0x30fc;
}
// Splits Anki furigana syntax (`床[とこ]`, `お 決[き]まり`) into base text and
// reading. Values without brackets pass through with reading null.
export function parseFuriganaAnnotatedText(value: string): {
text: string;
reading: string | null;
} {
if (!value.includes('[')) {
return { text: value, reading: null };
}
const text = value.replace(FURIGANA_BRACKET_PATTERN, '').replace(WHITESPACE_PATTERN, '');
const reading = value.replace(FURIGANA_SEGMENT_PATTERN, '$2').replace(WHITESPACE_PATTERN, '');
return { text, reading: reading.length > 0 ? reading : null };
}
// Returns the hiragana-normalized reading, or '' when the value is not a
// plausible kana reading (callers fall back to text-only matching then).
export function normalizeKnownReadingForLookup(value: string): string {
const parsed = parseFuriganaAnnotatedText(value.trim());
const candidate = (parsed.reading ?? parsed.text).trim();
if (!candidate) {
return '';
}
const hiragana = convertKatakanaToHiragana(candidate);
for (const char of hiragana) {
if (!isHiraganaReadingChar(char)) {
return '';
}
}
return hiragana;
}
export function makeKnownWordEntryKey(entry: KnownWordEntry): string {
return `${entry.word}\u0000${entry.reading ?? ''}`;
}
export function normalizeKnownWordEntryList(entries: KnownWordEntry[]): KnownWordEntry[] {
const byKey = new Map<string, KnownWordEntry>();
for (const entry of entries) {
const word = entry.word.trim();
if (!word) {
continue;
}
const reading = entry.reading?.trim() || null;
const normalized: KnownWordEntry = { word, reading };
byKey.set(makeKnownWordEntryKey(normalized), normalized);
}
return [...byKey.values()].sort((left, right) =>
makeKnownWordEntryKey(left).localeCompare(makeKnownWordEntryKey(right)),
);
}
export function knownWordEntryListsEqual(left: KnownWordEntry[], right: KnownWordEntry[]): boolean {
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
if (makeKnownWordEntryKey(left[index]!) !== makeKnownWordEntryKey(right[index]!)) {
return false;
}
}
return true;
}
+56 -7
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as mediaSource from './media-source'; import * as mediaSource from './media-source';
import { toMpvEdlValue } from './mpv-edl-test-utils';
const { resolveMediaGenerationInputPath } = mediaSource; const { resolveMediaGenerationInputPath } = mediaSource;
@@ -53,9 +54,11 @@ test('resolveMediaGenerationInputPath prefers stream-open-filename for remote me
}); });
test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video', async () => { test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video', async () => {
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
const edlSource = [ const edlSource = [
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm', `edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4', `!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
'!global_tags,title=test', '!global_tags,title=test',
].join(';'); ].join(';');
@@ -74,8 +77,52 @@ test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video
'video', 'video',
); );
assert.equal(audioResult, 'https://audio.example/videoplayback?mime=audio%2Fwebm'); assert.equal(audioResult, audioUrl);
assert.equal(videoResult, 'https://video.example/videoplayback?mime=video%2Fmp4'); assert.equal(videoResult, videoUrl);
});
test('resolveMediaGenerationInputPath strips mpv edl segment options from unwrapped streams', async () => {
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
const signedVideoUrl =
'https://rr1---sn.example.googlevideo.com/videoplayback?mime=video%2Fmp4&mn=sn-a,sn-b&lsig=abc%3D';
const edlSource = [
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(signedVideoUrl)},title=clip,length=73,timestamps=chapters`,
'!global_tags,title=test',
].join(';');
const result = await resolveMediaGenerationInputPath(
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
requestProperty: async () => edlSource,
},
'video',
);
assert.equal(result, signedVideoUrl);
});
test('resolveMediaGenerationInputPath ignores length-guarded URLs in mpv edl headers', async () => {
const initUrl = 'https://init.example/init.mp4';
const audioUrl = 'https://audio.example/stream';
const videoUrl = 'https://video.example/stream';
const edlSource = [
`edl://!mp4_dash,init=${toMpvEdlValue(initUrl)}`,
'!new_stream',
toMpvEdlValue(audioUrl),
'!new_stream',
toMpvEdlValue(videoUrl),
].join(';');
const audioResult = await resolveMediaGenerationInputPath(
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
requestProperty: async () => edlSource,
},
'audio',
);
assert.equal(audioResult, audioUrl);
}); });
test('resolveMediaGenerationInputPath falls back to currentVideoPath when stream-open-filename fails', async () => { test('resolveMediaGenerationInputPath falls back to currentVideoPath when stream-open-filename fails', async () => {
@@ -97,9 +144,11 @@ test('resolveMediaGenerationInput returns single-stream metadata for mpv EDL URL
).resolveMediaGenerationInput; ).resolveMediaGenerationInput;
assert.equal(typeof resolver, 'function'); assert.equal(typeof resolver, 'function');
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
const edlSource = [ const edlSource = [
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm', `edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4', `!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
].join(';'); ].join(';');
const result = await resolver!( const result = await resolver!(
@@ -117,7 +166,7 @@ test('resolveMediaGenerationInput returns single-stream metadata for mpv EDL URL
'audio', 'audio',
); );
assert.equal(result?.path, 'https://audio.example/videoplayback?mime=audio%2Fwebm'); assert.equal(result?.path, audioUrl);
assert.equal(result?.singleResolvedStream, true); assert.equal(result?.singleResolvedStream, true);
assert.equal(result?.inputOptions?.reconnect, true); assert.equal(result?.inputOptions?.reconnect, true);
assert.equal(result?.inputOptions?.userAgent, 'Mozilla/5.0'); assert.equal(result?.inputOptions?.userAgent, 'Mozilla/5.0');
+3 -3
View File
@@ -1,6 +1,7 @@
import { isRemoteMediaPath } from '../jimaku/utils'; import { isRemoteMediaPath } from '../jimaku/utils';
import type { MediaInput, MediaInputOptions } from '../media-input'; import type { MediaInput, MediaInputOptions } from '../media-input';
import type { MpvClient } from '../types/runtime'; import type { MpvClient } from '../types/runtime';
import { extractFileUrlsFromMpvEdlSource } from './mpv-edl';
export type MediaGenerationKind = 'audio' | 'video'; export type MediaGenerationKind = 'audio' | 'video';
export type MediaGenerationInputSource = export type MediaGenerationInputSource =
@@ -73,9 +74,8 @@ function normalizeHeaderName(value: string): string | null {
} }
function extractUrlsFromMpvEdlSource(source: string): string[] { function extractUrlsFromMpvEdlSource(source: string): string[] {
const matches = source.matchAll(/%\d+%(https?:\/\/.*?)(?=;!new_stream|;!global_tags|$)/gms); return extractFileUrlsFromMpvEdlSource(source)
return [...matches] .map((value) => trimToNonEmptyString(value))
.map((match) => trimToNonEmptyString(match[1]))
.filter((value): value is string => value !== null); .filter((value): value is string => value !== null);
} }
@@ -0,0 +1,3 @@
export function toMpvEdlValue(value: string): string {
return `%${Buffer.byteLength(value, 'utf8')}%${value}`;
}
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { extractFileUrlsFromMpvEdlSource } from './mpv-edl';
import { toMpvEdlValue } from './mpv-edl-test-utils';
test('extractFileUrlsFromMpvEdlSource honors length-guarded file values', () => {
const url =
'https://rr1---sn.example.googlevideo.com/videoplayback?mime=video%2Fmp4&mn=sn-a,sn-b&lsig=abc%3D';
const source = `edl://!new_stream;${toMpvEdlValue(url)},title=clip,length=73`;
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [url]);
});
test('extractFileUrlsFromMpvEdlSource reads file parameters', () => {
const initUrl = 'https://init.example/init.mp4';
const fileUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
const source = `edl://!mp4_dash,init=${toMpvEdlValue(initUrl)};file=${toMpvEdlValue(
fileUrl,
)},length=42`;
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [fileUrl]);
});
test('extractFileUrlsFromMpvEdlSource aggregates file URLs across entries', () => {
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
const source = [
'edl://!new_stream',
toMpvEdlValue(audioUrl),
'!new_stream',
`file=${toMpvEdlValue(videoUrl)},length=50`,
'!global_tags,title=test',
].join(';');
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [audioUrl, videoUrl]);
});
+195
View File
@@ -0,0 +1,195 @@
const EDL_URI_PREFIX = 'edl://';
const BYTE_COMMA = ','.charCodeAt(0);
const BYTE_CR = '\r'.charCodeAt(0);
const BYTE_EQUALS = '='.charCodeAt(0);
const BYTE_EXCLAMATION = '!'.charCodeAt(0);
const BYTE_LF = '\n'.charCodeAt(0);
const BYTE_PERCENT = '%'.charCodeAt(0);
const BYTE_SEMICOLON = ';'.charCodeAt(0);
function isDigitByte(value: number | undefined): value is number {
return value !== undefined && value >= 48 && value <= 57;
}
function isEntrySeparator(value: number | undefined): boolean {
return value === BYTE_SEMICOLON || value === BYTE_LF || value === BYTE_CR;
}
function isParamSeparator(value: number | undefined): boolean {
return value === BYTE_COMMA || isEntrySeparator(value);
}
function decodeBytes(buffer: Buffer, start: number, end: number): string {
return buffer.subarray(start, end).toString('utf8');
}
function isHttpUrl(value: string): boolean {
return /^https?:\/\//i.test(value);
}
function toEdlDataBuffer(source: string): Buffer {
const data = source.startsWith(EDL_URI_PREFIX) ? source.slice(EDL_URI_PREFIX.length) : source;
return Buffer.from(data, 'utf8');
}
function parseLengthGuardedValue(
buffer: Buffer,
position: number,
): { value: string; end: number } | null {
if (buffer[position] !== BYTE_PERCENT) {
return null;
}
let cursor = position + 1;
if (!isDigitByte(buffer[cursor])) {
return null;
}
let byteLength = 0;
while (true) {
const digit = buffer[cursor];
if (!isDigitByte(digit)) {
break;
}
byteLength = byteLength * 10 + (digit - 48);
cursor += 1;
}
if (buffer[cursor] !== BYTE_PERCENT) {
return null;
}
const valueStart = cursor + 1;
const valueEnd = valueStart + byteLength;
if (valueEnd > buffer.length) {
return null;
}
return {
value: decodeBytes(buffer, valueStart, valueEnd),
end: valueEnd,
};
}
function skipEntrySeparators(buffer: Buffer, position: number): number {
let cursor = position;
while (cursor < buffer.length && isEntrySeparator(buffer[cursor])) {
cursor += 1;
}
return cursor;
}
function skipEntry(buffer: Buffer, position: number): number {
let cursor = position;
while (cursor < buffer.length) {
const guardedValue = parseLengthGuardedValue(buffer, cursor);
if (guardedValue) {
cursor = guardedValue.end;
continue;
}
if (isEntrySeparator(buffer[cursor])) {
break;
}
cursor += 1;
}
return cursor;
}
function parseRawValue(buffer: Buffer, position: number): { value: string; end: number } {
let cursor = position;
while (
cursor < buffer.length &&
!isParamSeparator(buffer[cursor]) &&
buffer[cursor] !== BYTE_EXCLAMATION
) {
cursor += 1;
}
return {
value: decodeBytes(buffer, position, cursor),
end: cursor,
};
}
function parseParamValue(buffer: Buffer, position: number): { value: string; end: number } {
return parseLengthGuardedValue(buffer, position) ?? parseRawValue(buffer, position);
}
function parseOptionalParamName(
buffer: Buffer,
position: number,
): { name: string | null; valueStart: number } {
let cursor = position;
while (
cursor < buffer.length &&
!isParamSeparator(buffer[cursor]) &&
buffer[cursor] !== BYTE_PERCENT &&
buffer[cursor] !== BYTE_EXCLAMATION
) {
if (buffer[cursor] === BYTE_EQUALS) {
return {
name: decodeBytes(buffer, position, cursor),
valueStart: cursor + 1,
};
}
cursor += 1;
}
return { name: null, valueStart: position };
}
function parseSegmentEntry(buffer: Buffer, position: number): { urls: string[]; end: number } {
const urls: string[] = [];
let cursor = position;
let unnamedParamIndex = 0;
while (cursor < buffer.length && !isEntrySeparator(buffer[cursor])) {
const { name, valueStart } = parseOptionalParamName(buffer, cursor);
const value = parseParamValue(buffer, valueStart);
const lowerName = name?.toLowerCase() ?? null;
const isFileParam = lowerName === 'file' || (lowerName === null && unnamedParamIndex === 0);
if (isFileParam && isHttpUrl(value.value)) {
urls.push(value.value);
}
if (lowerName === null) {
unnamedParamIndex += 1;
}
cursor = value.end;
if (buffer[cursor] === BYTE_COMMA) {
cursor += 1;
continue;
}
if (!isEntrySeparator(buffer[cursor])) {
cursor = skipEntry(buffer, cursor);
}
}
return { urls, end: cursor };
}
export function extractFileUrlsFromMpvEdlSource(source: string): string[] {
const buffer = toEdlDataBuffer(source);
const urls: string[] = [];
let cursor = 0;
while (cursor < buffer.length) {
cursor = skipEntrySeparators(buffer, cursor);
if (cursor >= buffer.length) {
break;
}
if (buffer[cursor] === BYTE_EXCLAMATION) {
cursor = skipEntry(buffer, cursor);
continue;
}
const segment = parseSegmentEntry(buffer, cursor);
urls.push(...segment.urls);
cursor = segment.end;
}
return urls;
}
+4 -1
View File
@@ -2,7 +2,10 @@ export interface NoteFieldValueInfo {
fields: Record<string, { value: string }>; fields: Record<string, { value: string }>;
} }
export function getNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: string): string | null { export function getNoteFieldValue(
noteInfo: NoteFieldValueInfo,
preferredName: string,
): string | null {
const resolvedFieldName = Object.keys(noteInfo.fields).find( const resolvedFieldName = Object.keys(noteInfo.fields).find(
(fieldName) => fieldName.toLowerCase() === preferredName.toLowerCase(), (fieldName) => fieldName.toLowerCase() === preferredName.toLowerCase(),
); );
@@ -32,7 +32,7 @@ export interface PendingYoutubeMediaQueueDeps {
'generateAudio' | 'generateScreenshot' | 'generateAnimatedImage' 'generateAudio' | 'generateScreenshot' | 'generateAnimatedImage'
>; >;
getConfig: () => AnkiConnectConfig; getConfig: () => AnkiConnectConfig;
getCurrentVideoPath: () => string | undefined; getCurrentVideoPath: () => Promise<string | undefined> | string | undefined;
getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null; getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null;
shouldRequireRemoteMediaCache: () => boolean; shouldRequireRemoteMediaCache: () => boolean;
getSubtitleMediaRange: (context?: SubtitleMiningContext) => { getSubtitleMediaRange: (context?: SubtitleMiningContext) => {
@@ -104,7 +104,7 @@ export class PendingYoutubeMediaQueue {
context?: SubtitleMiningContext; context?: SubtitleMiningContext;
label: string | number; label: string | number;
}): Promise<boolean> { }): Promise<boolean> {
const sourceUrl = trimToNonEmptyString(this.deps.getCurrentVideoPath()); const sourceUrl = trimToNonEmptyString(await this.deps.getCurrentVideoPath());
const getCachedMediaPath = this.deps.getCachedMediaPath; const getCachedMediaPath = this.deps.getCachedMediaPath;
if (!sourceUrl || this.deps.shouldRequireRemoteMediaCache() !== true || !getCachedMediaPath) { if (!sourceUrl || this.deps.shouldRequireRemoteMediaCache() !== true || !getCachedMediaPath) {
return false; return false;
@@ -272,6 +272,7 @@ export class PendingYoutubeMediaQueue {
job.endTime, job.endTime,
config.media?.audioPadding, config.media?.audioPadding,
undefined, undefined,
config.media?.normalizeAudio !== false,
); );
if (audioBuffer) { if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer); await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
+1
View File
@@ -92,6 +92,7 @@ test('loads defaults when config is missing', () => {
model: '', model: '',
systemPrompt: '', systemPrompt: '',
}); });
assert.equal(config.ankiConnect.media.normalizeAudio, true);
assert.equal(config.startupWarmups.lowPowerMode, false); assert.equal(config.startupWarmups.lowPowerMode, false);
assert.equal(config.startupWarmups.mecab, true); assert.equal(config.startupWarmups.mecab, true);
assert.equal(config.startupWarmups.yomitanExtension, true); assert.equal(config.startupWarmups.yomitanExtension, true);
@@ -51,6 +51,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
animatedMaxHeight: 0, animatedMaxHeight: 0,
animatedCrf: 35, animatedCrf: 35,
syncAnimatedImageToWordAudio: true, syncAnimatedImageToWordAudio: true,
normalizeAudio: true,
audioPadding: 0, audioPadding: 0,
fallbackDuration: 3.0, fallbackDuration: 3.0,
maxMediaDuration: 30, maxMediaDuration: 30,
@@ -110,6 +110,7 @@ test('config option registry includes critical paths and has unique entries', ()
'subtitleStyle.autoPauseVideoOnYomitanPopup', 'subtitleStyle.autoPauseVideoOnYomitanPopup',
'ankiConnect.enabled', 'ankiConnect.enabled',
'subtitleStyle.nameMatchEnabled', 'subtitleStyle.nameMatchEnabled',
'ankiConnect.media.normalizeAudio',
'anilist.characterDictionary.collapsibleSections.description', 'anilist.characterDictionary.collapsibleSections.description',
'mpv.executablePath', 'mpv.executablePath',
'mpv.launchMode', 'mpv.launchMode',
@@ -181,6 +181,12 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.ankiConnect.media.generateAudio, defaultValue: defaultConfig.ankiConnect.media.generateAudio,
description: 'Generate sentence audio for mined cards.', description: 'Generate sentence audio for mined cards.',
}, },
{
path: 'ankiConnect.media.normalizeAudio',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.media.normalizeAudio,
description: 'Normalize generated sentence audio loudness during media extraction.',
},
{ {
path: 'ankiConnect.media.generateImage', path: 'ankiConnect.media.generateImage',
kind: 'boolean', kind: 'boolean',
@@ -311,7 +317,7 @@ export function buildIntegrationConfigOptionRegistry(
kind: 'object', kind: 'object',
defaultValue: defaultConfig.ankiConnect.knownWords.decks, defaultValue: defaultConfig.ankiConnect.knownWords.decks,
description: description:
'Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.', 'Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.',
}, },
{ {
path: 'ankiConnect.isKiku.fieldGrouping', path: 'ankiConnect.isKiku.fieldGrouping',
@@ -729,7 +729,7 @@ describe('stats server API routes', () => {
const res = await app.request('/api/stats/trends/dashboard?range=90d&groupBy=month'); const res = await app.request('/api/stats/trends/dashboard?range=90d&groupBy=month');
assert.equal(res.status, 200); assert.equal(res.status, 200);
const body = await res.json(); const body = await res.json();
assert.deepEqual(seenArgs, ['90d', 'month']); assert.deepEqual(seenArgs, ['90d', 'month', true]);
assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime); assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime);
assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary); assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary);
}); });
@@ -747,7 +747,7 @@ describe('stats server API routes', () => {
const res = await app.request('/api/stats/trends/dashboard?range=365d&groupBy=month'); const res = await app.request('/api/stats/trends/dashboard?range=365d&groupBy=month');
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.deepEqual(seenArgs, ['365d', 'month']); assert.deepEqual(seenArgs, ['365d', 'month', true]);
}); });
it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => { it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => {
@@ -763,7 +763,25 @@ describe('stats server API routes', () => {
const res = await app.request('/api/stats/trends/dashboard?range=weird&groupBy=year'); const res = await app.request('/api/stats/trends/dashboard?range=weird&groupBy=year');
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.deepEqual(seenArgs, ['30d', 'day']); assert.deepEqual(seenArgs, ['30d', 'day', true]);
});
it('GET /api/stats/trends/dashboard forwards fillEmpty=false to disable zero-fill', async () => {
let seenArgs: unknown[] = [];
const app = createStatsApp(
createMockTracker({
getTrendsDashboard: async (...args: unknown[]) => {
seenArgs = args;
return TRENDS_DASHBOARD;
},
}),
);
const res = await app.request(
'/api/stats/trends/dashboard?range=30d&groupBy=day&fillEmpty=false',
);
assert.equal(res.status, 200);
assert.deepEqual(seenArgs, ['30d', 'day', false]);
}); });
it('GET /api/stats/vocabulary/occurrences returns recent occurrence rows for a word', async () => { it('GET /api/stats/vocabulary/occurrences returns recent occurrence rows for a word', async () => {
+2
View File
@@ -45,6 +45,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
kind: 'audio' | 'video', kind: 'audio' | 'video',
) => Promise<string | null>; ) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean; shouldRequireRemoteMediaCache?: () => boolean;
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
createFieldGroupingCallback: () => ( createFieldGroupingCallback: () => (
@@ -114,6 +115,7 @@ export function registerAnkiJimakuIpcRuntime(
options.showOverlayNotification, options.showOverlayNotification,
options.getCachedMediaPath, options.getCachedMediaPath,
options.shouldRequireRemoteMediaCache, options.shouldRequireRemoteMediaCache,
options.getYoutubeMediaSourceUrl,
); );
integration.start(); integration.start();
options.setAnkiIntegration(integration); options.setAnkiIntegration(integration);
+40
View File
@@ -374,6 +374,46 @@ test('handleCliCommand processes --start for second-instance when overlay runtim
); );
}); });
test('handleCliCommand ensures background stats server for initial --start --background', () => {
const ensured: number[] = [];
const { deps } = createDeps({
ensureBackgroundStatsServer: () => {
ensured.push(1);
},
});
handleCliCommand(makeArgs({ start: true, background: true }), 'initial', deps);
assert.equal(ensured.length, 1);
});
test('handleCliCommand ensures background stats server for second-instance --start --background', () => {
const ensured: number[] = [];
const { deps } = createDeps({
isOverlayRuntimeInitialized: () => true,
ensureBackgroundStatsServer: () => {
ensured.push(1);
},
});
handleCliCommand(makeArgs({ start: true, background: true }), 'second-instance', deps);
assert.equal(ensured.length, 1);
});
test('handleCliCommand does not ensure background stats server for foreground --start', () => {
const ensured: number[] = [];
const { deps } = createDeps({
ensureBackgroundStatsServer: () => {
ensured.push(1);
},
});
handleCliCommand(makeArgs({ start: true }), 'initial', deps);
assert.equal(ensured.length, 0);
});
test('handleCliCommand forces setup open for second-instance setup command', () => { test('handleCliCommand forces setup open for second-instance setup command', () => {
const { deps, calls } = createDeps(); const { deps, calls } = createDeps();
+7
View File
@@ -106,6 +106,7 @@ export interface CliCommandServiceDeps {
mode: NonNullable<CliArgs['youtubeMode']>; mode: NonNullable<CliArgs['youtubeMode']>;
source: CliCommandSource; source: CliCommandSource;
}) => Promise<void>; }) => Promise<void>;
ensureBackgroundStatsServer?: () => void;
printHelp: () => void; printHelp: () => void;
hasMainWindow: () => boolean; hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number; getMultiCopyTimeoutMs: () => number;
@@ -185,6 +186,7 @@ interface AnilistCliRuntime {
interface AppCliRuntime { interface AppCliRuntime {
stop: () => void; stop: () => void;
hasMainWindow: () => boolean; hasMainWindow: () => boolean;
ensureBackgroundStatsServer?: () => void;
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand']; runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandServiceDeps['runEnsureLinuxRuntimePluginAssetsCommand']; runEnsureLinuxRuntimePluginAssetsCommand: CliCommandServiceDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow']; runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
@@ -299,6 +301,7 @@ export function createCliCommandDepsRuntime(
runUpdateCommand: options.app.runUpdateCommand, runUpdateCommand: options.app.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: options.app.runEnsureLinuxRuntimePluginAssetsCommand, runEnsureLinuxRuntimePluginAssetsCommand: options.app.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow, runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
ensureBackgroundStatsServer: options.app.ensureBackgroundStatsServer,
printHelp: options.ui.printHelp, printHelp: options.ui.printHelp,
hasMainWindow: options.app.hasMainWindow, hasMainWindow: options.app.hasMainWindow,
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs, getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
@@ -393,6 +396,10 @@ export function handleCliCommand(
deps.log(`Starting MPV IPC connection on socket: ${socketPath}`); deps.log(`Starting MPV IPC connection on socket: ${socketPath}`);
} }
if (args.start && args.background) {
deps.ensureBackgroundStatsServer?.();
}
if (args.sessionAction) { if (args.sessionAction) {
dispatchCliSessionAction( dispatchCliSessionAction(
args.sessionAction, args.sessionAction,

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