mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-21 05:01:26 -07:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ffa2bce94
|
||
|
|
88b73656a7
|
||
|
|
e77aa5c3b3 | ||
|
|
121b688afb | ||
|
|
727be5683a | ||
|
|
138bfe9759 | ||
|
|
0aba17d95b
|
||
|
|
a5d0b63792 |
No files matched your search
@@ -1,5 +1,55 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.20.1 (2026-10-03)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **YouTube Browser Window**:
|
||||||
|
- Browse YouTube with your login kept across restarts. Open it with `subminer youtube`, `subminer yt`, or tray **Browse YouTube**.
|
||||||
|
- Clicking a video plays it in mpv. Middle-click, Shift/Ctrl-click, or the right-click menu adds it to mpv's playlist.
|
||||||
|
- Queued videos get the same Japanese subtitle setup, and the rest of the queue is kept on Windows.
|
||||||
|
- Closing the window quits a SubMiner started by `subminer youtube`.
|
||||||
|
- **Whisper Subtitles for YouTube**:
|
||||||
|
- Set `youtube.subtitleSource` to `whisper` to transcribe YouTube videos with Whisper instead of using YouTube's captions.
|
||||||
|
- The video stays paused while Whisper transcribes, and the subtitle generation modal shows progress.
|
||||||
|
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos, including Windows stream playback.
|
||||||
|
- **Scroll Wheel and Mouse Bindings**: `keybindings` now accepts scroll wheel keys, and the settings key editor can capture them. Mouse button and wheel bindings from mpv now work while the cursor is over the overlay, including on Hyprland.
|
||||||
|
- **Run Commands from Session Help**: During playback, press `Enter` or double-click a row in session help to run that command.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Release Assets**: Releases no longer include package-size reports.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Hyprland Overlay**: The overlay stays clickable after mpv goes fullscreen with `mpv.backend: x11`, and it no longer interrupts mpv while mpv enters fullscreen.
|
||||||
|
- **mpv Key Bindings**: Bindings from mpv such as `9`/`0` for volume now work while the overlay has focus, even if the overlay loaded before mpv connected.
|
||||||
|
- **Jellyfin**:
|
||||||
|
- The Japanese subtitle track is selected as soon as it downloads, without waiting for the other tracks.
|
||||||
|
- Japanese and English tracks are auto-selected on episodes whose embedded subtitles are images.
|
||||||
|
- Timing review previews and mined card audio use the audio track you are playing.
|
||||||
|
- **Media Timing Review**: Trim handles are now thin lines on the clip edges, and original subtitle boundaries no longer cover the waveform. The preview cursor no longer stutters on macOS with Bluetooth headphones.
|
||||||
|
- **YouTube Subtitles**: Japanese subtitles no longer fail with "HTTP 429" on videos where YouTube also lists a machine-translated Japanese track.
|
||||||
|
- **Stats**:
|
||||||
|
- Mined cards are counted once in session and lifetime stats.
|
||||||
|
- Word and kanji dates and the new-words charts stay accurate after deleting sessions. Existing data is repaired in the background.
|
||||||
|
- Overview totals, charts, and the activity calendar refresh after deleting sessions.
|
||||||
|
- **Yomitan Data Safety**: Electron is updated to 43.7.7. SubMiner now refuses to load your Yomitan data with an unsupported or downgraded runtime. Automatic character dictionary changes stop if Yomitan suddenly reports no dictionaries.
|
||||||
|
- **AppImage in Sandboxes**: The AppImage now opens its window when launched in a sandbox such as `firejail --appimage`.
|
||||||
|
- **Ctrl+C Crash**: SubMiner no longer crashes with "write EPIPE" when the terminal command that started it is stopped with Ctrl+C.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- **Docs Site**: The docs site is shorter and easier to scan, with a key and default table for each config block. Outdated pages were corrected, and the MeCab install command for Arch Linux is fixed.
|
||||||
|
- **Stats Docs**: The docs now cover the Overview refresh after deleting sessions and how discovery dates are kept after old sessions are pruned.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
- **Release Notes**: Release notes and `CHANGELOG.md` are now generated as short items per feature or area.
|
||||||
|
- **Dev Profile**: Development launches use a separate `SubMiner-dev` profile unless production-profile access is requested.
|
||||||
|
- **Dependencies**: undici is updated to 7.29.1 to clear the security advisories that were blocking CI.
|
||||||
|
- **Package Checks**: CI no longer compares package sizes. Package content checks still run.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## v0.20.0 (2026-09-23)
|
## v0.20.0 (2026-09-23)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"@electron/asar": "3.4.1",
|
"@electron/asar": "3.4.1",
|
||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"electron": "42.10.0",
|
"electron": "43.7.7",
|
||||||
"electron-builder": "26.16.1",
|
"electron-builder": "26.16.1",
|
||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
@@ -347,7 +347,7 @@
|
|||||||
|
|
||||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||||
|
|
||||||
"electron": ["electron@42.10.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-yBWnZO8tKId0ADvGa4+imPmm3+8Kfs8MtkUPhQQJz1AjTnUUDwCTWkQEU0CrArVVL97Jo4Q/0mVcLHciicBDGQ=="],
|
"electron": ["electron@43.7.7", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-y0pg+KhWLAkW2Ts2njkFFNFl3KtB1FmbUp9Te9i+R2Og2v1fcI/I6MADJ7jvnzB5UKdt+U+0EUoCnVL79xMPMA=="],
|
||||||
|
|
||||||
"electron-builder": ["electron-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.16.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-LrLK65QX5PUYYODXqp23FKrV7CILTtVY7mrJckNknO9jLNSMiqFkKbSMiDRw4CjOADMPVDdWLxY4mezOZWswxg=="],
|
"electron-builder": ["electron-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.16.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-LrLK65QX5PUYYODXqp23FKrV7CILTtVY7mrJckNknO9jLNSMiqFkKbSMiDRw4CjOADMPVDdWLxY4mezOZWswxg=="],
|
||||||
|
|
||||||
@@ -467,7 +467,7 @@
|
|||||||
|
|
||||||
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
||||||
|
|
||||||
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
|
"http-cache-semantics": ["http-cache-semantics@4.3.0", "", {}, "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA=="],
|
||||||
|
|
||||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ How fragments turn into a release:
|
|||||||
|
|
||||||
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
|
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
|
||||||
- The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries.
|
- The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries.
|
||||||
- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use short top-level items with one nested bullet per distinct change, instead of packing a release's worth of detail into a single paragraph bullet. An item with only one thing to say stays inline on the top-level bullet. Release notes and prerelease notes additionally cover user benefit and any useful action note in their nested bullets.
|
- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use one item per feature or area, inline by default: `- **Name**: one or two short sentences.` Items with several separate changes may use a few short nested bullets instead of a run-on sentence. Items say what changed and how to reach it; implementation details, edge cases, and step-by-step usage belong in the docs, not the release body.
|
||||||
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
|
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
|
||||||
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
|
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: linux
|
|
||||||
|
|
||||||
- Fixed the AppImage exiting without opening a window when launched inside a sandbox that mounts the image itself, such as `firejail --appimage` (used by the AppImage catalog test). The detached background app now runs straight from the sandbox's squashfs mount instead of trying to re-mount the AppImage over FUSE.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: app
|
|
||||||
|
|
||||||
- SubMiner no longer crashes with "write EPIPE" when the terminal command that started it is stopped with Ctrl+C.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
type: docs
|
|
||||||
area: docs
|
|
||||||
|
|
||||||
- Rewrote the docs site to be shorter and easier to scan: pages lead with setup and use, reference material lives in compact tables, and internal detail was cut from user pages.
|
|
||||||
- The configuration reference now has a short explanation and a key/default table for each config block.
|
|
||||||
- Fixed docs that no longer matched current behavior.
|
|
||||||
- Fixed the Arch Linux install command for MeCab, which is only in the AUR (`mecab-git`), and brought the README requirements and quick start in line with the installation guide.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Keep the subtitle overlay mapped during fullscreen transitions on Hyprland, avoiding focus changes from repeated hide/show calls that can cancel mpv fullscreen entry. Preserve the hide/show refresh on Sway.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Fixed the overlay (subtitles and sidebar) becoming unclickable after mpv goes fullscreen on Hyprland with `mpv.backend: x11`. The launcher no longer strips `HYPRLAND_INSTANCE_SIGNATURE` from the X11 environment, so the overlay can lift Hyprland's fullscreen input block again.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: jellyfin
|
|
||||||
|
|
||||||
- Jellyfin playback now selects the Japanese subtitle track, and starts subtitle annotations, as soon as that track downloads instead of waiting for every other subtitle track. Tracks now download in parallel, so a slow embedded track Jellyfin has to extract no longer delays the primary subtitles.
|
|
||||||
- Jellyfin episodes with image-based embedded subtitles (PGS, DVD, DVB) now auto-select the Japanese and English tracks. SubMiner no longer requests those tracks as text, and a subtitle track that fails to download no longer cancels selection of the others.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: jellyfin
|
|
||||||
|
|
||||||
- Timing review previews and mined card audio now use the audio track you are playing on Jellyfin direct-play streams, instead of the track Jellyfin marks as default.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Fixed mpv key bindings (input.conf and mpv defaults, e.g. `9`/`0` volume) doing nothing while the overlay had focus when the overlay loaded before SubMiner connected to mpv, common in `mpv.backend: x11` mode. The overlay now reloads mpv's bindings once mpv connects.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Added scroll wheel keys (`WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`, with modifiers) to `keybindings`, including capture from the settings key editor.
|
|
||||||
- Mouse button and scroll wheel bindings from mpv (`input.conf` and mpv defaults, e.g. double-click fullscreen, wheel volume, back/forward for playlist) now work while the cursor is over the overlay. On Hyprland, where the overlay always receives input, they previously did nothing. SubMiner bindings and the right-click pause still take priority.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: dependencies
|
|
||||||
|
|
||||||
- Updated Electron to 42.10.0 and undici to 7.29.1 to resolve the security advisories blocking CI.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: release
|
|
||||||
|
|
||||||
- Removed package-size JSON reports from future releases and their CI size comparisons. Package-content validation remains enabled.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Session help can run the highlighted command during playback: press `Enter` or double-click a row. Hovering a row with the mouse makes it the `Enter` target. Help opened from the tray with no video loaded stays read-only.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- On macOS with Bluetooth headphones such as AirPods, the timing review preview cursor now moves smoothly across the selection instead of stuttering and jumping to the end partway through. This affected Opus audio, including Jellyfin streams.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Media timing review trim handles are now thin lines centered on the clip edge, so it's clear where the clip starts and ends and less of the waveform is covered.
|
|
||||||
- Original subtitle boundaries use orange bars without text tags, leaving the waveform unobstructed.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: youtube
|
|
||||||
|
|
||||||
- Added a YouTube browser window (`subminer youtube`/`subminer yt`, or tray **Browse YouTube**) that keeps your YouTube login across restarts.
|
|
||||||
- Clicking a video plays it in mpv instead of the page; middle-click, Shift/Ctrl-click, or the right-click menu queues it in mpv's playlist.
|
|
||||||
- Queued YouTube videos get the same Japanese subtitle setup when mpv reaches them.
|
|
||||||
- Closing the YouTube window quits a SubMiner started by `subminer youtube` (after mpv closes, if a video is still playing).
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: youtube
|
|
||||||
|
|
||||||
- YouTube Japanese subtitles no longer fail with "HTTP 429" on videos where YouTube also lists a machine-translated Japanese track; SubMiner now picks the real track and falls back to yt-dlp if a direct subtitle download is refused.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: youtube
|
|
||||||
|
|
||||||
- Added `youtube.subtitleSource`: set it to `whisper` to transcribe YouTube videos with Whisper instead of downloading YouTube's captions. The default stays `youtube`.
|
|
||||||
- Whisper mode keeps the video paused while it transcribes, with progress in the subtitle generation modal; close the modal to keep watching, or cancel to continue without subtitles.
|
|
||||||
- The subtitle generation modal (`Ctrl+Shift+G`) now also works on YouTube videos, whatever `youtube.subtitleSource` is set to.
|
|
||||||
- Whisper mode downloads a small audio-only stream and deletes it once generation ends, or when you switch videos, close mpv, or quit.
|
|
||||||
@@ -1,5 +1,55 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.20.1 (2026-10-03)
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
- **YouTube Browser Window**:
|
||||||
|
- Browse YouTube with your login kept across restarts. Open it with `subminer youtube`, `subminer yt`, or tray **Browse YouTube**.
|
||||||
|
- Clicking a video plays it in mpv. Middle-click, Shift/Ctrl-click, or the right-click menu adds it to mpv's playlist.
|
||||||
|
- Queued videos get the same Japanese subtitle setup, and the rest of the queue is kept on Windows.
|
||||||
|
- Closing the window quits a SubMiner started by `subminer youtube`.
|
||||||
|
- **Whisper Subtitles for YouTube**:
|
||||||
|
- Set `youtube.subtitleSource` to `whisper` to transcribe YouTube videos with Whisper instead of using YouTube's captions.
|
||||||
|
- The video stays paused while Whisper transcribes, and the subtitle generation modal shows progress.
|
||||||
|
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos, including Windows stream playback.
|
||||||
|
- **Scroll Wheel and Mouse Bindings**: `keybindings` now accepts scroll wheel keys, and the settings key editor can capture them. Mouse button and wheel bindings from mpv now work while the cursor is over the overlay, including on Hyprland.
|
||||||
|
- **Run Commands from Session Help**: During playback, press `Enter` or double-click a row in session help to run that command.
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
- **Release Assets**: Releases no longer include package-size reports.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- **Hyprland Overlay**: The overlay stays clickable after mpv goes fullscreen with `mpv.backend: x11`, and it no longer interrupts mpv while mpv enters fullscreen.
|
||||||
|
- **mpv Key Bindings**: Bindings from mpv such as `9`/`0` for volume now work while the overlay has focus, even if the overlay loaded before mpv connected.
|
||||||
|
- **Jellyfin**:
|
||||||
|
- The Japanese subtitle track is selected as soon as it downloads, without waiting for the other tracks.
|
||||||
|
- Japanese and English tracks are auto-selected on episodes whose embedded subtitles are images.
|
||||||
|
- Timing review previews and mined card audio use the audio track you are playing.
|
||||||
|
- **Media Timing Review**: Trim handles are now thin lines on the clip edges, and original subtitle boundaries no longer cover the waveform. The preview cursor no longer stutters on macOS with Bluetooth headphones.
|
||||||
|
- **YouTube Subtitles**: Japanese subtitles no longer fail with "HTTP 429" on videos where YouTube also lists a machine-translated Japanese track.
|
||||||
|
- **Stats**:
|
||||||
|
- Mined cards are counted once in session and lifetime stats.
|
||||||
|
- Word and kanji dates and the new-words charts stay accurate after deleting sessions. Existing data is repaired in the background.
|
||||||
|
- Overview totals, charts, and the activity calendar refresh after deleting sessions.
|
||||||
|
- **Yomitan Data Safety**: Electron is updated to 43.7.7. SubMiner now refuses to load your Yomitan data with an unsupported or downgraded runtime. Automatic character dictionary changes stop if Yomitan suddenly reports no dictionaries.
|
||||||
|
- **AppImage in Sandboxes**: The AppImage now opens its window when launched in a sandbox such as `firejail --appimage`.
|
||||||
|
- **Ctrl+C Crash**: SubMiner no longer crashes with "write EPIPE" when the terminal command that started it is stopped with Ctrl+C.
|
||||||
|
|
||||||
|
**Docs**
|
||||||
|
- **Docs Site**: The docs site is shorter and easier to scan, with a key and default table for each config block. Outdated pages were corrected, and the MeCab install command for Arch Linux is fixed.
|
||||||
|
- **Stats Docs**: The docs now cover the Overview refresh after deleting sessions and how discovery dates are kept after old sessions are pruned.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
**Internal**
|
||||||
|
- **Release Notes**: Release notes and `CHANGELOG.md` are now generated as short items per feature or area.
|
||||||
|
- **Dev Profile**: Development launches use a separate `SubMiner-dev` profile unless production-profile access is requested.
|
||||||
|
- **Dependencies**: undici is updated to 7.29.1 to clear the security advisories that were blocking CI.
|
||||||
|
- **Package Checks**: CI no longer compares package sizes. Package content checks still run.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## v0.20.0 (2026-09-23)
|
## v0.20.0 (2026-09-23)
|
||||||
|
|
||||||
**Added**
|
**Added**
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ electron . --start --dev --log-level debug # verbose launch of an existing bui
|
|||||||
electron . --background # tray/background mode
|
electron . --background # tray/background mode
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Development and debug launches use a separate `SubMiner-dev` profile so runtime experiments cannot modify the installed app's configuration or Yomitan dictionaries. To intentionally use the production profile for a development launch, set `SUBMINER_USE_PRODUCTION_PROFILE=1`. Use that override only after backing up the profile.
|
||||||
|
|
||||||
|
Launch source builds through `bun run dev` or `bun run electron`. SubMiner refuses to load its profile when the running Electron major differs from the version pinned by the repository.
|
||||||
|
|
||||||
To test through the mpv plugin without exporting `SUBMINER_BINARY_PATH` each time, point `mpv.subminerBinaryPath` in your config at the dev script. The launcher passes it to the plugin at runtime:
|
To test through the mpv plugin without exporting `SUBMINER_BINARY_PATH` each time, point `mpv.subminerBinaryPath` in your config at the dev script. The launcher passes it to the plugin at runtime:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ To share stats and watch history between machines, use [`subminer sync <host>`](
|
|||||||
|
|
||||||
Recent sessions, a streak calendar, watch-time history, and totals for completed episodes and shows.
|
Recent sessions, a streak calendar, watch-time history, and totals for completed episodes and shows.
|
||||||
|
|
||||||
|
Deleting a session or session group refreshes the Overview totals, charts, calendar, and vocabulary summary automatically. If a refresh fails, Overview keeps the previous totals visible and offers **Retry**.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Library
|
### Library
|
||||||
@@ -122,4 +124,6 @@ Leave out `--lookback-days` to scan all history. Word and kanji counts are corre
|
|||||||
|
|
||||||
By default SubMiner keeps everything. To limit history, set `immersionTracking.retentionPreset` to `minimal`, `balanced`, or `deep-history`, or set the `immersionTracking.retention.*Days` values yourself (`0` keeps all). Lifetime totals and vocabulary counts are stored separately and stay exact when old sessions are pruned.
|
By default SubMiner keeps everything. To limit history, set `immersionTracking.retentionPreset` to `minimal`, `balanced`, or `deep-history`, or set the `immersionTracking.retention.*Days` values yourself (`0` keeps all). Lifetime totals and vocabulary counts are stored separately and stay exact when old sessions are pruned.
|
||||||
|
|
||||||
|
Deleting retained sessions preserves earlier word and kanji discovery dates from pruned history. New occurrences record observation time so deletion can update their dates exactly. Older history used write time, so an earlier discovery date is preserved when its origin cannot be determined.
|
||||||
|
|
||||||
See [Immersion tracking](/configuration#immersion-tracking) and [Stats dashboard](/configuration#stats-dashboard) in the config reference for every option and default.
|
See [Immersion tracking](/configuration#immersion-tracking) and [Stats dashboard](/configuration#stats-dashboard) in the config reference for every option and default.
|
||||||
@@ -67,6 +67,16 @@ If you have not set up dictionaries yet, start with [Yomitan setup](/usage#yomit
|
|||||||
|
|
||||||
Word boundaries come from Yomitan's parser. Some splits will be wrong, since Japanese has no spaces.
|
Word boundaries come from Yomitan's parser. Some splits will be wrong, since Japanese has no spaces.
|
||||||
|
|
||||||
|
## "Electron downgrade blocked" or "Unsupported Electron runtime"
|
||||||
|
|
||||||
|
SubMiner refuses to load Yomitan storage when the current Electron major does not match the app build, or when the profile was previously opened by a newer Electron major or minor version. Patch downgrades within the same major and minor version are allowed, such as `43.7.3` to `43.7.2`. Downgrades from `43.7.x` to `43.6.x`, or from Electron 43 to 42, are blocked. Launch the packaged app or use the repository's `bun run dev` command. Do not delete the runtime safety record to force an older major or minor version to open the profile.
|
||||||
|
|
||||||
|
Older SubMiner builds without this guard cannot enforce the safety record. When testing Electron 43 before returning to an older Electron 42 build, use the separate `SubMiner-dev` profile described in [development](/development#run-locally) so the older build never opens the upgraded profile.
|
||||||
|
|
||||||
|
## "Yomitan reported zero dictionaries after previously reporting ..."
|
||||||
|
|
||||||
|
SubMiner detected that a previously non-empty Yomitan profile suddenly appears empty. Automatic character-dictionary changes are blocked so they cannot overwrite the suspicious state. Close SubMiner, preserve the profile directory, and restore a known-good backup before importing or deleting dictionaries.
|
||||||
|
|
||||||
## "Yomitan extension not found in any search path"
|
## "Yomitan extension not found in any search path"
|
||||||
|
|
||||||
The bundled Yomitan is missing. Re-download the AppImage, or place an unpacked Yomitan extension in `~/.config/SubMiner/yomitan`. Source builds must run `bun run build` first to produce `build/yomitan`.
|
The bundled Yomitan is missing. Re-download the AppImage, or place an unpacked Yomitan extension in `~/.config/SubMiner/yomitan`. Source builds must run `bun run build` first to produce `build/yomitan`.
|
||||||
|
|||||||
@@ -145,6 +145,10 @@ Press `Alt+C` to choose a controller and remap buttons. Click an action's **Lear
|
|||||||
|
|
||||||
On controllers that report the W3C standard layout, the default quit button lands on `L2` instead of `Select`. Remap it with `Alt+C`. All options are in [Configuration](/configuration#controller-support).
|
On controllers that report the W3C standard layout, the default quit button lands on `L2` instead of `Select`. Remap it with `Alt+C`. All options are in [Configuration](/configuration#controller-support).
|
||||||
|
|
||||||
|
## Development profiles
|
||||||
|
|
||||||
|
`--dev` and `--debug` use a separate `SubMiner-dev` profile. They do not read or modify dictionaries and configuration from the installed app unless `SUBMINER_USE_PRODUCTION_PROFILE=1` is explicitly set. See [Development](/development#run-locally).
|
||||||
|
|
||||||
## Changing settings while you watch
|
## Changing settings while you watch
|
||||||
|
|
||||||
SubMiner watches your config file and applies most changes without a restart, including subtitle style, keybindings, and most Anki settings. If a change needs a restart, SubMiner tells you. If the file has an error, it keeps the last working config and shows a notification. See [Configuration](/configuration).
|
SubMiner watches your config file and applies most changes without a restart, including subtitle style, keybindings, and most Anki settings. If a change needs a restart, SubMiner tells you. If the file has an error, it keeps the last working config and shows a notification. See [Configuration](/configuration).
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.20.1-beta.1",
|
"version": "0.20.1",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
"@electron/asar": "3.4.1",
|
"@electron/asar": "3.4.1",
|
||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"electron": "42.10.0",
|
"electron": "43.7.7",
|
||||||
"electron-builder": "26.16.1",
|
"electron-builder": "26.16.1",
|
||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
|
|||||||
+55
-49
@@ -1,73 +1,76 @@
|
|||||||
> 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-version: 0.20.1-beta.1 -->
|
<!-- prerelease-version: 0.20.1-beta.2; since: v0.20.1-beta.1 -->
|
||||||
|
|
||||||
|
## Changes since v0.20.1-beta.1
|
||||||
|
|
||||||
|
- Upgraded the desktop runtime to Electron 43.7.7.
|
||||||
|
- SubMiner now refuses to start on an unsupported Electron runtime, or after an Electron major or minor downgrade, so your Yomitan profile data stays safe; patch downgrades are still allowed.
|
||||||
|
- Automatic character-dictionary updates now stop if a Yomitan profile that had dictionaries suddenly reports none.
|
||||||
|
- Development launches now use a separate `SubMiner-dev` profile unless you explicitly ask for the production profile.
|
||||||
|
- Sentence mining (single and multi-line) no longer counts each card twice in session and lifetime stats.
|
||||||
|
- Deleting sessions now updates word and kanji "first seen" and "last seen" dates correctly, and retention pruning keeps historical discovery dates and counts.
|
||||||
|
- Words hidden by the vocabulary filter are no longer counted in the new-words charts, so the charts now match the vocabulary summary.
|
||||||
|
- A one-time background repair on next launch fixes stale "last seen" dates and existing chart mismatches.
|
||||||
|
- Overview totals, charts, the activity calendar, and the vocabulary summary now refresh after you delete sessions, and a failed refresh keeps the dashboard visible with a retry option.
|
||||||
|
- Subtitle generation now works during Windows YouTube playback and loads the generated subtitles into the playing video.
|
||||||
|
- On Windows, moving on to a video queued from the YouTube browser window no longer drops the rest of mpv's queue.
|
||||||
|
- The stats docs now cover the automatic Overview refresh after deletions and how discovery dates are kept through retention pruning.
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **YouTube Browser Window**:
|
- **YouTube Browser Window**:
|
||||||
- Open a YouTube browser with `subminer youtube` (or `subminer yt`), or pick **Browse YouTube** from the tray. It stays logged in to YouTube across restarts.
|
- Open it with `subminer youtube` (or `subminer yt`), or pick **Browse YouTube** from the tray. It stays logged in across restarts.
|
||||||
- Clicking a video plays it in mpv instead of on the page.
|
- Clicking a video plays it in mpv. Middle-click, Shift-click, Ctrl-click, or the right-click menu adds it to mpv's playlist.
|
||||||
- Middle-click, Shift-click, Ctrl-click, or the right-click menu adds a video to mpv's playlist.
|
- Queued videos get the same Japanese subtitle setup when they start.
|
||||||
- When a queued video starts, it gets the same Japanese subtitle setup.
|
- Closing the window quits SubMiner if you started it with `subminer youtube`. If a video is still playing, SubMiner waits until mpv closes.
|
||||||
- If you started SubMiner with `subminer youtube`, closing the YouTube window quits SubMiner. If a video is still playing, SubMiner quits after mpv closes.
|
|
||||||
- **Whisper Subtitles for YouTube**:
|
- **Whisper Subtitles for YouTube**:
|
||||||
- Set `youtube.subtitleSource` to `whisper` to make subtitles with Whisper instead of downloading YouTube's captions. The default is still `youtube`.
|
- Set `youtube.subtitleSource` to `whisper` to make subtitles with Whisper instead of downloading YouTube's captions.
|
||||||
- The video stays paused while Whisper works, and progress shows in the subtitle generation modal. Close the modal to keep watching, or cancel to carry on without subtitles.
|
- The video stays paused while Whisper works, and progress shows in the subtitle generation modal. You can close the modal to keep watching, or cancel.
|
||||||
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos with either `youtube.subtitleSource` setting.
|
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos, including on Windows.
|
||||||
- Whisper downloads only a small audio stream and deletes it when generation ends, or when you switch videos, close mpv, or quit.
|
|
||||||
- **Mouse and Scroll Wheel Bindings**:
|
- **Mouse and Scroll Wheel Bindings**:
|
||||||
- `keybindings` now accepts scroll wheel keys (`WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`), with or without modifiers. You can also record them in the settings key editor.
|
- You can now use scroll wheel keys (`WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`) in `keybindings` and record them in the settings key editor.
|
||||||
- Mouse button and scroll wheel bindings from mpv now work while the cursor is over the overlay. This covers your `input.conf` and mpv's defaults, such as double-click for fullscreen, the wheel for volume, and back/forward for the playlist.
|
- mpv's mouse button and wheel bindings now work while the cursor is over the overlay. This matters most on Hyprland, where these bindings did nothing before.
|
||||||
- Hyprland users benefit most: the overlay always takes input there, so these bindings did nothing before.
|
- When a binding conflicts, SubMiner's own bindings and right-click pause take priority over mpv's.
|
||||||
- SubMiner's own bindings and right-click pause still win over mpv's.
|
- **Session Help Commands**: During playback, press `Enter` or double-click a row in session help to run that command. Hovering over a row makes it the target for `Enter`.
|
||||||
- **Session Help Commands**:
|
|
||||||
- During playback, press `Enter` or double-click a row in session help to run that command.
|
|
||||||
- Hovering over a row makes it the target for `Enter`.
|
|
||||||
- Session help opened from the tray with no video loaded is still read-only.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Release Assets**: Releases no longer include package-size JSON reports. Package contents are still validated before release.
|
- **Release Assets**: Releases no longer include package-size JSON reports.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Jellyfin Subtitles**:
|
- **Jellyfin Subtitles**:
|
||||||
- The Japanese subtitle track is selected, and annotations start, as soon as that track downloads. SubMiner no longer waits for every other track.
|
- The Japanese track is selected, and annotations start, as soon as it downloads. SubMiner no longer waits for the other tracks.
|
||||||
- Subtitle tracks now download in parallel, so a slow embedded track no longer holds up the main subtitles.
|
- Japanese and English tracks are now picked automatically on episodes with image-based subtitles (PGS, DVD, DVB).
|
||||||
- Episodes with image-based subtitles (PGS, DVD, DVB) now select the Japanese and English tracks automatically.
|
- If one track fails to download, the other tracks are still selected.
|
||||||
- If one subtitle track fails to download, the other tracks are still selected.
|
- **Jellyfin Audio Track**: On direct-play streams, timing review previews and mined card audio now use the audio track you're playing.
|
||||||
- **Jellyfin Audio Track**: On direct-play Jellyfin streams, timing review previews and mined card audio now use the audio track you're playing, not the one Jellyfin marks as default.
|
|
||||||
- **Media Timing Review**:
|
- **Media Timing Review**:
|
||||||
- Trim handles are now thin lines centered on the clip edges. The start and end of the clip are easier to see, and less of the waveform is hidden.
|
- Trim handles are now thin lines centered on the clip edges.
|
||||||
- The original subtitle boundaries are now plain orange bars without text labels, so the waveform stays visible.
|
- Original subtitle boundaries are now plain orange bars with no labels.
|
||||||
- On macOS with Bluetooth headphones such as AirPods, the preview cursor now moves smoothly. Before, it stuttered and jumped to the end partway through. This affected Opus audio, including Jellyfin streams.
|
- The preview cursor no longer stutters on macOS with Bluetooth headphones such as AirPods.
|
||||||
- **Hyprland Fullscreen**:
|
- **Hyprland Fullscreen**: The overlay stays visible when mpv enters or leaves fullscreen. With `mpv.backend: x11`, subtitles and the sidebar stay clickable after going fullscreen.
|
||||||
- The subtitle overlay now stays visible when mpv enters or leaves fullscreen, so focus changes no longer cancel fullscreen. Sway still refreshes the overlay as before.
|
- **mpv Key Bindings in the Overlay**: mpv key bindings, such as `9`/`0` for volume, now work while the overlay has focus, even when the overlay loaded before mpv connected.
|
||||||
- With `mpv.backend: x11`, subtitles and the sidebar no longer stop responding to clicks after mpv goes fullscreen.
|
- **YouTube Japanese Subtitles**: Some videos also list a machine-translated Japanese track. On those, Japanese subtitles no longer fail with "HTTP 429". If YouTube refuses a direct download, SubMiner retries with yt-dlp.
|
||||||
- **mpv Key Bindings in the Overlay**:
|
- **Stats Accuracy**:
|
||||||
- mpv key bindings, such as `9`/`0` for volume, now work while the overlay has focus.
|
- Sentence mining no longer counts each card twice in session and lifetime stats.
|
||||||
- They used to do nothing if the overlay loaded before SubMiner connected to mpv, which was common with `mpv.backend: x11`. The overlay now reloads mpv's bindings once mpv connects.
|
- Overview totals, charts, the activity calendar, and the vocabulary summary now update after you delete sessions.
|
||||||
- **YouTube Japanese Subtitles**:
|
- Word and kanji dates stay correct after deleting sessions. New-word charts now match the vocabulary summary.
|
||||||
- Some videos also list a machine-translated Japanese track. On those, Japanese subtitles no longer fail with "HTTP 429", because SubMiner now picks the real Japanese track.
|
- **Dictionary Safety**: Automatic character dictionary changes now stop if a Yomitan profile that had dictionaries suddenly reports none. SubMiner also refuses to load Yomitan data after a downgrade to an older Electron version.
|
||||||
- If YouTube refuses a direct subtitle download, SubMiner retries with yt-dlp.
|
- **AppImage in Sandboxes**: The AppImage now opens its window in sandboxes that mount the image themselves, such as `firejail --appimage`.
|
||||||
- **AppImage in Sandboxes**: The AppImage now opens its window when started in a sandbox that mounts the image itself, such as `firejail --appimage`. Before, it exited without opening a window.
|
- **Ctrl+C Crash**: Stopping the terminal command that started SubMiner with Ctrl+C no longer crashes it with a "write EPIPE" error.
|
||||||
- **Ctrl+C Crash**: Stopping the terminal command that started SubMiner with Ctrl+C no longer crashes SubMiner with a "write EPIPE" error.
|
- **Security Updates**: Electron is updated to 43.7.7 and undici to 7.29.1, which fixes known security advisories.
|
||||||
- **Security Updates**: Electron is updated to 42.10.0 and undici to 7.29.1 to fix known security advisories.
|
|
||||||
|
|
||||||
### Docs
|
### Docs
|
||||||
|
|
||||||
- **Docs Site Rewrite**:
|
- **Docs Site Rewrite**: Pages are shorter and easier to scan. The configuration reference now has a short explanation and a table of keys and defaults for each config block. Pages that no longer matched current behavior are fixed.
|
||||||
- Pages are shorter and easier to scan. Each one starts with setup and usage, and reference details are in compact tables.
|
- **Arch Linux Install**: The MeCab install command now uses the AUR package `mecab-git`. The README now matches the installation guide.
|
||||||
- The configuration reference now gives each config block a short explanation and a table of keys and defaults.
|
- **Stats**: The docs now cover the Overview refreshing after you delete sessions, and how first-seen dates are kept when old sessions are cleaned up.
|
||||||
- Pages that no longer matched how SubMiner works have been corrected.
|
|
||||||
- **Arch Linux Install**:
|
|
||||||
- The MeCab install command is fixed. MeCab is only in the AUR, as `mecab-git`.
|
|
||||||
- The README's requirements and quick start now match the installation guide.
|
|
||||||
|
|
||||||
## What's Changed
|
## What's Changed
|
||||||
|
|
||||||
|
- fix(storage): protect Yomitan profiles across Electron runtime changes by @ksyasuda in #211
|
||||||
- fix(jellyfin): select subtitles faster and survive failed track downloads by @ksyasuda in #270
|
- fix(jellyfin): select subtitles faster and survive failed track downloads by @ksyasuda in #270
|
||||||
- fix(jellyfin): use the current audio track for media generation by @ksyasuda in #271
|
- fix(jellyfin): use the current audio track for media generation by @ksyasuda in #271
|
||||||
- feat(youtube): add YouTube browser window and Whisper subtitle source by @ksyasuda in #273
|
- feat(youtube): add YouTube browser window and Whisper subtitle source by @ksyasuda in #273
|
||||||
@@ -77,14 +80,17 @@
|
|||||||
- fix(linux): skip AppImage mount keepalive on sandbox squashfs mounts by @ksyasuda in #277
|
- fix(linux): skip AppImage mount keepalive on sandbox squashfs mounts by @ksyasuda in #277
|
||||||
- fix(overlay): reload imported mpv keys after mpv connects by @ksyasuda in #278
|
- fix(overlay): reload imported mpv keys after mpv connects by @ksyasuda in #278
|
||||||
- feat(overlay): forward mpv mouse button and wheel bindings by @ksyasuda in #279
|
- feat(overlay): forward mpv mouse button and wheel bindings by @ksyasuda in #279
|
||||||
|
- fix(stats): count mined cards once and keep lexical stats exact after deletes by @ksyasuda in #280
|
||||||
|
- fix(youtube): keep mpv queue when advancing to a queued video on Windows by @ksyasuda in #281
|
||||||
|
- fix(subtitles): allow Whisper generation during Windows YouTube stream playback by @ksyasuda in #282
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
See the [installation guide](https://docs.subminer.moe/installation) for full setup steps.
|
See the README and docs/installation guide for full setup steps.
|
||||||
|
|
||||||
## Assets
|
## Assets
|
||||||
|
|
||||||
- Linux: `SubMiner-*.AppImage`
|
- Linux: `SubMiner.AppImage`
|
||||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||||
- Optional extras: `subminer-assets.tar.gz`, the `subminer` launcher, and the Windows `subminer.cmd` launcher
|
- Optional extras: `subminer-assets.tar.gz`, the `subminer` launcher, and the Windows `subminer.cmd` launcher
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
## Highlights
|
||||||
|
### Added
|
||||||
|
- **YouTube Browser Window**:
|
||||||
|
- Browse YouTube in a window that keeps your login across restarts. Open it with `subminer youtube` (or `subminer yt`), or choose **Browse YouTube** from the tray.
|
||||||
|
- Clicking a video plays it in mpv. Middle-click, Shift/Ctrl-click, or the right-click menu adds it to mpv's playlist.
|
||||||
|
- Queued videos get the same Japanese subtitle setup when they start playing.
|
||||||
|
- **Whisper Subtitles for YouTube**: Set `youtube.subtitleSource` to `whisper` to transcribe YouTube videos with Whisper instead of using YouTube's captions. The subtitle generation modal (`Ctrl+Shift+G`) now also works on YouTube videos.
|
||||||
|
- **Scroll Wheel and Mouse Bindings**: `keybindings` now accepts scroll wheel keys, and the settings key editor can capture them. Mouse button and wheel bindings from mpv now work while the cursor is over the overlay.
|
||||||
|
- **Run Commands From Session Help**: Press `Enter` or double-click a row in session help to run that command during playback.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **YouTube**:
|
||||||
|
- Japanese subtitles no longer fail with "HTTP 429" on videos that also have a machine-translated Japanese track.
|
||||||
|
- On Windows, the subtitle generation modal now works during YouTube playback.
|
||||||
|
- On Windows, playing a video queued from the YouTube browser window no longer clears the rest of mpv's queue.
|
||||||
|
- **Jellyfin**:
|
||||||
|
- The Japanese subtitle track is selected as soon as it downloads, instead of waiting for every other track.
|
||||||
|
- Japanese and English image-based subtitles (PGS, DVD, DVB) are now selected automatically.
|
||||||
|
- Timing review previews and mined card audio use the audio track you are playing.
|
||||||
|
- **Hyprland Overlay**: The overlay no longer interrupts mpv when it goes fullscreen. With `mpv.backend: x11`, the overlay stays clickable after mpv goes fullscreen.
|
||||||
|
- **mpv Key Bindings**: Keys from mpv's defaults and your `input.conf` (such as `9` and `0` for volume) now work while the overlay has focus, even when the overlay loads before SubMiner connects to mpv.
|
||||||
|
- **Media Timing Review**: Trim handles are now thin lines on the clip edges, and the original subtitle boundaries are plain orange bars. On macOS with Bluetooth headphones such as AirPods, the preview cursor now moves smoothly.
|
||||||
|
- **Stats**:
|
||||||
|
- Each mined card is counted once instead of twice.
|
||||||
|
- Overview totals, charts, the calendar, and the vocabulary summary update right after you delete sessions.
|
||||||
|
- Word and kanji dates stay correct after deletes and pruning, and the new-words charts now agree with the vocabulary summary. Existing data is repaired in the background on the next launch.
|
||||||
|
- **Yomitan Profile Safety**: Electron is updated to 43.7.7, and SubMiner now refuses to load your Yomitan data with an older or unsupported runtime. Automatic character-dictionary updates stop if a profile that had dictionaries suddenly reports none.
|
||||||
|
- **Security Updates**: Updated Electron and undici to resolve security advisories.
|
||||||
|
- **AppImage in Sandboxes**: The AppImage now opens a window when launched inside a sandbox such as `firejail --appimage`.
|
||||||
|
- **Ctrl+C Crash**: SubMiner no longer crashes with "write EPIPE" when you stop the terminal command that started it with Ctrl+C.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- **Docs Site**: The docs site is shorter and easier to scan, and the configuration reference now has a table of keys and defaults for each section. Outdated pages were corrected, and the Arch Linux MeCab install now uses the AUR package `mecab-git`.
|
||||||
|
- **Stats**: Added docs on how the Overview refreshes after you delete sessions and how discovery dates are kept after pruning.
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
|
||||||
|
- fix(storage): protect Yomitan profiles across Electron runtime changes by @ksyasuda in #211
|
||||||
|
- fix(jellyfin): select subtitles faster and survive failed track downloads by @ksyasuda in #270
|
||||||
|
- fix(jellyfin): use the current audio track for media generation by @ksyasuda in #271
|
||||||
|
- feat(youtube): add YouTube browser window and Whisper subtitle source by @ksyasuda in #273
|
||||||
|
- fix(overlay): thin centered trim handles and label-free subtitle boundaries in timing review by @ksyasuda in #274
|
||||||
|
- feat(overlay): run session help commands with Enter or double-click by @ksyasuda in #275
|
||||||
|
- fix(overlay): keep overlay mapped during Hyprland/Sway fullscreen by @ksyasuda in #276
|
||||||
|
- fix(linux): skip AppImage mount keepalive on sandbox squashfs mounts by @ksyasuda in #277
|
||||||
|
- fix(overlay): reload imported mpv keys after mpv connects by @ksyasuda in #278
|
||||||
|
- feat(overlay): forward mpv mouse button and wheel bindings by @ksyasuda in #279
|
||||||
|
- fix(stats): count mined cards once and keep lexical stats exact after deletes by @ksyasuda in #280
|
||||||
|
- fix(youtube): keep mpv queue when advancing to a queued video on Windows by @ksyasuda in #281
|
||||||
|
- fix(subtitles): allow Whisper generation during Windows YouTube stream playback by @ksyasuda in #282
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
See the README and docs/installation guide for full setup steps.
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
- Linux: `SubMiner.AppImage`
|
||||||
|
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||||
|
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||||
|
- Optional extras: `subminer-assets.tar.gz`, the `subminer` launcher, and the Windows `subminer.cmd` launcher
|
||||||
|
- Bun corresponding source: `bun-v1.3.5-source.tar.gz` and its `.sha256` file
|
||||||
|
|
||||||
|
Both launcher downloads use Bun included with the SubMiner app. Download `subminer` on Linux or macOS and `subminer.cmd` on Windows.
|
||||||
|
|
||||||
|
The app bundles an unmodified Bun 1.3.5 runtime. Bun is MIT licensed and statically links JavaScriptCore (LGPL 2.0) and TinyCC (LGPL 2.1). License texts and third-party notices ship inside the app under `resources/bun/licenses`, and the source archive above contains the matching Bun, WebKit, and dependency sources for relinking.
|
||||||
@@ -44,20 +44,12 @@ function fragmentTypesInPrompt(input: string): string[] {
|
|||||||
.map((line) => line.slice('type: '.length).trim());
|
.map((line) => line.slice('type: '.length).trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertPromptRequestsNestedBullets(input: string): void {
|
function assertPromptRequestsConciseItems(input: string): void {
|
||||||
assert.match(input, /In both modes, split every item into one nested bullet per distinct change/);
|
assert.match(input, /Default to inline/);
|
||||||
assert.match(input, /Never stack several distinct changes into one long paragraph-shaped bullet/);
|
assert.match(input, /Use nested bullets only when an item has several distinct changes/);
|
||||||
assert.match(input, /Keep nested bullets short, concrete, and readable by non-technical users/);
|
assert.match(input, /Never write paragraph-length bullets/);
|
||||||
assert.match(input, /Avoid paragraph-style release-note bullets/);
|
assert.match(input, /Merge aggressively: one item per feature or area/);
|
||||||
}
|
assert.match(input, /Release notes summarize; the docs hold the details/);
|
||||||
|
|
||||||
function assertReleaseNotesPromptRequestsNestedBullets(input: string): void {
|
|
||||||
assertPromptRequestsNestedBullets(input);
|
|
||||||
assert.match(
|
|
||||||
input,
|
|
||||||
/In MODE: release-notes, nested bullets should also cover user benefit and any user action/,
|
|
||||||
);
|
|
||||||
assert.match(input, /Do not require the exact nested labels/);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultPolishedBody(input: string): string {
|
function defaultPolishedBody(input: string): string {
|
||||||
@@ -454,14 +446,13 @@ test('writeChangelogArtifacts prompts Claude to summarize the final stable outco
|
|||||||
prompt,
|
prompt,
|
||||||
/Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/,
|
/Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/,
|
||||||
);
|
);
|
||||||
assertPromptRequestsNestedBullets(prompt);
|
assertPromptRequestsConciseItems(prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
const releaseNotesPrompt = stub.calls.find(
|
const releaseNotesPrompt = stub.calls.find(
|
||||||
(call) => modeFromPrompt(call.input) === 'release-notes',
|
(call) => modeFromPrompt(call.input) === 'release-notes',
|
||||||
);
|
);
|
||||||
assert.ok(releaseNotesPrompt, 'expected a release-notes Claude invocation');
|
assert.ok(releaseNotesPrompt, 'expected a release-notes Claude invocation');
|
||||||
assertReleaseNotesPromptRequestsNestedBullets(releaseNotesPrompt.input);
|
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(workspace, { recursive: true, force: true });
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -817,7 +808,11 @@ test('writePrereleaseNotesForVersion prompts Claude to revise stale prerelease b
|
|||||||
prompt,
|
prompt,
|
||||||
/Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/,
|
/Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/,
|
||||||
);
|
);
|
||||||
assertReleaseNotesPromptRequestsNestedBullets(prompt);
|
assert.match(
|
||||||
|
prompt,
|
||||||
|
/shorten long baseline bullets and collapse nested lists that do not need to be nested/,
|
||||||
|
);
|
||||||
|
assertPromptRequestsConciseItems(prompt);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(workspace, { recursive: true, force: true });
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-16
@@ -476,21 +476,29 @@ You will receive a list of FRAGMENT entries below. Each fragment has metadata (t
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
Do not include the Internal section at all in MODE: release-notes; internal fragments will not be present in the input for that mode.
|
Do not include the Internal section at all in MODE: release-notes; internal fragments will not be present in the input for that mode.
|
||||||
4. Each top-level change item should:
|
4. Start every item with a short bold feature/area name in title case and a colon. Pick the name from the fragment's bullet content, not the raw 'area:' slug.
|
||||||
- Lead with a short feature/area name in title case. Pick the name from the fragment's bullet content, not the raw 'area:' slug.
|
- Default to inline: put one or two short sentences right after the colon. If the important details fit in two short sentences, stay inline.
|
||||||
- Be written in user-facing language. Drop implementation jargon, internal class names, file paths, and PR numbers.
|
- Use nested bullets only when an item has several distinct changes a user would care about separately and inline text would turn into a long run-on sentence. Then leave the top-level bullet as just the name and indent two to four nested bullets by two spaces.
|
||||||
- Be merged with related bullets when possible. If five fragments all touch Windows overlay z-order/focus/restore, write one or two bullets that summarize the overall improvement instead of five.
|
- Each nested bullet is a single short sentence, roughly 20 words or fewer. Never write paragraph-length bullets. Cut minor details rather than adding sentences.
|
||||||
|
5. Keep items brief. Release notes summarize; the docs hold the details.
|
||||||
|
- Say what changed for the user and how to reach it (shortcut, command, setting, or menu). Stop there.
|
||||||
|
- Leave out how it works internally, edge cases, precedence rules, defaults, fallbacks, tool discovery, migration mechanics, and step-by-step usage.
|
||||||
|
- Name a config key only when users must set it to use the change or to keep their current behavior.
|
||||||
|
- Write in user-facing language. Drop implementation jargon, internal class names, file paths, and PR numbers.
|
||||||
|
- Merge aggressively: one item per feature or area. If five fragments touch Jellyfin subtitles, audio, and auth, write one Jellyfin item, not five. Group small unrelated fixes for the same area into one item.
|
||||||
- Drop bullets that only describe PR housekeeping, CodeRabbit follow-ups, or test-only changes that don't affect users.
|
- Drop bullets that only describe PR housekeeping, CodeRabbit follow-ups, or test-only changes that don't affect users.
|
||||||
- Preserve the substance of breaking changes that remain breaking after applying the Release Outcome Rules. Do not soften or omit them.
|
- Preserve the substance of breaking changes that remain breaking after applying the Release Outcome Rules, including any action users must take. Do not soften or omit them.
|
||||||
5. In both modes, split every item into one nested bullet per distinct change. Write a short bold name on the top-level bullet, then indent the details two spaces:
|
6. Match the length and density of this example:
|
||||||
- **Playlist Browser**:
|
### Added
|
||||||
- Saved shows now open without rescanning the library.
|
- **Subtitle Selection Modal**: Optional modal for picking primary and secondary subtitle tracks. Enable it in Settings under Behavior, then press g followed by s.
|
||||||
- The picker remembers the last folder you browsed between launches.
|
### Changed
|
||||||
Each nested bullet covers exactly one change, behavior, or user-visible outcome. Never stack several distinct changes into one long paragraph-shaped bullet.
|
- **Faster Sync**: Sync between macOS and Linux machines now uses compressed, incremental rsync transfers. Windows and machines without rsync fall back to compressed scp.
|
||||||
Aim for two to five nested bullets per item. When an item genuinely has only one thing to say, put it inline on the top-level bullet ("- **Playlist Browser**: Saved shows now open without rescanning the library.") instead of emitting a single nested bullet.
|
### Fixed
|
||||||
Keep nested bullets short, concrete, and readable by non-technical users. Avoid paragraph-style release-note bullets.
|
- **Subtitle Sidebar**: Space keeps its playback action after clicking a cue, and the sidebar no longer jumps away from the current position during subtitle gaps.
|
||||||
Bullets inside the Internal section may stay single-level.
|
- **Jellyfin 12**:
|
||||||
6. In MODE: release-notes, nested bullets should also cover user benefit and any user action or compatibility note when useful. Do not require the exact nested labels; natural phrasing is fine. Omit the action bullet when no action is needed.
|
- Playback, subtitles, artwork, and "Play on SubMiner" now work on Jellyfin 12.
|
||||||
|
- Casting no longer drops after about a minute.
|
||||||
|
- Mined cards get the episode title again.
|
||||||
7. Do not invent features. Every bullet must be grounded in the input fragments.
|
7. Do not invent features. Every bullet must be grounded in the input fragments.
|
||||||
8. Do not include the version heading (## v...) — that wrapper is added by the caller.
|
8. Do not include the version heading (## v...) — that wrapper is added by the caller.
|
||||||
|
|
||||||
@@ -791,7 +799,7 @@ function polishFragmentsWithClaude(
|
|||||||
? [
|
? [
|
||||||
'## Existing Prerelease Notes',
|
'## Existing Prerelease Notes',
|
||||||
'',
|
'',
|
||||||
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Existing prerelease notes are a baseline, not an immutable changelog. Reuse reviewed highlight bullets when they still describe the current outcome, but replace stale beta or RC wording when new fragments supersede it. Merge in only new or changed fragment material, and deduplicate instead of restating existing bullets. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, any "Changes since" section, or the Installation or Assets sections.',
|
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Existing prerelease notes are a baseline, not an immutable changelog. Reuse reviewed highlight bullets when they still describe the current outcome, but replace stale beta or RC wording when new fragments supersede it. Merge in only new or changed fragment material, and deduplicate instead of restating existing bullets. The output rules above win over the baseline format: shorten long baseline bullets and collapse nested lists that do not need to be nested. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, any "Changes since" section, or the Installation or Assets sections.',
|
||||||
'',
|
'',
|
||||||
].join('\n')
|
].join('\n')
|
||||||
: '';
|
: '';
|
||||||
@@ -814,7 +822,7 @@ Rules:
|
|||||||
3. ADDED fragments describe changes that are new in this build; summarize them.
|
3. ADDED fragments describe changes that are new in this build; summarize them.
|
||||||
4. MODIFIED fragments include BEFORE and AFTER content. Describe only the behavioral difference between them. If the edit is editorial (rewording, deduplication, reformatting, reconciling stale phrasing) with no user-visible behavior change, omit it entirely.
|
4. MODIFIED fragments include BEFORE and AFTER content. Describe only the behavioral difference between them. If the edit is editorial (rewording, deduplication, reformatting, reconciling stale phrasing) with no user-visible behavior change, omit it entirely.
|
||||||
5. DELETED fragments mean the described change was removed or reverted before this build; say so explicitly.
|
5. DELETED fragments mean the described change was removed or reverted before this build; say so explicitly.
|
||||||
6. Keep bullets short and concrete. Use nested bullets sparingly.
|
6. Keep each bullet to one short, concrete sentence. Use nested bullets sparingly.
|
||||||
7. Do not invent changes. Every bullet must be grounded in the diffs.
|
7. Do not invent changes. Every bullet must be grounded in the diffs.
|
||||||
8. If no bullet survives rules 2-5, output exactly this single line:
|
8. If no bullet survives rules 2-5, output exactly this single line:
|
||||||
- No user-facing changes since PREVIOUS_TAG.
|
- No user-facing changes since PREVIOUS_TAG.
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ test('stripFilenameTags normalizes common media-title formats', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fetchIfMissing backfills a missing blob from an existing cover URL', async () => {
|
async function backfillMissingCoverBlob(): Promise<void> {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -103,9 +103,15 @@ test('fetchIfMissing backfills a missing blob from an existing cover URL', async
|
|||||||
db.close();
|
db.close();
|
||||||
cleanupDbPath(dbPath);
|
cleanupDbPath(dbPath);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
test('fetchIfMissing reuses cached cover art from another video in the same anime', async () => {
|
test(
|
||||||
|
'fetchIfMissing backfills a missing blob from an existing cover URL',
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
backfillMissingCoverBlob,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function reuseCachedAnimeCoverArt(): Promise<void> {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -179,7 +185,13 @@ test('fetchIfMissing reuses cached cover art from another video in the same anim
|
|||||||
db.close();
|
db.close();
|
||||||
cleanupDbPath(dbPath);
|
cleanupDbPath(dbPath);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
'fetchIfMissing reuses cached cover art from another video in the same anime',
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
reuseCachedAnimeCoverArt,
|
||||||
|
);
|
||||||
|
|
||||||
function createJsonResponse(payload: unknown): Response {
|
function createJsonResponse(payload: unknown): Response {
|
||||||
return new Response(JSON.stringify(payload), {
|
return new Response(JSON.stringify(payload), {
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import path from 'node:path';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { Database } from '../sqlite.js';
|
import { Database } from '../sqlite.js';
|
||||||
import type { DatabaseSync } from '../sqlite.js';
|
import type { DatabaseSync } from '../sqlite.js';
|
||||||
import { ensureSchema } from '../storage.js';
|
import { createTrackerPreparedStatements, ensureSchema, executeQueuedWrite } from '../storage.js';
|
||||||
import { deleteSession, deleteSessions, deleteVideo } from '../query-maintenance.js';
|
import { deleteSession, deleteSessions, deleteVideo } from '../query-maintenance.js';
|
||||||
|
import { pruneRawRetention } from '../maintenance.js';
|
||||||
|
|
||||||
const DAY_MS = 86_400_000;
|
const DAY_MS = 86_400_000;
|
||||||
const BASE_MS = 1_700_000_000_000;
|
const BASE_MS = 1_700_000_000_000;
|
||||||
@@ -30,7 +31,13 @@ function cleanupDbPath(dbPath: string): void {
|
|||||||
*/
|
*/
|
||||||
function seed(
|
function seed(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
lines: Array<{ session: 1 | 2; wordId: number; dayOffset: number; count?: number }>,
|
lines: Array<{
|
||||||
|
session: 1 | 2;
|
||||||
|
wordId: number;
|
||||||
|
dayOffset: number;
|
||||||
|
msOffset?: number;
|
||||||
|
count?: number;
|
||||||
|
}>,
|
||||||
options: { legacyRows?: boolean } = {},
|
options: { legacyRows?: boolean } = {},
|
||||||
): void {
|
): void {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
@@ -65,7 +72,7 @@ function seed(
|
|||||||
let lineId = 0;
|
let lineId = 0;
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
lineId += 1;
|
lineId += 1;
|
||||||
const seenMs = BASE_MS + line.dayOffset * DAY_MS;
|
const seenMs = BASE_MS + line.dayOffset * DAY_MS + (line.msOffset ?? 0);
|
||||||
insertLine.run(lineId, line.session, line.session, lineId, `line ${lineId}`, seenMs, seenMs);
|
insertLine.run(lineId, line.session, line.session, lineId, `line ${lineId}`, seenMs, seenMs);
|
||||||
insertWord.run(line.wordId, `語${line.wordId}`, `語${line.wordId}`);
|
insertWord.run(line.wordId, `語${line.wordId}`, `語${line.wordId}`);
|
||||||
if (options.legacyRows) {
|
if (options.legacyRows) {
|
||||||
@@ -83,13 +90,13 @@ function seed(
|
|||||||
FROM imm_word_line_occurrences o WHERE o.word_id = imm_words.id
|
FROM imm_word_line_occurrences o WHERE o.word_id = imm_words.id
|
||||||
),
|
),
|
||||||
first_seen = (
|
first_seen = (
|
||||||
SELECT MIN(sl.CREATED_DATE) / 1000
|
SELECT MIN(sl.CREATED_DATE) / 1000.0
|
||||||
FROM imm_word_line_occurrences o
|
FROM imm_word_line_occurrences o
|
||||||
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
|
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
|
||||||
WHERE o.word_id = imm_words.id
|
WHERE o.word_id = imm_words.id
|
||||||
),
|
),
|
||||||
last_seen = (
|
last_seen = (
|
||||||
SELECT MAX(sl.LAST_UPDATE_DATE) / 1000
|
SELECT MAX(sl.LAST_UPDATE_DATE) / 1000.0
|
||||||
FROM imm_word_line_occurrences o
|
FROM imm_word_line_occurrences o
|
||||||
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
|
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
|
||||||
WHERE o.word_id = imm_words.id
|
WHERE o.word_id = imm_words.id
|
||||||
@@ -200,6 +207,167 @@ test('deleting the latest session moves last_seen back to the surviving line', (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const legacyRows of [false, true]) {
|
||||||
|
test(`deleting the latest session keeps fractional seen timestamps exact (legacyRows=${legacyRows})`, () => {
|
||||||
|
// Stored seen values are fractional seconds; whole-second rounding used to miss
|
||||||
|
// the removed extreme (or truncate the refreshed one).
|
||||||
|
const { db, dbPath } = createDb(
|
||||||
|
[
|
||||||
|
{ session: 1, wordId: 22, dayOffset: 0, msOffset: 251 },
|
||||||
|
{ session: 2, wordId: 22, dayOffset: 5, msOffset: 882 },
|
||||||
|
],
|
||||||
|
{ legacyRows },
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
deleteSession(db, 2);
|
||||||
|
|
||||||
|
const word = readWord(db, 22);
|
||||||
|
assert.equal(word?.frequency, 1);
|
||||||
|
assert.equal(word?.firstSeen, (BASE_MS + 251) / 1000);
|
||||||
|
assert.equal(word?.lastSeen, (BASE_MS + 251) / 1000);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('deleting legacy history preserves first_seen when it predates the flush timestamp', () => {
|
||||||
|
const { db, dbPath } = createDb([
|
||||||
|
{ session: 1, wordId: 23, dayOffset: 0, msOffset: 400 },
|
||||||
|
{ session: 2, wordId: 23, dayOffset: 5, msOffset: 400 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// A queue delay and retention-pruned history cannot be distinguished from
|
||||||
|
// the old flush-time timestamp. Preserve the earlier date in either case.
|
||||||
|
db.prepare('UPDATE imm_words SET first_seen = ? WHERE id = 23').run(BASE_MS / 1000);
|
||||||
|
|
||||||
|
deleteSession(db, 1);
|
||||||
|
|
||||||
|
assert.equal(readWord(db, 23)?.firstSeen, BASE_MS / 1000);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const legacyRows of [false, true]) {
|
||||||
|
for (const deletedSession of [2, 3]) {
|
||||||
|
test(`deleting retained session ${deletedSession} preserves pruned first_seen (legacyRows=${legacyRows})`, () => {
|
||||||
|
const { db, dbPath } = createDb(
|
||||||
|
[
|
||||||
|
{ session: 1, wordId: 24, dayOffset: 0 },
|
||||||
|
{ session: 2, wordId: 24, dayOffset: 1 },
|
||||||
|
],
|
||||||
|
{ legacyRows },
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const latestMs = BASE_MS + 2 * DAY_MS;
|
||||||
|
db.exec(`
|
||||||
|
INSERT INTO imm_sessions(session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE)
|
||||||
|
VALUES (3, 's3', 2, '${latestMs}', '${latestMs + 1000}', 2, ${latestMs}, ${latestMs});
|
||||||
|
INSERT INTO imm_subtitle_lines(line_id, session_id, video_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
|
||||||
|
VALUES (3, 3, 2, 3, '猫', ${latestMs}, ${latestMs});
|
||||||
|
INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms)
|
||||||
|
VALUES (3, 24, 1, ${legacyRows ? 'NULL' : latestMs});
|
||||||
|
UPDATE imm_words SET frequency = 3, last_seen = ${latestMs / 1000} WHERE id = 24;
|
||||||
|
INSERT INTO imm_kanji(id, kanji, first_seen, last_seen, frequency)
|
||||||
|
SELECT 24, '猫', first_seen, last_seen, frequency FROM imm_words WHERE id = 24;
|
||||||
|
INSERT INTO imm_kanji_line_occurrences(line_id, kanji_id, occurrence_count, seen_ms)
|
||||||
|
SELECT line_id, 24, occurrence_count, seen_ms FROM imm_word_line_occurrences WHERE word_id = 24;
|
||||||
|
`);
|
||||||
|
pruneRawRetention(db, latestMs, {
|
||||||
|
eventsRetentionMs: Infinity,
|
||||||
|
telemetryRetentionMs: Infinity,
|
||||||
|
sessionsRetentionMs: 1.5 * DAY_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteSession(db, deletedSession);
|
||||||
|
|
||||||
|
for (const table of ['imm_words', 'imm_kanji']) {
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT frequency, first_seen AS firstSeen, last_seen AS lastSeen FROM ${table} WHERE id = 24`,
|
||||||
|
)
|
||||||
|
.get() as { frequency: number; firstSeen: number; lastSeen: number };
|
||||||
|
assert.equal(row.frequency, 2);
|
||||||
|
assert.equal(row.firstSeen, BASE_MS / 1000, `${table} keeps the pruned discovery date`);
|
||||||
|
assert.equal(row.lastSeen, (BASE_MS + (deletedSession === 2 ? 2 : 1) * DAY_MS) / 1000);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('deleting a queued subtitle uses observation time despite a delayed flush', () => {
|
||||||
|
const { db, dbPath } = createDb([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stmts = createTrackerPreparedStatements(db);
|
||||||
|
for (const sessionId of [1, 2]) {
|
||||||
|
const seenMs = BASE_MS + (sessionId - 1) * DAY_MS + 400;
|
||||||
|
executeQueuedWrite(
|
||||||
|
{
|
||||||
|
kind: 'subtitleLine',
|
||||||
|
sessionId,
|
||||||
|
videoId: sessionId,
|
||||||
|
lineIndex: 1,
|
||||||
|
segmentStartMs: null,
|
||||||
|
segmentEndMs: null,
|
||||||
|
text: '猫',
|
||||||
|
wordOccurrences: [
|
||||||
|
{
|
||||||
|
headword: '猫',
|
||||||
|
word: '猫',
|
||||||
|
reading: 'ねこ',
|
||||||
|
partOfSpeech: 'noun',
|
||||||
|
pos1: '名詞',
|
||||||
|
pos2: '一般',
|
||||||
|
pos3: '',
|
||||||
|
occurrenceCount: 1,
|
||||||
|
frequencyRank: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
kanjiOccurrences: [{ kanji: '猫', occurrenceCount: 1 }],
|
||||||
|
firstSeen: seenMs / 1000,
|
||||||
|
lastSeen: seenMs / 1000,
|
||||||
|
},
|
||||||
|
stmts,
|
||||||
|
);
|
||||||
|
for (const table of ['imm_word_line_occurrences', 'imm_kanji_line_occurrences']) {
|
||||||
|
const row = db
|
||||||
|
.prepare(`SELECT seen_ms AS seenMs FROM ${table} WHERE line_id = ?`)
|
||||||
|
.get(sessionId) as { seenMs: number };
|
||||||
|
assert.equal(row.seenMs, seenMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSession(db, 1);
|
||||||
|
|
||||||
|
for (const table of ['imm_words', 'imm_kanji']) {
|
||||||
|
const row = db
|
||||||
|
.prepare(`SELECT frequency, first_seen AS firstSeen, last_seen AS lastSeen FROM ${table}`)
|
||||||
|
.get() as {
|
||||||
|
frequency: number;
|
||||||
|
firstSeen: number;
|
||||||
|
lastSeen: number;
|
||||||
|
};
|
||||||
|
assert.equal(row.frequency, 1);
|
||||||
|
assert.equal(row.firstSeen, (BASE_MS + DAY_MS + 400) / 1000);
|
||||||
|
assert.equal(row.lastSeen, (BASE_MS + DAY_MS + 400) / 1000);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('deleting an interior occurrence leaves the surrounding extremes untouched', () => {
|
test('deleting an interior occurrence leaves the surrounding extremes untouched', () => {
|
||||||
// Session 2 carries the middle occurrence; sessions bracket it in time.
|
// Session 2 carries the middle occurrence; sessions bracket it in time.
|
||||||
const { db, dbPath } = createDb([
|
const { db, dbPath } = createDb([
|
||||||
|
|||||||
@@ -4567,13 +4567,13 @@ test('deleteSession rebuilds word and kanji aggregates from retained subtitle li
|
|||||||
|
|
||||||
assert.ok(sharedWordRow);
|
assert.ok(sharedWordRow);
|
||||||
assert.equal(sharedWordRow.frequency, 1);
|
assert.equal(sharedWordRow.frequency, 1);
|
||||||
assert.equal(sharedWordRow.first_seen, Math.floor(keptTs / 1000));
|
assert.equal(sharedWordRow.first_seen, keptTs / 1000);
|
||||||
assert.equal(sharedWordRow.last_seen, Math.floor(keptTs / 1000));
|
assert.equal(sharedWordRow.last_seen, keptTs / 1000);
|
||||||
assert.equal(deletedOnlyWordRow ?? null, null);
|
assert.equal(deletedOnlyWordRow ?? null, null);
|
||||||
assert.ok(sharedKanjiRow);
|
assert.ok(sharedKanjiRow);
|
||||||
assert.equal(sharedKanjiRow.frequency, 1);
|
assert.equal(sharedKanjiRow.frequency, 1);
|
||||||
assert.equal(sharedKanjiRow.first_seen, Math.floor(keptTs / 1000));
|
assert.equal(sharedKanjiRow.first_seen, keptTs / 1000);
|
||||||
assert.equal(sharedKanjiRow.last_seen, Math.floor(keptTs / 1000));
|
assert.equal(sharedKanjiRow.last_seen, keptTs / 1000);
|
||||||
assert.equal(deletedOnlyKanjiRow ?? null, null);
|
assert.equal(deletedOnlyKanjiRow ?? null, null);
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
} from './query-lexical';
|
} from './query-lexical';
|
||||||
import { Database } from './sqlite';
|
import { Database } from './sqlite';
|
||||||
import type { DatabaseSync } from './sqlite';
|
import type { DatabaseSync } from './sqlite';
|
||||||
import { ensureSchema } from './storage';
|
import { createTrackerPreparedStatements, ensureSchema, executeQueuedWrite } from './storage';
|
||||||
|
import { BASE_MS, createDb, seedEndedSession, seedVideo } from './__tests__/lifetime-test-fixtures';
|
||||||
|
|
||||||
function makeDbPath(): string {
|
function makeDbPath(): string {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-rollups-'));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-rollups-'));
|
||||||
@@ -125,6 +126,121 @@ test('lexical rollup rebuild excludes rows hidden by vocabulary persistence rule
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('re-seeing a filtered legacy word keeps charts consistent with the summary', () => {
|
||||||
|
const db = createDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const videoId = seedVideo(db, null, 'episode');
|
||||||
|
const sessionId = seedEndedSession(db, videoId, BASE_MS, { activeMs: 1_000 });
|
||||||
|
const firstSeen = BASE_MS / 1000;
|
||||||
|
// Legacy row whose stored merged-token POS fails the persistence filter.
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO imm_words(
|
||||||
|
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||||
|
) VALUES ('二死', 'にし', 'にし', 'other', '助詞|動詞', '格助詞|自立', '一般|*', ?, ?, 1)`,
|
||||||
|
).run(firstSeen, firstSeen);
|
||||||
|
rebuildLexicalDailyRollups(db);
|
||||||
|
|
||||||
|
// The incoming token passes the filter, but the upsert keeps the stored POS.
|
||||||
|
executeQueuedWrite(
|
||||||
|
{
|
||||||
|
kind: 'subtitleLine',
|
||||||
|
sessionId,
|
||||||
|
videoId,
|
||||||
|
lineIndex: 1,
|
||||||
|
segmentStartMs: null,
|
||||||
|
segmentEndMs: null,
|
||||||
|
text: 'にし',
|
||||||
|
wordOccurrences: [
|
||||||
|
{
|
||||||
|
headword: '二死',
|
||||||
|
word: 'にし',
|
||||||
|
reading: 'にし',
|
||||||
|
partOfSpeech: 'verb',
|
||||||
|
pos1: '動詞',
|
||||||
|
pos2: '自立',
|
||||||
|
pos3: '*',
|
||||||
|
frequencyRank: null,
|
||||||
|
occurrenceCount: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
kanjiOccurrences: [],
|
||||||
|
firstSeen: firstSeen + 60,
|
||||||
|
lastSeen: firstSeen + 60,
|
||||||
|
},
|
||||||
|
createTrackerPreparedStatements(db),
|
||||||
|
);
|
||||||
|
|
||||||
|
const plotted = getVocabularyChartData(db).newWordsTimeline.reduce(
|
||||||
|
(sum, day) => sum + day.wordCount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.equal(getVocabularySummary(db, null).uniqueWords, 0);
|
||||||
|
assert.equal(plotted, 0);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lexical rollup rebuild repairs last_seen left pointing at deleted history', () => {
|
||||||
|
const db = createDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const videoId = seedVideo(db, null, 'episode');
|
||||||
|
const sessionId = seedEndedSession(db, videoId, BASE_MS, { activeMs: 1_000 });
|
||||||
|
const lineId = Number(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO imm_subtitle_lines(session_id, video_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
|
||||||
|
VALUES (?, ?, 1, '猫', ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(sessionId, videoId, BASE_MS, BASE_MS).lastInsertRowid,
|
||||||
|
);
|
||||||
|
const seen = (BASE_MS + 251) / 1000;
|
||||||
|
// first_seen this early may be real history pruned by retention; never touched.
|
||||||
|
const earlyFirst = seen - 86_400.5;
|
||||||
|
const staleLast = seen + 86_400.631;
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO imm_words(id, headword, word, reading, first_seen, last_seen, frequency)
|
||||||
|
VALUES (1, '猫', '猫', 'ねこ', ?, ?, 1), (2, '犬', '犬', 'いぬ', ?, ?, 1)`,
|
||||||
|
).run(earlyFirst, staleLast, seen - 0.1, seen - 0.1);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO imm_kanji(id, kanji, first_seen, last_seen, frequency) VALUES (1, '猫', ?, ?, 1)`,
|
||||||
|
).run(earlyFirst, staleLast);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms)
|
||||||
|
VALUES (?, 1, 1, ?), (?, 2, 1, ?)`,
|
||||||
|
).run(lineId, BASE_MS + 251, lineId, BASE_MS + 251);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO imm_kanji_line_occurrences(line_id, kanji_id, occurrence_count, seen_ms)
|
||||||
|
VALUES (?, 1, 1, ?)`,
|
||||||
|
).run(lineId, BASE_MS + 251);
|
||||||
|
|
||||||
|
rebuildLexicalDailyRollups(db);
|
||||||
|
|
||||||
|
const bounds = (table: string): Array<[number, number]> =>
|
||||||
|
(
|
||||||
|
db
|
||||||
|
.prepare(`SELECT first_seen AS first, last_seen AS last FROM ${table} ORDER BY id`)
|
||||||
|
.all() as Array<{
|
||||||
|
first: number;
|
||||||
|
last: number;
|
||||||
|
}>
|
||||||
|
).map((row) => [row.first, row.last]);
|
||||||
|
assert.deepEqual(
|
||||||
|
bounds('imm_words'),
|
||||||
|
[
|
||||||
|
[earlyFirst, seen],
|
||||||
|
[seen - 0.1, seen - 0.1],
|
||||||
|
],
|
||||||
|
'healthy rows are untouched',
|
||||||
|
);
|
||||||
|
assert.deepEqual(bounds('imm_kanji'), [[earlyFirst, seen]]);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('lexical rollup rebuild tolerates nullable legacy vocabulary text', () => {
|
test('lexical rollup rebuild tolerates nullable legacy vocabulary text', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
@@ -268,7 +384,7 @@ test('current lexical rollup readiness accepts legacy integer state storage', ()
|
|||||||
state_value INTEGER NOT NULL
|
state_value INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
INSERT INTO imm_rollup_state(state_key, state_value)
|
INSERT INTO imm_rollup_state(state_key, state_value)
|
||||||
VALUES ('lexical_daily_rollups_version', 2);
|
VALUES ('lexical_daily_rollups_version', 3);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
assert.equal(areLexicalDailyRollupsReady(db), true);
|
assert.equal(areLexicalDailyRollupsReady(db), true);
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ const LOCAL_EPOCH_DAY_SQL = `
|
|||||||
) - 2440587.5 AS INTEGER)
|
) - 2440587.5 AS INTEGER)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const LEXICAL_DAILY_ROLLUP_VERSION = '2';
|
// v3 re-derives visibility for rows a re-seen word forced visible and repairs
|
||||||
|
// last_seen values left behind by session deletes.
|
||||||
|
const LEXICAL_DAILY_ROLLUP_VERSION = '3';
|
||||||
const LEXICAL_DAILY_ROLLUP_VERSION_KEY = 'lexical_daily_rollups_version';
|
const LEXICAL_DAILY_ROLLUP_VERSION_KEY = 'lexical_daily_rollups_version';
|
||||||
const VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE = 5_000;
|
const VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE = 5_000;
|
||||||
|
|
||||||
@@ -26,6 +28,32 @@ export function localEpochDaySql(value: string): string {
|
|||||||
return LOCAL_EPOCH_DAY_SQL.replaceAll('%VALUE%', value);
|
return LOCAL_EPOCH_DAY_SQL.replaceAll('%VALUE%', value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Session deletes once rounded removed timestamps to whole seconds, missing
|
||||||
|
// fractional last_seen extremes that pointed at deleted history. New seen_ms
|
||||||
|
// values match observation time; legacy values were stamped later at flush.
|
||||||
|
// Retention prunes the oldest sessions first, so healthy rows never exceed
|
||||||
|
// their newest surviving occurrence. Rows with undated occurrences are skipped.
|
||||||
|
//
|
||||||
|
// first_seen is deliberately not repaired: a stale bound is indistinguishable
|
||||||
|
// from real history whose occurrences retention pruned, and frequency does not
|
||||||
|
// reliably tell them apart.
|
||||||
|
function repairStaleLastSeen(db: DatabaseSync): void {
|
||||||
|
for (const [table, occurrenceTable, col] of [
|
||||||
|
['imm_words', 'imm_word_line_occurrences', 'word_id'],
|
||||||
|
['imm_kanji', 'imm_kanji_line_occurrences', 'kanji_id'],
|
||||||
|
] as const) {
|
||||||
|
const newestSeen = `(SELECT MAX(o.seen_ms) FROM ${occurrenceTable} o WHERE o.${col} = ${table}.id) / 1000.0`;
|
||||||
|
db.exec(`
|
||||||
|
UPDATE ${table}
|
||||||
|
SET last_seen = ${newestSeen}
|
||||||
|
WHERE last_seen > ${newestSeen}
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM ${occurrenceTable} o WHERE o.${col} = ${table}.id AND o.seen_ms IS NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createWordRollupTriggers(db: DatabaseSync): void {
|
function createWordRollupTriggers(db: DatabaseSync): void {
|
||||||
const dayForNew = localEpochDaySql('NEW.first_seen');
|
const dayForNew = localEpochDaySql('NEW.first_seen');
|
||||||
const dayForOld = localEpochDaySql('OLD.first_seen');
|
const dayForOld = localEpochDaySql('OLD.first_seen');
|
||||||
@@ -186,6 +214,7 @@ export function rebuildLexicalDailyRollups(db: DatabaseSync): void {
|
|||||||
lastId = vocabularyRows[vocabularyRows.length - 1]!.id;
|
lastId = vocabularyRows[vocabularyRows.length - 1]!.id;
|
||||||
if (vocabularyRows.length < VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE) break;
|
if (vocabularyRows.length < VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE) break;
|
||||||
}
|
}
|
||||||
|
repairStaleLastSeen(db);
|
||||||
db.exec('DELETE FROM imm_lexical_daily_rollups');
|
db.exec('DELETE FROM imm_lexical_daily_rollups');
|
||||||
db.exec(`
|
db.exec(`
|
||||||
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
|
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
|
||||||
|
|||||||
@@ -298,9 +298,11 @@ export function planLexicalRemovalsForVideos(
|
|||||||
return planLexicalRemovals(db, `sl.video_id IN (${makePlaceholders(videoIds)})`, videoIds);
|
return planLexicalRemovals(db, `sl.video_id IN (${makePlaceholders(videoIds)})`, videoIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// first_seen/last_seen are REAL seconds that keep millisecond fractions; never round
|
||||||
|
// them, or comparisons against stored extremes miss and refreshes drift.
|
||||||
function toStoredSeenSeconds(ms: number | null): number | null {
|
function toStoredSeenSeconds(ms: number | null): number | null {
|
||||||
if (ms === null || !Number.isFinite(ms)) return null;
|
if (ms === null || !Number.isFinite(ms)) return null;
|
||||||
return Math.floor(ms / 1000);
|
return ms / 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -320,6 +322,13 @@ export function applyLexicalRemovals(db: DatabaseSync, plan: LexicalRemovalPlan)
|
|||||||
applyRemovalsForEntity(db, 'kanji', plan.kanji);
|
applyRemovalsForEntity(db, 'kanji', plan.kanji);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A timestamp repair must keep subtraction-exact lifetime counts and any
|
||||||
|
// first_seen date that the deleted occurrences cannot account for.
|
||||||
|
type LexicalAggregateOverride = {
|
||||||
|
frequency: number | null;
|
||||||
|
firstSeen: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
function applyRemovalsForEntity(
|
function applyRemovalsForEntity(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
entity: LexicalEntity,
|
entity: LexicalEntity,
|
||||||
@@ -358,7 +367,7 @@ function applyRemovalsForEntity(
|
|||||||
`UPDATE ${entityTable} SET frequency = ?, first_seen = ?, last_seen = ? WHERE id = ?`,
|
`UPDATE ${entityTable} SET frequency = ?, first_seen = ?, last_seen = ? WHERE id = ?`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const needsExactRefresh: number[] = [];
|
const needsExactRefresh = new Map<number, LexicalAggregateOverride>();
|
||||||
|
|
||||||
for (const removal of removals) {
|
for (const removal of removals) {
|
||||||
const current = selectStmt.get(removal.id) as {
|
const current = selectStmt.get(removal.id) as {
|
||||||
@@ -369,17 +378,9 @@ function applyRemovalsForEntity(
|
|||||||
if (!current) continue;
|
if (!current) continue;
|
||||||
|
|
||||||
const nextFrequency = (current.frequency ?? 0) - removal.removedFrequency;
|
const nextFrequency = (current.frequency ?? 0) - removal.removedFrequency;
|
||||||
if (nextFrequency <= 0) {
|
// Legacy occurrences use flush time, which can follow the actual observation.
|
||||||
// The rows in scope are already gone by now, so anything still pointing at
|
// An earlier stored date may also belong to retention-pruned history. Only
|
||||||
// this entity means the stored frequency was stale rather than exhausted.
|
// move it when the removed timestamps prove that its observation was deleted.
|
||||||
if (hasOccurrencesStmt.get(removal.id)) {
|
|
||||||
needsExactRefresh.push(removal.id);
|
|
||||||
} else {
|
|
||||||
deleteStmt.run(removal.id);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const removedFirstSeen = toStoredSeenSeconds(removal.removedFirstSeenMs);
|
const removedFirstSeen = toStoredSeenSeconds(removal.removedFirstSeenMs);
|
||||||
const removedLastSeen = toStoredSeenSeconds(removal.removedLastSeenMs);
|
const removedLastSeen = toStoredSeenSeconds(removal.removedLastSeenMs);
|
||||||
const firstSeenMayHaveMoved =
|
const firstSeenMayHaveMoved =
|
||||||
@@ -388,11 +389,26 @@ function applyRemovalsForEntity(
|
|||||||
const lastSeenMayHaveMoved =
|
const lastSeenMayHaveMoved =
|
||||||
current.lastSeen === null ||
|
current.lastSeen === null ||
|
||||||
(removedLastSeen !== null && removedLastSeen >= current.lastSeen);
|
(removedLastSeen !== null && removedLastSeen >= current.lastSeen);
|
||||||
|
const exactRefreshOverride = {
|
||||||
|
frequency: nextFrequency > 0 ? nextFrequency : null,
|
||||||
|
firstSeen: firstSeenMayHaveMoved ? null : current.firstSeen,
|
||||||
|
};
|
||||||
|
if (nextFrequency <= 0) {
|
||||||
|
// The rows in scope are already gone by now, so anything still pointing at
|
||||||
|
// this entity means the stored frequency was stale rather than exhausted.
|
||||||
|
if (hasOccurrencesStmt.get(removal.id)) {
|
||||||
|
needsExactRefresh.set(removal.id, exactRefreshOverride);
|
||||||
|
} else {
|
||||||
|
deleteStmt.run(removal.id);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (firstSeenMayHaveMoved || lastSeenMayHaveMoved) {
|
if (firstSeenMayHaveMoved || lastSeenMayHaveMoved) {
|
||||||
// Undated pre-migration occurrences are invisible to the seeks below;
|
// Undated pre-migration occurrences are invisible to the seeks below;
|
||||||
// fall back to the full re-aggregate that resolves their dates.
|
// resolve their dates without discarding retention-pruned aggregates.
|
||||||
if (hasUndatedOccurrenceStmt.get(removal.id)) {
|
if (hasUndatedOccurrenceStmt.get(removal.id)) {
|
||||||
needsExactRefresh.push(removal.id);
|
needsExactRefresh.set(removal.id, exactRefreshOverride);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const minSeenMs = (minSeenStmt.get(removal.id) as { value: number | null }).value;
|
const minSeenMs = (minSeenStmt.get(removal.id) as { value: number | null }).value;
|
||||||
@@ -400,13 +416,13 @@ function applyRemovalsForEntity(
|
|||||||
if (minSeenMs === null || maxSeenMs === null) {
|
if (minSeenMs === null || maxSeenMs === null) {
|
||||||
// Frequency says occurrences remain but none exist: stale row, let the
|
// Frequency says occurrences remain but none exist: stale row, let the
|
||||||
// exact refresh reconcile (it deletes rows with nothing left).
|
// exact refresh reconcile (it deletes rows with nothing left).
|
||||||
needsExactRefresh.push(removal.id);
|
needsExactRefresh.set(removal.id, exactRefreshOverride);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
updateAggregatesStmt.run(
|
updateAggregatesStmt.run(
|
||||||
nextFrequency,
|
nextFrequency,
|
||||||
Math.floor(Number(minSeenMs) / 1000),
|
firstSeenMayHaveMoved ? toStoredSeenSeconds(Number(minSeenMs)) : current.firstSeen,
|
||||||
Math.floor(Number(maxSeenMs) / 1000),
|
toStoredSeenSeconds(Number(maxSeenMs)),
|
||||||
removal.id,
|
removal.id,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@@ -416,13 +432,17 @@ function applyRemovalsForEntity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (entity === 'word') {
|
if (entity === 'word') {
|
||||||
refreshWordAggregates(db, needsExactRefresh);
|
refreshWordAggregates(db, [...needsExactRefresh.keys()], needsExactRefresh);
|
||||||
} else {
|
} else {
|
||||||
refreshKanjiAggregates(db, needsExactRefresh);
|
refreshKanjiAggregates(db, [...needsExactRefresh.keys()], needsExactRefresh);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void {
|
function refreshWordAggregates(
|
||||||
|
db: DatabaseSync,
|
||||||
|
wordIds: number[],
|
||||||
|
overrides?: ReadonlyMap<number, LexicalAggregateOverride>,
|
||||||
|
): void {
|
||||||
if (wordIds.length === 0) {
|
if (wordIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -470,15 +490,19 @@ function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
updateStmt.run(
|
updateStmt.run(
|
||||||
row.frequency,
|
overrides?.get(row.wordId)?.frequency ?? row.frequency,
|
||||||
Math.floor(row.firstSeen / 1000),
|
overrides?.get(row.wordId)?.firstSeen ?? toStoredSeenSeconds(row.firstSeen),
|
||||||
Math.floor(row.lastSeen / 1000),
|
toStoredSeenSeconds(row.lastSeen),
|
||||||
row.wordId,
|
row.wordId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshKanjiAggregates(db: DatabaseSync, kanjiIds: number[]): void {
|
function refreshKanjiAggregates(
|
||||||
|
db: DatabaseSync,
|
||||||
|
kanjiIds: number[],
|
||||||
|
overrides?: ReadonlyMap<number, LexicalAggregateOverride>,
|
||||||
|
): void {
|
||||||
if (kanjiIds.length === 0) {
|
if (kanjiIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -526,9 +550,9 @@ function refreshKanjiAggregates(db: DatabaseSync, kanjiIds: number[]): void {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
updateStmt.run(
|
updateStmt.run(
|
||||||
row.frequency,
|
overrides?.get(row.kanjiId)?.frequency ?? row.frequency,
|
||||||
Math.floor(row.firstSeen / 1000),
|
overrides?.get(row.kanjiId)?.firstSeen ?? toStoredSeenSeconds(row.firstSeen),
|
||||||
Math.floor(row.lastSeen / 1000),
|
toStoredSeenSeconds(row.lastSeen),
|
||||||
row.kanjiId,
|
row.kanjiId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { normalizeTitleIdentity } from '../../utils/title-normalization';
|
|||||||
import type { DatabaseSync } from './sqlite';
|
import type { DatabaseSync } from './sqlite';
|
||||||
import { nowMs } from './time';
|
import { nowMs } from './time';
|
||||||
import { ensureLexicalDailyRollupTables, markLexicalDailyRollupsReady } from './lexical-rollups';
|
import { ensureLexicalDailyRollupTables, markLexicalDailyRollupsReady } from './lexical-rollups';
|
||||||
|
import { isVocabularyStatsRowVisible, type VocabularyVisibilityRow } from './vocabulary-visibility';
|
||||||
import { SCHEMA_VERSION } from './types';
|
import { SCHEMA_VERSION } from './types';
|
||||||
import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types';
|
import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types';
|
||||||
import { toDbMs, toDbTimestamp } from './query-shared';
|
import { toDbMs, toDbTimestamp } from './query-shared';
|
||||||
@@ -17,7 +18,8 @@ export interface TrackerPreparedStatements {
|
|||||||
wordUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
wordUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||||
kanjiUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
kanjiUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||||
subtitleLineInsertStmt: ReturnType<DatabaseSync['prepare']>;
|
subtitleLineInsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||||
wordIdSelectStmt: ReturnType<DatabaseSync['prepare']>;
|
wordSelectStmt: ReturnType<DatabaseSync['prepare']>;
|
||||||
|
wordVisibilityUpdateStmt: ReturnType<DatabaseSync['prepare']>;
|
||||||
kanjiIdSelectStmt: ReturnType<DatabaseSync['prepare']>;
|
kanjiIdSelectStmt: ReturnType<DatabaseSync['prepare']>;
|
||||||
wordLineOccurrenceUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
wordLineOccurrenceUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||||
kanjiLineOccurrenceUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
kanjiLineOccurrenceUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||||
@@ -1759,7 +1761,6 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
|
|||||||
pos1 = COALESCE(NULLIF(imm_words.pos1, ''), excluded.pos1),
|
pos1 = COALESCE(NULLIF(imm_words.pos1, ''), excluded.pos1),
|
||||||
pos2 = COALESCE(NULLIF(imm_words.pos2, ''), excluded.pos2),
|
pos2 = COALESCE(NULLIF(imm_words.pos2, ''), excluded.pos2),
|
||||||
pos3 = COALESCE(NULLIF(imm_words.pos3, ''), excluded.pos3),
|
pos3 = COALESCE(NULLIF(imm_words.pos3, ''), excluded.pos3),
|
||||||
vocabulary_visible = 1,
|
|
||||||
first_seen = MIN(COALESCE(first_seen, excluded.first_seen), excluded.first_seen),
|
first_seen = MIN(COALESCE(first_seen, excluded.first_seen), excluded.first_seen),
|
||||||
last_seen = MAX(COALESCE(last_seen, excluded.last_seen), excluded.last_seen),
|
last_seen = MAX(COALESCE(last_seen, excluded.last_seen), excluded.last_seen),
|
||||||
frequency_rank = CASE
|
frequency_rank = CASE
|
||||||
@@ -1787,10 +1788,16 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
|
|||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||||
)
|
)
|
||||||
`),
|
`),
|
||||||
wordIdSelectStmt: db.prepare(`
|
wordSelectStmt: db.prepare(`
|
||||||
SELECT id FROM imm_words
|
SELECT id, word, headword, reading, part_of_speech AS partOfSpeech,
|
||||||
|
pos1, pos2, pos3, frequency_rank AS frequencyRank,
|
||||||
|
vocabulary_visible AS vocabularyVisible
|
||||||
|
FROM imm_words
|
||||||
WHERE headword = ? AND word = ? AND reading = ?
|
WHERE headword = ? AND word = ? AND reading = ?
|
||||||
`),
|
`),
|
||||||
|
wordVisibilityUpdateStmt: db.prepare(`
|
||||||
|
UPDATE imm_words SET vocabulary_visible = ? WHERE id = ?
|
||||||
|
`),
|
||||||
kanjiIdSelectStmt: db.prepare(`
|
kanjiIdSelectStmt: db.prepare(`
|
||||||
SELECT id FROM imm_kanji
|
SELECT id FROM imm_kanji
|
||||||
WHERE kanji = ?
|
WHERE kanji = ?
|
||||||
@@ -1842,14 +1849,18 @@ function incrementWordAggregate(
|
|||||||
occurrence.frequencyRank ?? null,
|
occurrence.frequencyRank ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const row = stmts.wordIdSelectStmt.get(
|
const row = stmts.wordSelectStmt.get(occurrence.headword, occurrence.word, occurrence.reading) as
|
||||||
occurrence.headword,
|
| (VocabularyVisibilityRow & { id: number; vocabularyVisible: number })
|
||||||
occurrence.word,
|
| null;
|
||||||
occurrence.reading,
|
|
||||||
) as { id: number } | null;
|
|
||||||
if (!row?.id) {
|
if (!row?.id) {
|
||||||
throw new Error(`Failed to resolve imm_words id for ${occurrence.headword}`);
|
throw new Error(`Failed to resolve imm_words id for ${occurrence.headword}`);
|
||||||
}
|
}
|
||||||
|
// The upsert keeps the first-seen POS metadata, so derive visibility from the
|
||||||
|
// stored row (as the vocabulary summary does) rather than the incoming token.
|
||||||
|
const visible = isVocabularyStatsRowVisible(row) ? 1 : 0;
|
||||||
|
if (row.vocabularyVisible !== visible) {
|
||||||
|
stmts.wordVisibilityUpdateStmt.run(visible, row.id);
|
||||||
|
}
|
||||||
return row.id;
|
return row.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1952,18 +1963,16 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
|
|||||||
currentMs,
|
currentMs,
|
||||||
);
|
);
|
||||||
const lineId = Number(lineResult.lastInsertRowid);
|
const lineId = Number(lineResult.lastInsertRowid);
|
||||||
|
// Match aggregate bounds to the observation, even if this write was queued
|
||||||
|
// for a while. Flush time cannot identify which occurrence set first_seen.
|
||||||
|
const seenMs = toDbTimestamp(Math.round(write.firstSeen * 1000));
|
||||||
for (const occurrence of write.wordOccurrences) {
|
for (const occurrence of write.wordOccurrences) {
|
||||||
const wordId = incrementWordAggregate(stmts, occurrence, write.firstSeen, write.lastSeen);
|
const wordId = incrementWordAggregate(stmts, occurrence, write.firstSeen, write.lastSeen);
|
||||||
stmts.wordLineOccurrenceUpsertStmt.run(lineId, wordId, occurrence.occurrenceCount, currentMs);
|
stmts.wordLineOccurrenceUpsertStmt.run(lineId, wordId, occurrence.occurrenceCount, seenMs);
|
||||||
}
|
}
|
||||||
for (const occurrence of write.kanjiOccurrences) {
|
for (const occurrence of write.kanjiOccurrences) {
|
||||||
const kanjiId = incrementKanjiAggregate(stmts, occurrence, write.firstSeen, write.lastSeen);
|
const kanjiId = incrementKanjiAggregate(stmts, occurrence, write.firstSeen, write.lastSeen);
|
||||||
stmts.kanjiLineOccurrenceUpsertStmt.run(
|
stmts.kanjiLineOccurrenceUpsertStmt.run(lineId, kanjiId, occurrence.occurrenceCount, seenMs);
|
||||||
lineId,
|
|
||||||
kanjiId,
|
|
||||||
occurrence.occurrenceCount,
|
|
||||||
currentMs,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,7 +276,6 @@ test('handleMultiCopyDigit copies backward from the current subtitle after a bac
|
|||||||
test('handleMineSentenceDigit reports async create failures', async () => {
|
test('handleMineSentenceDigit reports async create failures', async () => {
|
||||||
const osd: string[] = [];
|
const osd: string[] = [];
|
||||||
const logs: Array<{ message: string; err: unknown }> = [];
|
const logs: Array<{ message: string; err: unknown }> = [];
|
||||||
let cardsMined = 0;
|
|
||||||
|
|
||||||
handleMineSentenceDigit(2, {
|
handleMineSentenceDigit(2, {
|
||||||
subtitleTimingTracker: {
|
subtitleTimingTracker: {
|
||||||
@@ -296,9 +295,6 @@ test('handleMineSentenceDigit reports async create failures', async () => {
|
|||||||
getCurrentSecondarySubText: () => 'sub2',
|
getCurrentSecondarySubText: () => 'sub2',
|
||||||
showMpvOsd: (text) => osd.push(text),
|
showMpvOsd: (text) => osd.push(text),
|
||||||
logError: (message, err) => logs.push({ message, err }),
|
logError: (message, err) => logs.push({ message, err }),
|
||||||
onCardsMined: (count) => {
|
|
||||||
cardsMined += count;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
@@ -307,37 +303,6 @@ test('handleMineSentenceDigit reports async create failures', async () => {
|
|||||||
assert.equal(logs[0]?.message, 'mineSentenceMultiple failed:');
|
assert.equal(logs[0]?.message, 'mineSentenceMultiple failed:');
|
||||||
assert.equal((logs[0]?.err as Error).message, 'mine boom');
|
assert.equal((logs[0]?.err as Error).message, 'mine boom');
|
||||||
assert.ok(osd.some((entry) => entry.includes('Mine sentence failed: mine boom')));
|
assert.ok(osd.some((entry) => entry.includes('Mine sentence failed: mine boom')));
|
||||||
assert.equal(cardsMined, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handleMineSentenceDigit increments successful card count', async () => {
|
|
||||||
const osd: string[] = [];
|
|
||||||
let cardsMined = 0;
|
|
||||||
|
|
||||||
handleMineSentenceDigit(2, {
|
|
||||||
subtitleTimingTracker: {
|
|
||||||
getRecentBlocks: () => ['one', 'two'],
|
|
||||||
getCurrentSubtitle: () => null,
|
|
||||||
findTiming: (text) =>
|
|
||||||
text === 'one' ? { startTime: 1, endTime: 3 } : { startTime: 4, endTime: 7 },
|
|
||||||
},
|
|
||||||
ankiIntegration: {
|
|
||||||
updateLastAddedFromClipboard: async () => {},
|
|
||||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
|
||||||
markLastCardAsAudioCard: async () => {},
|
|
||||||
createSentenceCard: async () => true,
|
|
||||||
},
|
|
||||||
getCurrentSecondarySubText: () => 'sub2',
|
|
||||||
showMpvOsd: (text) => osd.push(text),
|
|
||||||
logError: () => {},
|
|
||||||
onCardsMined: (count) => {
|
|
||||||
cardsMined += count;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
|
|
||||||
assert.equal(cardsMined, 1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleMineSentenceDigit keeps per-entry timings when subtitle text repeats', async () => {
|
test('handleMineSentenceDigit keeps per-entry timings when subtitle text repeats', async () => {
|
||||||
|
|||||||
@@ -201,7 +201,6 @@ export function handleMineSentenceDigit(
|
|||||||
getCurrentSecondarySubText: () => string | undefined;
|
getCurrentSecondarySubText: () => string | undefined;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
logError: (message: string, err: unknown) => void;
|
logError: (message: string, err: unknown) => void;
|
||||||
onCardsMined?: (count: number) => void;
|
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
if (!deps.subtitleTimingTracker || !deps.ankiIntegration) return;
|
if (!deps.subtitleTimingTracker || !deps.ankiIntegration) return;
|
||||||
@@ -233,14 +232,9 @@ export function handleMineSentenceDigit(
|
|||||||
entries,
|
entries,
|
||||||
deps.getCurrentSecondarySubText,
|
deps.getCurrentSecondarySubText,
|
||||||
);
|
);
|
||||||
const cardsToMine = 1;
|
// Card creation records the mined card (with its note id) itself.
|
||||||
deps.ankiIntegration
|
deps.ankiIntegration
|
||||||
.createSentenceCard(sentence, rangeStart, rangeEnd, secondarySubText)
|
.createSentenceCard(sentence, rangeStart, rangeEnd, secondarySubText)
|
||||||
.then((created) => {
|
|
||||||
if (created) {
|
|
||||||
deps.onCardsMined?.(cardsToMine);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
deps.logError('mineSentenceMultiple failed:', err);
|
deps.logError('mineSentenceMultiple failed:', err);
|
||||||
deps.showMpvOsd(`Mine sentence failed: ${(err as Error).message}`);
|
deps.showMpvOsd(`Mine sentence failed: ${(err as Error).message}`);
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ test('non-macOS modal overlay remains a regular window', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(options.type, undefined);
|
assert.equal(options.type, undefined);
|
||||||
|
assert.equal(options.roundedCorners, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
|
test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function buildOverlayWindowOptions(
|
|||||||
paintWhenInitiallyHidden: true,
|
paintWhenInitiallyHidden: true,
|
||||||
backgroundColor: '#00000000',
|
backgroundColor: '#00000000',
|
||||||
frame: false,
|
frame: false,
|
||||||
|
...(platform === 'linux' ? { roundedCorners: false } : {}),
|
||||||
alwaysOnTop: shouldStartAlwaysOnTop,
|
alwaysOnTop: shouldStartAlwaysOnTop,
|
||||||
skipTaskbar: true,
|
skipTaskbar: true,
|
||||||
resizable: shouldAllowCompositorResize,
|
resizable: shouldAllowCompositorResize,
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export function buildStatsWindowOptions(options: {
|
|||||||
width: options.bounds?.width ?? DEFAULT_STATS_WINDOW_WIDTH,
|
width: options.bounds?.width ?? DEFAULT_STATS_WINDOW_WIDTH,
|
||||||
height: options.bounds?.height ?? DEFAULT_STATS_WINDOW_HEIGHT,
|
height: options.bounds?.height ?? DEFAULT_STATS_WINDOW_HEIGHT,
|
||||||
frame: false,
|
frame: false,
|
||||||
|
...(platform === 'linux' ? { roundedCorners: false } : {}),
|
||||||
transparent: false,
|
transparent: false,
|
||||||
alwaysOnTop: true,
|
alwaysOnTop: true,
|
||||||
resizable: false,
|
resizable: false,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ test('buildStatsWindowOptions remains a regular window off macOS', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(options.type, undefined);
|
assert.equal(options.type, undefined);
|
||||||
|
assert.equal(options.roundedCorners, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats panels present after document load on macOS', () => {
|
test('stats panels present after document load on macOS', () => {
|
||||||
|
|||||||
@@ -640,3 +640,116 @@ test('configureEarlyAppPaths creates a fresh macOS config directory before Elect
|
|||||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('configureEarlyAppPaths isolates development runs from the production profile', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-dev-profile-'));
|
||||||
|
const devProfileDir = path.posix.join(xdgConfigHome, 'SubMiner-dev');
|
||||||
|
try {
|
||||||
|
const userDataPath = configureEarlyAppPaths(
|
||||||
|
{
|
||||||
|
setName: (name) => calls.push(`name:${name}`),
|
||||||
|
setPath: (key, value) => calls.push(`path:${key}:${value}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: 'linux',
|
||||||
|
homeDir: xdgConfigHome,
|
||||||
|
xdgConfigHome,
|
||||||
|
existsSync: () => false,
|
||||||
|
argv: ['electron', '.', '--start', '--dev'],
|
||||||
|
env: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(userDataPath, devProfileDir);
|
||||||
|
assert.deepEqual(calls, ['name:SubMiner', `path:userData:${devProfileDir}`]);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('configureEarlyAppPaths ignores development flags forwarded to mpv', () => {
|
||||||
|
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-mpv-flags-'));
|
||||||
|
const configDir = path.posix.join(xdgConfigHome, 'SubMiner');
|
||||||
|
try {
|
||||||
|
for (const forwardedFlag of ['--dev', '--debug']) {
|
||||||
|
let selectedPath = '';
|
||||||
|
const userDataPath = configureEarlyAppPaths(
|
||||||
|
{
|
||||||
|
setName: () => {},
|
||||||
|
setPath: (_key, value) => {
|
||||||
|
selectedPath = value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: 'linux',
|
||||||
|
homeDir: xdgConfigHome,
|
||||||
|
xdgConfigHome,
|
||||||
|
existsSync: () => false,
|
||||||
|
argv: ['electron', '.', '--launch-mpv', forwardedFlag],
|
||||||
|
env: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(userDataPath, configDir);
|
||||||
|
assert.equal(selectedPath, configDir);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('configureEarlyAppPaths uses the supplied environment for config discovery', () => {
|
||||||
|
const paths: string[] = [];
|
||||||
|
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-injected-env-'));
|
||||||
|
const configDir = path.posix.join(xdgConfigHome, 'SubMiner');
|
||||||
|
try {
|
||||||
|
const userDataPath = configureEarlyAppPaths(
|
||||||
|
{
|
||||||
|
setName: () => {},
|
||||||
|
setPath: (_key, value) => paths.push(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: 'linux',
|
||||||
|
homeDir: path.posix.join(xdgConfigHome, 'home'),
|
||||||
|
existsSync: () => false,
|
||||||
|
argv: ['electron', '.', '--start'],
|
||||||
|
env: { XDG_CONFIG_HOME: xdgConfigHome },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(userDataPath, configDir);
|
||||||
|
assert.deepEqual(paths, [configDir]);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('configureEarlyAppPaths allows an explicit production-profile development run', () => {
|
||||||
|
const paths: string[] = [];
|
||||||
|
const configBaseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-prod-profile-'));
|
||||||
|
// Use the host platform so the selected directory is a real path mkdirSync can create.
|
||||||
|
const configDir = path.join(configBaseDir, 'SubMiner');
|
||||||
|
try {
|
||||||
|
const userDataPath = configureEarlyAppPaths(
|
||||||
|
{
|
||||||
|
setName: () => {},
|
||||||
|
setPath: (_key, value) => paths.push(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: process.platform,
|
||||||
|
appDataDir: configBaseDir,
|
||||||
|
xdgConfigHome: configBaseDir,
|
||||||
|
homeDir: configBaseDir,
|
||||||
|
existsSync: () => false,
|
||||||
|
argv: ['electron', '.', '--debug'],
|
||||||
|
env: { SUBMINER_USE_PRODUCTION_PROFILE: '1' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(userDataPath, configDir);
|
||||||
|
assert.deepEqual(paths, [configDir]);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(configBaseDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { CliArgs, hasExplicitCommand, parseArgs, shouldStartApp } from './cli/args';
|
import { CliArgs, hasExplicitCommand, parseArgs, shouldStartApp } from './cli/args';
|
||||||
import { resolveConfigDir } from './config/path-resolution';
|
import { resolveConfigDir } from './config/path-resolution';
|
||||||
@@ -14,6 +15,8 @@ const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
|
|||||||
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
|
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
|
||||||
const MAX_TRANSPORTED_APP_ARGS = 256;
|
const MAX_TRANSPORTED_APP_ARGS = 256;
|
||||||
const APP_NAME = 'SubMiner';
|
const APP_NAME = 'SubMiner';
|
||||||
|
const DEVELOPMENT_APP_NAME = 'SubMiner-dev';
|
||||||
|
export const USE_PRODUCTION_PROFILE_ENV = 'SUBMINER_USE_PRODUCTION_PROFILE';
|
||||||
const DEFAULT_APP_CONTROL_HANDOFF_TIMEOUT_MS = 500;
|
const DEFAULT_APP_CONTROL_HANDOFF_TIMEOUT_MS = 500;
|
||||||
const MACOS_APP_CONTROL_HANDOFF_TIMEOUT_MS = 3000;
|
const MACOS_APP_CONTROL_HANDOFF_TIMEOUT_MS = 3000;
|
||||||
const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
|
const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
|
||||||
@@ -53,6 +56,8 @@ type EarlyAppPathOptions = {
|
|||||||
xdgConfigHome?: string;
|
xdgConfigHome?: string;
|
||||||
homeDir?: string;
|
homeDir?: string;
|
||||||
existsSync?: (candidate: string) => boolean;
|
existsSync?: (candidate: string) => boolean;
|
||||||
|
argv?: string[];
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
};
|
};
|
||||||
|
|
||||||
function removeLsfgLayer(env: NodeJS.ProcessEnv): void {
|
function removeLsfgLayer(env: NodeJS.ProcessEnv): void {
|
||||||
@@ -252,13 +257,25 @@ export function normalizeStartupArgv(argv: string[], env: NodeJS.ProcessEnv): st
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function configureEarlyAppPaths(app: EarlyAppLike, options?: EarlyAppPathOptions): string {
|
export function configureEarlyAppPaths(app: EarlyAppLike, options?: EarlyAppPathOptions): string {
|
||||||
const userDataPath = resolveConfigDir({
|
const platform = options?.platform ?? process.platform;
|
||||||
platform: options?.platform ?? process.platform,
|
const env = options?.env ?? process.env;
|
||||||
appDataDir: options?.appDataDir ?? process.env.APPDATA,
|
const configDir = resolveConfigDir({
|
||||||
xdgConfigHome: options?.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
|
platform,
|
||||||
|
appDataDir: options?.appDataDir ?? env.APPDATA,
|
||||||
|
xdgConfigHome: options?.xdgConfigHome ?? env.XDG_CONFIG_HOME,
|
||||||
homeDir: options?.homeDir ?? os.homedir(),
|
homeDir: options?.homeDir ?? os.homedir(),
|
||||||
existsSync: options?.existsSync ?? fs.existsSync,
|
existsSync: options?.existsSync ?? fs.existsSync,
|
||||||
});
|
});
|
||||||
|
const argv = options?.argv ?? process.argv;
|
||||||
|
const launchMpvIndex = argv.indexOf('--launch-mpv');
|
||||||
|
const appArgv = launchMpvIndex === -1 ? argv : argv.slice(0, launchMpvIndex);
|
||||||
|
const useDevelopmentProfile =
|
||||||
|
(appArgv.includes('--dev') || appArgv.includes('--debug')) &&
|
||||||
|
env[USE_PRODUCTION_PROFILE_ENV] !== '1';
|
||||||
|
const platformPath = platform === 'win32' ? path.win32 : path.posix;
|
||||||
|
const userDataPath = useDevelopmentProfile
|
||||||
|
? platformPath.join(platformPath.dirname(configDir), DEVELOPMENT_APP_NAME)
|
||||||
|
: configDir;
|
||||||
|
|
||||||
// The entry process requests its singleton lock before main-process config bootstrap.
|
// The entry process requests its singleton lock before main-process config bootstrap.
|
||||||
fs.mkdirSync(userDataPath, { recursive: true });
|
fs.mkdirSync(userDataPath, { recursive: true });
|
||||||
|
|||||||
+20
-2
@@ -22,7 +22,10 @@ import {
|
|||||||
shouldHandleStatsDaemonCommandAtEntry,
|
shouldHandleStatsDaemonCommandAtEntry,
|
||||||
spawnDetachedApp,
|
spawnDetachedApp,
|
||||||
} from './main-entry-runtime';
|
} from './main-entry-runtime';
|
||||||
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
import {
|
||||||
|
requestSingleInstanceLockEarly,
|
||||||
|
shouldBypassSingleInstanceLockForArgv,
|
||||||
|
} from './main/early-single-instance';
|
||||||
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
|
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
|
||||||
import { isAppControlServerAvailable, sendAppControlCommand } from './shared/app-control-client';
|
import { isAppControlServerAvailable, sendAppControlCommand } from './shared/app-control-client';
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +38,7 @@ import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/win
|
|||||||
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
|
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
|
||||||
import { handleSyncCliAtEntry } from './main/sync-cli';
|
import { handleSyncCliAtEntry } from './main/sync-cli';
|
||||||
import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error';
|
import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error';
|
||||||
|
import { enforceElectronRuntimeGuard } from './main/electron-runtime-guard';
|
||||||
import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
|
import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
|
||||||
import {
|
import {
|
||||||
applyLogFileTogglesToEnv,
|
applyLogFileTogglesToEnv,
|
||||||
@@ -192,11 +196,25 @@ registerFatalErrorHandlers({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function startMainProcess(): void {
|
function startMainProcess(): void {
|
||||||
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app);
|
// Normal launches serialize the runtime guard with the profile-scoped lock. Stats daemon
|
||||||
|
// commands keep their existing lock bypass when Electron runs in Node mode.
|
||||||
|
const gotSingleInstanceLock =
|
||||||
|
shouldBypassSingleInstanceLockForArgv(process.argv) || requestSingleInstanceLockEarly(app);
|
||||||
if (!gotSingleInstanceLock) {
|
if (!gotSingleInstanceLock) {
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runtimeGuard = enforceElectronRuntimeGuard({
|
||||||
|
electronVersion: process.versions.electron ?? '',
|
||||||
|
userDataPath,
|
||||||
|
});
|
||||||
|
if (!runtimeGuard.ok) {
|
||||||
|
console.error(runtimeGuard.details);
|
||||||
|
dialog.showErrorBox(runtimeGuard.title, runtimeGuard.details);
|
||||||
|
app.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
require('./main.js');
|
require('./main.js');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+14
-9
@@ -38,6 +38,10 @@ import { readMpvInputBindings } from './main/runtime/mpv-input-bindings';
|
|||||||
import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
|
import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
|
||||||
import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata';
|
import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata';
|
||||||
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
|
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
|
||||||
|
import {
|
||||||
|
assertYomitanDictionaryMutationSafe,
|
||||||
|
observeYomitanDictionaryCount,
|
||||||
|
} from './main/runtime/yomitan-dictionary-integrity';
|
||||||
import { startAppControlServer } from './main/runtime/app-control-server';
|
import { startAppControlServer } from './main/runtime/app-control-server';
|
||||||
import { createEnsureBackgroundStatsServerHandler } from './main/runtime/background-stats-startup';
|
import { createEnsureBackgroundStatsServerHandler } from './main/runtime/background-stats-startup';
|
||||||
import {
|
import {
|
||||||
@@ -772,6 +776,7 @@ type BootServices = MainBootServicesResult<
|
|||||||
const bootServices = createMainBootServices({
|
const bootServices = createMainBootServices({
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
argv: process.argv,
|
argv: process.argv,
|
||||||
|
configDir: app.getPath('userData'),
|
||||||
appDataDir: process.env.APPDATA,
|
appDataDir: process.env.APPDATA,
|
||||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||||
homeDir: os.homedir(),
|
homeDir: os.homedir(),
|
||||||
@@ -1480,6 +1485,10 @@ const firstRunSetupService = createFirstRunSetupService({
|
|||||||
error: (message, ...args) => logger.error(message, ...args),
|
error: (message, ...args) => logger.error(message, ...args),
|
||||||
info: (message, ...args) => logger.info(message, ...args),
|
info: (message, ...args) => logger.info(message, ...args),
|
||||||
});
|
});
|
||||||
|
const integrity = observeYomitanDictionaryCount(USER_DATA_PATH, dictionaries.length);
|
||||||
|
if (!integrity.safe) {
|
||||||
|
logger.error(`[dictionary:integrity] ${integrity.message}`);
|
||||||
|
}
|
||||||
return dictionaries.length;
|
return dictionaries.length;
|
||||||
},
|
},
|
||||||
isExternalYomitanConfigured: () =>
|
isExternalYomitanConfigured: () =>
|
||||||
@@ -2627,10 +2636,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
|||||||
),
|
),
|
||||||
getYomitanDictionaryInfo: async () => {
|
getYomitanDictionaryInfo: async () => {
|
||||||
await ensureYomitanExtensionLoaded();
|
await ensureYomitanExtensionLoaded();
|
||||||
return await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), {
|
const dictionaries = await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), {
|
||||||
error: (message, ...args) => logger.error(message, ...args),
|
error: (message, ...args) => logger.error(message, ...args),
|
||||||
info: (message, ...args) => logger.info(message, ...args),
|
info: (message, ...args) => logger.info(message, ...args),
|
||||||
});
|
});
|
||||||
|
assertYomitanDictionaryMutationSafe(USER_DATA_PATH, dictionaries.length);
|
||||||
|
return dictionaries;
|
||||||
},
|
},
|
||||||
importYomitanDictionary: async (zipPath) => {
|
importYomitanDictionary: async (zipPath) => {
|
||||||
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||||
@@ -5486,10 +5497,6 @@ const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDeps
|
|||||||
getPrimarySubtitle: () => captureCurrentPrimarySubtitleMiningContext(),
|
getPrimarySubtitle: () => captureCurrentPrimarySubtitleMiningContext(),
|
||||||
showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text),
|
showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text),
|
||||||
mineSentenceCardCore,
|
mineSentenceCardCore,
|
||||||
recordCardsMined: (count, noteIds) => {
|
|
||||||
ensureImmersionTrackerStarted();
|
|
||||||
appState.immersionTracker?.recordCardsMined(count, noteIds);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const mineSentenceCardHandler = createMineSentenceCardHandler(
|
const mineSentenceCardHandler = createMineSentenceCardHandler(
|
||||||
buildMineSentenceCardMainDepsHandler(),
|
buildMineSentenceCardMainDepsHandler(),
|
||||||
@@ -5522,10 +5529,6 @@ const buildHandleMineSentenceDigitMainDepsHandler =
|
|||||||
logError: (message, err) => {
|
logError: (message, err) => {
|
||||||
logger.error(message, err);
|
logger.error(message, err);
|
||||||
},
|
},
|
||||||
onCardsMined: (cards) => {
|
|
||||||
ensureImmersionTrackerStarted();
|
|
||||||
appState.immersionTracker?.recordCardsMined(cards);
|
|
||||||
},
|
|
||||||
handleMineSentenceDigitCore,
|
handleMineSentenceDigitCore,
|
||||||
});
|
});
|
||||||
const handleMineSentenceDigitMainDeps = buildHandleMineSentenceDigitMainDepsHandler();
|
const handleMineSentenceDigitMainDeps = buildHandleMineSentenceDigitMainDepsHandler();
|
||||||
@@ -6807,6 +6810,8 @@ const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
|
|||||||
'youtube-whisper.ja.srt',
|
'youtube-whisper.ja.srt',
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
resolveYoutubeSourceUrl: (mediaPath) =>
|
||||||
|
youtubePlaybackRuntime.getYoutubeSourceUrlForStream(mediaPath),
|
||||||
getMpvClient: () => appState.mpvClient,
|
getMpvClient: () => appState.mpvClient,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
|
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
|
||||||
|
|||||||
@@ -138,3 +138,56 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
|||||||
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config', 'exit:7']);
|
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config', 'exit:7']);
|
||||||
assert.equal(setPathValue, '/tmp/subminer-config');
|
assert.equal(setPathValue, '/tmp/subminer-config');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createMainBootServices honors the profile selected by the early entrypoint', () => {
|
||||||
|
const services = createMainBootServices({
|
||||||
|
platform: 'linux',
|
||||||
|
argv: ['electron', '.', '--dev'],
|
||||||
|
configDir: '/tmp/SubMiner-dev',
|
||||||
|
appDataDir: undefined,
|
||||||
|
xdgConfigHome: undefined,
|
||||||
|
homeDir: '/home/tester',
|
||||||
|
defaultMpvLogFile: '/tmp/default.log',
|
||||||
|
envMpvLog: undefined,
|
||||||
|
defaultTexthookerPort: 5174,
|
||||||
|
getDefaultSocketPath: () => '/tmp/subminer.sock',
|
||||||
|
resolveConfigDir: () => {
|
||||||
|
throw new Error('early profile should be authoritative');
|
||||||
|
},
|
||||||
|
existsSync: () => false,
|
||||||
|
mkdirSync: () => {},
|
||||||
|
joinPath: (...parts) => parts.join('/'),
|
||||||
|
app: {
|
||||||
|
setPath: () => {},
|
||||||
|
quit: () => {},
|
||||||
|
exit: () => {},
|
||||||
|
on: () => ({}),
|
||||||
|
whenReady: async () => {},
|
||||||
|
},
|
||||||
|
shouldBypassSingleInstanceLock: () => false,
|
||||||
|
requestSingleInstanceLockEarly: () => true,
|
||||||
|
registerSecondInstanceHandlerEarly: () => {},
|
||||||
|
onConfigStartupParseError: () => {},
|
||||||
|
createConfigService: (configDir) => ({ configDir }),
|
||||||
|
createAnilistTokenStore: (targetPath) => ({ targetPath }),
|
||||||
|
createJellyfinTokenStore: (targetPath) => ({ targetPath }),
|
||||||
|
createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
|
||||||
|
createSubtitleWebSocket: (payloadMode) => ({ payloadMode }),
|
||||||
|
createLogger: () => ({ warn: () => {}, info: () => {}, error: () => {} }),
|
||||||
|
createMainRuntimeRegistry: () => ({}),
|
||||||
|
createOverlayManager: () => ({ getMainWindow: () => null, getModalWindow: () => null }),
|
||||||
|
createOverlayModalInputState: () => ({
|
||||||
|
getModalInputExclusive: () => false,
|
||||||
|
handleModalInputStateChange: () => {},
|
||||||
|
}),
|
||||||
|
createOverlayContentMeasurementStore: () => ({}),
|
||||||
|
getSyncOverlayShortcutsForModal: () => () => {},
|
||||||
|
getSyncOverlayVisibilityForModal: () => () => {},
|
||||||
|
createOverlayModalRuntime: () => ({}),
|
||||||
|
createAppState: (input) => input,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(services.configDir, '/tmp/SubMiner-dev');
|
||||||
|
assert.equal(services.userDataPath, '/tmp/SubMiner-dev');
|
||||||
|
assert.deepEqual(services.configService, { configDir: '/tmp/SubMiner-dev' });
|
||||||
|
});
|
||||||
@@ -31,6 +31,7 @@ export interface MainBootServicesParams<
|
|||||||
> {
|
> {
|
||||||
platform: NodeJS.Platform;
|
platform: NodeJS.Platform;
|
||||||
argv: string[];
|
argv: string[];
|
||||||
|
configDir?: string;
|
||||||
appDataDir: string | undefined;
|
appDataDir: string | undefined;
|
||||||
xdgConfigHome: string | undefined;
|
xdgConfigHome: string | undefined;
|
||||||
homeDir: string;
|
homeDir: string;
|
||||||
@@ -174,7 +175,9 @@ export function createMainBootServices<
|
|||||||
TAppState,
|
TAppState,
|
||||||
TAppLifecycleApp
|
TAppLifecycleApp
|
||||||
> {
|
> {
|
||||||
const configDir = params.resolveConfigDir({
|
const configDir =
|
||||||
|
params.configDir ??
|
||||||
|
params.resolveConfigDir({
|
||||||
platform: params.platform,
|
platform: params.platform,
|
||||||
appDataDir: params.appDataDir,
|
appDataDir: params.appDataDir,
|
||||||
xdgConfigHome: params.xdgConfigHome,
|
xdgConfigHome: params.xdgConfigHome,
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { enforceElectronRuntimeGuard, SUPPORTED_ELECTRON_MAJOR } from './electron-runtime-guard';
|
||||||
|
|
||||||
|
function withTempDir(run: (directory: string) => void): void {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-electron-guard-'));
|
||||||
|
try {
|
||||||
|
run(directory);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('runtime guard major matches the pinned Electron dependency', () => {
|
||||||
|
const packageJson = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'),
|
||||||
|
) as { devDependencies: { electron: string } };
|
||||||
|
|
||||||
|
assert.equal(Number.parseInt(packageJson.devDependencies.electron.split('.', 1)[0]!, 10), 43);
|
||||||
|
assert.equal(SUPPORTED_ELECTRON_MAJOR, 43);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime guard records the supported Electron major', () => {
|
||||||
|
withTempDir((userDataPath) => {
|
||||||
|
const result = enforceElectronRuntimeGuard({
|
||||||
|
electronVersion: '43.4.1',
|
||||||
|
userDataPath,
|
||||||
|
supportedElectronMajor: 43,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.deepEqual(JSON.parse(fs.readFileSync(result.statePath, 'utf8')), {
|
||||||
|
highestElectronMajor: 43,
|
||||||
|
lastElectronVersion: '43.4.1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime guard rejects a runtime outside the build major without writing state', () => {
|
||||||
|
withTempDir((userDataPath) => {
|
||||||
|
const result = enforceElectronRuntimeGuard({
|
||||||
|
electronVersion: '44.0.0',
|
||||||
|
userDataPath,
|
||||||
|
supportedElectronMajor: 43,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
if (result.ok) return;
|
||||||
|
assert.equal(result.title, 'Unsupported Electron runtime');
|
||||||
|
assert.match(result.details, /requires Electron 43/);
|
||||||
|
assert.equal(fs.existsSync(result.statePath), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime guard rejects prerelease Electron versions without writing state', () => {
|
||||||
|
withTempDir((userDataPath) => {
|
||||||
|
const result = enforceElectronRuntimeGuard({
|
||||||
|
electronVersion: '43.4.1-beta.1',
|
||||||
|
userDataPath,
|
||||||
|
supportedElectronMajor: 43,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
if (result.ok) return;
|
||||||
|
assert.equal(result.title, 'SubMiner could not verify Electron');
|
||||||
|
assert.equal(fs.existsSync(result.statePath), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime guard blocks a profile downgrade before rewriting its safety record', () => {
|
||||||
|
withTempDir((userDataPath) => {
|
||||||
|
const statePath = path.join(userDataPath, 'electron-runtime.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
statePath,
|
||||||
|
JSON.stringify({ highestElectronMajor: 44, lastElectronVersion: '44.1.0' }),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = enforceElectronRuntimeGuard({
|
||||||
|
electronVersion: '43.4.1',
|
||||||
|
userDataPath,
|
||||||
|
supportedElectronMajor: 43,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
if (result.ok) return;
|
||||||
|
assert.equal(result.title, 'Electron downgrade blocked');
|
||||||
|
assert.match(result.details, /destroy Yomitan dictionaries/);
|
||||||
|
assert.equal(result.details.includes(statePath), true);
|
||||||
|
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), {
|
||||||
|
highestElectronMajor: 44,
|
||||||
|
lastElectronVersion: '44.1.0',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime guard blocks a minor downgrade even when the patch is higher', () => {
|
||||||
|
withTempDir((userDataPath) => {
|
||||||
|
const statePath = path.join(userDataPath, 'electron-runtime.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
statePath,
|
||||||
|
JSON.stringify({ highestElectronMajor: 43, lastElectronVersion: '43.4.1' }),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = enforceElectronRuntimeGuard({
|
||||||
|
electronVersion: '43.3.99',
|
||||||
|
userDataPath,
|
||||||
|
supportedElectronMajor: 43,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
if (result.ok) return;
|
||||||
|
assert.equal(result.title, 'Electron downgrade blocked');
|
||||||
|
assert.equal(result.details.includes(statePath), true);
|
||||||
|
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), {
|
||||||
|
highestElectronMajor: 43,
|
||||||
|
lastElectronVersion: '43.4.1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime guard allows a patch downgrade while preserving minor downgrade protection', () => {
|
||||||
|
withTempDir((userDataPath) => {
|
||||||
|
const statePath = path.join(userDataPath, 'electron-runtime.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
statePath,
|
||||||
|
JSON.stringify({ highestElectronMajor: 43, lastElectronVersion: '43.7.3' }),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = enforceElectronRuntimeGuard({
|
||||||
|
electronVersion: '43.7.2',
|
||||||
|
userDataPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
const updatedState = fs.readFileSync(statePath, 'utf8');
|
||||||
|
assert.deepEqual(JSON.parse(updatedState), {
|
||||||
|
highestElectronMajor: 43,
|
||||||
|
lastElectronVersion: '43.7.2',
|
||||||
|
});
|
||||||
|
|
||||||
|
const minorDowngrade = enforceElectronRuntimeGuard({
|
||||||
|
electronVersion: '43.6.99',
|
||||||
|
userDataPath,
|
||||||
|
});
|
||||||
|
assert.equal(minorDowngrade.ok, false);
|
||||||
|
if (minorDowngrade.ok) return;
|
||||||
|
assert.equal(minorDowngrade.title, 'Electron downgrade blocked');
|
||||||
|
assert.equal(fs.readFileSync(statePath, 'utf8'), updatedState);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime guard blocks an Electron 42 build from reopening an Electron 43 profile', () => {
|
||||||
|
withTempDir((userDataPath) => {
|
||||||
|
const statePath = path.join(userDataPath, 'electron-runtime.json');
|
||||||
|
const previousState = JSON.stringify({
|
||||||
|
highestElectronMajor: 43,
|
||||||
|
lastElectronVersion: '43.7.2',
|
||||||
|
});
|
||||||
|
fs.writeFileSync(statePath, previousState, 'utf8');
|
||||||
|
|
||||||
|
const result = enforceElectronRuntimeGuard({
|
||||||
|
electronVersion: '42.11.8',
|
||||||
|
userDataPath,
|
||||||
|
supportedElectronMajor: 42,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
if (result.ok) return;
|
||||||
|
assert.equal(result.title, 'Electron downgrade blocked');
|
||||||
|
assert.equal(fs.readFileSync(statePath, 'utf8'), previousState);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime guard fails closed when its safety record is malformed', () => {
|
||||||
|
withTempDir((userDataPath) => {
|
||||||
|
const statePath = path.join(userDataPath, 'electron-runtime.json');
|
||||||
|
fs.writeFileSync(statePath, '{}', 'utf8');
|
||||||
|
|
||||||
|
const result = enforceElectronRuntimeGuard({
|
||||||
|
electronVersion: '43.4.1',
|
||||||
|
userDataPath,
|
||||||
|
supportedElectronMajor: 43,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
if (result.ok) return;
|
||||||
|
assert.equal(result.title, 'SubMiner profile safety check failed');
|
||||||
|
assert.match(result.details, /invalid format/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { writeTextFileAtomicallyDurable } from '../shared/fs-utils';
|
||||||
|
|
||||||
|
export const SUPPORTED_ELECTRON_MAJOR = 43;
|
||||||
|
const RUNTIME_STATE_FILE_NAME = 'electron-runtime.json';
|
||||||
|
|
||||||
|
type ElectronRuntimeState = {
|
||||||
|
highestElectronMajor: number;
|
||||||
|
lastElectronVersion: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParsedElectronVersion = {
|
||||||
|
major: number;
|
||||||
|
minor: number;
|
||||||
|
patch: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ValidatedElectronRuntimeState = {
|
||||||
|
state: ElectronRuntimeState;
|
||||||
|
version: ParsedElectronVersion;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ElectronRuntimeGuardResult =
|
||||||
|
| { ok: true; statePath: string }
|
||||||
|
| { ok: false; title: string; details: string; statePath: string };
|
||||||
|
|
||||||
|
function parseElectronVersion(version: string): ParsedElectronVersion | null {
|
||||||
|
const match = /^(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$/.exec(version.trim());
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const major = Number.parseInt(match[1]!, 10);
|
||||||
|
const minor = Number.parseInt(match[2]!, 10);
|
||||||
|
const patch = Number.parseInt(match[3]!, 10);
|
||||||
|
if (![major, minor, patch].every((part) => Number.isSafeInteger(part) && part >= 0)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (major === 0) return null;
|
||||||
|
return { major, minor, patch };
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareElectronMajorMinorVersions(
|
||||||
|
left: ParsedElectronVersion,
|
||||||
|
right: ParsedElectronVersion,
|
||||||
|
): number {
|
||||||
|
return left.major - right.major || left.minor - right.minor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRuntimeState(statePath: string): ValidatedElectronRuntimeState | null {
|
||||||
|
if (!fs.existsSync(statePath)) return null;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8')) as Partial<ElectronRuntimeState>;
|
||||||
|
const highestElectronMajor = parsed.highestElectronMajor;
|
||||||
|
const lastElectronVersion =
|
||||||
|
typeof parsed.lastElectronVersion === 'string'
|
||||||
|
? parseElectronVersion(parsed.lastElectronVersion)
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
typeof highestElectronMajor !== 'number' ||
|
||||||
|
!Number.isSafeInteger(highestElectronMajor) ||
|
||||||
|
highestElectronMajor <= 0 ||
|
||||||
|
lastElectronVersion === null
|
||||||
|
) {
|
||||||
|
throw new Error('The runtime safety record has an invalid format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
highestElectronMajor,
|
||||||
|
lastElectronVersion: parsed.lastElectronVersion!,
|
||||||
|
},
|
||||||
|
version: lastElectronVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRuntimeState(statePath: string, state: ElectronRuntimeState): void {
|
||||||
|
writeTextFileAtomicallyDurable(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enforceElectronRuntimeGuard(options: {
|
||||||
|
electronVersion: string;
|
||||||
|
userDataPath: string;
|
||||||
|
supportedElectronMajor?: number;
|
||||||
|
}): ElectronRuntimeGuardResult {
|
||||||
|
const supportedElectronMajor = options.supportedElectronMajor ?? SUPPORTED_ELECTRON_MAJOR;
|
||||||
|
const statePath = path.join(options.userDataPath, RUNTIME_STATE_FILE_NAME);
|
||||||
|
const currentVersion = parseElectronVersion(options.electronVersion);
|
||||||
|
|
||||||
|
if (currentVersion === null) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
title: 'SubMiner could not verify Electron',
|
||||||
|
details: `Electron reported an invalid version: ${JSON.stringify(options.electronVersion)}. SubMiner did not load Yomitan storage.`,
|
||||||
|
statePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentVersion.major !== supportedElectronMajor) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
title: 'Unsupported Electron runtime',
|
||||||
|
details: [
|
||||||
|
`This SubMiner build requires Electron ${supportedElectronMajor}.`,
|
||||||
|
`The current runtime is Electron ${options.electronVersion}.`,
|
||||||
|
'',
|
||||||
|
'Launch SubMiner through its packaged application or the repository package scripts. Yomitan storage was not loaded.',
|
||||||
|
].join('\n'),
|
||||||
|
statePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let previousState: ValidatedElectronRuntimeState | null;
|
||||||
|
try {
|
||||||
|
previousState = readRuntimeState(statePath);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
title: 'SubMiner profile safety check failed',
|
||||||
|
details: [
|
||||||
|
`SubMiner could not read the runtime safety record at ${statePath}.`,
|
||||||
|
(error as Error).message,
|
||||||
|
'',
|
||||||
|
'Yomitan storage was not loaded. Repair or remove only this safety record after verifying the profile backup.',
|
||||||
|
].join('\n'),
|
||||||
|
statePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
previousState &&
|
||||||
|
(currentVersion.major < previousState.state.highestElectronMajor ||
|
||||||
|
compareElectronMajorMinorVersions(currentVersion, previousState.version) < 0)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
title: 'Electron downgrade blocked',
|
||||||
|
details: [
|
||||||
|
`This profile was previously opened with Electron ${previousState.state.lastElectronVersion}.`,
|
||||||
|
`The current runtime is Electron ${options.electronVersion}.`,
|
||||||
|
`Runtime safety record: ${statePath}.`,
|
||||||
|
'',
|
||||||
|
'Opening Chromium storage with an older Electron major or minor version can destroy Yomitan dictionaries. Upgrade SubMiner before using this profile. Patch downgrades within the same major and minor version are allowed.',
|
||||||
|
].join('\n'),
|
||||||
|
statePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeRuntimeState(statePath, {
|
||||||
|
highestElectronMajor: Math.max(
|
||||||
|
currentVersion.major,
|
||||||
|
previousState?.state.highestElectronMajor ?? 0,
|
||||||
|
),
|
||||||
|
lastElectronVersion: options.electronVersion,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
title: 'SubMiner profile safety check failed',
|
||||||
|
details: [
|
||||||
|
`SubMiner could not update the runtime safety record at ${statePath}.`,
|
||||||
|
(error as Error).message,
|
||||||
|
'',
|
||||||
|
'Yomitan storage was not loaded.',
|
||||||
|
].join('\n'),
|
||||||
|
statePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, statePath };
|
||||||
|
}
|
||||||
@@ -67,7 +67,6 @@ test('anki action main deps builders map callbacks', async () => {
|
|||||||
getPrimarySubtitle: () => ({ text: '正式な字幕', startTime: 1, endTime: 3 }),
|
getPrimarySubtitle: () => ({ text: '正式な字幕', startTime: 1, endTime: 3 }),
|
||||||
showMpvOsd: (text) => calls.push(`mine:${text}`),
|
showMpvOsd: (text) => calls.push(`mine:${text}`),
|
||||||
mineSentenceCardCore: async () => true,
|
mineSentenceCardCore: async () => true,
|
||||||
recordCardsMined: (count) => calls.push(`cards:${count}`),
|
|
||||||
})();
|
})();
|
||||||
assert.deepEqual(mine.getMpvClient(), { connected: true });
|
assert.deepEqual(mine.getMpvClient(), { connected: true });
|
||||||
assert.deepEqual(mine.getPrimarySubtitle?.(), {
|
assert.deepEqual(mine.getPrimarySubtitle?.(), {
|
||||||
@@ -81,16 +80,6 @@ test('anki action main deps builders map callbacks', async () => {
|
|||||||
mpvClient: { connected: true },
|
mpvClient: { connected: true },
|
||||||
showMpvOsd: () => {},
|
showMpvOsd: () => {},
|
||||||
});
|
});
|
||||||
mine.recordCardsMined(1);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, ['osd:x', 'update', 'fg:fg', 'trigger', 'audio:a', 'mark', 'mine:m']);
|
||||||
'osd:x',
|
|
||||||
'update',
|
|
||||||
'fg:fg',
|
|
||||||
'trigger',
|
|
||||||
'audio:a',
|
|
||||||
'mark',
|
|
||||||
'mine:m',
|
|
||||||
'cards:1',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
@@ -80,7 +80,6 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
|
|||||||
primarySubtitle?: PrimarySubtitle;
|
primarySubtitle?: PrimarySubtitle;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
}) => Promise<boolean>;
|
}) => Promise<boolean>;
|
||||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
|
||||||
}) {
|
}) {
|
||||||
return () => ({
|
return () => ({
|
||||||
getAnkiIntegration: () => deps.getAnkiIntegration(),
|
getAnkiIntegration: () => deps.getAnkiIntegration(),
|
||||||
@@ -95,6 +94,5 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
|
|||||||
primarySubtitle?: PrimarySubtitle;
|
primarySubtitle?: PrimarySubtitle;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
}) => deps.mineSentenceCardCore(options),
|
}) => deps.mineSentenceCardCore(options),
|
||||||
recordCardsMined: (count: number, noteIds?: number[]) => deps.recordCardsMined(count, noteIds),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ test('trigger and mark handlers delegate to core services', async () => {
|
|||||||
assert.deepEqual(calls, ['osd:group', 'group-core', 'osd:mark', 'mark-core']);
|
assert.deepEqual(calls, ['osd:group', 'group-core', 'osd:mark', 'mark-core']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mine sentence handler records mined cards only when core returns true', async () => {
|
test('mine sentence handler leaves card counting to card creation', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const integration = {};
|
const integration = {};
|
||||||
const mpvClient = {};
|
const mpvClient = {};
|
||||||
@@ -78,14 +78,13 @@ test('mine sentence handler records mined cards only when core returns true', as
|
|||||||
options.showMpvOsd('mine');
|
options.showMpvOsd('mine');
|
||||||
return created;
|
return created;
|
||||||
},
|
},
|
||||||
recordCardsMined: (count) => calls.push(`cards:${count}`),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
created = false;
|
created = false;
|
||||||
await mineSentenceCard();
|
await mineSentenceCard();
|
||||||
created = true;
|
created = true;
|
||||||
await mineSentenceCard();
|
await mineSentenceCard();
|
||||||
assert.deepEqual(calls, ['osd:mine', 'osd:mine', 'cards:1']);
|
assert.deepEqual(calls, ['osd:mine', 'osd:mine']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mine sentence handler forwards the canonical primary subtitle snapshot', async () => {
|
test('mine sentence handler forwards the canonical primary subtitle snapshot', async () => {
|
||||||
@@ -99,7 +98,6 @@ test('mine sentence handler forwards the canonical primary subtitle snapshot', a
|
|||||||
assert.equal(options.primarySubtitle, primarySubtitle);
|
assert.equal(options.primarySubtitle, primarySubtitle);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
recordCardsMined: () => {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await mineSentenceCard();
|
await mineSentenceCard();
|
||||||
|
|||||||
@@ -83,18 +83,15 @@ export function createMineSentenceCardHandler<TAnki, TMpv>(deps: {
|
|||||||
primarySubtitle?: PrimarySubtitle;
|
primarySubtitle?: PrimarySubtitle;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
}) => Promise<boolean>;
|
}) => Promise<boolean>;
|
||||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
|
||||||
}) {
|
}) {
|
||||||
|
// Card creation records the mined card (with its note id) itself.
|
||||||
return async (): Promise<void> => {
|
return async (): Promise<void> => {
|
||||||
const primarySubtitle = deps.getPrimarySubtitle?.();
|
const primarySubtitle = deps.getPrimarySubtitle?.();
|
||||||
const created = await deps.mineSentenceCardCore({
|
await deps.mineSentenceCardCore({
|
||||||
ankiIntegration: deps.getAnkiIntegration(),
|
ankiIntegration: deps.getAnkiIntegration(),
|
||||||
mpvClient: deps.getMpvClient(),
|
mpvClient: deps.getMpvClient(),
|
||||||
...(primarySubtitle ? { primarySubtitle } : {}),
|
...(primarySubtitle ? { primarySubtitle } : {}),
|
||||||
showMpvOsd: deps.showMpvOsd,
|
showMpvOsd: deps.showMpvOsd,
|
||||||
});
|
});
|
||||||
if (created) {
|
|
||||||
deps.recordCardsMined(1);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -45,20 +45,17 @@ test('mining action main deps builders map callbacks', () => {
|
|||||||
getCurrentSecondarySubText: () => 'sub',
|
getCurrentSecondarySubText: () => 'sub',
|
||||||
showMpvOsd: (text) => calls.push(`mine-osd:${text}`),
|
showMpvOsd: (text) => calls.push(`mine-osd:${text}`),
|
||||||
logError: (message) => calls.push(`err:${message}`),
|
logError: (message) => calls.push(`err:${message}`),
|
||||||
onCardsMined: (count) => calls.push(`cards:${count}`),
|
|
||||||
handleMineSentenceDigitCore: () => calls.push('mine-digit'),
|
handleMineSentenceDigitCore: () => calls.push('mine-digit'),
|
||||||
})();
|
})();
|
||||||
assert.equal(mineDigit.getCurrentSecondarySubText(), 'sub');
|
assert.equal(mineDigit.getCurrentSecondarySubText(), 'sub');
|
||||||
mineDigit.showMpvOsd('done');
|
mineDigit.showMpvOsd('done');
|
||||||
mineDigit.logError('bad', null);
|
mineDigit.logError('bad', null);
|
||||||
mineDigit.onCardsMined(2);
|
|
||||||
mineDigit.handleMineSentenceDigitCore(2, {
|
mineDigit.handleMineSentenceDigitCore(2, {
|
||||||
subtitleTimingTracker: { track: true },
|
subtitleTimingTracker: { track: true },
|
||||||
ankiIntegration: { enabled: true },
|
ankiIntegration: { enabled: true },
|
||||||
getCurrentSecondarySubText: () => 'sub',
|
getCurrentSecondarySubText: () => 'sub',
|
||||||
showMpvOsd: () => {},
|
showMpvOsd: () => {},
|
||||||
logError: () => {},
|
logError: () => {},
|
||||||
onCardsMined: () => {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
@@ -70,7 +67,6 @@ test('mining action main deps builders map callbacks', () => {
|
|||||||
'copy-current',
|
'copy-current',
|
||||||
'mine-osd:done',
|
'mine-osd:done',
|
||||||
'err:bad',
|
'err:bad',
|
||||||
'cards:2',
|
|
||||||
'mine-digit',
|
'mine-digit',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -57,7 +57,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler<
|
|||||||
getCurrentSecondarySubText: () => string | undefined;
|
getCurrentSecondarySubText: () => string | undefined;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
logError: (message: string, err: unknown) => void;
|
logError: (message: string, err: unknown) => void;
|
||||||
onCardsMined: (count: number) => void;
|
|
||||||
handleMineSentenceDigitCore: (
|
handleMineSentenceDigitCore: (
|
||||||
count: number,
|
count: number,
|
||||||
options: {
|
options: {
|
||||||
@@ -66,7 +65,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler<
|
|||||||
getCurrentSecondarySubText: () => string | undefined;
|
getCurrentSecondarySubText: () => string | undefined;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
logError: (message: string, err: unknown) => void;
|
logError: (message: string, err: unknown) => void;
|
||||||
onCardsMined: (count: number) => void;
|
|
||||||
},
|
},
|
||||||
) => void;
|
) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -76,7 +74,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler<
|
|||||||
getCurrentSecondarySubText: () => deps.getCurrentSecondarySubText(),
|
getCurrentSecondarySubText: () => deps.getCurrentSecondarySubText(),
|
||||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||||
logError: (message: string, err: unknown) => deps.logError(message, err),
|
logError: (message: string, err: unknown) => deps.logError(message, err),
|
||||||
onCardsMined: (count: number) => deps.onCardsMined(count),
|
|
||||||
handleMineSentenceDigitCore: (
|
handleMineSentenceDigitCore: (
|
||||||
count: number,
|
count: number,
|
||||||
options: {
|
options: {
|
||||||
@@ -85,7 +82,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler<
|
|||||||
getCurrentSecondarySubText: () => string | undefined;
|
getCurrentSecondarySubText: () => string | undefined;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
logError: (message: string, err: unknown) => void;
|
logError: (message: string, err: unknown) => void;
|
||||||
onCardsMined: (count: number) => void;
|
|
||||||
},
|
},
|
||||||
) => deps.handleMineSentenceDigitCore(count, options),
|
) => deps.handleMineSentenceDigitCore(count, options),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ test('mine sentence digit handler forwards all dependencies', () => {
|
|||||||
getCurrentSecondarySubText: () => 'secondary',
|
getCurrentSecondarySubText: () => 'secondary',
|
||||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||||
logError: (message) => calls.push(`err:${message}`),
|
logError: (message) => calls.push(`err:${message}`),
|
||||||
onCardsMined: (count) => calls.push(`cards:${count}`),
|
|
||||||
handleMineSentenceDigitCore: (count, options) => {
|
handleMineSentenceDigitCore: (count, options) => {
|
||||||
assert.equal(count, 4);
|
assert.equal(count, 4);
|
||||||
assert.equal(options.subtitleTimingTracker, tracker);
|
assert.equal(options.subtitleTimingTracker, tracker);
|
||||||
@@ -61,10 +60,9 @@ test('mine sentence digit handler forwards all dependencies', () => {
|
|||||||
assert.equal(options.getCurrentSecondarySubText(), 'secondary');
|
assert.equal(options.getCurrentSecondarySubText(), 'secondary');
|
||||||
options.showMpvOsd('mine');
|
options.showMpvOsd('mine');
|
||||||
options.logError('boom', new Error('x'));
|
options.logError('boom', new Error('x'));
|
||||||
options.onCardsMined(2);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
handleMineSentenceDigit(4);
|
handleMineSentenceDigit(4);
|
||||||
assert.deepEqual(calls, ['osd:mine', 'err:boom', 'cards:2']);
|
assert.deepEqual(calls, ['osd:mine', 'err:boom']);
|
||||||
});
|
});
|
||||||
@@ -48,7 +48,6 @@ export function createHandleMineSentenceDigitHandler<
|
|||||||
getCurrentSecondarySubText: () => string | undefined;
|
getCurrentSecondarySubText: () => string | undefined;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
logError: (message: string, err: unknown) => void;
|
logError: (message: string, err: unknown) => void;
|
||||||
onCardsMined: (count: number) => void;
|
|
||||||
handleMineSentenceDigitCore: (
|
handleMineSentenceDigitCore: (
|
||||||
count: number,
|
count: number,
|
||||||
options: {
|
options: {
|
||||||
@@ -57,7 +56,6 @@ export function createHandleMineSentenceDigitHandler<
|
|||||||
getCurrentSecondarySubText: () => string | undefined;
|
getCurrentSecondarySubText: () => string | undefined;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
logError: (message: string, err: unknown) => void;
|
logError: (message: string, err: unknown) => void;
|
||||||
onCardsMined: (count: number) => void;
|
|
||||||
},
|
},
|
||||||
) => void;
|
) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -68,7 +66,6 @@ export function createHandleMineSentenceDigitHandler<
|
|||||||
getCurrentSecondarySubText: deps.getCurrentSecondarySubText,
|
getCurrentSecondarySubText: deps.getCurrentSecondarySubText,
|
||||||
showMpvOsd: deps.showMpvOsd,
|
showMpvOsd: deps.showMpvOsd,
|
||||||
logError: deps.logError,
|
logError: deps.logError,
|
||||||
onCardsMined: deps.onCardsMined,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -368,7 +368,10 @@ test('cancelling a YouTube Whisper job resolves to null, and failures throw', as
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function youtubeFixture(generateYoutube: SubtitleGenerationRuntimeDeps['generateYoutube']) {
|
function youtubeFixture(
|
||||||
|
generateYoutube: SubtitleGenerationRuntimeDeps['generateYoutube'],
|
||||||
|
overrides: Partial<SubtitleGenerationRuntimeDeps> = {},
|
||||||
|
) {
|
||||||
const player = {
|
const player = {
|
||||||
path: 'https://www.youtube.com/watch?v=abcdefghijk&pp=search',
|
path: 'https://www.youtube.com/watch?v=abcdefghijk&pp=search',
|
||||||
paused: false,
|
paused: false,
|
||||||
@@ -385,7 +388,7 @@ function youtubeFixture(generateYoutube: SubtitleGenerationRuntimeDeps['generate
|
|||||||
return { error: 'success' };
|
return { error: 'success' };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const { runtime } = fixture({ getMpvClient: () => client, generateYoutube });
|
const { runtime } = fixture({ getMpvClient: () => client, generateYoutube, ...overrides });
|
||||||
return { runtime, player, commands };
|
return { runtime, player, commands };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,3 +446,63 @@ test('switching videos cancels a YouTube job from the modal without loading its
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Windows plays YouTube through a resolved googlevideo stream, so mpv's path is not a YouTube URL.
|
||||||
|
const STREAM_URL = 'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777';
|
||||||
|
const OTHER_STREAM_URL = 'https://rr2---sn.example.googlevideo.com/videoplayback?expire=1888888888';
|
||||||
|
const resolveStreamSource = (mediaPath: string): string | null =>
|
||||||
|
mediaPath === STREAM_URL
|
||||||
|
? 'https://www.youtube.com/watch?v=abcdefghijk'
|
||||||
|
: mediaPath === OTHER_STREAM_URL
|
||||||
|
? 'https://www.youtube.com/watch?v=zyxwvutsrqp'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
test('the modal generates for a YouTube video playing through a resolved stream URL', async () => {
|
||||||
|
let jobSignal: AbortSignal | null = null;
|
||||||
|
const { runtime, player, commands } = youtubeFixture(
|
||||||
|
async (input) => {
|
||||||
|
jobSignal = input.signal;
|
||||||
|
assert.equal(input.url, 'https://www.youtube.com/watch?v=abcdefghijk');
|
||||||
|
// Reloading the same video's stream must not cancel the job.
|
||||||
|
runtime.handleMediaPathChange(STREAM_URL);
|
||||||
|
return '/tmp/subs/youtube-whisper.ja.srt';
|
||||||
|
},
|
||||||
|
{ resolveYoutubeSourceUrl: resolveStreamSource },
|
||||||
|
);
|
||||||
|
player.path = STREAM_URL;
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
(await runtime.getStatus()).mediaPath,
|
||||||
|
'https://www.youtube.com/watch?v=abcdefghijk',
|
||||||
|
);
|
||||||
|
assert.deepEqual(await runtime.start(), {
|
||||||
|
ok: true,
|
||||||
|
outputPath: '/tmp/subs/youtube-whisper.ja.srt',
|
||||||
|
message: 'Japanese subtitles generated and loaded.',
|
||||||
|
});
|
||||||
|
assert.equal((jobSignal as AbortSignal | null)?.aborted, false);
|
||||||
|
assert.deepEqual(
|
||||||
|
commands.find((command) => command[0] === 'sub-add'),
|
||||||
|
['sub-add', '/tmp/subs/youtube-whisper.ja.srt', 'select', 'Generated Japanese', 'ja'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switching to another resolved YouTube stream cancels the modal job', async () => {
|
||||||
|
let jobSignal: AbortSignal | null = null;
|
||||||
|
const { runtime, player } = youtubeFixture(
|
||||||
|
(input) =>
|
||||||
|
new Promise((_, reject) => {
|
||||||
|
jobSignal = input.signal;
|
||||||
|
input.signal.addEventListener('abort', () => reject(new Error('Aborted')), { once: true });
|
||||||
|
}),
|
||||||
|
{ resolveYoutubeSourceUrl: resolveStreamSource },
|
||||||
|
);
|
||||||
|
player.path = STREAM_URL;
|
||||||
|
|
||||||
|
const job = runtime.start();
|
||||||
|
while (!jobSignal) await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
player.path = OTHER_STREAM_URL;
|
||||||
|
runtime.handleMediaPathChange(player.path);
|
||||||
|
|
||||||
|
assert.deepEqual(await job, { ok: false, message: 'Cancelled.' });
|
||||||
|
});
|
||||||
@@ -51,6 +51,11 @@ export interface SubtitleGenerationRuntimeDeps {
|
|||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
onProgress: (progress: SubtitleGenerationProgress) => void;
|
onProgress: (progress: SubtitleGenerationProgress) => void;
|
||||||
}) => Promise<string>;
|
}) => Promise<string>;
|
||||||
|
/**
|
||||||
|
* Maps a playing stream URL back to its YouTube page URL. Windows YouTube playback opens a
|
||||||
|
* resolved direct stream in mpv, so mpv's path alone does not identify the video.
|
||||||
|
*/
|
||||||
|
resolveYoutubeSourceUrl?: (mediaPath: string) => string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function currentLocalMedia(client: GenerationMpvClient | null): Promise<string | null> {
|
async function currentLocalMedia(client: GenerationMpvClient | null): Promise<string | null> {
|
||||||
@@ -62,12 +67,6 @@ async function currentLocalMedia(client: GenerationMpvClient | null): Promise<st
|
|||||||
return typeof directory === 'string' ? path.resolve(directory, media) : null;
|
return typeof directory === 'string' ? path.resolve(directory, media) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function currentYoutubeMedia(client: GenerationMpvClient | null): Promise<string | null> {
|
|
||||||
if (!client?.connected) return null;
|
|
||||||
const media = await client.requestProperty('path');
|
|
||||||
return typeof media === 'string' ? toYoutubeWatchUrl(media) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadGeneratedSubtitles(client: GenerationMpvClient, outputPath: string) {
|
async function loadGeneratedSubtitles(client: GenerationMpvClient, outputPath: string) {
|
||||||
const loaded = await client.request([
|
const loaded = await client.request([
|
||||||
'sub-add',
|
'sub-add',
|
||||||
@@ -136,6 +135,15 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
|||||||
: '',
|
: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// The YouTube page behind a resolved stream URL, or the media path itself.
|
||||||
|
function youtubeSource(mediaPath: string | null | undefined): string | null | undefined {
|
||||||
|
return (mediaPath && deps.resolveYoutubeSourceUrl?.(mediaPath)) || mediaPath;
|
||||||
|
}
|
||||||
|
async function currentYoutubeMedia(client: GenerationMpvClient | null): Promise<string | null> {
|
||||||
|
if (!client?.connected) return null;
|
||||||
|
const media = await client.requestProperty('path');
|
||||||
|
return typeof media === 'string' ? toYoutubeWatchUrl(youtubeSource(media)) : null;
|
||||||
|
}
|
||||||
const report = (update: SubtitleGenerationProgress) => {
|
const report = (update: SubtitleGenerationProgress) => {
|
||||||
progress = update;
|
progress = update;
|
||||||
deps.onProgress(update);
|
deps.onProgress(update);
|
||||||
@@ -246,7 +254,7 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
|
|||||||
/** Stops a YouTube job once its video is no longer playing, which also deletes its audio. */
|
/** Stops a YouTube job once its video is no longer playing, which also deletes its audio. */
|
||||||
handleMediaPathChange(mediaPath: string | null | undefined): void {
|
handleMediaPathChange(mediaPath: string | null | undefined): void {
|
||||||
if (!youtubeJobUrl) return;
|
if (!youtubeJobUrl) return;
|
||||||
if (extractYoutubeVideoId(mediaPath) !== extractYoutubeVideoId(youtubeJobUrl))
|
if (extractYoutubeVideoId(youtubeSource(mediaPath)) !== extractYoutubeVideoId(youtubeJobUrl))
|
||||||
controller?.abort();
|
controller?.abort();
|
||||||
},
|
},
|
||||||
async setVadEnabled(enabled: boolean): Promise<SubtitleGenerationStatus> {
|
async setVadEnabled(enabled: boolean): Promise<SubtitleGenerationStatus> {
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
assertYomitanDictionaryMutationSafe,
|
||||||
|
observeYomitanDictionaryCount,
|
||||||
|
} from './yomitan-dictionary-integrity';
|
||||||
|
|
||||||
|
function withTempDir(run: (directory: string) => void): void {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-dictionary-integrity-'));
|
||||||
|
try {
|
||||||
|
run(directory);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('dictionary integrity observation establishes and updates a non-empty baseline', () => {
|
||||||
|
withTempDir((userDataPath) => {
|
||||||
|
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 5), {
|
||||||
|
safe: true,
|
||||||
|
previousCount: null,
|
||||||
|
});
|
||||||
|
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 3), {
|
||||||
|
safe: true,
|
||||||
|
previousCount: 5,
|
||||||
|
});
|
||||||
|
const statePath = path.join(userDataPath, 'yomitan-dictionary-integrity.json');
|
||||||
|
const unchangedTimestamp = new Date('2000-01-01T00:00:00.000Z');
|
||||||
|
fs.utimesSync(statePath, unchangedTimestamp, unchangedTimestamp);
|
||||||
|
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 3), {
|
||||||
|
safe: true,
|
||||||
|
previousCount: 3,
|
||||||
|
});
|
||||||
|
assert.equal(fs.statSync(statePath).mtimeMs, unchangedTimestamp.getTime());
|
||||||
|
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), { lastKnownNonEmptyCount: 3 });
|
||||||
|
assert.deepEqual(fs.readdirSync(userDataPath), ['yomitan-dictionary-integrity.json']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dictionary integrity permits an empty profile before dictionaries are installed', () => {
|
||||||
|
withTempDir((userDataPath) => {
|
||||||
|
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 0), {
|
||||||
|
safe: true,
|
||||||
|
previousCount: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dictionary integrity rejects invalid counts without creating a safety record', () => {
|
||||||
|
withTempDir((userDataPath) => {
|
||||||
|
for (const invalidCount of [Number.NaN, Number.POSITIVE_INFINITY, -1]) {
|
||||||
|
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, invalidCount), {
|
||||||
|
safe: false,
|
||||||
|
previousCount: null,
|
||||||
|
message: 'SubMiner could not verify Yomitan dictionary storage: invalid dictionary count.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
fs.existsSync(path.join(userDataPath, 'yomitan-dictionary-integrity.json')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dictionary integrity migrates the previous count from first-run setup state', () => {
|
||||||
|
withTempDir((userDataPath) => {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(userDataPath, 'setup-state.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 4,
|
||||||
|
status: 'completed',
|
||||||
|
completedAt: '2026-08-18T00:00:00.000Z',
|
||||||
|
completionSource: 'user',
|
||||||
|
yomitanSetupMode: 'internal',
|
||||||
|
lastSeenYomitanDictionaryCount: 4,
|
||||||
|
pluginInstallStatus: 'installed',
|
||||||
|
pluginInstallPathSummary: null,
|
||||||
|
windowsMpvShortcutPreferences: {
|
||||||
|
startMenuEnabled: true,
|
||||||
|
desktopEnabled: false,
|
||||||
|
},
|
||||||
|
windowsMpvShortcutLastStatus: 'installed',
|
||||||
|
bunInstallStatus: 'installed',
|
||||||
|
launcherInstallStatus: 'installed',
|
||||||
|
launcherInstallPath: '/home/tester/.local/bin/subminer',
|
||||||
|
}),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => assertYomitanDictionaryMutationSafe(userDataPath, 0),
|
||||||
|
/reported zero dictionaries after previously reporting 4/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dictionary integrity blocks automatic mutation after a non-empty profile becomes empty', () => {
|
||||||
|
withTempDir((userDataPath) => {
|
||||||
|
observeYomitanDictionaryCount(userDataPath, 6);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => assertYomitanDictionaryMutationSafe(userDataPath, 0),
|
||||||
|
/reported zero dictionaries after previously reporting 6/,
|
||||||
|
);
|
||||||
|
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 0), {
|
||||||
|
safe: false,
|
||||||
|
previousCount: 6,
|
||||||
|
message:
|
||||||
|
'Yomitan reported zero dictionaries after previously reporting 6. SubMiner blocked automatic dictionary changes because Chromium storage may have been reset. Close SubMiner and restore or inspect the profile before changing dictionaries.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dictionary integrity fails closed when its state is malformed', () => {
|
||||||
|
withTempDir((userDataPath) => {
|
||||||
|
const statePath = path.join(userDataPath, 'yomitan-dictionary-integrity.json');
|
||||||
|
fs.writeFileSync(statePath, '{}', 'utf8');
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => assertYomitanDictionaryMutationSafe(userDataPath, 2),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(error instanceof Error);
|
||||||
|
assert.match(error.message, /could not verify Yomitan dictionary storage/);
|
||||||
|
assert.equal(error.message.includes(statePath), true);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { writeTextFileAtomicallyDurable } from '../../shared/fs-utils';
|
||||||
|
import { getSetupStatePath, readSetupState } from '../../shared/setup-state';
|
||||||
|
|
||||||
|
const INTEGRITY_STATE_FILE_NAME = 'yomitan-dictionary-integrity.json';
|
||||||
|
|
||||||
|
type DictionaryIntegrityState = {
|
||||||
|
lastKnownNonEmptyCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DictionaryIntegrityObservation =
|
||||||
|
| { safe: true; previousCount: number | null }
|
||||||
|
| { safe: false; previousCount: number | null; message: string };
|
||||||
|
|
||||||
|
function getStatePath(userDataPath: string): string {
|
||||||
|
return path.join(userDataPath, INTEGRITY_STATE_FILE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readState(statePath: string): DictionaryIntegrityState | null {
|
||||||
|
if (!fs.existsSync(statePath)) return null;
|
||||||
|
const parsed = JSON.parse(
|
||||||
|
fs.readFileSync(statePath, 'utf8'),
|
||||||
|
) as Partial<DictionaryIntegrityState>;
|
||||||
|
const lastKnownNonEmptyCount = parsed.lastKnownNonEmptyCount;
|
||||||
|
if (
|
||||||
|
typeof lastKnownNonEmptyCount !== 'number' ||
|
||||||
|
!Number.isSafeInteger(lastKnownNonEmptyCount) ||
|
||||||
|
lastKnownNonEmptyCount <= 0
|
||||||
|
) {
|
||||||
|
throw new Error('The dictionary integrity record has an invalid format.');
|
||||||
|
}
|
||||||
|
return { lastKnownNonEmptyCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeState(statePath: string, state: DictionaryIntegrityState): void {
|
||||||
|
writeTextFileAtomicallyDurable(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLegacySetupCount(userDataPath: string): number | null {
|
||||||
|
const setupState = readSetupState(getSetupStatePath(userDataPath));
|
||||||
|
return setupState && setupState.lastSeenYomitanDictionaryCount > 0
|
||||||
|
? setupState.lastSeenYomitanDictionaryCount
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function observeYomitanDictionaryCount(
|
||||||
|
userDataPath: string,
|
||||||
|
dictionaryCount: number,
|
||||||
|
): DictionaryIntegrityObservation {
|
||||||
|
if (!Number.isSafeInteger(dictionaryCount) || dictionaryCount < 0) {
|
||||||
|
return {
|
||||||
|
safe: false,
|
||||||
|
previousCount: null,
|
||||||
|
message: 'SubMiner could not verify Yomitan dictionary storage: invalid dictionary count.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCount = dictionaryCount;
|
||||||
|
const statePath = getStatePath(userDataPath);
|
||||||
|
let state: DictionaryIntegrityState | null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
state = readState(statePath);
|
||||||
|
if (state === null) {
|
||||||
|
const legacyCount = readLegacySetupCount(userDataPath);
|
||||||
|
state = legacyCount === null ? null : { lastKnownNonEmptyCount: legacyCount };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
safe: false,
|
||||||
|
previousCount: null,
|
||||||
|
message: `SubMiner could not verify Yomitan dictionary storage at ${statePath}: ${(error as Error).message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedCount === 0 && state !== null) {
|
||||||
|
return {
|
||||||
|
safe: false,
|
||||||
|
previousCount: state.lastKnownNonEmptyCount,
|
||||||
|
message: [
|
||||||
|
`Yomitan reported zero dictionaries after previously reporting ${state.lastKnownNonEmptyCount}.`,
|
||||||
|
'SubMiner blocked automatic dictionary changes because Chromium storage may have been reset.',
|
||||||
|
'Close SubMiner and restore or inspect the profile before changing dictionaries.',
|
||||||
|
].join(' '),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedCount > 0 && normalizedCount !== state?.lastKnownNonEmptyCount) {
|
||||||
|
try {
|
||||||
|
writeState(statePath, { lastKnownNonEmptyCount: normalizedCount });
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
safe: false,
|
||||||
|
previousCount: state?.lastKnownNonEmptyCount ?? null,
|
||||||
|
message: `SubMiner could not update the Yomitan dictionary integrity record: ${(error as Error).message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { safe: true, previousCount: state?.lastKnownNonEmptyCount ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertYomitanDictionaryMutationSafe(
|
||||||
|
userDataPath: string,
|
||||||
|
dictionaryCount: number,
|
||||||
|
): void {
|
||||||
|
const observation = observeYomitanDictionaryCount(userDataPath, dictionaryCount);
|
||||||
|
if (!observation.safe) {
|
||||||
|
throw new Error(observation.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -298,3 +298,116 @@ test('prepare youtube playback accepts a fresh-start path change when the direct
|
|||||||
'replace',
|
'replace',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('prepare youtube playback swaps a queued entry for its direct stream without clearing the playlist', async () => {
|
||||||
|
const commands: Array<Array<string>> = [];
|
||||||
|
const directUrl = 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc';
|
||||||
|
const observedPaths = ['https://www.youtube.com/watch?v=queued2', directUrl];
|
||||||
|
let requestCount = 0;
|
||||||
|
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||||
|
requestPath: async () => {
|
||||||
|
const value = observedPaths[Math.min(requestCount, observedPaths.length - 1)] ?? null;
|
||||||
|
requestCount += 1;
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
requestProperty: async (name) => {
|
||||||
|
if (name === 'playlist') {
|
||||||
|
return [
|
||||||
|
{ filename: 'https://www.youtube.com/watch?v=queued1' },
|
||||||
|
{ filename: 'https://www.youtube.com/watch?v=queued2', current: true, playing: true },
|
||||||
|
{ filename: 'https://www.youtube.com/watch?v=queued3' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (name === 'track-list') return [{ type: 'video', id: 1 }];
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
sendMpvCommand: (command) => commands.push(command),
|
||||||
|
wait: createWaitStub(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = await prepare({
|
||||||
|
url: directUrl,
|
||||||
|
sourceUrl: 'https://www.youtube.com/watch?v=queued2',
|
||||||
|
timeoutMs: 1500,
|
||||||
|
pollIntervalMs: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(ok, true);
|
||||||
|
assert.deepEqual(commands.slice(4), [
|
||||||
|
['loadfile', directUrl, 'append'],
|
||||||
|
['playlist-move', '3', '2'],
|
||||||
|
['playlist-remove', '1'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepare youtube playback replaces the playlist when the current entry is a different video', async () => {
|
||||||
|
const commands: Array<Array<string>> = [];
|
||||||
|
const directUrl = 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc';
|
||||||
|
let nowTick = 0;
|
||||||
|
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||||
|
requestPath: async () => 'https://www.youtube.com/watch?v=other',
|
||||||
|
requestProperty: async (name) => {
|
||||||
|
if (name === 'playlist') {
|
||||||
|
return [
|
||||||
|
{ filename: 'https://www.youtube.com/watch?v=other', current: true, playing: true },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
sendMpvCommand: (command) => commands.push(command),
|
||||||
|
wait: createWaitStub(),
|
||||||
|
now: () => {
|
||||||
|
nowTick += 100;
|
||||||
|
return nowTick;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prepare({
|
||||||
|
url: directUrl,
|
||||||
|
sourceUrl: 'https://www.youtube.com/watch?v=picked',
|
||||||
|
timeoutMs: 200,
|
||||||
|
pollIntervalMs: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(commands.slice(4), [['loadfile', directUrl, 'replace']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepare youtube playback does not touch the playlist when the playing entry is another video', async () => {
|
||||||
|
const commands: Array<Array<string>> = [];
|
||||||
|
const directUrl = 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc';
|
||||||
|
let nowTick = 0;
|
||||||
|
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||||
|
// mpv's path still reports the queued video, but it is mid-advance: the selected entry moved
|
||||||
|
// on and the playing entry is another queued video.
|
||||||
|
requestPath: async () => 'https://www.youtube.com/watch?v=queued2',
|
||||||
|
requestProperty: async (name) => {
|
||||||
|
if (name === 'playlist') {
|
||||||
|
return [
|
||||||
|
{ filename: 'https://www.youtube.com/watch?v=queued1', playing: true },
|
||||||
|
{ filename: 'https://www.youtube.com/watch?v=queued2', current: true },
|
||||||
|
{ filename: 'https://www.youtube.com/watch?v=queued3' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
sendMpvCommand: (command) => commands.push(command),
|
||||||
|
wait: createWaitStub(),
|
||||||
|
now: () => {
|
||||||
|
nowTick += 100;
|
||||||
|
return nowTick;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prepare({
|
||||||
|
url: directUrl,
|
||||||
|
sourceUrl: 'https://www.youtube.com/watch?v=queued2',
|
||||||
|
timeoutMs: 200,
|
||||||
|
pollIntervalMs: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
commands.some((command) => command[0] === 'playlist-remove'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.deepEqual(commands.slice(4), [['loadfile', directUrl, 'replace']]);
|
||||||
|
});
|
||||||
@@ -2,6 +2,11 @@ import { isYoutubeMediaPath } from './youtube-playback';
|
|||||||
|
|
||||||
type YoutubePlaybackLaunchInput = {
|
type YoutubePlaybackLaunchInput = {
|
||||||
url: string;
|
url: string;
|
||||||
|
/**
|
||||||
|
* YouTube page URL `url` was resolved from (Windows loads a direct stream URL). Lets a playlist
|
||||||
|
* entry for the same video be swapped in place instead of replacing the whole playlist.
|
||||||
|
*/
|
||||||
|
sourceUrl?: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
pollIntervalMs?: number;
|
pollIntervalMs?: number;
|
||||||
};
|
};
|
||||||
@@ -81,6 +86,52 @@ function hasPlayableMediaTracks(trackListRaw: unknown): boolean {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Index of the entry mpv is actually playing, or null while it is between entries. */
|
||||||
|
function findPlayingPlaylistEntry(playlist: unknown[]): { index: number; filename: string } | null {
|
||||||
|
const index = playlist.findIndex(
|
||||||
|
(entry) =>
|
||||||
|
typeof entry === 'object' &&
|
||||||
|
entry !== null &&
|
||||||
|
(entry as Record<string, unknown>).playing === true,
|
||||||
|
);
|
||||||
|
if (index < 0) return null;
|
||||||
|
const filename = (playlist[index] as Record<string, unknown>).filename;
|
||||||
|
return typeof filename === 'string' ? { index, filename } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swaps mpv's playing playlist entry for `targetUrl` while keeping the rest of the playlist, e.g.
|
||||||
|
* a queued YouTube watch URL replaced by its resolved direct stream on Windows. Uses only
|
||||||
|
* long-standing playlist commands (no `loadfile insert-*`, which needs mpv 0.38+).
|
||||||
|
* Returns false unless the playing entry is confirmed to be `sourceUrl`'s video, so the caller
|
||||||
|
* falls back to `replace` instead of removing some other queued entry.
|
||||||
|
*/
|
||||||
|
async function swapCurrentPlaylistEntry(
|
||||||
|
deps: YoutubePlaybackLaunchDeps,
|
||||||
|
targetUrl: string,
|
||||||
|
sourceUrl: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!deps.requestProperty) return false;
|
||||||
|
let playlist: unknown;
|
||||||
|
try {
|
||||||
|
// One snapshot so the position, count, and entry identity agree with each other.
|
||||||
|
playlist = await deps.requestProperty('playlist');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(playlist)) return false;
|
||||||
|
const playing = findPlayingPlaylistEntry(playlist);
|
||||||
|
if (!playing || !targetsSameYoutubeVideo(playing.filename, sourceUrl)) return false;
|
||||||
|
|
||||||
|
// The appended entry lands at index `count`; move it right after the playing entry, then remove
|
||||||
|
// the playing entry so mpv advances onto it.
|
||||||
|
const count = playlist.length;
|
||||||
|
deps.sendMpvCommand(['loadfile', targetUrl, 'append']);
|
||||||
|
deps.sendMpvCommand(['playlist-move', String(count), String(playing.index + 1)]);
|
||||||
|
deps.sendMpvCommand(['playlist-remove', String(playing.index)]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function sendPlaybackPrepCommands(sendMpvCommand: (command: Array<string>) => void): void {
|
function sendPlaybackPrepCommands(sendMpvCommand: (command: Array<string>) => void): void {
|
||||||
sendMpvCommand(['set_property', 'pause', 'yes']);
|
sendMpvCommand(['set_property', 'pause', 'yes']);
|
||||||
sendMpvCommand(['set_property', 'sub-auto', 'no']);
|
sendMpvCommand(['set_property', 'sub-auto', 'no']);
|
||||||
@@ -120,8 +171,15 @@ export function createPrepareYoutubePlaybackInMpvHandler(deps: YoutubePlaybackLa
|
|||||||
// Keep polling; mpv can report the target path before tracks are ready.
|
// Keep polling; mpv can report the target path before tracks are ready.
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
const sourceUrl = input.sourceUrl?.trim() ?? '';
|
||||||
|
const swapped =
|
||||||
|
sourceUrl !== '' &&
|
||||||
|
targetsSameYoutubeVideo(previousPath, sourceUrl) &&
|
||||||
|
(await swapCurrentPlaylistEntry(deps, targetUrl, sourceUrl));
|
||||||
|
if (!swapped) {
|
||||||
deps.sendMpvCommand(['loadfile', targetUrl, 'replace']);
|
deps.sendMpvCommand(['loadfile', targetUrl, 'replace']);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const deadline = now() + timeoutMs;
|
const deadline = now() + timeoutMs;
|
||||||
while (now() < deadline) {
|
while (now() < deadline) {
|
||||||
|
|||||||
@@ -112,8 +112,8 @@ test('youtube playback runtime resolves the socket path lazily for windows start
|
|||||||
calls.push(`wait-connected:${timeoutMs}`);
|
calls.push(`wait-connected:${timeoutMs}`);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
prepareYoutubePlaybackInMpv: async ({ url }) => {
|
prepareYoutubePlaybackInMpv: async ({ url, sourceUrl }) => {
|
||||||
calls.push(`prepare:${url}`);
|
calls.push(`prepare:${url}:${sourceUrl}`);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
runYoutubePlaybackFlow: async ({ url }) => {
|
runYoutubePlaybackFlow: async ({ url }) => {
|
||||||
@@ -143,6 +143,51 @@ test('youtube playback runtime resolves the socket path lazily for windows start
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.ok(calls.some((entry) => entry.includes('--input-ipc-server=/tmp/updated.sock')));
|
assert.ok(calls.some((entry) => entry.includes('--input-ipc-server=/tmp/updated.sock')));
|
||||||
|
// The page URL rides along so prepare can swap a queued entry for the direct stream in place.
|
||||||
|
assert.ok(calls.includes('prepare:https://example.com/direct:https://youtu.be/demo'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('youtube playback runtime maps resolved windows streams back to their page urls', async () => {
|
||||||
|
const streamFor = (url: string) =>
|
||||||
|
`https://rr1---sn.example.googlevideo.com/videoplayback?src=${encodeURIComponent(url)}`;
|
||||||
|
const firstUrl = 'https://www.youtube.com/watch?v=abcdefghijk';
|
||||||
|
const secondUrl = 'https://www.youtube.com/watch?v=bcdefghijkl';
|
||||||
|
const runtime = createYoutubePlaybackRuntime({
|
||||||
|
platform: 'win32',
|
||||||
|
directPlaybackFormat: 'b',
|
||||||
|
mpvYtdlFormat: 'bestvideo+bestaudio',
|
||||||
|
autoLaunchTimeoutMs: 2_000,
|
||||||
|
connectTimeoutMs: 1_000,
|
||||||
|
getSocketPath: () => '/tmp/mpv.sock',
|
||||||
|
getMpvConnected: () => true,
|
||||||
|
invalidatePendingAutoplayReadyFallbacks: () => {},
|
||||||
|
setAppOwnedFlowInFlight: () => {},
|
||||||
|
ensureYoutubePlaybackRuntimeReady: async () => {},
|
||||||
|
resolveYoutubePlaybackUrl: async (url) => streamFor(url),
|
||||||
|
launchWindowsMpv: async () => ({ ok: false }),
|
||||||
|
waitForYoutubeMpvConnected: async () => true,
|
||||||
|
// The second video never loads, so the first stream keeps playing.
|
||||||
|
prepareYoutubePlaybackInMpv: async ({ url }) => url === streamFor(firstUrl),
|
||||||
|
runYoutubePlaybackFlow: async () => {},
|
||||||
|
logInfo: () => {},
|
||||||
|
logWarn: () => {},
|
||||||
|
schedule: () => 1 as never,
|
||||||
|
clearScheduled: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(runtime.getYoutubeSourceUrlForStream(streamFor(firstUrl)), null);
|
||||||
|
await runtime.runYoutubePlaybackFlow({ url: firstUrl, source: 'second-instance' });
|
||||||
|
await runtime
|
||||||
|
.runYoutubePlaybackFlow({ url: secondUrl, source: 'second-instance' })
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
assert.equal(runtime.getYoutubeSourceUrlForStream(streamFor(firstUrl)), firstUrl);
|
||||||
|
assert.equal(runtime.getYoutubeSourceUrlForStream(streamFor(secondUrl)), secondUrl);
|
||||||
|
assert.equal(
|
||||||
|
runtime.getYoutubeSourceUrlForStream('https://rr2---sn.example.googlevideo.com/other'),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(runtime.getYoutubeSourceUrlForStream('/video/episode.mkv'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('youtube playback runtime starts media cache without blocking the subtitle flow', async () => {
|
test('youtube playback runtime starts media cache without blocking the subtitle flow', async () => {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export type YoutubePlaybackRuntimeDeps = {
|
|||||||
resolveYoutubePlaybackUrl: (url: string, format: string) => Promise<string>;
|
resolveYoutubePlaybackUrl: (url: string, format: string) => Promise<string>;
|
||||||
launchWindowsMpv: (playbackUrl: string, args: string[]) => Promise<LaunchResult>;
|
launchWindowsMpv: (playbackUrl: string, args: string[]) => Promise<LaunchResult>;
|
||||||
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>;
|
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>;
|
||||||
prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise<boolean>;
|
prepareYoutubePlaybackInMpv: (request: { url: string; sourceUrl: string }) => Promise<boolean>;
|
||||||
startYoutubeMediaCache?: (url: string) => void | Promise<void>;
|
startYoutubeMediaCache?: (url: string) => void | Promise<void>;
|
||||||
runYoutubePlaybackFlow: (request: { url: string }) => Promise<void>;
|
runYoutubePlaybackFlow: (request: { url: string }) => Promise<void>;
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
@@ -35,6 +35,10 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
|||||||
let quitOnDisconnectArmed = false;
|
let quitOnDisconnectArmed = false;
|
||||||
let quitOnDisconnectArmTimer: ReturnType<typeof setTimeout> | null = null;
|
let quitOnDisconnectArmTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let playbackFlowGeneration = 0;
|
let playbackFlowGeneration = 0;
|
||||||
|
// Windows mpv plays resolved stream URLs, so remember which YouTube page each came from. Kept for
|
||||||
|
// every resolved stream: a later failed load must not orphan the stream still playing, and mpv
|
||||||
|
// can return to an earlier swapped-in playlist entry.
|
||||||
|
const directPlaybackSources = new Map<string, string>();
|
||||||
|
|
||||||
const clearYoutubePlayQuitOnDisconnectArmTimer = (): void => {
|
const clearYoutubePlayQuitOnDisconnectArmTimer = (): void => {
|
||||||
if (quitOnDisconnectArmTimer) {
|
if (quitOnDisconnectArmTimer) {
|
||||||
@@ -65,6 +69,7 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
|||||||
request.url,
|
request.url,
|
||||||
deps.directPlaybackFormat,
|
deps.directPlaybackFormat,
|
||||||
);
|
);
|
||||||
|
directPlaybackSources.set(playbackUrl, request.url);
|
||||||
deps.logInfo('Resolved direct YouTube playback URL for Windows MPV startup.');
|
deps.logInfo('Resolved direct YouTube playback URL for Windows MPV startup.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
deps.logWarn(
|
deps.logWarn(
|
||||||
@@ -122,7 +127,10 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaReady = await deps.prepareYoutubePlaybackInMpv({ url: playbackUrl });
|
const mediaReady = await deps.prepareYoutubePlaybackInMpv({
|
||||||
|
url: playbackUrl,
|
||||||
|
sourceUrl: request.url,
|
||||||
|
});
|
||||||
if (!mediaReady) {
|
if (!mediaReady) {
|
||||||
throw new Error('Timed out waiting for mpv to load the requested YouTube URL.');
|
throw new Error('Timed out waiting for mpv to load the requested YouTube URL.');
|
||||||
}
|
}
|
||||||
@@ -156,5 +164,8 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
|||||||
clearYoutubePlayQuitOnDisconnectArmTimer,
|
clearYoutubePlayQuitOnDisconnectArmTimer,
|
||||||
getQuitOnDisconnectArmed: (): boolean => quitOnDisconnectArmed,
|
getQuitOnDisconnectArmed: (): boolean => quitOnDisconnectArmed,
|
||||||
runYoutubePlaybackFlow,
|
runYoutubePlaybackFlow,
|
||||||
|
/** YouTube page URL for a stream URL this runtime resolved for Windows playback, else null. */
|
||||||
|
getYoutubeSourceUrlForStream: (mediaPath: string | null | undefined): string | null =>
|
||||||
|
directPlaybackSources.get(mediaPath?.trim() ?? '') ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
export function ensureDir(dirPath: string): void {
|
export function ensureDir(dirPath: string): void {
|
||||||
if (fs.existsSync(dirPath)) return;
|
if (fs.existsSync(dirPath)) return;
|
||||||
@@ -12,3 +13,48 @@ export function ensureDirForFile(filePath: string): void {
|
|||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncDirectory(directoryPath: string): void {
|
||||||
|
// Node cannot open Windows directory handles for fsync. The atomic rename still prevents
|
||||||
|
// torn files there, while supported platforms also flush the directory entry below.
|
||||||
|
if (process.platform === 'win32') return;
|
||||||
|
|
||||||
|
const directory = fs.openSync(directoryPath, 'r');
|
||||||
|
try {
|
||||||
|
fs.fsyncSync(directory);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(directory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes and flushes a sibling temporary file before atomically replacing the target. */
|
||||||
|
export function writeTextFileAtomicallyDurable(filePath: string, content: string): void {
|
||||||
|
const directoryPath = path.dirname(filePath);
|
||||||
|
ensureDir(directoryPath);
|
||||||
|
const temporaryPath = path.join(directoryPath, `.${path.basename(filePath)}.${randomUUID()}.tmp`);
|
||||||
|
let temporaryFile: number | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
temporaryFile = fs.openSync(temporaryPath, 'wx', 0o600);
|
||||||
|
fs.writeFileSync(temporaryFile, content, 'utf8');
|
||||||
|
fs.fsyncSync(temporaryFile);
|
||||||
|
fs.closeSync(temporaryFile);
|
||||||
|
temporaryFile = undefined;
|
||||||
|
fs.renameSync(temporaryPath, filePath);
|
||||||
|
syncDirectory(directoryPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (temporaryFile !== undefined) {
|
||||||
|
try {
|
||||||
|
fs.closeSync(temporaryFile);
|
||||||
|
} catch {
|
||||||
|
// Best effort cleanup preserves the original write failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.rmSync(temporaryPath, { force: true });
|
||||||
|
} catch {
|
||||||
|
// The temporary file is disposable; the existing target stays untouched before rename.
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { Window } from 'happy-dom';
|
||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { apiClient } from '../../lib/api-client';
|
||||||
|
import { setDeleteConfirmPresenter } from '../../lib/delete-confirm';
|
||||||
|
import { localDayFromMs } from '../../lib/formatters';
|
||||||
|
import type { OverviewData, SessionSummary } from '../../types/stats';
|
||||||
|
import { OverviewTab } from './OverviewTab';
|
||||||
|
|
||||||
|
function installDom() {
|
||||||
|
const window = new Window();
|
||||||
|
const properties = {
|
||||||
|
window,
|
||||||
|
document: window.document,
|
||||||
|
HTMLElement: window.HTMLElement,
|
||||||
|
ResizeObserver: window.ResizeObserver,
|
||||||
|
IS_REACT_ACT_ENVIRONMENT: true,
|
||||||
|
};
|
||||||
|
const previous = Object.keys(properties).map(
|
||||||
|
(key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
|
||||||
|
);
|
||||||
|
for (const [key, value] of Object.entries(properties)) {
|
||||||
|
Object.defineProperty(globalThis, key, { value, configurable: true, writable: true });
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
for (const [key, descriptor] of previous) {
|
||||||
|
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||||
|
else Reflect.deleteProperty(globalThis, key);
|
||||||
|
}
|
||||||
|
window.happyDOM.abort();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function session(sessionId: number): SessionSummary {
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
canonicalTitle: `Test episode ${sessionId}`,
|
||||||
|
videoId: null,
|
||||||
|
animeId: null,
|
||||||
|
animeTitle: 'Test anime',
|
||||||
|
startedAtMs: Date.now(),
|
||||||
|
endedAtMs: Date.now(),
|
||||||
|
totalWatchedMs: 60_000,
|
||||||
|
activeWatchedMs: 60_000,
|
||||||
|
linesSeen: 1,
|
||||||
|
tokensSeen: 10,
|
||||||
|
cardsMined: 2,
|
||||||
|
lookupCount: 0,
|
||||||
|
lookupHits: 0,
|
||||||
|
yomitanLookupCount: 0,
|
||||||
|
knownWordsSeen: 1,
|
||||||
|
knownWordRate: 0.1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function overview(sessions: SessionSummary[]): OverviewData {
|
||||||
|
const count = sessions.length;
|
||||||
|
return {
|
||||||
|
sessions,
|
||||||
|
rollups: count
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
rollupDayOrMonth: localDayFromMs(Date.now()),
|
||||||
|
videoId: null,
|
||||||
|
totalSessions: count,
|
||||||
|
totalActiveMin: count,
|
||||||
|
totalLinesSeen: count,
|
||||||
|
totalTokensSeen: count * 10,
|
||||||
|
totalCards: count * 2,
|
||||||
|
cardsPerHour: null,
|
||||||
|
tokensPerMin: null,
|
||||||
|
lookupHitRate: null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
hints: {
|
||||||
|
totalSessions: count,
|
||||||
|
activeSessions: 0,
|
||||||
|
episodesToday: count,
|
||||||
|
activeAnimeCount: count ? 1 : 0,
|
||||||
|
totalEpisodesWatched: count,
|
||||||
|
totalAnimeCompleted: 0,
|
||||||
|
totalActiveMin: count,
|
||||||
|
activeDays: count ? 1 : 0,
|
||||||
|
totalCards: count * 2,
|
||||||
|
totalTokensSeen: count * 10,
|
||||||
|
totalLookupCount: 0,
|
||||||
|
totalLookupHits: 0,
|
||||||
|
totalYomitanLookupCount: 0,
|
||||||
|
newWordsToday: count,
|
||||||
|
newWordsThisWeek: count,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function metric(container: HTMLElement, label: string): string {
|
||||||
|
const element = [...container.querySelectorAll('div')].find(
|
||||||
|
(element) => element.textContent === label,
|
||||||
|
);
|
||||||
|
assert.ok(element?.parentElement, `expected metric ${label}`);
|
||||||
|
return (element.parentElement.textContent ?? '').replace(label, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mode of ['session', 'day', 'anime', 'failed'] as const) {
|
||||||
|
test(`Overview refreshes all dependent data after ${mode} deletion`, async () => {
|
||||||
|
const uninstallDom = installDom();
|
||||||
|
const original = { ...apiClient };
|
||||||
|
const restoreConfirm = setDeleteConfirmPresenter(() => true);
|
||||||
|
let sessions =
|
||||||
|
mode === 'session' || mode === 'failed'
|
||||||
|
? [session(1)]
|
||||||
|
: [
|
||||||
|
{ ...session(1), animeId: 7 },
|
||||||
|
{ ...session(2), animeId: 7 },
|
||||||
|
];
|
||||||
|
const reads = { overview: 0, sessions: 0, calendar: 0, words: 0 };
|
||||||
|
const deletedIds: number[] = [];
|
||||||
|
apiClient.getOverview = async () => {
|
||||||
|
reads.overview++;
|
||||||
|
return overview(sessions);
|
||||||
|
};
|
||||||
|
apiClient.getSessions = async () => {
|
||||||
|
reads.sessions++;
|
||||||
|
return sessions;
|
||||||
|
};
|
||||||
|
apiClient.getStreakCalendar = async () => {
|
||||||
|
reads.calendar++;
|
||||||
|
return [{ epochDay: localDayFromMs(Date.now()), totalActiveMin: sessions.length }];
|
||||||
|
};
|
||||||
|
apiClient.getKnownWordsSummary = async () => {
|
||||||
|
reads.words++;
|
||||||
|
return { totalUniqueWords: 10 + sessions.length, knownWordCount: 5 };
|
||||||
|
};
|
||||||
|
apiClient.getCoverImages = async () => ({ anime: {}, media: {} });
|
||||||
|
async function deleteSessions(ids: number[]) {
|
||||||
|
if (mode === 'failed') throw new Error('Deletion failed');
|
||||||
|
deletedIds.push(...ids);
|
||||||
|
sessions = sessions.filter((session) => !ids.includes(session.sessionId));
|
||||||
|
}
|
||||||
|
apiClient.deleteSession = async (id) => deleteSessions([id]);
|
||||||
|
apiClient.deleteSessions = deleteSessions;
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
try {
|
||||||
|
await act(async () =>
|
||||||
|
root.render(
|
||||||
|
<OverviewTab onNavigateToMediaDetail={() => {}} onNavigateToSession={() => {}} />,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const count = sessions.length;
|
||||||
|
assert.match(metric(container, 'Cards Mined Today'), new RegExp(`^${count * 2}`));
|
||||||
|
assert.match(metric(container, 'Known Words'), new RegExp(`/ ${10 + count}`));
|
||||||
|
assert.equal(container.querySelectorAll('.bg-ctp-green\\/30').length, 1);
|
||||||
|
const prefix =
|
||||||
|
mode === 'day'
|
||||||
|
? 'Delete all sessions from '
|
||||||
|
: mode === 'anime'
|
||||||
|
? 'Delete all sessions for '
|
||||||
|
: 'Delete session ';
|
||||||
|
const button = [...container.querySelectorAll('button')].find((button) =>
|
||||||
|
button.getAttribute('aria-label')?.startsWith(prefix),
|
||||||
|
);
|
||||||
|
assert.ok(button, `expected ${mode} delete button`);
|
||||||
|
await act(async () => button.click());
|
||||||
|
if (mode === 'failed') {
|
||||||
|
assert.match(container.textContent ?? '', /Deletion failed/);
|
||||||
|
assert.match(metric(container, 'Cards Mined Today'), /^2/);
|
||||||
|
assert.deepEqual(reads, { overview: 1, sessions: 1, calendar: 1, words: 1 });
|
||||||
|
assert.deepEqual(deletedIds, []);
|
||||||
|
} else {
|
||||||
|
assert.deepEqual(deletedIds, count === 1 ? [1] : [1, 2]);
|
||||||
|
for (const label of [
|
||||||
|
'Cards Mined Today',
|
||||||
|
'Episodes Today',
|
||||||
|
'Sessions',
|
||||||
|
'Cards Mined',
|
||||||
|
'Words Today',
|
||||||
|
'New Words Today',
|
||||||
|
]) {
|
||||||
|
assert.equal(metric(container, label), '0', `${label} should reflect the deletion`);
|
||||||
|
}
|
||||||
|
assert.match(metric(container, 'Known Words'), /5\s*\/ 10/);
|
||||||
|
assert.equal(container.querySelectorAll('.bg-ctp-green\\/30').length, 0);
|
||||||
|
assert.match(container.textContent ?? '', /No sessions yet/);
|
||||||
|
assert.deepEqual(reads, { overview: 2, sessions: 2, calendar: 2, words: 2 });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
restoreConfirm();
|
||||||
|
Object.assign(apiClient, original);
|
||||||
|
uninstallDom();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const failedRead of ['overview', 'sessions', 'calendar'] as const) {
|
||||||
|
test(`Overview stays available during a failed ${failedRead} refresh and can retry`, async () => {
|
||||||
|
const uninstallDom = installDom();
|
||||||
|
const original = { ...apiClient };
|
||||||
|
const restoreConfirm = setDeleteConfirmPresenter(() => true);
|
||||||
|
let sessions = [session(1)];
|
||||||
|
let refreshing = false;
|
||||||
|
let failRefresh = true;
|
||||||
|
let rejectRefresh = (_error: Error) => {};
|
||||||
|
const pendingRefresh = new Promise<never>((_resolve, reject) => {
|
||||||
|
rejectRefresh = reject;
|
||||||
|
});
|
||||||
|
apiClient.getOverview = async () => {
|
||||||
|
if (refreshing && failRefresh && failedRead === 'overview') return pendingRefresh;
|
||||||
|
return overview(sessions);
|
||||||
|
};
|
||||||
|
apiClient.getSessions = async () => {
|
||||||
|
if (refreshing && failRefresh && failedRead === 'sessions') return pendingRefresh;
|
||||||
|
return sessions;
|
||||||
|
};
|
||||||
|
apiClient.getStreakCalendar = async () => {
|
||||||
|
if (refreshing && failRefresh && failedRead === 'calendar') return pendingRefresh;
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
apiClient.getKnownWordsSummary = async () => ({
|
||||||
|
totalUniqueWords: 0,
|
||||||
|
knownWordCount: 0,
|
||||||
|
});
|
||||||
|
apiClient.deleteSession = async () => {
|
||||||
|
sessions = [];
|
||||||
|
refreshing = true;
|
||||||
|
};
|
||||||
|
// A calendar-only failure still lets the overview totals refresh.
|
||||||
|
const staleCards = failedRead === 'calendar' ? '0' : '2';
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
try {
|
||||||
|
await act(async () =>
|
||||||
|
root.render(
|
||||||
|
<OverviewTab onNavigateToMediaDetail={() => {}} onNavigateToSession={() => {}} />,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const deleteButton = [...container.querySelectorAll('button')].find((button) =>
|
||||||
|
button.getAttribute('aria-label')?.startsWith('Delete session '),
|
||||||
|
);
|
||||||
|
assert.ok(deleteButton);
|
||||||
|
await act(async () => deleteButton.click());
|
||||||
|
assert.match(container.textContent ?? '', /Tracking Snapshot/);
|
||||||
|
assert.equal(metric(container, 'Cards Mined Today'), staleCards);
|
||||||
|
assert.match(container.textContent ?? '', /No sessions yet/);
|
||||||
|
|
||||||
|
await act(async () => rejectRefresh(new Error(`${failedRead} unavailable`)));
|
||||||
|
assert.match(container.textContent ?? '', /Tracking Snapshot/);
|
||||||
|
assert.equal(metric(container, 'Cards Mined Today'), staleCards);
|
||||||
|
assert.match(container.querySelector('[role="alert"]')?.textContent ?? '', /unavailable/);
|
||||||
|
|
||||||
|
const retryButton = [...container.querySelectorAll('button')].find(
|
||||||
|
(button) => button.textContent === 'Retry',
|
||||||
|
);
|
||||||
|
assert.ok(retryButton, 'failed refresh should offer a retry');
|
||||||
|
failRefresh = false;
|
||||||
|
await act(async () => retryButton.click());
|
||||||
|
assert.equal(metric(container, 'Cards Mined Today'), '0');
|
||||||
|
assert.equal(container.querySelector('[role="alert"]'), null);
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
restoreConfirm();
|
||||||
|
Object.assign(apiClient, original);
|
||||||
|
uninstallDom();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -27,8 +27,9 @@ export function OverviewTab({
|
|||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
isActive = true,
|
isActive = true,
|
||||||
}: OverviewTabProps) {
|
}: OverviewTabProps) {
|
||||||
const { data, sessions, setSessions, loading, error } = useOverview();
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const { calendar, loading: calLoading } = useStreakCalendar(90);
|
const { data, sessions, setSessions, loading, error } = useOverview(refreshKey);
|
||||||
|
const { calendar, loading: calLoading, error: calendarError } = useStreakCalendar(90, refreshKey);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
const [deletingIds, setDeletingIds] = useState<Set<number>>(new Set());
|
const [deletingIds, setDeletingIds] = useState<Set<number>>(new Set());
|
||||||
const [knownWordsSummary, setKnownWordsSummary] = useState<{
|
const [knownWordsSummary, setKnownWordsSummary] = useState<{
|
||||||
@@ -49,7 +50,7 @@ export function OverviewTab({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [refreshKey]);
|
||||||
|
|
||||||
const handleDeleteSession = async (session: SessionSummary) => {
|
const handleDeleteSession = async (session: SessionSummary) => {
|
||||||
if (!(await confirmSessionDelete())) return;
|
if (!(await confirmSessionDelete())) return;
|
||||||
@@ -58,6 +59,7 @@ export function OverviewTab({
|
|||||||
try {
|
try {
|
||||||
await apiClient.deleteSession(session.sessionId);
|
await apiClient.deleteSession(session.sessionId);
|
||||||
setSessions((prev) => prev.filter((s) => s.sessionId !== session.sessionId));
|
setSessions((prev) => prev.filter((s) => s.sessionId !== session.sessionId));
|
||||||
|
setRefreshKey((prev) => prev + 1);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
|
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -82,6 +84,7 @@ export function OverviewTab({
|
|||||||
await apiClient.deleteSessions(ids);
|
await apiClient.deleteSessions(ids);
|
||||||
const idSet = new Set(ids);
|
const idSet = new Set(ids);
|
||||||
setSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId)));
|
setSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId)));
|
||||||
|
setRefreshKey((prev) => prev + 1);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.');
|
setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -108,6 +111,7 @@ export function OverviewTab({
|
|||||||
await apiClient.deleteSessions(ids);
|
await apiClient.deleteSessions(ids);
|
||||||
const idSet = new Set(ids);
|
const idSet = new Set(ids);
|
||||||
setSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId)));
|
setSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId)));
|
||||||
|
setRefreshKey((prev) => prev + 1);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.');
|
setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -119,9 +123,32 @@ export function OverviewTab({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
const refreshError = error ?? calendarError;
|
||||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
const errorNotice = refreshError ? (
|
||||||
if (!data) return null;
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="flex items-center justify-between gap-3 rounded-lg border border-ctp-red/30 bg-ctp-red/10 p-3 text-sm text-ctp-red"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{!data
|
||||||
|
? 'Could not load Overview.'
|
||||||
|
: error
|
||||||
|
? 'Could not refresh Overview. Displayed totals may be out of date.'
|
||||||
|
: 'Could not refresh the activity calendar. It may be out of date.'}{' '}
|
||||||
|
{refreshError}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRefreshKey((prev) => prev + 1)}
|
||||||
|
className="shrink-0 rounded border border-ctp-red/30 px-3 py-1 hover:bg-ctp-red/10"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
if (loading && !data) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||||
|
if (!data) return errorNotice;
|
||||||
|
|
||||||
const summary = buildOverviewSummary(data);
|
const summary = buildOverviewSummary(data);
|
||||||
const streakData = buildStreakCalendar(calendar);
|
const streakData = buildStreakCalendar(calendar);
|
||||||
@@ -129,6 +156,7 @@ export function OverviewTab({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{errorNotice}
|
||||||
<HeroStats summary={summary} sessions={sessions} />
|
<HeroStats summary={summary} sessions={sessions} />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { getStatsClient } from './useStatsApi';
|
import { getStatsClient } from './useStatsApi';
|
||||||
import type { OverviewData, SessionSummary } from '../types/stats';
|
import type { OverviewData, SessionSummary } from '../types/stats';
|
||||||
|
|
||||||
export function useOverview() {
|
export function useOverview(refreshKey = 0) {
|
||||||
const [data, setData] = useState<OverviewData | null>(null);
|
const [data, setData] = useState<OverviewData | null>(null);
|
||||||
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -30,7 +30,7 @@ export function useOverview() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [refreshKey]);
|
||||||
|
|
||||||
return { data, sessions, setSessions, loading, error };
|
return { data, sessions, setSessions, loading, error };
|
||||||
}
|
}
|
||||||
@@ -2,13 +2,15 @@ import { useState, useEffect } from 'react';
|
|||||||
import { getStatsClient } from './useStatsApi';
|
import { getStatsClient } from './useStatsApi';
|
||||||
import type { StreakCalendarDay } from '../types/stats';
|
import type { StreakCalendarDay } from '../types/stats';
|
||||||
|
|
||||||
export function useStreakCalendar(days = 90) {
|
export function useStreakCalendar(days = 90, refreshKey = 0) {
|
||||||
const [calendar, setCalendar] = useState<StreakCalendarDay[]>([]);
|
const [calendar, setCalendar] = useState<StreakCalendarDay[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
getStatsClient()
|
getStatsClient()
|
||||||
.getStreakCalendar(days)
|
.getStreakCalendar(days)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -23,7 +25,7 @@ export function useStreakCalendar(days = 90) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [days]);
|
}, [days, refreshKey]);
|
||||||
|
|
||||||
return { calendar, loading, error };
|
return { calendar, loading, error };
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user