Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
beeab564b4
|
|||
|
cab7975a2b
|
|||
|
846292809c
|
|||
| 8712780d08 | |||
| 7b1a005a65 | |||
|
84c75f50aa
|
|||
|
8b838f2c7d
|
|||
|
db4139ba0b
|
|||
| 6c251502b3 | |||
| ae40934d3a | |||
|
cdb1475a54
|
|||
|
a2e49b369b
|
|||
| 7f13aed50a | |||
|
8b21a2bca8
|
|||
|
925413adfe
|
|||
| d0644ab2eb | |||
| d253710c2e | |||
|
c3df510e4f
|
|||
| 187f68e5b6 | |||
|
0e254cbbef
|
|||
|
7b94adafbd
|
|||
| 61f39d1e09 | |||
| e7739de51c | |||
| ad1d240f20 | |||
|
8b0ef662bc
|
|||
| 0a58c20ad7 | |||
| 38ddb29aa0 | |||
| 8b9a70c5a6 | |||
| 48a084914a | |||
| a042b04357 | |||
| 35ca2afc6f | |||
| b14f977e33 | |||
| eef4500599 | |||
|
73af1451b7
|
|||
| 36a3704815 | |||
| 359cb0a301 | |||
|
4b10e85053
|
|||
| c942a2cf2d | |||
| f65afa6046 | |||
|
389d8e06e0
|
|||
|
0008b55b70
|
|||
|
d16ae9c745
|
|||
| 36f94151b8 | |||
| 5326ad32f5 | |||
|
d199376364
|
@@ -55,9 +55,6 @@ jobs:
|
||||
- name: Verify generated config examples
|
||||
run: bun run verify:config-example
|
||||
|
||||
- name: Internal docs knowledge-base checks
|
||||
run: bun run test:docs:kb
|
||||
|
||||
- name: Test suite (source)
|
||||
run: bun run test:fast
|
||||
|
||||
@@ -71,6 +68,9 @@ jobs:
|
||||
path: coverage/test-src/lcov.info
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Stats UI tests
|
||||
run: bun run test:stats
|
||||
|
||||
- name: Launcher smoke suite (source)
|
||||
run: bun run test:launcher:smoke:src
|
||||
|
||||
|
||||
@@ -70,6 +70,9 @@ jobs:
|
||||
path: coverage/test-src/lcov.info
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Stats UI tests
|
||||
run: bun run test:stats
|
||||
|
||||
- name: Launcher smoke suite (source)
|
||||
run: bun run test:launcher:smoke:src
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@ jobs:
|
||||
path: coverage/test-src/lcov.info
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Stats UI tests
|
||||
run: bun run test:stats
|
||||
|
||||
- name: Launcher smoke suite (source)
|
||||
run: bun run test:launcher:smoke:src
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
/package-lock.json
|
||||
|
||||
# Superpowers brainstorming
|
||||
.superpowers/
|
||||
@@ -16,6 +17,8 @@ coverage/
|
||||
|
||||
# Launcher build artifact (produced by make build-launcher)
|
||||
/subminer
|
||||
/main-entry.js
|
||||
/main-entry.js.map
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
@@ -42,7 +42,10 @@ Start here, then leave this file.
|
||||
- Config/schema/defaults: `bun run test:config`; if template/defaults changed, `bun run generate:config-example`
|
||||
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||
- Runtime-compat / dist-sensitive: `bun run test:runtime:compat`
|
||||
- Stats dashboard UI (`stats/`): `bun run test:stats`
|
||||
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||
- Docs-only: `bun run docs:test`, then `bun run docs:build`
|
||||
- Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json`
|
||||
|
||||
## Docs Upkeep
|
||||
|
||||
|
||||
@@ -1,5 +1,86 @@
|
||||
# Changelog
|
||||
|
||||
## v0.18.0 (2026-07-10)
|
||||
|
||||
### Added
|
||||
- Sentence Audio Normalization: Generated sentence audio is now normalized to -23 LUFS by default, and clips mined from playback mirror mpv's software volume curve with a limiter to prevent clipping. Both behaviors are configurable independently.
|
||||
- Watch History Command: Added `subminer -H` / `--history` to browse watch history, replay or continue episodes, or pick one via fzf or rofi, with cover art shown in the rofi picker.
|
||||
|
||||
### Changed
|
||||
- Fzf Preview Layout: Moved fzf previews below launcher menus, giving long titles and metadata more room.
|
||||
- Known-Word Highlighting: Now compares subtitle and Anki-card readings, preventing false matches between homographs and unrelated words that share a reading, while still supporting matching across kana and kanji spellings.
|
||||
- Annotation Filtering: Standalone suffix tokens (e.g. さん, れる) are now excluded from JLPT/frequency/N+1 highlighting by default, matching how particles and interjections are treated; configurable via the pos2 exclusion setting.
|
||||
- App Icon: Replaced the app icon with new pixel-art submarine artwork contributed by the community, used across the app icon, tray, notifications, README, docs site, and Stats page.
|
||||
- Stats Trend Charts: Overhauled with persisted title visibility, per-chart title limits, "top" and "most recent" ranking modes, an option to show or hide empty days, calendar-aligned periods, and value-sorted tooltips.
|
||||
|
||||
### Fixed
|
||||
- Background Stats Server: `subminer app` background launches now auto-start the stats server when enabled, and skip startup if one is already running.
|
||||
- Character Name Highlighting: Character dictionaries now split unspaced native names more reliably, and portraits, highlights, and hover lookup survive punctuation, unmatched text, and competing dictionary matches without incorrectly splitting longer words.
|
||||
- Highlighting Coverage: Frequency/JLPT highlighting and vocabulary stats now include content adverbs (e.g. 確かに, やはり) and kanji nouns MeCab tags as non-independent (e.g. 日, 点, 以外), while still suppressing interjections, pronouns, and grammar fragments; lexicalized kana expressions like かといって keep their annotations.
|
||||
- Cover Art Fetching: Stats now fetches AniList cover art as soon as a new series starts playing, and backfills missing art for existing series on the next Stats visit.
|
||||
- Kiku Field Grouping: The manual field-grouping dialog now stays above fullscreen mpv, remains usable across repeated attempts, closes abandoned windows after timeouts, and reports a clear error when the original card can no longer be loaded.
|
||||
- Karaoke Subtitle Collapse: Secondary subtitles no longer stack dozens of one-syllable lines during openings and endings; repeated events collapse into one line, capped to a strip at the top.
|
||||
- Unparsed Token Hover: Subtitle text Yomitan can't parse (truncated inflections, elongation runs) remains hoverable, while staying excluded from highlighting, N+1 candidate math, and vocabulary stats.
|
||||
- YouTube Streaming: Fixed direct YouTube stream extraction that could corrupt signed URLs and cause ffmpeg 403 errors.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- Test lanes moved to `scripts/test-lanes.ts` with per-directory discovery and isolated per-file timeouts; CI now covers previously orphaned stats, scripts, plugin process-retry, and runtime-compat suites, plus a new stats lane in the change-verification workflow.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.17.2 (2026-06-28)
|
||||
|
||||
### Fixed
|
||||
- YouTube Background Cache: Fixed Windows YouTube background media cache startup for YouTube URLs opened directly in mpv, including resolved stream URLs when mpv still exposes the original YouTube playlist entry, so queued Anki media updates can append audio and images after the cache finishes.
|
||||
- YouTube Subtitle Picker: Manual subtitle picker requests now show an immediate configured notification while SubMiner probes tracks and opens the modal. Subtitle download progress is replaced with a transient success notification after tracks load.
|
||||
|
||||
## v0.17.1 (2026-06-27)
|
||||
|
||||
### Added
|
||||
- YouTube Media Cache Mode: Adds `youtube.mediaCache.mode` with `direct` and `background` options. Background mode uses a yt-dlp cache download when direct stream extraction is unreliable — creates a text-only card immediately, queues media updates for mined notes, and fills audio/image fields once the download finishes. Progress is announced via overlay/OSD notifications. Downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`). Switching back to direct mode cancels any in-flight background download.
|
||||
|
||||
### Fixed
|
||||
- Log Export: Fixed log filenames to use the local date so exports around UTC midnight include the current day's logs rather than stale prior-day files. Expanded export redaction to mask IPs, emails, auth and cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL parameters.
|
||||
- YouTube Card Media: Improved media generation reliability by sending safer ffmpeg options for resolved streams and skipping stale stream maps (including cached YouTube files). Hardened background cache downloads with IPv4 and extractor retry flags; failed downloads now notify the user and clear queued media updates instead of leaving them silently pending. Stale background cache files are cleaned on startup and before each new download.
|
||||
|
||||
## v0.17.0 (2026-06-15)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Subtitle Delay Keybindings**: Updated default overlay subtitle delay and step bindings to match mpv conventions: `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` with OSD feedback. The previous SubMiner-only adjacent-cue delay action has been removed.
|
||||
- **Update Notifications**: New installs now default update notifications to overlay-only instead of overlay + system notifications.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Anki – Highlight Word**: Fixed bolding of the mined word in Kiku sentence and sentence-furigana fields when the source Yomitan sentence did not already contain bold markup.
|
||||
- **Anki – Lapis/Kiku Word Cards**: Fixed word-and-sentence marker missing from Lapis/Kiku word cards enriched through SubMiner, which could hide sentence context on the card front.
|
||||
- **Anki – Windows Media Generation**: Fixed known-word cache refreshes when no deck is configured, and fixed audio/image generation after background launches by recreating missing FFmpeg temp directories before clipping.
|
||||
- **Character Dictionary – Windows**: Fixed the Windows "SubMiner mpv" shortcut so character dictionary auto-sync can fall back to mpv's current video path when app media state is not yet ready.
|
||||
- **Notifications**: Restored the SubMiner app icon on system notifications that do not supply a custom notification image.
|
||||
- **Overlay – macOS Yomitan Popup**: Fixed Yomitan popup focus after card mining or popup reload; fixed popups staying open when clicking transparent overlay space — click-away now closes the popup and returns click passthrough to mpv without a hide/reappear cycle.
|
||||
- **Overlay – Linux Auto-Pause Startup**: Fixed the visible overlay on Linux auto-paused startup to stay interactive during the initial measurement gap; startup subtitle cache misses now paint raw text before tokenization finishes, and temporarily empty `sub-text` refreshes are resolved before warm readiness resumes playback.
|
||||
- **Overlay – Playlist Advance**: Fixed the visible overlay being dismissed when mpv advances to the next playlist item, including when the next episode loads after the warm transition delay.
|
||||
- **Overlay – Windows Subtitle Bar**: Fixed shaky hover and click interaction on the Windows subtitle bar when a video attaches to an already-running background SubMiner instance.
|
||||
- **Stats – AniList Linking**: Fixed manual AniList linking from the stats anime page so auto-searches strip the generated "Season N" suffix and query only the anime title.
|
||||
- **Updates – Linux Support Assets**: Fixed Linux updates to correctly install and refresh the launcher runtime plugin copy and rofi theme alongside AppImage and launcher updates; unrelated SubMiner data directories are now left untouched and plugin copies are staged before replacing the live runtime. Fixed first-launch playback on fresh installs by auto-installing missing managed support assets from the bundled app before mpv starts.
|
||||
|
||||
### Docs
|
||||
|
||||
- **Linux Update Flows**: Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal launcher playback auto-installs those managed support assets on a fresh install if either is missing.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
|
||||
- **Runtime Modules**: Split main-process runtime wiring into focused modules without changing user-facing behavior; hardened helpers against stale background stats daemon PIDs, stalled subtitle extraction, and dropped async errors.
|
||||
- **Release CI**: Fixed GitHub release notes to preserve the `What's Changed` and `New Contributors` attribution sections when CI regenerates from the committed changelog; scoped prerelease note reuse to the same base version so a new beta line starts from current fragments.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.16.0 (2026-06-10)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -213,6 +213,7 @@ On **Windows**, just run `SubMiner.exe` and the setup will open automatically on
|
||||
subminer video.mkv # launch mpv with SubMiner
|
||||
subminer /path/to/dir # pick a file with fzf
|
||||
subminer -R /path/to/dir # pick a file with rofi (Linux only)
|
||||
subminer -H # browse local watch history (replay / next episode / browse)
|
||||
```
|
||||
|
||||
On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 433 B After Width: | Height: | Size: 253 B |
|
Before Width: | Height: | Size: 580 B After Width: | Height: | Size: 366 B |
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Fixed Highlight Word not bolding the mined word in Kiku sentence and sentence-furigana fields when the source Yomitan sentence field did not already contain bold markup.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: notifications
|
||||
|
||||
- Restored the SubMiner app icon on system notifications that do not provide a custom notification image.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Fixed Lapis/Kiku word cards enriched through SubMiner missing the word-and-sentence marker, which could hide sentence context on the card front.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: docs
|
||||
area: updates
|
||||
|
||||
- Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal launcher playback auto-installs those managed support assets if either one is missing.
|
||||
@@ -1,6 +0,0 @@
|
||||
type: fixed
|
||||
area: updates
|
||||
|
||||
- Fixed Linux updates so the managed support-asset install now creates and refreshes both the launcher runtime plugin copy and the rofi theme alongside AppImage and launcher updates.
|
||||
- Fixed Linux support-asset refreshes so unrelated SubMiner data directories are left alone and plugin copies are staged before replacing the live runtime plugin.
|
||||
- Fixed first Linux launcher playback on fresh installs by auto-installing the managed runtime plugin copy and rofi theme from the bundled app before mpv starts when either support asset is missing.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed macOS Yomitan popup focus after card mining or popup reload while still allowing click-away to close the popup without a hide/reappear cycle.
|
||||
- Fixed macOS Yomitan popups staying open when clicking transparent overlay space; click-away is captured for popup close, then passthrough returns to mpv.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed auto-paused Linux visible overlay startup so the overlay stays interactive during the first measurement gap, startup subtitle cache misses paint raw text before tokenization finishes, and temporarily empty mpv `sub-text` refreshes parsed cues before synthetic warm readiness can resume playback.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: overlay
|
||||
|
||||
- Updated default overlay subtitle delay/step bindings to match mpv: `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` and show subtitle delay on the OSD. Removed the old SubMiner-only adjacent-cue delay action.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Kept the visible overlay active while mpv advances to the next playlist item, even when the next episode loads after the warm transition delay.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: release
|
||||
|
||||
- Kept the GitHub release `What's Changed` and `New Contributors` attribution sections when CI regenerates release notes from the committed changelog.
|
||||
- Scoped prerelease note reuse to the same base version so a new beta line starts from current fragments instead of stale notes from older prereleases.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: internal
|
||||
area: runtime
|
||||
|
||||
- Split main-process runtime wiring into focused modules without changing user-facing behavior.
|
||||
- Hardened split runtime helpers against stale background stats daemon PIDs, stalled subtitle extraction, and dropped async errors.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Fixed manual AniList linking from the stats anime page so automatic searches drop the generated `Season N` suffix and search only the anime title.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: updates
|
||||
|
||||
- New installs now default update notifications to overlay-only instead of overlay + system notifications.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Fixed known-word cache refreshes without a configured deck by using AnkiConnect's valid all-notes query instead of `is:note`.
|
||||
- Fixed Windows media generation after background launches by recreating missing FFmpeg temp output directories before clipping audio or images.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: dictionary
|
||||
|
||||
- Fixed Windows `SubMiner mpv` shortcut launches so character dictionary auto-sync can fall back to mpv's current video path when app media state is not ready yet.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed shaky Windows subtitle-bar hover/click interaction when a video attaches to an already-running background SubMiner app.
|
||||
@@ -515,7 +515,7 @@
|
||||
// ==========================================
|
||||
// AnkiConnect Integration
|
||||
// Automatic Anki updates and media generation options.
|
||||
// Hot-reload: ankiConnect.ai.enabled, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
|
||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
|
||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||
// Most other AnkiConnect settings still require restart.
|
||||
// ==========================================
|
||||
@@ -559,6 +559,8 @@
|
||||
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
|
||||
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Changes apply live. Values: true | false
|
||||
"mirrorMpvVolume": true, // Apply mpv's current software volume curve to generated sentence audio. Changes apply live. Values: true | false
|
||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
@@ -568,7 +570,7 @@
|
||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
|
||||
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
|
||||
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.
|
||||
}, // Known words setting.
|
||||
"behavior": {
|
||||
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
||||
@@ -618,7 +620,11 @@
|
||||
"primarySubLanguages": [
|
||||
"ja",
|
||||
"jpn"
|
||||
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||
], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||
"mediaCache": {
|
||||
"mode": "direct", // How YouTube card audio/images are extracted. Values: direct | background
|
||||
"maxHeight": 720 // Maximum video height downloaded for the YouTube background media cache. Set to 0 for unlimited.
|
||||
} // Media cache setting.
|
||||
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -161,13 +161,17 @@ Audio is extracted from the video file using the subtitle's start and end timest
|
||||
"ankiConnect": {
|
||||
"media": {
|
||||
"generateAudio": true,
|
||||
"normalizeAudio": true, // normalize generated clip loudness
|
||||
"mirrorMpvVolume": true, // apply the current mpv volume level
|
||||
"audioPadding": 0, // optional seconds before and after subtitle timing
|
||||
"maxMediaDuration": 30 // cap total duration in seconds
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream.
|
||||
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream. Generated sentence audio is loudness-normalized by default during extraction; set `normalizeAudio` to `false` to keep raw source loudness. Changing this setting applies to the next extraction without restarting SubMiner.
|
||||
|
||||
`mirrorMpvVolume` is also enabled by default. Immediately before extracting each playback-overlay card's audio, SubMiner reads mpv's numeric `volume` and applies mpv's cubic software-volume curve after loudness normalization. For example, mpv volume `50` produces `0.5³ = 0.125` gain. Amplified output above mpv volume `100` is limited to a `-1 dBFS` ceiling before MP3 encoding to prevent clipping. It ignores mpv's separate `mute` state. If the volume property is missing, invalid, or unavailable, extraction continues with unity scaling; disabling this option skips the query and volume filter. Changing this setting applies to the next extraction without restarting SubMiner. YouTube cards queued for a background media-cache download retain the volume captured when the card was mined. Stats-dashboard mining does not currently have access to the active mpv property client, so it does not apply mpv volume scaling.
|
||||
|
||||
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
|
||||
|
||||
@@ -347,6 +351,8 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
||||
"imageType": "static",
|
||||
"imageFormat": "jpg",
|
||||
"imageQuality": 92,
|
||||
"normalizeAudio": true,
|
||||
"mirrorMpvVolume": true,
|
||||
"audioPadding": 0,
|
||||
"maxMediaDuration": 30,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,97 @@
|
||||
# Changelog
|
||||
|
||||
## v0.16.0 (2026-06-10)
|
||||
## v0.18.0 (2026-07-10)
|
||||
|
||||
**Added**
|
||||
- Sentence Audio Normalization: Generated sentence audio is now normalized to -23 LUFS by default, and clips mined from playback mirror mpv's software volume curve with a limiter to prevent clipping. Both behaviors are configurable independently.
|
||||
- Watch History Command: Added `subminer -H` / `--history` to browse watch history, replay or continue episodes, or pick one via fzf or rofi, with cover art shown in the rofi picker.
|
||||
|
||||
**Changed**
|
||||
- Fzf Preview Layout: Moved fzf previews below launcher menus, giving long titles and metadata more room.
|
||||
- Known-Word Highlighting: Now compares subtitle and Anki-card readings, preventing false matches between homographs and unrelated words that share a reading, while still supporting matching across kana and kanji spellings.
|
||||
- Annotation Filtering: Standalone suffix tokens (e.g. さん, れる) are now excluded from JLPT/frequency/N+1 highlighting by default, matching how particles and interjections are treated; configurable via the pos2 exclusion setting.
|
||||
- App Icon: Replaced the app icon with new pixel-art submarine artwork contributed by the community, used across the app icon, tray, notifications, README, docs site, and Stats page.
|
||||
- Stats Trend Charts: Overhauled with persisted title visibility, per-chart title limits, "top" and "most recent" ranking modes, an option to show or hide empty days, calendar-aligned periods, and value-sorted tooltips.
|
||||
|
||||
**Fixed**
|
||||
- Background Stats Server: `subminer app` background launches now auto-start the stats server when enabled, and skip startup if one is already running.
|
||||
- Character Name Highlighting: Character dictionaries now split unspaced native names more reliably, and portraits, highlights, and hover lookup survive punctuation, unmatched text, and competing dictionary matches without incorrectly splitting longer words.
|
||||
- Highlighting Coverage: Frequency/JLPT highlighting and vocabulary stats now include content adverbs (e.g. 確かに, やはり) and kanji nouns MeCab tags as non-independent (e.g. 日, 点, 以外), while still suppressing interjections, pronouns, and grammar fragments; lexicalized kana expressions like かといって keep their annotations.
|
||||
- Cover Art Fetching: Stats now fetches AniList cover art as soon as a new series starts playing, and backfills missing art for existing series on the next Stats visit.
|
||||
- Kiku Field Grouping: The manual field-grouping dialog now stays above fullscreen mpv, remains usable across repeated attempts, closes abandoned windows after timeouts, and reports a clear error when the original card can no longer be loaded.
|
||||
- Karaoke Subtitle Collapse: Secondary subtitles no longer stack dozens of one-syllable lines during openings and endings; repeated events collapse into one line, capped to a strip at the top.
|
||||
- Unparsed Token Hover: Subtitle text Yomitan can't parse (truncated inflections, elongation runs) remains hoverable, while staying excluded from highlighting, N+1 candidate math, and vocabulary stats.
|
||||
- YouTube Streaming: Fixed direct YouTube stream extraction that could corrupt signed URLs and cause ffmpeg 403 errors.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
**Internal**
|
||||
- Test lanes moved to `scripts/test-lanes.ts` with per-directory discovery and isolated per-file timeouts; CI now covers previously orphaned stats, scripts, plugin process-retry, and runtime-compat suites, plus a new stats lane in the change-verification workflow.
|
||||
|
||||
</details>
|
||||
|
||||
## Previous Versions
|
||||
|
||||
<details>
|
||||
<summary>v0.17.x</summary>
|
||||
|
||||
<h2>v0.17.2 (2026-06-28)</h2>
|
||||
|
||||
**Fixed**
|
||||
- YouTube Background Cache: Fixed Windows YouTube background media cache startup for YouTube URLs opened directly in mpv, including resolved stream URLs when mpv still exposes the original YouTube playlist entry, so queued Anki media updates can append audio and images after the cache finishes.
|
||||
- YouTube Subtitle Picker: Manual subtitle picker requests now show an immediate configured notification while SubMiner probes tracks and opens the modal. Subtitle download progress is replaced with a transient success notification after tracks load.
|
||||
|
||||
<h2>v0.17.1 (2026-06-27)</h2>
|
||||
|
||||
**Added**
|
||||
- YouTube Media Cache Mode: Adds `youtube.mediaCache.mode` with `direct` and `background` options. Background mode uses a yt-dlp cache download when direct stream extraction is unreliable — creates a text-only card immediately, queues media updates for mined notes, and fills audio/image fields once the download finishes. Progress is announced via overlay/OSD notifications. Downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`). Switching back to direct mode cancels any in-flight background download.
|
||||
|
||||
**Fixed**
|
||||
- Log Export: Fixed log filenames to use the local date so exports around UTC midnight include the current day's logs rather than stale prior-day files. Expanded export redaction to mask IPs, emails, auth and cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL parameters.
|
||||
- YouTube Card Media: Improved media generation reliability by sending safer ffmpeg options for resolved streams and skipping stale stream maps (including cached YouTube files). Hardened background cache downloads with IPv4 and extractor retry flags; failed downloads now notify the user and clear queued media updates instead of leaving them silently pending. Stale background cache files are cleaned on startup and before each new download.
|
||||
|
||||
<h2>v0.17.0 (2026-06-15)</h2>
|
||||
|
||||
**Changed**
|
||||
|
||||
- **Subtitle Delay Keybindings**: Updated default overlay subtitle delay and step bindings to match mpv conventions: `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` with OSD feedback. The previous SubMiner-only adjacent-cue delay action has been removed.
|
||||
- **Update Notifications**: New installs now default update notifications to overlay-only instead of overlay + system notifications.
|
||||
|
||||
**Fixed**
|
||||
|
||||
- **Anki – Highlight Word**: Fixed bolding of the mined word in Kiku sentence and sentence-furigana fields when the source Yomitan sentence did not already contain bold markup.
|
||||
- **Anki – Lapis/Kiku Word Cards**: Fixed word-and-sentence marker missing from Lapis/Kiku word cards enriched through SubMiner, which could hide sentence context on the card front.
|
||||
- **Anki – Windows Media Generation**: Fixed known-word cache refreshes when no deck is configured, and fixed audio/image generation after background launches by recreating missing FFmpeg temp directories before clipping.
|
||||
- **Character Dictionary – Windows**: Fixed the Windows "SubMiner mpv" shortcut so character dictionary auto-sync can fall back to mpv's current video path when app media state is not yet ready.
|
||||
- **Notifications**: Restored the SubMiner app icon on system notifications that do not supply a custom notification image.
|
||||
- **Overlay – macOS Yomitan Popup**: Fixed Yomitan popup focus after card mining or popup reload; fixed popups staying open when clicking transparent overlay space — click-away now closes the popup and returns click passthrough to mpv without a hide/reappear cycle.
|
||||
- **Overlay – Linux Auto-Pause Startup**: Fixed the visible overlay on Linux auto-paused startup to stay interactive during the initial measurement gap; startup subtitle cache misses now paint raw text before tokenization finishes, and temporarily empty `sub-text` refreshes are resolved before warm readiness resumes playback.
|
||||
- **Overlay – Playlist Advance**: Fixed the visible overlay being dismissed when mpv advances to the next playlist item, including when the next episode loads after the warm transition delay.
|
||||
- **Overlay – Windows Subtitle Bar**: Fixed shaky hover and click interaction on the Windows subtitle bar when a video attaches to an already-running background SubMiner instance.
|
||||
- **Stats – AniList Linking**: Fixed manual AniList linking from the stats anime page so auto-searches strip the generated "Season N" suffix and query only the anime title.
|
||||
- **Updates – Linux Support Assets**: Fixed Linux updates to correctly install and refresh the launcher runtime plugin copy and rofi theme alongside AppImage and launcher updates; unrelated SubMiner data directories are now left untouched and plugin copies are staged before replacing the live runtime. Fixed first-launch playback on fresh installs by auto-installing missing managed support assets from the bundled app before mpv starts.
|
||||
|
||||
**Docs**
|
||||
|
||||
- **Linux Update Flows**: Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal launcher playback auto-installs those managed support assets on a fresh install if either is missing.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
**Internal**
|
||||
|
||||
- **Runtime Modules**: Split main-process runtime wiring into focused modules without changing user-facing behavior; hardened helpers against stale background stats daemon PIDs, stalled subtitle extraction, and dropped async errors.
|
||||
- **Release CI**: Fixed GitHub release notes to preserve the `What's Changed` and `New Contributors` attribution sections when CI regenerates from the committed changelog; scoped prerelease note reuse to the same base version so a new beta line starts from current fragments.
|
||||
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v0.16.x</summary>
|
||||
|
||||
<h2>v0.16.0 (2026-06-10)</h2>
|
||||
|
||||
**Breaking Changes**
|
||||
|
||||
@@ -24,7 +115,7 @@
|
||||
- **Stats Browsing**: Remembers library card size; retries stored cover art without extra AniList lookups; preserves PNG/WebP MIME types; honors custom AnkiConnect URLs for Browse; shows progress during session deletes.
|
||||
- **Startup Notifications**: Tokenization, subtitle annotation, and character dictionary status now route through queued overlay notifications in `overlay`/`both` mode instead of falling back to mpv OSD while the overlay loads.
|
||||
- **Notification Deduplication**: Cycling subtitle modes updates the active overlay card in place rather than stacking duplicates; repeated progress updates (e.g. subsync) tick in place without flickering.
|
||||
- **Update Notification Default**: New installs default `notificationType` to `overlay`, while `both` remains available for overlay + system notifications.
|
||||
- **Update Notification Default**: New installs default `notificationType` to `both` so update alerts appear in both overlay and system notifications.
|
||||
|
||||
**Fixed**
|
||||
|
||||
@@ -48,7 +139,7 @@
|
||||
|
||||
</details>
|
||||
|
||||
## Previous Versions
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v0.15.x</summary>
|
||||
|
||||
@@ -56,6 +56,8 @@ A single character produces many searchable terms so that names are recognized r
|
||||
- Family name alone: 須々木
|
||||
- Given name alone: 心一
|
||||
|
||||
Unspaced native names (AniList often stores 渡辺真奈美 without a separator) are split into family/given parts with MeCab when it is available: person-name POS tags (姓/名) decide the boundary, validated against AniList's romanized first/last name readings. Without MeCab, a length heuristic based on the romanized readings guesses the boundary — and because that guess can be ambiguous (東紫乃 could be 東+紫乃 or 東紫+乃), terms are generated for the top two candidate boundaries so the real surname still matches. Snapshots built without MeCab are regenerated automatically once MeCab becomes available, upgrading them to the exact splits.
|
||||
|
||||
**Middle-dot removal** (common in katakana foreign names):
|
||||
|
||||
- ア・リ・ス → アリス (combined), plus individual segments
|
||||
|
||||
@@ -188,6 +188,9 @@ Control the minimum log level for runtime output:
|
||||
| `files.launcher` | boolean | Write launcher command logs (default: `true`) |
|
||||
| `files.mpv` | boolean | Write mpv player logs. Enable temporarily for mpv/plugin debugging. |
|
||||
|
||||
Log filenames use the local calendar date, for example `app-YYYY-MM-DD.log`, `launcher-YYYY-MM-DD.log`, and `mpv-YYYY-MM-DD.log`.
|
||||
Log export creates a sanitized copy of those files; it does not rewrite the original log files on disk.
|
||||
|
||||
### Updates
|
||||
|
||||
Configure automatic update checks and update notifications:
|
||||
@@ -948,6 +951,8 @@ Enable automatic Anki card creation and updates with media generation:
|
||||
"animatedMaxWidth": 640,
|
||||
"animatedMaxHeight": 0,
|
||||
"animatedCrf": 35,
|
||||
"normalizeAudio": true,
|
||||
"mirrorMpvVolume": true,
|
||||
"audioPadding": 0,
|
||||
"fallbackDuration": 3,
|
||||
"maxMediaDuration": 30
|
||||
@@ -998,6 +1003,8 @@ This example is intentionally compact. The option table below documents availabl
|
||||
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
|
||||
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
|
||||
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
||||
| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. Changes apply live. |
|
||||
| `media.mirrorMpvVolume` | `true`, `false` | Apply mpv's cubic software-volume curve to each generated sentence-audio clip (default: `true`). This ignores mpv's separate mute state, falls back to unity scaling if volume cannot be read, and applies changes live. |
|
||||
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
||||
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||
@@ -1520,14 +1527,24 @@ Set defaults used by managed subtitle auto-selection and the `subminer` launcher
|
||||
```json
|
||||
{
|
||||
"youtube": {
|
||||
"primarySubLanguages": ["ja", "jpn"]
|
||||
"primarySubLanguages": ["ja", "jpn"],
|
||||
"mediaCache": {
|
||||
"mode": "direct",
|
||||
"maxHeight": 720
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| --------------------- | -------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) |
|
||||
| Option | Values | Description |
|
||||
| ---------------------- | ------------------------ | ------------------------------------------------------------------------------------------------ |
|
||||
| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) |
|
||||
| `mediaCache.mode` | `direct` \| `background` | YouTube card audio/image extraction mode (default `direct`) |
|
||||
| `mediaCache.maxHeight` | number | Maximum background cache download height. Set `0` for unlimited (default `720`) |
|
||||
|
||||
`mediaCache.mode: "direct"` extracts card media from the active YouTube stream URL. `mediaCache.mode: "background"` starts a separate yt-dlp media download after YouTube playback has loaded, including YouTube URLs opened directly in mpv and resolved stream URLs when mpv still exposes the original YouTube playlist entry. Playback and subtitle loading do not wait for that download. Use background mode if direct card media generation hits YouTube `403` errors from expiring stream URLs.
|
||||
|
||||
Background cache downloads are capped by `mediaCache.maxHeight`, which defaults to 720p; set it to `0` to let yt-dlp choose the best available height. Downloads use IPv4 and yt-dlp retry flags to reduce YouTube throttling failures. SubMiner announces when the background cache download starts and when the cache is ready, using the configured notification surface; overlay and OSD messages queue until the overlay or mpv is ready. If you mine cards before the cache is ready, SubMiner creates the text fields immediately, queues the audio/image work for those note IDs, shows a status notification, and fills the media fields once the cached file is ready. If the cache download fails, SubMiner shows a failure notification, shows queued-card failure notifications, and clears the pending updates.
|
||||
|
||||
Current launcher behavior:
|
||||
|
||||
|
||||
@@ -80,18 +80,24 @@ Default lanes:
|
||||
|
||||
```bash
|
||||
bun run test # alias for test:fast
|
||||
bun run test:fast # default fast lane
|
||||
bun run test:full # maintained source + launcher-unit + runtime compat surface
|
||||
bun run test:fast # full source lanes: src + launcher-unit + scripts + runtime compat
|
||||
bun run test:runtime:compat # compiled/runtime compatibility slice only
|
||||
bun run test:env # launcher/plugin + env-sensitive verification
|
||||
bun run test:stats # stats dashboard UI suite
|
||||
bun run test:immersion:sqlite # SQLite persistence lane
|
||||
bun run test:subtitle # maintained alass/ffsubsync subtitle surface
|
||||
```
|
||||
|
||||
- `bun run test` and `bun run test:fast` cover config/core suites plus representative entry/runtime, Anki integration, release-workflow coverage, typecheck, and runtime-registry checks.
|
||||
- `bun run test:full` is the maintained full surface: Bun-compatible `src/**` discovery, Bun-compatible launcher unit discovery, and the compiled/runtime compatibility lane for suites routed through `dist/**`.
|
||||
Test lane membership is defined once in `scripts/test-lanes.ts` and discovered by
|
||||
directory, so new test files join their lane automatically. `scripts/run-test-lane.mjs`
|
||||
runs each test file in its own `bun test` process (per-file isolation) so a hanging
|
||||
test or leaked global in one file cannot cascade into the rest of the lane; pass
|
||||
`--jobs N` to parallelize or `--single-process` for one shared process.
|
||||
|
||||
- `bun run test` and `bun run test:fast` cover the full discovered `src/**` suite, launcher unit tests, `scripts/**` tests, and the compiled/runtime compatibility lane.
|
||||
- `bun run test:runtime:compat` covers the compiled/runtime slice directly: `ipc`, `anki-jimaku-ipc`, `overlay-manager`, `config-validation`, `startup-config`, and `registry`.
|
||||
- `bun run test:env` covers environment-sensitive checks: launcher smoke/plugin verification plus the Bun source SQLite lane.
|
||||
- `bun run test:stats` runs the stats dashboard suite under `stats/src/**`.
|
||||
- `bun run test:immersion:sqlite` is the reproducible persistence lane when you need real DB-backed SQLite coverage under Bun.
|
||||
|
||||
The Bun-managed discovery lanes intentionally exclude a small compiled/runtime-focused set: `src/core/services/ipc.test.ts`, `src/core/services/anki-jimaku-ipc.test.ts`, `src/core/services/overlay-manager.test.ts`, `src/main/config-validation.test.ts`, `src/main/runtime/startup-config.test.ts`, and `src/main/runtime/registry.test.ts`. `bun run test:runtime:compat` keeps them in the standard workflow via `dist/**`.
|
||||
@@ -126,11 +132,11 @@ Focused commands:
|
||||
```bash
|
||||
bun run test:config # Source-level config schema/validation tests
|
||||
bun run test:launcher # Launcher regression tests (config discovery + command routing)
|
||||
bun run test:core # Source-level core regression tests (default lane)
|
||||
bun run test:launcher:smoke:src # Launcher e2e smoke: launcher -> mpv IPC -> overlay start/stop wiring
|
||||
bun run test:launcher:env:src # Launcher smoke + Lua plugin gate
|
||||
bun run test:src # Bun-managed maintained src/** discovery lane
|
||||
bun run test:launcher:unit:src # Bun-managed maintained launcher unit lane
|
||||
bun run test:scripts # Bun-managed scripts/** test lane
|
||||
bun run test:immersion:sqlite:src # Bun source lane
|
||||
```
|
||||
|
||||
@@ -144,8 +150,6 @@ Smoke and optional deep dist commands:
|
||||
bun run build # compile dist artifacts
|
||||
bun run test:immersion:sqlite # compile + run SQLite-backed immersion tests under Bun
|
||||
bun run test:smoke:dist # explicit smoke scope for compiled runtime
|
||||
bun run test:config:dist # optional full dist config suite
|
||||
bun run test:core:dist # optional full dist core suite
|
||||
```
|
||||
|
||||
Use `bun run test:immersion:sqlite` when you need real DB-backed coverage for the immersion tracker.
|
||||
|
||||
@@ -95,7 +95,7 @@ Stats server config lives under `stats`:
|
||||
|
||||
- `toggleKey` is overlay-local, not a system-wide shortcut.
|
||||
- `serverPort` controls the localhost dashboard URL.
|
||||
- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running.
|
||||
- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running. Background app launches (`subminer app`) start the stats server immediately, registering it so later launches reuse it instead of starting another one.
|
||||
- `autoOpenBrowser` controls whether `subminer stats` launches the dashboard URL in your browser after ensuring the server is running.
|
||||
- `subminer stats` forces the dashboard server to start even when `autoStartServer` is `false`.
|
||||
- `subminer stats -b` starts or reuses the dedicated background stats daemon and exits after startup acknowledgement.
|
||||
|
||||
@@ -61,6 +61,23 @@ Override with the `SUBMINER_ROFI_THEME` environment variable:
|
||||
SUBMINER_ROFI_THEME=/path/to/custom-theme.rasi subminer -R
|
||||
```
|
||||
|
||||
## Watch History
|
||||
|
||||
`subminer -H` (or `--history`) browses your local watch history, sourced from the immersion tracker database. It works with both pickers: fzf by default, rofi with `-R -H`.
|
||||
|
||||
```bash
|
||||
subminer -H # fzf history browser
|
||||
subminer -R -H # rofi history browser
|
||||
```
|
||||
|
||||
The first menu lists every locally watched series, most recently watched first, using the parsed media title (e.g. the anime title) when available and the directory name otherwise. Selecting a series opens an action menu:
|
||||
|
||||
- **Replay last watched** — replays the most recently watched episode
|
||||
- **Next episode** — plays the episode after the last watched one (continues into the next season directory when the season ends)
|
||||
- **Browse episodes** — lists the video files in the series directory in episode order, using the same fzf/rofi episode picker as directory browsing; if the series has multiple season directories, a season menu is shown first
|
||||
|
||||
Series whose directories are not currently accessible (e.g. an unmounted network share) are hidden from the list. Watch history requires the immersion tracker database (`immersionTracking.dbPath`, default `<config dir>/immersion.sqlite`), which SubMiner populates during playback.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
@@ -83,7 +100,7 @@ subminer stats -b # start background stats daemon
|
||||
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows |
|
||||
| `subminer doctor` | Dependency + config + socket diagnostics |
|
||||
| `subminer settings` | Open the SubMiner settings window |
|
||||
| `subminer logs -e` | Export a sanitized log ZIP and print its path |
|
||||
| `subminer logs -e` | Export a sanitized local-date log ZIP and print its path |
|
||||
| `subminer config path` | Print active config file path |
|
||||
| `subminer config show` | Print active config contents |
|
||||
| `subminer mpv status` | Check mpv socket readiness |
|
||||
@@ -105,6 +122,7 @@ Use `subminer <subcommand> -h` for command-specific help.
|
||||
| `-d, --directory` | Video search directory (default: cwd) |
|
||||
| `-r, --recursive` | Search directories recursively |
|
||||
| `-R, --rofi` | Use rofi instead of fzf |
|
||||
| `-H, --history` | Browse local watch history (see [Watch History](#watch-history)) |
|
||||
| `--setup` | Open first-run setup popup manually |
|
||||
| `-v, --version` | Print installed SubMiner version |
|
||||
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 15 KiB |
@@ -515,7 +515,7 @@
|
||||
// ==========================================
|
||||
// AnkiConnect Integration
|
||||
// Automatic Anki updates and media generation options.
|
||||
// Hot-reload: ankiConnect.ai.enabled, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
|
||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
|
||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||
// Most other AnkiConnect settings still require restart.
|
||||
// ==========================================
|
||||
@@ -559,6 +559,8 @@
|
||||
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
|
||||
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Changes apply live. Values: true | false
|
||||
"mirrorMpvVolume": true, // Apply mpv's current software volume curve to generated sentence audio. Changes apply live. Values: true | false
|
||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
@@ -568,7 +570,7 @@
|
||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
|
||||
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
|
||||
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.
|
||||
}, // Known words setting.
|
||||
"behavior": {
|
||||
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
||||
@@ -618,7 +620,11 @@
|
||||
"primarySubLanguages": [
|
||||
"ja",
|
||||
"jpn"
|
||||
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||
], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||
"mediaCache": {
|
||||
"mode": "direct", // How YouTube card audio/images are extracted. Values: direct | background
|
||||
"maxHeight": 720 // Maximum video height downloaded for the YouTube background media cache. Set to 0 for unlimited.
|
||||
} // Media cache setting.
|
||||
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||
|
||||
// ==========================================
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 633 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -90,7 +90,7 @@ SubMiner looks up each token's `frequencyRank` from `term_meta_bank_*.json` file
|
||||
When `sourcePath` is omitted, SubMiner searches default install/runtime locations for `frequency-dictionary` directories automatically.
|
||||
|
||||
::: info
|
||||
Frequency highlighting skips tokens that look like non-lexical noise (kana reduplication, short kana endings like `っ`), even when dictionary ranks exist.
|
||||
Frequency highlighting skips tokens that look like non-lexical noise (kana reduplication, short kana endings like `っ`), even when dictionary ranks exist. For merged kana tokens, SubMiner keeps a rank when the dictionary headword reading covers the full token (for example, `かと言って` / `かといって`), while grammar wrapped around a shorter lemma remains unannotated.
|
||||
:::
|
||||
|
||||
::: info
|
||||
|
||||
@@ -148,7 +148,7 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin corre
|
||||
SubMiner.AppImage --help # Show all options
|
||||
```
|
||||
|
||||
The tray menu includes `Export Logs`, which creates the same sanitized log ZIP as `subminer logs -e` and shows the archive path when complete.
|
||||
The tray menu includes `Export Logs`, which creates the same sanitized local-date log ZIP as `subminer logs -e` and shows the archive path when complete. Export sanitization masks common PII and secrets, including home-directory usernames, IP addresses, emails, auth/cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL query strings. The exported copy is sanitized; source log files remain unredacted on disk.
|
||||
|
||||
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
|
||||
|
||||
@@ -191,7 +191,7 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
|
||||
- `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
|
||||
- `subminer doctor`: health checks for core dependencies and runtime paths.
|
||||
- `subminer settings`: open the SubMiner settings window (also `subminer --settings`).
|
||||
- `subminer logs -e`: export a sanitized ZIP of today's logs, or the most recent logs when no current-day log exists.
|
||||
- `subminer logs -e`: export a sanitized ZIP of today's local-date logs, or the most recent logs when no current-day log exists. The exported copy masks common PII and secrets; on-disk logs are unchanged.
|
||||
- `subminer config`: config file helpers (`path`, `show`).
|
||||
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
|
||||
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
|
||||
|
||||
@@ -74,20 +74,30 @@ Press **Ctrl+Alt+C** during YouTube playback to open the subtitle picker overlay
|
||||
- Select different primary and secondary tracks
|
||||
- Retry track loading if the auto-load failed or picked the wrong track
|
||||
|
||||
SubMiner shows an "Opening YouTube subtitle picker..." status through your configured notification
|
||||
surface while it probes tracks and prepares the modal, then updates the subtitle download progress
|
||||
card to a success notification after the selected tracks load.
|
||||
|
||||
The picker displays each track with its language, kind (manual/auto), and title when available.
|
||||
|
||||
## Subtitle Format Handling
|
||||
|
||||
SubMiner handles several YouTube subtitle formats transparently:
|
||||
|
||||
| Format | Handling |
|
||||
| ------ | -------- |
|
||||
| `srt`, `vtt` | Used directly (preferred for manual tracks) |
|
||||
| Format | Handling |
|
||||
| ---------------------- | -------------------------------------------------------- |
|
||||
| `srt`, `vtt` | Used directly (preferred for manual tracks) |
|
||||
| `srv1`, `srv2`, `srv3` | YouTube TimedText XML --- converted to VTT automatically |
|
||||
| Auto-generated VTT | Normalized to remove rolling-caption text duplication |
|
||||
| Auto-generated VTT | Normalized to remove rolling-caption text duplication |
|
||||
|
||||
For auto-generated tracks, SubMiner prefers `srv3` > `srv2` > `srv1` > `vtt` (TimedText XML produces cleaner output). For manual tracks, `srt` > `vtt` is preferred.
|
||||
|
||||
## Card Media Cache
|
||||
|
||||
By default, YouTube card audio and screenshots are extracted directly from mpv's active stream URLs. If generated card media fails with YouTube `403` errors, set `youtube.mediaCache.mode` to `"background"`. Background mode starts a separate `yt-dlp` media download after playback loads, including YouTube URLs opened directly in mpv and resolved stream URLs when mpv still exposes the original YouTube playlist entry. It creates text fields immediately, queues audio/image work for mined notes, and fills those fields once the local cache file is ready.
|
||||
|
||||
Background cache downloads use IPv4 and retry flags to reduce YouTube throttling failures. If the background download still fails, SubMiner shows a cache failure notification, shows queued-card failure notifications, and clears those pending updates so cards are not left waiting silently.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### Primary Subtitle Languages
|
||||
@@ -95,13 +105,13 @@ For auto-generated tracks, SubMiner prefers `srv3` > `srv2` > `srv1` > `vtt` (Ti
|
||||
```jsonc
|
||||
{
|
||||
"youtube": {
|
||||
"primarySubLanguages": ["ja", "jpn"]
|
||||
}
|
||||
"primarySubLanguages": ["ja", "jpn"],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| Option | Type | Description |
|
||||
| --------------------- | ---------- | ------------------------------------------------------------------------------------- |
|
||||
| `primarySubLanguages` | `string[]` | Language priority for YouTube primary subtitle auto-loading (default `["ja", "jpn"]`) |
|
||||
|
||||
### Secondary Subtitle Languages
|
||||
@@ -113,16 +123,16 @@ Secondary track selection uses the shared `secondarySub` config:
|
||||
"secondarySub": {
|
||||
"secondarySubLanguages": [],
|
||||
"autoLoadSecondarySub": false,
|
||||
"defaultMode": "hover"
|
||||
}
|
||||
"defaultMode": "hover",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track. Default is empty (`[]`). For YouTube, SubMiner always tries an English track first regardless of this list. |
|
||||
| `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary track (default: `false`) |
|
||||
| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
|
||||
| Option | Type | Description |
|
||||
| ----------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track. Default is empty (`[]`). For YouTube, SubMiner always tries an English track first regardless of this list. |
|
||||
| `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary track (default: `false`) |
|
||||
| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
|
||||
|
||||
Precedence: CLI flag > environment variable > `config.jsonc` > built-in default.
|
||||
|
||||
@@ -131,6 +141,7 @@ Precedence: CLI flag > environment variable > `config.jsonc` > built-in default.
|
||||
- **No subtitles found**: The video may not have Japanese subtitles. Open the picker with `Ctrl+Alt+C` to see all available tracks.
|
||||
- **yt-dlp not found**: Install `yt-dlp` and ensure it is on `PATH`, or set `SUBMINER_YTDLP_BIN` to the binary path.
|
||||
- **Probe timeout**: `yt-dlp` has a 15-second timeout per operation. Slow connections or rate-limited IPs may hit this. Retry or update `yt-dlp`.
|
||||
- **Card media `403` errors**: Switch `youtube.mediaCache.mode` from `"direct"` to `"background"` so card media is generated from a local `yt-dlp` cache instead of ffmpeg reading an expiring YouTube stream URL.
|
||||
- **Auto-caption quality**: YouTube auto-generated captions vary in quality. Manual subtitles (when available) are always preferred.
|
||||
- **`ytsearch:` targets**: `subminer ytsearch:"keyword"` plays the first search result. Subtitle availability depends on the matched video.
|
||||
- **Secondary subtitle fails**: Secondary track failures never block playback. The primary subtitle loads independently.
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<!-- read_when: changing managed mpv startup, pause-until-ready, or visible overlay boot ordering -->
|
||||
|
||||
# Early Managed Overlay Startup Design
|
||||
|
||||
Status: approved
|
||||
Date: 2026-06-06
|
||||
|
||||
## Problem
|
||||
|
||||
Managed mpv startup can pause playback immediately, then leave SubMiner's tray and visible overlay
|
||||
unavailable until Yomitan/tokenization warmups finish. Startup notifications therefore miss the
|
||||
overlay surface and fall back to non-overlay status paths.
|
||||
|
||||
## Chosen Approach
|
||||
|
||||
For cold `--start --background --managed-playback` launches, handle initial args before waiting for
|
||||
the deferred overlay warmup. That lets the tray and visible overlay shell initialize immediately
|
||||
while the existing tokenization warmups continue in the background.
|
||||
|
||||
The mpv plugin pause gate stays armed. Playback release still waits for SubMiner's autoplay-ready
|
||||
signal, which is emitted only after tokenization warmup and visible-overlay readiness. Existing
|
||||
second-instance attach behavior remains unchanged: when the launcher finds an already-running
|
||||
background app, it sends the same control command to that process and reuses its warmups/tokenizer.
|
||||
|
||||
## Checks
|
||||
|
||||
- Add a startup ordering regression test for managed background playback.
|
||||
- Keep the existing deferred startup ordering for non-managed launches.
|
||||
- Run the startup/runtime test slice plus SubMiner verification lane.
|
||||
@@ -1,27 +0,0 @@
|
||||
<!-- read_when: changing overlay notification hover, macOS mouse passthrough, or notification actions -->
|
||||
|
||||
# macOS Notification Hover Stability Design
|
||||
|
||||
Status: approved
|
||||
Date: 2026-06-09
|
||||
|
||||
## Problem
|
||||
|
||||
On macOS, hovering a character dictionary build notification can make the card flicker and slide as
|
||||
if it is hiding, then snap back. The likely trigger is the notification stack changing the overlay
|
||||
window's mouse-passthrough state for a progress card that has no user action.
|
||||
|
||||
## Chosen Approach
|
||||
|
||||
Keep non-action overlay notifications visually stable and click-through on hover. Only notifications
|
||||
with explicit actions should request interactive overlay input. The notification history panel keeps
|
||||
its existing interactive behavior.
|
||||
|
||||
This avoids a macOS mouseenter/mouseleave passthrough loop for passive progress cards while
|
||||
preserving clickable notification actions.
|
||||
|
||||
## Checks
|
||||
|
||||
- Add a renderer regression test for passive notification hover.
|
||||
- Keep action-bearing notification cards interactive.
|
||||
- Run the targeted overlay notification and mouse-ignore tests.
|
||||
@@ -3,10 +3,22 @@
|
||||
# Verification
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-05-23
|
||||
Last verified: 2026-07-06
|
||||
Owner: Kyle Yasuda
|
||||
Read when: selecting the right verification lane for a change
|
||||
|
||||
## Lane Infrastructure
|
||||
|
||||
- Lane membership is defined once in `scripts/test-lanes.ts` and discovered by
|
||||
directory — new test files join their lane automatically; never hand-list test
|
||||
files in `package.json`.
|
||||
- `scripts/run-test-lane.mjs` runs each test file in its own `bun test` process
|
||||
(per-file isolation with a wall timeout) so a hanging test or leaked global in
|
||||
one file cannot cascade into the rest of the lane. `--jobs N` parallelizes;
|
||||
`--single-process` restores the shared-process mode for debugging.
|
||||
- `bun run test:fast` is the full source gate: discovered `src/**`, launcher
|
||||
unit, `scripts/**`, and the compiled runtime-compat slice.
|
||||
|
||||
## Default Handoff Gate
|
||||
|
||||
```bash
|
||||
@@ -31,6 +43,8 @@ bun run docs:build
|
||||
- Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed
|
||||
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||
- Runtime-compat / compiled behavior: `bun run test:runtime:compat`
|
||||
- Stats dashboard UI: `bun run test:stats`
|
||||
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||
- Coverage for the maintained source lane: `bun run test:coverage:src`
|
||||
- Deep/local full gate: default handoff gate above
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Minimal ambient typing for bun:sqlite. The launcher always runs under bun
|
||||
// (see the build banner in package.json), but the repo typechecks with plain
|
||||
// tsc which has no bun type definitions.
|
||||
declare module 'bun:sqlite' {
|
||||
export interface RunResult {
|
||||
changes: number;
|
||||
lastInsertRowid: number | bigint;
|
||||
}
|
||||
|
||||
export interface Statement<ReturnType = unknown, ParamsType extends unknown[] = unknown[]> {
|
||||
all(...params: ParamsType): ReturnType[];
|
||||
get(...params: ParamsType): ReturnType | undefined;
|
||||
run(...params: ParamsType): RunResult;
|
||||
}
|
||||
|
||||
export class Database {
|
||||
constructor(
|
||||
filename: string,
|
||||
options?: { readonly?: boolean; readwrite?: boolean; create?: boolean },
|
||||
);
|
||||
query<ReturnType = unknown, ParamsType extends unknown[] = unknown[]>(
|
||||
sql: string,
|
||||
): Statement<ReturnType, ParamsType>;
|
||||
prepare<ReturnType = unknown, ParamsType extends unknown[] = unknown[]>(
|
||||
sql: string,
|
||||
): Statement<ReturnType, ParamsType>;
|
||||
run(sql: string, ...params: unknown[]): RunResult;
|
||||
close(throwOnError?: boolean): void;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fail, log } from '../log.js';
|
||||
import { commandExists } from '../util.js';
|
||||
import {
|
||||
collectVideos,
|
||||
findRofiTheme,
|
||||
formatPickerLaunchError,
|
||||
showFzfMenu,
|
||||
showRofiMenu,
|
||||
} from '../picker.js';
|
||||
import {
|
||||
findNextEpisode,
|
||||
groupHistoryBySeries,
|
||||
listSeasonDirs,
|
||||
materializeCoverArt,
|
||||
queryLocalWatchHistory,
|
||||
resolveImmersionDbPath,
|
||||
sortVideosByEpisode,
|
||||
type HistorySeriesEntry,
|
||||
} from '../history.js';
|
||||
import type { Args } from '../types.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
function checkPickerDependencies(args: Args): void {
|
||||
if (args.useRofi) {
|
||||
if (!commandExists('rofi')) fail('Missing dependency: rofi');
|
||||
return;
|
||||
}
|
||||
if (!commandExists('fzf')) fail('Missing dependency: fzf');
|
||||
}
|
||||
|
||||
function showRofiIndexMenu(
|
||||
labels: string[],
|
||||
prompt: string,
|
||||
themePath: string | null,
|
||||
icons: Array<string | null> = [],
|
||||
): number {
|
||||
const rofiArgs = ['-dmenu', '-i', '-matching', 'fuzzy', '-format', 'i', '-p', prompt];
|
||||
const hasIcons = icons.some(Boolean);
|
||||
if (hasIcons) rofiArgs.push('-show-icons');
|
||||
if (themePath) {
|
||||
rofiArgs.push('-theme', themePath);
|
||||
} else {
|
||||
rofiArgs.push('-theme-str', 'configuration { font: "Noto Sans CJK JP Regular 8";}');
|
||||
}
|
||||
if (hasIcons) {
|
||||
rofiArgs.push('-theme-str', 'configuration { show-icons: true; }');
|
||||
rofiArgs.push('-theme-str', 'element-icon { enabled: true; size: 3em; }');
|
||||
}
|
||||
const lines = labels.map((label, index) =>
|
||||
icons[index] ? `${label}\u0000icon\u001f${icons[index]}` : label,
|
||||
);
|
||||
const result = spawnSync('rofi', rofiArgs, {
|
||||
input: `${lines.join('\n')}\n`,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
});
|
||||
if (result.error) {
|
||||
fail(formatPickerLaunchError('rofi', result.error as NodeJS.ErrnoException));
|
||||
}
|
||||
const out = (result.stdout || '').trim();
|
||||
if (!out) return -1;
|
||||
const idx = Number.parseInt(out, 10);
|
||||
return Number.isInteger(idx) && idx >= 0 && idx < labels.length ? idx : -1;
|
||||
}
|
||||
|
||||
function showFzfIndexMenu(labels: string[], prompt: string): number {
|
||||
const lines = labels.map((label, index) => `${index}\t${label}`);
|
||||
const result = spawnSync(
|
||||
'fzf',
|
||||
[
|
||||
'--ansi',
|
||||
'--reverse',
|
||||
'--ignore-case',
|
||||
`--prompt=${prompt}: `,
|
||||
'--delimiter=\t',
|
||||
'--with-nth=2..',
|
||||
],
|
||||
{
|
||||
input: `${lines.join('\n')}\n`,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
},
|
||||
);
|
||||
if (result.error) {
|
||||
fail(formatPickerLaunchError('fzf', result.error as NodeJS.ErrnoException));
|
||||
}
|
||||
const picked = (result.stdout || '').trim();
|
||||
const tab = picked.indexOf('\t');
|
||||
if (tab === -1) return -1;
|
||||
const idx = Number.parseInt(picked.slice(0, tab), 10);
|
||||
return Number.isInteger(idx) && idx >= 0 && idx < labels.length ? idx : -1;
|
||||
}
|
||||
|
||||
function pickIndex(
|
||||
labels: string[],
|
||||
prompt: string,
|
||||
useRofi: boolean,
|
||||
themePath: string | null,
|
||||
icons: Array<string | null> = [],
|
||||
): number {
|
||||
if (labels.length === 0) return -1;
|
||||
return useRofi
|
||||
? showRofiIndexMenu(labels, prompt, themePath, icons)
|
||||
: showFzfIndexMenu(labels, prompt);
|
||||
}
|
||||
|
||||
function formatEpisodeLabel(entry: HistorySeriesEntry): string {
|
||||
const { parsedSeason, parsedEpisode } = entry.lastWatched;
|
||||
if (parsedEpisode === null) return '';
|
||||
return parsedSeason !== null ? `S${parsedSeason}E${parsedEpisode}` : `E${parsedEpisode}`;
|
||||
}
|
||||
|
||||
function formatSeriesLabel(entry: HistorySeriesEntry): string {
|
||||
const episodeLabel = formatEpisodeLabel(entry);
|
||||
return episodeLabel ? `${entry.displayName} [last: ${episodeLabel}]` : entry.displayName;
|
||||
}
|
||||
|
||||
function pickEpisodeFromDir(dir: string, context: LauncherCommandContext): string | null {
|
||||
const { args, scriptPath } = context;
|
||||
const videos = sortVideosByEpisode(collectVideos(dir, false));
|
||||
if (videos.length === 0) {
|
||||
fail(`No video files found in: ${dir}`);
|
||||
}
|
||||
const selected = args.useRofi
|
||||
? showRofiMenu(videos, dir, false, scriptPath, args.logLevel)
|
||||
: showFzfMenu(videos);
|
||||
return selected || null;
|
||||
}
|
||||
|
||||
function browseEpisodes(
|
||||
entry: HistorySeriesEntry,
|
||||
context: LauncherCommandContext,
|
||||
themePath: string | null,
|
||||
): string | null {
|
||||
const { args } = context;
|
||||
const seasons = listSeasonDirs(entry.seriesRoot);
|
||||
let dir = entry.seriesRoot;
|
||||
|
||||
if (seasons.length > 1) {
|
||||
const idx = pickIndex(
|
||||
seasons.map((season) => season.name),
|
||||
`${entry.displayName} — Season`,
|
||||
args.useRofi,
|
||||
themePath,
|
||||
);
|
||||
if (idx < 0) return null;
|
||||
dir = seasons[idx]!.path;
|
||||
} else if (seasons.length === 1 && collectVideos(dir, false).length === 0) {
|
||||
dir = seasons[0]!.path;
|
||||
}
|
||||
|
||||
return pickEpisodeFromDir(dir, context);
|
||||
}
|
||||
|
||||
export async function runHistoryCommand(context: LauncherCommandContext): Promise<string | null> {
|
||||
const { args, scriptPath } = context;
|
||||
|
||||
checkPickerDependencies(args);
|
||||
const themePath = args.useRofi ? findRofiTheme(scriptPath) : null;
|
||||
|
||||
const dbPath = resolveImmersionDbPath();
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
fail(`Watch history database not found: ${dbPath}`);
|
||||
}
|
||||
|
||||
const rows = queryLocalWatchHistory(dbPath);
|
||||
const series = groupHistoryBySeries(rows);
|
||||
if (series.length === 0) {
|
||||
fail('No local watch history found (or watched directories are not accessible).');
|
||||
}
|
||||
|
||||
log('info', args.logLevel, `Watch history: ${series.length} series found in ${dbPath}`);
|
||||
|
||||
const coverPaths = args.useRofi
|
||||
? materializeCoverArt(
|
||||
dbPath,
|
||||
series.map((seriesEntry) => seriesEntry.coverBlobHash),
|
||||
)
|
||||
: new Map<string, string>();
|
||||
const seriesIcons = series.map((seriesEntry) =>
|
||||
seriesEntry.coverBlobHash ? (coverPaths.get(seriesEntry.coverBlobHash) ?? null) : null,
|
||||
);
|
||||
|
||||
const seriesIdx = pickIndex(
|
||||
series.map(formatSeriesLabel),
|
||||
'Watch History',
|
||||
args.useRofi,
|
||||
themePath,
|
||||
seriesIcons,
|
||||
);
|
||||
if (seriesIdx < 0) return null;
|
||||
const entry = series[seriesIdx]!;
|
||||
|
||||
const lastPath = path.resolve(entry.lastWatched.sourcePath);
|
||||
const lastExists = fs.existsSync(lastPath);
|
||||
const nextEpisode = findNextEpisode(lastPath);
|
||||
|
||||
const actions: Array<{ kind: 'replay' | 'next' | 'browse'; label: string }> = [];
|
||||
if (lastExists) {
|
||||
actions.push({ kind: 'replay', label: `Replay last watched — ${path.basename(lastPath)}` });
|
||||
}
|
||||
if (nextEpisode) {
|
||||
actions.push({ kind: 'next', label: `Next episode — ${path.basename(nextEpisode)}` });
|
||||
}
|
||||
actions.push({ kind: 'browse', label: 'Browse episodes' });
|
||||
|
||||
const entryIcon = seriesIcons[seriesIdx] ?? null;
|
||||
const actionIdx = pickIndex(
|
||||
actions.map((action) => action.label),
|
||||
entry.displayName,
|
||||
args.useRofi,
|
||||
themePath,
|
||||
actions.map(() => entryIcon),
|
||||
);
|
||||
if (actionIdx < 0) return null;
|
||||
|
||||
switch (actions[actionIdx]!.kind) {
|
||||
case 'replay':
|
||||
return lastPath;
|
||||
case 'next':
|
||||
return nextEpisode;
|
||||
case 'browse':
|
||||
return browseEpisodes(entry, context, themePath);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ function createContext(): LauncherCommandContext {
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
history: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
|
||||
@@ -198,6 +198,7 @@ export function createDefaultArgs(
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
history: false,
|
||||
logLevel: loggingConfig.level ?? 'warn',
|
||||
logRotation: loggingConfig.rotation ?? 7,
|
||||
passwordStore: '',
|
||||
@@ -231,6 +232,7 @@ export function applyRootOptionsToArgs(
|
||||
if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel);
|
||||
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
|
||||
if (options.rofi === true) parsed.useRofi = true;
|
||||
if (options.history === true) parsed.history = true;
|
||||
if (options.update === true) parsed.update = true;
|
||||
if (options.version === true) parsed.version = true;
|
||||
if (options.settings === true) parsed.settings = true;
|
||||
|
||||
@@ -64,6 +64,7 @@ function applyRootOptions(program: Command): void {
|
||||
.option('--settings', 'Open settings window')
|
||||
.option('-u, --update', 'Check for updates')
|
||||
.option('-R, --rofi', 'Use rofi picker')
|
||||
.option('-H, --history', 'Browse local watch history')
|
||||
.option('-S, --start-overlay', 'Auto-start overlay')
|
||||
.option('-T, --no-texthooker', 'Disable texthooker-ui server');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { withReadonlyWalRetry } from './history-db.js';
|
||||
|
||||
const COVER_EXTENSIONS = ['.jpg', '.png', '.webp', '.gif'] as const;
|
||||
const SAFE_COVER_HASH_PATTERN = /^[a-z0-9_-]+$/i;
|
||||
|
||||
export function getDefaultCoverCacheDir(): string {
|
||||
return path.join(os.homedir(), '.cache', 'subminer', 'covers');
|
||||
}
|
||||
|
||||
export function detectImageExtension(blob: Buffer): string {
|
||||
if (blob.length >= 8 && blob.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) {
|
||||
return '.png';
|
||||
}
|
||||
if (blob.length >= 3 && blob[0] === 0xff && blob[1] === 0xd8 && blob[2] === 0xff) {
|
||||
return '.jpg';
|
||||
}
|
||||
if (
|
||||
blob.length >= 12 &&
|
||||
blob.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
blob.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return '.webp';
|
||||
}
|
||||
if (blob.length >= 4 && blob.subarray(0, 3).toString('ascii') === 'GIF') {
|
||||
return '.gif';
|
||||
}
|
||||
return '.jpg';
|
||||
}
|
||||
|
||||
function findCachedCover(cacheDir: string, hash: string): string | null {
|
||||
for (const ext of COVER_EXTENSIONS) {
|
||||
const candidate = path.join(cacheDir, `${hash}${ext}`);
|
||||
try {
|
||||
if (fs.statSync(candidate).size > 0) return candidate;
|
||||
} catch {
|
||||
// not cached with this extension
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function queryCoverBlobs(
|
||||
dbPath: string,
|
||||
hashes: string[],
|
||||
options: { readonly?: boolean; readwrite?: boolean; create?: boolean },
|
||||
): Map<string, Buffer> {
|
||||
const blobs = new Map<string, Buffer>();
|
||||
const db = new Database(dbPath, options);
|
||||
try {
|
||||
const hasBlobTable = db
|
||||
.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'imm_cover_art_blobs'`)
|
||||
.get();
|
||||
if (!hasBlobTable) return blobs;
|
||||
|
||||
const stmt = db.query<{ cover_blob: Uint8Array | null }>(
|
||||
'SELECT cover_blob FROM imm_cover_art_blobs WHERE blob_hash = ?',
|
||||
);
|
||||
for (const hash of hashes) {
|
||||
const row = stmt.get(hash);
|
||||
if (row?.cover_blob && row.cover_blob.length > 0) {
|
||||
blobs.set(hash, Buffer.from(row.cover_blob));
|
||||
}
|
||||
}
|
||||
return blobs;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeCoverHash(hash: string | null | undefined): hash is string {
|
||||
return typeof hash === 'string' && SAFE_COVER_HASH_PATTERN.test(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures cover art blobs referenced by hash exist as image files in the cache
|
||||
* directory, extracting missing ones from the stats database. Returns a map of
|
||||
* blob hash to on-disk image path for every cover that could be materialized.
|
||||
*/
|
||||
export function materializeCoverArt(
|
||||
dbPath: string,
|
||||
hashes: Array<string | null | undefined>,
|
||||
cacheDir: string = getDefaultCoverCacheDir(),
|
||||
): Map<string, string> {
|
||||
const wanted = Array.from(new Set(hashes.filter(isSafeCoverHash)));
|
||||
const resolved = new Map<string, string>();
|
||||
if (wanted.length === 0) return resolved;
|
||||
|
||||
const missing: string[] = [];
|
||||
for (const hash of wanted) {
|
||||
const cached = findCachedCover(cacheDir, hash);
|
||||
if (cached) {
|
||||
resolved.set(hash, cached);
|
||||
} else {
|
||||
missing.push(hash);
|
||||
}
|
||||
}
|
||||
if (missing.length === 0) return resolved;
|
||||
|
||||
let blobs: Map<string, Buffer>;
|
||||
try {
|
||||
blobs = withReadonlyWalRetry(dbPath, (options) => queryCoverBlobs(dbPath, missing, options));
|
||||
} catch {
|
||||
return resolved;
|
||||
}
|
||||
if (blobs.size === 0) return resolved;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
} catch {
|
||||
return resolved;
|
||||
}
|
||||
for (const [hash, blob] of blobs) {
|
||||
const target = path.join(cacheDir, `${hash}${detectImageExtension(blob)}`);
|
||||
try {
|
||||
fs.writeFileSync(target, blob);
|
||||
resolved.set(hash, target);
|
||||
} catch {
|
||||
// cache write failure just means no icon for this entry
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { resolveConfigDir } from '../src/config/path-resolution.js';
|
||||
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
|
||||
import type { HistoryVideoRow } from './history-types.js';
|
||||
import { resolvePathMaybe } from './util.js';
|
||||
|
||||
export function resolveImmersionDbPath(): string {
|
||||
const root = readLauncherMainConfigObject();
|
||||
const tracking =
|
||||
root?.immersionTracking &&
|
||||
typeof root.immersionTracking === 'object' &&
|
||||
!Array.isArray(root.immersionTracking)
|
||||
? (root.immersionTracking as Record<string, unknown>)
|
||||
: null;
|
||||
const configured = typeof tracking?.dbPath === 'string' ? tracking.dbPath.trim() : '';
|
||||
if (configured) return resolvePathMaybe(configured);
|
||||
|
||||
const configDir = resolveConfigDir({
|
||||
platform: process.platform,
|
||||
appDataDir: process.env.APPDATA,
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
existsSync: fs.existsSync,
|
||||
});
|
||||
return path.join(configDir, 'immersion.sqlite');
|
||||
}
|
||||
|
||||
interface RawHistoryRow {
|
||||
video_id: number;
|
||||
source_path: string | null;
|
||||
parsed_title: string | null;
|
||||
parsed_season: number | null;
|
||||
parsed_episode: number | null;
|
||||
anime_title: string | null;
|
||||
last_watched_ms: number | bigint | null;
|
||||
cover_blob_hash: string | null;
|
||||
}
|
||||
|
||||
export function queryLocalWatchHistory(dbPath: string): HistoryVideoRow[] {
|
||||
return withReadonlyWalRetry(dbPath, (options) => readHistoryRows(dbPath, options));
|
||||
}
|
||||
|
||||
export function withReadonlyWalRetry<T>(
|
||||
dbPath: string,
|
||||
query: (options: { readonly?: boolean; readwrite?: boolean; create?: boolean }) => T,
|
||||
): T {
|
||||
try {
|
||||
return query({ readonly: true });
|
||||
} catch (error) {
|
||||
if (!isReadonlyWalRetryError(error, dbPath)) throw error;
|
||||
return query({ readwrite: true, create: false });
|
||||
}
|
||||
}
|
||||
|
||||
export function isReadonlyWalRetryError(error: unknown, dbPath: string): boolean {
|
||||
if (!isWalModeSqliteDatabase(dbPath)) return false;
|
||||
const code =
|
||||
typeof error === 'object' && error !== null && 'code' in error
|
||||
? String((error as { code?: unknown }).code ?? '')
|
||||
: '';
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const text = `${code} ${message}`.toLowerCase();
|
||||
return (
|
||||
text.includes('readonly') ||
|
||||
text.includes('read-only') ||
|
||||
text.includes('attempt to write a readonly database') ||
|
||||
text.includes('sqlite_cantopen') ||
|
||||
text.includes('unable to open database file')
|
||||
);
|
||||
}
|
||||
|
||||
function isWalModeSqliteDatabase(dbPath: string): boolean {
|
||||
const header = Buffer.alloc(20);
|
||||
let fd: number | null = null;
|
||||
try {
|
||||
fd = fs.openSync(dbPath, 'r');
|
||||
if (fs.readSync(fd, header, 0, header.length, 0) < header.length) return false;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
if (fd !== null) fs.closeSync(fd);
|
||||
}
|
||||
return header.subarray(0, 16).toString('ascii') === 'SQLite format 3\0' && header[18] === 2;
|
||||
}
|
||||
|
||||
function tableExists(db: Database, tableName: string): boolean {
|
||||
return Boolean(
|
||||
db.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(tableName),
|
||||
);
|
||||
}
|
||||
|
||||
function readHistoryRows(
|
||||
dbPath: string,
|
||||
options: { readonly?: boolean; readwrite?: boolean; create?: boolean },
|
||||
): HistoryVideoRow[] {
|
||||
const db = new Database(dbPath, options);
|
||||
try {
|
||||
const hasMediaArt = tableExists(db, 'imm_media_art');
|
||||
const coverSelect = hasMediaArt
|
||||
? `COALESCE(
|
||||
ma.cover_blob_hash,
|
||||
(SELECT ma2.cover_blob_hash
|
||||
FROM imm_media_art ma2
|
||||
JOIN imm_videos v2 ON v2.video_id = ma2.video_id
|
||||
WHERE v2.anime_id = v.anime_id AND ma2.cover_blob_hash IS NOT NULL
|
||||
LIMIT 1)
|
||||
) AS cover_blob_hash`
|
||||
: 'NULL AS cover_blob_hash';
|
||||
const coverJoin = hasMediaArt ? 'LEFT JOIN imm_media_art ma ON ma.video_id = v.video_id' : '';
|
||||
const rows = db
|
||||
.query<RawHistoryRow>(
|
||||
`
|
||||
SELECT
|
||||
v.video_id,
|
||||
v.source_path,
|
||||
v.parsed_title,
|
||||
v.parsed_season,
|
||||
v.parsed_episode,
|
||||
COALESCE(a.title_romaji, a.canonical_title) AS anime_title,
|
||||
MAX(CAST(s.started_at_ms AS INTEGER)) AS last_watched_ms,
|
||||
${coverSelect}
|
||||
FROM imm_sessions s
|
||||
JOIN imm_videos v ON v.video_id = s.video_id
|
||||
LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||
${coverJoin}
|
||||
WHERE v.source_type = 1 AND v.source_path IS NOT NULL AND v.source_path != ''
|
||||
GROUP BY v.video_id
|
||||
ORDER BY last_watched_ms DESC
|
||||
`,
|
||||
)
|
||||
.all();
|
||||
|
||||
return rows
|
||||
.filter((row) => typeof row.source_path === 'string' && row.source_path.length > 0)
|
||||
.map((row) => ({
|
||||
videoId: row.video_id,
|
||||
sourcePath: row.source_path!,
|
||||
parsedTitle: row.parsed_title,
|
||||
parsedSeason: row.parsed_season,
|
||||
parsedEpisode: row.parsed_episode,
|
||||
animeTitle: row.anime_title,
|
||||
lastWatchedMs: Number(row.last_watched_ms ?? 0),
|
||||
coverBlobHash: row.cover_blob_hash,
|
||||
}));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseMediaInfo } from '../src/jimaku/utils.js';
|
||||
import { collectVideos } from './picker.js';
|
||||
import type { HistorySeriesEntry, HistoryVideoRow, SeasonDirEntry } from './history-types.js';
|
||||
|
||||
const SEASON_DIR_PATTERN = /^(?:season|s)[\s._-]*(\d{1,3})\b/i;
|
||||
|
||||
export function seasonNumberFromDirName(name: string): number | null {
|
||||
const match = name.trim().match(SEASON_DIR_PATTERN);
|
||||
if (!match) return null;
|
||||
const parsed = Number.parseInt(match[1]!, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function resolveSeriesRoot(filePath: string): string {
|
||||
const parent = path.dirname(filePath);
|
||||
if (seasonNumberFromDirName(path.basename(parent)) !== null) {
|
||||
return path.dirname(parent);
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
export function groupHistoryBySeries(
|
||||
rows: HistoryVideoRow[],
|
||||
existsFn: (candidate: string) => boolean = fs.existsSync,
|
||||
): HistorySeriesEntry[] {
|
||||
const byRoot = new Map<string, HistorySeriesEntry>();
|
||||
const sorted = [...rows].sort((a, b) => b.lastWatchedMs - a.lastWatchedMs);
|
||||
|
||||
for (const row of sorted) {
|
||||
const seriesRoot = resolveSeriesRoot(row.sourcePath);
|
||||
const existing = byRoot.get(seriesRoot);
|
||||
if (existing) {
|
||||
if (existing.coverBlobHash === null && row.coverBlobHash !== null) {
|
||||
existing.coverBlobHash = row.coverBlobHash;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!existsFn(seriesRoot)) continue;
|
||||
const displayName =
|
||||
row.parsedTitle?.trim() || row.animeTitle?.trim() || path.basename(seriesRoot);
|
||||
byRoot.set(seriesRoot, {
|
||||
seriesRoot,
|
||||
displayName,
|
||||
lastWatched: row,
|
||||
coverBlobHash: row.coverBlobHash,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(byRoot.values());
|
||||
}
|
||||
|
||||
function compareNatural(a: string, b: string): number {
|
||||
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
export function sortVideosByEpisode(videos: string[]): string[] {
|
||||
const parsed = videos.map((video) => ({ video, info: parseMediaInfo(video) }));
|
||||
parsed.sort((a, b) => {
|
||||
if (a.info.episode !== null && b.info.episode !== null) {
|
||||
const seasonA = a.info.season ?? 0;
|
||||
const seasonB = b.info.season ?? 0;
|
||||
if (seasonA !== seasonB) return seasonA - seasonB;
|
||||
if (a.info.episode !== b.info.episode) return a.info.episode - b.info.episode;
|
||||
}
|
||||
return compareNatural(a.video, b.video);
|
||||
});
|
||||
return parsed.map((entry) => entry.video);
|
||||
}
|
||||
|
||||
function dirContainsVideo(dir: string): boolean {
|
||||
return collectVideos(dir, true).length > 0;
|
||||
}
|
||||
|
||||
export function listSeasonDirs(seriesRoot: string): SeasonDirEntry[] {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(seriesRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dirs = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: path.join(seriesRoot, entry.name),
|
||||
season: seasonNumberFromDirName(entry.name),
|
||||
}))
|
||||
.filter((entry) => dirContainsVideo(entry.path));
|
||||
|
||||
dirs.sort((a, b) => {
|
||||
if (a.season !== null && b.season !== null && a.season !== b.season) {
|
||||
return a.season - b.season;
|
||||
}
|
||||
return compareNatural(a.name, b.name);
|
||||
});
|
||||
return dirs;
|
||||
}
|
||||
|
||||
function findFirstEpisodeInNextSeason(resolvedLast: string, dir: string): string | null {
|
||||
const seriesRoot = resolveSeriesRoot(resolvedLast);
|
||||
if (seriesRoot === dir) return null;
|
||||
const seasons = listSeasonDirs(seriesRoot);
|
||||
const currentIdx = seasons.findIndex((season) => path.resolve(season.path) === dir);
|
||||
if (currentIdx < 0 || currentIdx + 1 >= seasons.length) return null;
|
||||
const nextSeason = sortVideosByEpisode(collectVideos(seasons[currentIdx + 1]!.path, false));
|
||||
return nextSeason[0] ?? null;
|
||||
}
|
||||
|
||||
export function findNextEpisode(lastPath: string): string | null {
|
||||
const resolvedLast = path.resolve(lastPath);
|
||||
const dir = path.dirname(resolvedLast);
|
||||
const episodes = sortVideosByEpisode(collectVideos(dir, false));
|
||||
const idx = episodes.indexOf(resolvedLast);
|
||||
|
||||
if (idx >= 0) {
|
||||
if (idx + 1 < episodes.length) return episodes[idx + 1]!;
|
||||
} else {
|
||||
const lastInfo = parseMediaInfo(resolvedLast);
|
||||
if (lastInfo.episode !== null) {
|
||||
const candidate = episodes.find((episode) => {
|
||||
const info = parseMediaInfo(episode);
|
||||
return info.episode !== null && info.episode > lastInfo.episode!;
|
||||
});
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return findFirstEpisodeInNextSeason(resolvedLast, dir);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface HistoryVideoRow {
|
||||
videoId: number;
|
||||
sourcePath: string;
|
||||
parsedTitle: string | null;
|
||||
parsedSeason: number | null;
|
||||
parsedEpisode: number | null;
|
||||
animeTitle: string | null;
|
||||
lastWatchedMs: number;
|
||||
coverBlobHash: string | null;
|
||||
}
|
||||
|
||||
export interface HistorySeriesEntry {
|
||||
seriesRoot: string;
|
||||
displayName: string;
|
||||
lastWatched: HistoryVideoRow;
|
||||
coverBlobHash: string | null;
|
||||
}
|
||||
|
||||
export interface SeasonDirEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
season: number | null;
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import {
|
||||
detectImageExtension,
|
||||
findNextEpisode,
|
||||
groupHistoryBySeries,
|
||||
isReadonlyWalRetryError,
|
||||
listSeasonDirs,
|
||||
materializeCoverArt,
|
||||
queryLocalWatchHistory,
|
||||
resolveSeriesRoot,
|
||||
seasonNumberFromDirName,
|
||||
sortVideosByEpisode,
|
||||
type HistoryVideoRow,
|
||||
} from './history.js';
|
||||
|
||||
function makeRow(overrides: Partial<HistoryVideoRow> = {}): HistoryVideoRow {
|
||||
return {
|
||||
videoId: 1,
|
||||
sourcePath: '/media/anime/Show/Season-1/Show - S01E01.mkv',
|
||||
parsedTitle: 'Show',
|
||||
parsedSeason: 1,
|
||||
parsedEpisode: 1,
|
||||
animeTitle: null,
|
||||
lastWatchedMs: 1000,
|
||||
coverBlobHash: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('seasonNumberFromDirName detects common season directory names', () => {
|
||||
assert.equal(seasonNumberFromDirName('Season-1'), 1);
|
||||
assert.equal(seasonNumberFromDirName('Season 2'), 2);
|
||||
assert.equal(seasonNumberFromDirName('S03'), 3);
|
||||
assert.equal(seasonNumberFromDirName('season_04'), 4);
|
||||
assert.equal(seasonNumberFromDirName('Specials'), null);
|
||||
assert.equal(seasonNumberFromDirName('Show Name'), null);
|
||||
});
|
||||
|
||||
test('resolveSeriesRoot skips season directories', () => {
|
||||
assert.equal(
|
||||
resolveSeriesRoot('/media/anime/Show/Season-1/Show - S01E01.mkv'),
|
||||
'/media/anime/Show',
|
||||
);
|
||||
assert.equal(resolveSeriesRoot('/media/anime/Show/Show - 01.mkv'), '/media/anime/Show');
|
||||
});
|
||||
|
||||
test('groupHistoryBySeries keeps most recent entry per series root', () => {
|
||||
const rows = [
|
||||
makeRow({ videoId: 1, parsedEpisode: 1, lastWatchedMs: 1000 }),
|
||||
makeRow({
|
||||
videoId: 2,
|
||||
sourcePath: '/media/anime/Show/Season-1/Show - S01E02.mkv',
|
||||
parsedEpisode: 2,
|
||||
lastWatchedMs: 3000,
|
||||
}),
|
||||
makeRow({
|
||||
videoId: 3,
|
||||
sourcePath: '/media/anime/Other/Other - 05.mkv',
|
||||
parsedTitle: 'Other',
|
||||
parsedSeason: null,
|
||||
parsedEpisode: 5,
|
||||
lastWatchedMs: 2000,
|
||||
}),
|
||||
];
|
||||
|
||||
const series = groupHistoryBySeries(rows, () => true);
|
||||
|
||||
assert.equal(series.length, 2);
|
||||
assert.equal(series[0]?.displayName, 'Show');
|
||||
assert.equal(series[0]?.seriesRoot, '/media/anime/Show');
|
||||
assert.equal(series[0]?.lastWatched.parsedEpisode, 2);
|
||||
assert.equal(series[1]?.displayName, 'Other');
|
||||
});
|
||||
|
||||
test('groupHistoryBySeries filters series roots that no longer exist', () => {
|
||||
const rows = [
|
||||
makeRow({ videoId: 1 }),
|
||||
makeRow({
|
||||
videoId: 2,
|
||||
sourcePath: '/gone/anime/Missing/Season-1/Missing - S01E01.mkv',
|
||||
parsedTitle: 'Missing',
|
||||
lastWatchedMs: 5000,
|
||||
}),
|
||||
];
|
||||
|
||||
const series = groupHistoryBySeries(rows, (candidate) => !candidate.startsWith('/gone/'));
|
||||
|
||||
assert.equal(series.length, 1);
|
||||
assert.equal(series[0]?.displayName, 'Show');
|
||||
});
|
||||
|
||||
test('groupHistoryBySeries falls back to directory name for display', () => {
|
||||
const rows = [
|
||||
makeRow({
|
||||
sourcePath: '/media/anime/Some Show Dir/video.mkv',
|
||||
parsedTitle: null,
|
||||
animeTitle: null,
|
||||
}),
|
||||
];
|
||||
|
||||
const series = groupHistoryBySeries(rows, () => true);
|
||||
|
||||
assert.equal(series[0]?.displayName, 'Some Show Dir');
|
||||
});
|
||||
|
||||
test('sortVideosByEpisode orders by parsed episode with natural fallback', () => {
|
||||
const videos = [
|
||||
'/media/Show/Show - S01E10 - Ten.mkv',
|
||||
'/media/Show/Show - S01E02 - Two.mkv',
|
||||
'/media/Show/Show - S01E01 - One.mkv',
|
||||
];
|
||||
|
||||
assert.deepEqual(sortVideosByEpisode(videos), [
|
||||
'/media/Show/Show - S01E01 - One.mkv',
|
||||
'/media/Show/Show - S01E02 - Two.mkv',
|
||||
'/media/Show/Show - S01E10 - Ten.mkv',
|
||||
]);
|
||||
});
|
||||
|
||||
function createSeriesTree(): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-'));
|
||||
const seriesRoot = path.join(root, 'Show');
|
||||
const season1 = path.join(seriesRoot, 'Season-1');
|
||||
const season2 = path.join(seriesRoot, 'Season-2');
|
||||
fs.mkdirSync(season1, { recursive: true });
|
||||
fs.mkdirSync(season2, { recursive: true });
|
||||
fs.mkdirSync(path.join(seriesRoot, 'extras-empty'), { recursive: true });
|
||||
for (const name of ['Show - S01E01.mkv', 'Show - S01E02.mkv', 'Show - S01E03.mkv']) {
|
||||
fs.writeFileSync(path.join(season1, name), '');
|
||||
}
|
||||
fs.writeFileSync(path.join(season2, 'Show - S02E01.mkv'), '');
|
||||
fs.writeFileSync(path.join(season1, 'notes.txt'), '');
|
||||
return seriesRoot;
|
||||
}
|
||||
|
||||
test('listSeasonDirs returns only video-bearing directories in season order', () => {
|
||||
const seriesRoot = createSeriesTree();
|
||||
try {
|
||||
const seasons = listSeasonDirs(seriesRoot);
|
||||
assert.deepEqual(
|
||||
seasons.map((entry) => entry.name),
|
||||
['Season-1', 'Season-2'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
seasons.map((entry) => entry.season),
|
||||
[1, 2],
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findNextEpisode advances within a season and across seasons', () => {
|
||||
const seriesRoot = createSeriesTree();
|
||||
try {
|
||||
const season1 = path.join(seriesRoot, 'Season-1');
|
||||
const season2 = path.join(seriesRoot, 'Season-2');
|
||||
|
||||
assert.equal(
|
||||
findNextEpisode(path.join(season1, 'Show - S01E02.mkv')),
|
||||
path.join(season1, 'Show - S01E03.mkv'),
|
||||
);
|
||||
assert.equal(
|
||||
findNextEpisode(path.join(season1, 'Show - S01E03.mkv')),
|
||||
path.join(season2, 'Show - S02E01.mkv'),
|
||||
);
|
||||
assert.equal(findNextEpisode(path.join(season2, 'Show - S02E01.mkv')), null);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findNextEpisode falls back to episode numbers when file was removed', () => {
|
||||
const seriesRoot = createSeriesTree();
|
||||
try {
|
||||
const season1 = path.join(seriesRoot, 'Season-1');
|
||||
const missing = path.join(season1, 'Show - S01E02 - Deleted Cut.mkv');
|
||||
assert.equal(findNextEpisode(missing), path.join(season1, 'Show - S01E03.mkv'));
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findNextEpisode advances seasons when a deleted file was the last episode', () => {
|
||||
const seriesRoot = createSeriesTree();
|
||||
try {
|
||||
const season1 = path.join(seriesRoot, 'Season-1');
|
||||
const season2 = path.join(seriesRoot, 'Season-2');
|
||||
const missing = path.join(season1, 'Show - S01E03 - Deleted Cut.mkv');
|
||||
assert.equal(findNextEpisode(missing), path.join(season2, 'Show - S02E01.mkv'));
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const PNG_MAGIC = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
|
||||
|
||||
function createHistoryDb(
|
||||
dbPath: string,
|
||||
options: { wal?: boolean; coverArt?: boolean } = {},
|
||||
): void {
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
if (options.wal) db.run('PRAGMA journal_mode = WAL;');
|
||||
db.run(`
|
||||
CREATE TABLE imm_anime(
|
||||
anime_id INTEGER PRIMARY KEY,
|
||||
canonical_title TEXT,
|
||||
title_romaji TEXT
|
||||
);
|
||||
`);
|
||||
db.run(`
|
||||
CREATE TABLE imm_videos(
|
||||
video_id INTEGER PRIMARY KEY,
|
||||
anime_id INTEGER,
|
||||
source_type INTEGER,
|
||||
source_path TEXT,
|
||||
parsed_title TEXT,
|
||||
parsed_season INTEGER,
|
||||
parsed_episode INTEGER
|
||||
);
|
||||
`);
|
||||
db.run(`
|
||||
CREATE TABLE imm_sessions(
|
||||
session_id INTEGER PRIMARY KEY,
|
||||
video_id INTEGER,
|
||||
started_at_ms TEXT
|
||||
);
|
||||
`);
|
||||
db.run(`INSERT INTO imm_anime VALUES (1, 'Show Season 1', 'Show Romaji');`);
|
||||
db.run(`
|
||||
INSERT INTO imm_videos VALUES
|
||||
(1, 1, 1, '/media/Show/Season-1/Show - S01E01.mkv', 'Show', 1, 1),
|
||||
(2, 1, 1, '/media/Show/Season-1/Show - S01E02.mkv', 'Show', 1, 2),
|
||||
(3, NULL, 2, NULL, 'Remote Show', NULL, NULL),
|
||||
(4, NULL, 1, '', 'Empty Path', NULL, NULL);
|
||||
`);
|
||||
db.run(`
|
||||
INSERT INTO imm_sessions VALUES
|
||||
(1, 1, '1000'),
|
||||
(2, 1, '5000'),
|
||||
(3, 2, '3000'),
|
||||
(4, 3, '9000');
|
||||
`);
|
||||
if (options.coverArt) {
|
||||
db.run(`
|
||||
CREATE TABLE imm_media_art(
|
||||
video_id INTEGER PRIMARY KEY,
|
||||
cover_blob_hash TEXT
|
||||
);
|
||||
`);
|
||||
db.run(`
|
||||
CREATE TABLE imm_cover_art_blobs(
|
||||
blob_hash TEXT PRIMARY KEY,
|
||||
cover_blob BLOB NOT NULL
|
||||
);
|
||||
`);
|
||||
// Art only on video 1; video 2 resolves it through the shared anime_id.
|
||||
db.run(`INSERT INTO imm_media_art VALUES (1, 'hash-1');`);
|
||||
db.query('INSERT INTO imm_cover_art_blobs VALUES (?, ?)').run('hash-1', PNG_MAGIC);
|
||||
}
|
||||
if (options.wal) db.run('PRAGMA wal_checkpoint(TRUNCATE);');
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function assertHistoryRows(dbPath: string): void {
|
||||
const rows = queryLocalWatchHistory(dbPath);
|
||||
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0]?.videoId, 1);
|
||||
assert.equal(rows[0]?.lastWatchedMs, 5000);
|
||||
assert.equal(rows[0]?.animeTitle, 'Show Romaji');
|
||||
assert.equal(rows[1]?.videoId, 2);
|
||||
assert.equal(rows[1]?.lastWatchedMs, 3000);
|
||||
}
|
||||
|
||||
test('queryLocalWatchHistory returns local files ordered by most recent session', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-db-'));
|
||||
const dbPath = path.join(dir, 'immersion.sqlite');
|
||||
try {
|
||||
createHistoryDb(dbPath);
|
||||
assertHistoryRows(dbPath);
|
||||
const rows = queryLocalWatchHistory(dbPath);
|
||||
assert.equal(rows[0]?.coverBlobHash, null);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('queryLocalWatchHistory resolves cover hashes directly and via shared anime', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-art-'));
|
||||
const dbPath = path.join(dir, 'immersion.sqlite');
|
||||
try {
|
||||
createHistoryDb(dbPath, { coverArt: true });
|
||||
const rows = queryLocalWatchHistory(dbPath);
|
||||
assert.equal(rows[0]?.videoId, 1);
|
||||
assert.equal(rows[0]?.coverBlobHash, 'hash-1');
|
||||
assert.equal(rows[1]?.videoId, 2);
|
||||
assert.equal(rows[1]?.coverBlobHash, 'hash-1');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('materializeCoverArt extracts blobs to the cache dir and reuses cached files', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-covers-'));
|
||||
const dbPath = path.join(dir, 'immersion.sqlite');
|
||||
const cacheDir = path.join(dir, 'covers');
|
||||
try {
|
||||
createHistoryDb(dbPath, { coverArt: true });
|
||||
|
||||
const covers = materializeCoverArt(
|
||||
dbPath,
|
||||
['hash-1', 'hash-1', null, 'hash-missing'],
|
||||
cacheDir,
|
||||
);
|
||||
const coverPath = covers.get('hash-1');
|
||||
assert.ok(coverPath);
|
||||
assert.equal(path.extname(coverPath!), '.png');
|
||||
assert.ok(fs.statSync(coverPath!).size > 0);
|
||||
assert.equal(covers.has('hash-missing'), false);
|
||||
|
||||
// Cached file is reused even when the database has disappeared.
|
||||
fs.rmSync(dbPath);
|
||||
const cachedCovers = materializeCoverArt(dbPath, ['hash-1'], cacheDir);
|
||||
assert.equal(cachedCovers.get('hash-1'), coverPath);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('materializeCoverArt rejects cover hashes that escape the cache dir', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-cover-safety-'));
|
||||
const dbPath = path.join(dir, 'immersion.sqlite');
|
||||
const cacheDir = path.join(dir, 'covers');
|
||||
const unsafeHash = '../escape';
|
||||
try {
|
||||
createHistoryDb(dbPath, { coverArt: true });
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
db.query('INSERT INTO imm_cover_art_blobs VALUES (?, ?)').run(unsafeHash, PNG_MAGIC);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
const covers = materializeCoverArt(dbPath, [unsafeHash], cacheDir);
|
||||
|
||||
assert.equal(covers.has(unsafeHash), false);
|
||||
assert.equal(fs.existsSync(path.join(dir, 'escape.png')), false);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('detectImageExtension identifies common cover formats', () => {
|
||||
assert.equal(detectImageExtension(PNG_MAGIC), '.png');
|
||||
assert.equal(detectImageExtension(Buffer.from([0xff, 0xd8, 0xff, 0xe0])), '.jpg');
|
||||
assert.equal(detectImageExtension(Buffer.from('RIFF0000WEBPVP8 ', 'ascii')), '.webp');
|
||||
assert.equal(detectImageExtension(Buffer.from('GIF89a', 'ascii')), '.gif');
|
||||
assert.equal(detectImageExtension(Buffer.from('unknown', 'ascii')), '.jpg');
|
||||
});
|
||||
|
||||
test('groupHistoryBySeries backfills cover hash from older rows of the same series', () => {
|
||||
const rows = [
|
||||
makeRow({ videoId: 2, parsedEpisode: 2, lastWatchedMs: 3000, coverBlobHash: null }),
|
||||
makeRow({ videoId: 1, parsedEpisode: 1, lastWatchedMs: 1000, coverBlobHash: 'hash-1' }),
|
||||
];
|
||||
|
||||
const series = groupHistoryBySeries(rows, () => true);
|
||||
|
||||
assert.equal(series.length, 1);
|
||||
assert.equal(series[0]?.lastWatched.videoId, 2);
|
||||
assert.equal(series[0]?.coverBlobHash, 'hash-1');
|
||||
});
|
||||
|
||||
test('queryLocalWatchHistory reads a cleanly-closed WAL database', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-wal-'));
|
||||
const dbPath = path.join(dir, 'immersion.sqlite');
|
||||
try {
|
||||
createHistoryDb(dbPath, { wal: true });
|
||||
// Reproduce the state after the app shuts down cleanly: WAL journal mode
|
||||
// with no -wal/-shm sidecar files on disk. A read-only connection then
|
||||
// fails at query time because it cannot recreate them.
|
||||
fs.rmSync(`${dbPath}-wal`, { force: true });
|
||||
fs.rmSync(`${dbPath}-shm`, { force: true });
|
||||
assertHistoryRows(dbPath);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('isReadonlyWalRetryError only accepts readonly errors from WAL-mode databases', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-retry-'));
|
||||
const walDbPath = path.join(dir, 'wal.sqlite');
|
||||
const rollbackDbPath = path.join(dir, 'rollback.sqlite');
|
||||
try {
|
||||
createHistoryDb(walDbPath, { wal: true });
|
||||
createHistoryDb(rollbackDbPath);
|
||||
|
||||
assert.equal(
|
||||
isReadonlyWalRetryError(
|
||||
Object.assign(new Error('attempt to write a readonly database'), {
|
||||
code: 'SQLITE_READONLY',
|
||||
}),
|
||||
walDbPath,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isReadonlyWalRetryError(
|
||||
Object.assign(new Error('unable to open database file'), {
|
||||
code: 'SQLITE_CANTOPEN',
|
||||
}),
|
||||
walDbPath,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isReadonlyWalRetryError(new Error('no such table: imm_sessions'), walDbPath),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isReadonlyWalRetryError(
|
||||
Object.assign(new Error('attempt to write a readonly database'), {
|
||||
code: 'SQLITE_READONLY',
|
||||
}),
|
||||
rollbackDbPath,
|
||||
),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './history-art.js';
|
||||
export * from './history-db.js';
|
||||
export * from './history-navigation.js';
|
||||
export type { HistorySeriesEntry, HistoryVideoRow, SeasonDirEntry } from './history-types.js';
|
||||
@@ -29,6 +29,7 @@ function createArgs(): Args {
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
history: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
|
||||
@@ -2,9 +2,10 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { getDefaultLauncherLogFile, getDefaultMpvLogFile } from './types.js';
|
||||
import { localDateKey } from '../src/shared/log-files.js';
|
||||
|
||||
test('getDefaultMpvLogFile uses APPDATA on windows', () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = localDateKey(new Date());
|
||||
const resolved = getDefaultMpvLogFile({
|
||||
platform: 'win32',
|
||||
homeDir: 'C:\\Users\\tester',
|
||||
@@ -20,7 +21,7 @@ test('getDefaultMpvLogFile uses APPDATA on windows', () => {
|
||||
});
|
||||
|
||||
test('getDefaultLauncherLogFile uses launcher prefix', () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = localDateKey(new Date());
|
||||
const resolved = getDefaultLauncherLogFile({
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
|
||||
@@ -65,11 +65,36 @@ function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv
|
||||
APPDATA: xdgConfigHome,
|
||||
LOCALAPPDATA: path.join(homeDir, 'AppData', 'Local'),
|
||||
XDG_CONFIG_HOME: xdgConfigHome,
|
||||
// Pin the data dir under the temp home so the Linux runtime-plugin preflight
|
||||
// resolves managed asset paths deterministically (not the CI runner's).
|
||||
XDG_DATA_HOME: path.join(homeDir, '.local', 'share'),
|
||||
PATH: pathValue,
|
||||
Path: pathValue,
|
||||
};
|
||||
}
|
||||
|
||||
// On Linux the playback path runs `ensureLinuxRuntimePluginAvailable`, which —
|
||||
// when the runtime plugin/theme are missing — spawns the app with
|
||||
// `--ensure-linux-runtime-plugin-assets` and polls up to 30s
|
||||
// (RESPONSE_TIMEOUT_MS) for an install response. A fake app that just exits
|
||||
// never writes that response, so the launcher hangs and the test times out on
|
||||
// Linux CI (the preflight is a no-op on macOS/Windows). This shell prelude makes
|
||||
// the fake app install the managed plugin/theme and write the response, matching
|
||||
// launcher/smoke.e2e.test.ts. Prepend it to each fake app that reaches playback.
|
||||
const RUNTIME_PLUGIN_PREFLIGHT_SH = `if [ "$1" = "--ensure-linux-runtime-plugin-assets" ]; then
|
||||
data="\${XDG_DATA_HOME:-$HOME/.local/share}/SubMiner"
|
||||
mkdir -p "$data/plugin/subminer" "$data/themes"
|
||||
printf -- '-- test plugin\\n' > "$data/plugin/subminer/main.lua"
|
||||
printf 'test=true\\n' > "$data/plugin/subminer.conf"
|
||||
printf '/* test theme */\\n' > "$data/themes/subminer.rasi"
|
||||
if [ "$2" = "--ensure-linux-runtime-plugin-assets-response-path" ] && [ -n "$3" ]; then
|
||||
mkdir -p "$(dirname "$3")"
|
||||
printf '{"ok":true,"status":"installed","path":"%s"}' "$data/plugin/subminer/main.lua" > "$3"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
`;
|
||||
|
||||
test('config path uses XDG_CONFIG_HOME override', () => {
|
||||
withTempDir((root) => {
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
@@ -237,7 +262,7 @@ test('doctor refresh-known-words forwards app refresh command without requiring
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -264,7 +289,7 @@ test('launcher settings option forwards app settings window command', () => {
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -288,7 +313,7 @@ test('launcher settings command forwards app settings window command', () => {
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -369,7 +394,7 @@ test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, ()
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
||||
fs.writeFileSync(appPath, `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}exit 0\n`);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
fs.writeFileSync(
|
||||
@@ -460,7 +485,7 @@ test('launcher forwards non-info log level into mpv logging args', { timeout: 15
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
||||
fs.writeFileSync(appPath, `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}exit 0\n`);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
fs.writeFileSync(
|
||||
@@ -539,7 +564,7 @@ test('launcher routes youtube urls through regular playback startup', { timeout:
|
||||
);
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -566,17 +591,22 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
|
||||
fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755);
|
||||
fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755);
|
||||
|
||||
// Note: no SUBMINER_TEST_CAPTURE here. When set, the launcher intercepts
|
||||
// *every* app command — including the Linux runtime-plugin preflight's
|
||||
// `--ensure-linux-runtime-plugin-assets` install — and returns without
|
||||
// running the fake app, so the preflight would poll 30s for a response that
|
||||
// never arrives and time out. This test asserts on the mpv args, not on
|
||||
// captured app args, so capture isn't needed.
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
|
||||
Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
|
||||
DISPLAY: ':99',
|
||||
XDG_SESSION_TYPE: 'x11',
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_MPV_ARGS: mpvArgsPath,
|
||||
SUBMINER_TEST_CAPTURE: path.join(root, 'captured-args.txt'),
|
||||
};
|
||||
const result = runLauncher(['https://www.youtube.com/watch?v=abc123'], env);
|
||||
// Pass an explicit backend so overlay startup doesn't probe for a display
|
||||
// (headless CI has none), matching launcher/smoke.e2e.test.ts.
|
||||
const result = runLauncher(['--backend', 'x11', 'https://www.youtube.com/watch?v=abc123'], env);
|
||||
|
||||
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||
const forwardedArgs = fs
|
||||
@@ -597,7 +627,7 @@ test('dictionary command forwards --dictionary and --dictionary-target to app co
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -626,7 +656,7 @@ test('dictionary command forwards manual AniList selection modes to app command
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -763,7 +793,7 @@ test('jellyfin discovery routes to app --background and remote announce with log
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -790,7 +820,7 @@ test('jellyfin discovery via jf alias forwards remote announce for cast visibili
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -817,7 +847,7 @@ test('jellyfin login routes credentials to app command', () => {
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -856,7 +886,7 @@ test('jellyfin setup forwards password-store to app command', () => {
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { runDictionaryCommand } from './commands/dictionary-command.js';
|
||||
import { runLogsCommand } from './commands/logs-command.js';
|
||||
import { runStatsCommand } from './commands/stats-command.js';
|
||||
import { runJellyfinCommand } from './commands/jellyfin-command.js';
|
||||
import { runHistoryCommand } from './commands/history-command.js';
|
||||
import { runPlaybackCommand } from './commands/playback-command.js';
|
||||
import { runUpdateCommand } from './commands/update-command.js';
|
||||
|
||||
@@ -142,6 +143,16 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (appContext.args.history) {
|
||||
const selected = await runHistoryCommand(appContext);
|
||||
if (!selected) {
|
||||
log('info', args.logLevel, 'No watch history selection made, exiting');
|
||||
return;
|
||||
}
|
||||
appContext.args.target = selected;
|
||||
appContext.args.targetKind = 'file';
|
||||
}
|
||||
|
||||
await runPlaybackCommand(appContext);
|
||||
}
|
||||
|
||||
|
||||
@@ -570,6 +570,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
history: false,
|
||||
logLevel: 'error',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
|
||||
@@ -42,6 +42,19 @@ test('parseArgs maps root settings window option', () => {
|
||||
assert.equal(parsed.settings, true);
|
||||
});
|
||||
|
||||
test('parseArgs maps root watch history flags', () => {
|
||||
const shortParsed = parseArgs(['-H'], 'subminer', {});
|
||||
const longParsed = parseArgs(['--history'], 'subminer', {});
|
||||
const rofiParsed = parseArgs(['-R', '-H'], 'subminer', {});
|
||||
const defaultParsed = parseArgs([], 'subminer', {});
|
||||
|
||||
assert.equal(shortParsed.history, true);
|
||||
assert.equal(longParsed.history, true);
|
||||
assert.equal(rofiParsed.history, true);
|
||||
assert.equal(rofiParsed.useRofi, true);
|
||||
assert.equal(defaultParsed.history, false);
|
||||
});
|
||||
|
||||
test('parseArgs maps root update flags without conflicting with jellyfin username', () => {
|
||||
const shortParsed = parseArgs(['-u'], 'subminer', {});
|
||||
const longParsed = parseArgs(['--update'], 'subminer', {});
|
||||
|
||||
@@ -56,7 +56,7 @@ export function showFzfFlatMenu(
|
||||
`--prompt=${prompt}`,
|
||||
'--delimiter=\t',
|
||||
'--with-nth=2',
|
||||
'--preview-window=right:50%:wrap',
|
||||
'--preview-window=down:50%:wrap',
|
||||
'--preview',
|
||||
previewCommand,
|
||||
];
|
||||
@@ -468,7 +468,7 @@ thumb=$(get_thumb)
|
||||
'--prompt=Select Video: ',
|
||||
'--delimiter=\t',
|
||||
'--with-nth=1',
|
||||
'--preview-window=right:50%:wrap',
|
||||
'--preview-window=down:50%:wrap',
|
||||
'--preview',
|
||||
previewCmd,
|
||||
],
|
||||
|
||||
@@ -112,6 +112,7 @@ export interface Args {
|
||||
texthookerOnly: boolean;
|
||||
texthookerOpenBrowser: boolean;
|
||||
useRofi: boolean;
|
||||
history: boolean;
|
||||
logLevel: LogLevel;
|
||||
logRotation: LogRotation;
|
||||
passwordStore: string;
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const node_os_1 = __importDefault(require("node:os"));
|
||||
const node_child_process_1 = require("node:child_process");
|
||||
const electron_1 = require("electron");
|
||||
const help_1 = require("./cli/help");
|
||||
const main_entry_runtime_1 = require("./main-entry-runtime");
|
||||
const early_single_instance_1 = require("./main/early-single-instance");
|
||||
const main_entry_launch_config_1 = require("./main-entry-launch-config");
|
||||
const app_control_client_1 = require("./shared/app-control-client");
|
||||
const first_run_setup_plugin_1 = require("./main/runtime/first-run-setup-plugin");
|
||||
const windows_mpv_launch_1 = require("./main/runtime/windows-mpv-launch");
|
||||
const stats_daemon_entry_1 = require("./stats-daemon-entry");
|
||||
const fatal_error_1 = require("./main/fatal-error");
|
||||
const mpv_logging_args_1 = require("./shared/mpv-logging-args");
|
||||
const log_files_1 = require("./shared/log-files");
|
||||
const DEFAULT_TEXTHOOKER_PORT = 5174;
|
||||
function appendWindowsMpvLaunchLog(message, logRotation) {
|
||||
if (!(0, log_files_1.isLogFileEnabled)('app')) {
|
||||
return;
|
||||
}
|
||||
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||
(0, log_files_1.appendLogLine)(process.env.SUBMINER_APP_LOG?.trim() || (0, log_files_1.resolveDefaultLogFilePath)('app'), `[subminer] - ${timestamp} - INFO - [main:windows-mpv-launch] ${message}`, { rotation: logRotation });
|
||||
}
|
||||
function applySanitizedEnv(sanitizedEnv) {
|
||||
if (sanitizedEnv.NODE_NO_WARNINGS) {
|
||||
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
|
||||
}
|
||||
if (sanitizedEnv.VK_INSTANCE_LAYERS) {
|
||||
process.env.VK_INSTANCE_LAYERS = sanitizedEnv.VK_INSTANCE_LAYERS;
|
||||
}
|
||||
else {
|
||||
delete process.env.VK_INSTANCE_LAYERS;
|
||||
}
|
||||
}
|
||||
function resolveBundledWindowsMpvPluginEntrypoint() {
|
||||
return ((0, first_run_setup_plugin_1.resolvePackagedRuntimePluginPath)({
|
||||
dirname: __dirname,
|
||||
appPath: electron_1.app.getAppPath(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
}) ?? undefined);
|
||||
}
|
||||
function buildInstalledWindowsMpvPluginMessage(pathValue, version) {
|
||||
return [
|
||||
'SubMiner detected an installed mpv plugin at:',
|
||||
pathValue,
|
||||
'',
|
||||
"This mpv session will use the installed plugin. Remove it to use SubMiner's bundled runtime plugin automatically.",
|
||||
`Detected plugin version: ${version ?? 'unknown or legacy'}`,
|
||||
].join('\n');
|
||||
}
|
||||
async function promptForWindowsLegacyMpvPluginRemoval(mpvPath, detection) {
|
||||
const response = await electron_1.dialog.showMessageBox({
|
||||
type: 'warning',
|
||||
title: 'SubMiner mpv plugin detected',
|
||||
message: buildInstalledWindowsMpvPluginMessage(detection.path ?? 'unknown path', detection.version),
|
||||
detail: 'Remove the legacy SubMiner mpv plugin files from mpv before launching this video? This moves the files to the OS trash. SubMiner-managed playback will then use the bundled runtime plugin.',
|
||||
buttons: ['Remove legacy plugin', 'Continue with installed plugin', 'Cancel'],
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
});
|
||||
if (response.response === 2) {
|
||||
return 'cancel';
|
||||
}
|
||||
if (response.response === 1) {
|
||||
return 'continue';
|
||||
}
|
||||
const candidates = (0, first_run_setup_plugin_1.detectInstalledFirstRunPluginCandidates)({
|
||||
platform: 'win32',
|
||||
homeDir: node_os_1.default.homedir(),
|
||||
appDataDir: electron_1.app.getPath('appData'),
|
||||
mpvExecutablePath: mpvPath,
|
||||
});
|
||||
const result = await (0, first_run_setup_plugin_1.removeLegacyMpvPluginCandidates)({
|
||||
candidates,
|
||||
trashItem: (candidatePath) => electron_1.shell.trashItem(candidatePath),
|
||||
});
|
||||
if (result.ok) {
|
||||
await electron_1.dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Legacy mpv plugin removed',
|
||||
message: 'Legacy mpv plugin removed. SubMiner-managed playback will use the bundled runtime plugin.',
|
||||
});
|
||||
return 'removed';
|
||||
}
|
||||
await electron_1.dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Could not remove legacy mpv plugin',
|
||||
message: 'Some legacy SubMiner mpv plugin files could not be moved to the trash.',
|
||||
detail: result.failedPaths.map((failure) => `${failure.path}: ${failure.message}`).join('\n'),
|
||||
});
|
||||
return 'cancel';
|
||||
}
|
||||
function createWindowsRuntimePluginPolicy() {
|
||||
return {
|
||||
detectInstalledMpvPlugin: (mpvPath) => (0, first_run_setup_plugin_1.detectInstalledMpvPlugin)({
|
||||
platform: 'win32',
|
||||
homeDir: node_os_1.default.homedir(),
|
||||
appDataDir: electron_1.app.getPath('appData'),
|
||||
mpvExecutablePath: mpvPath,
|
||||
}),
|
||||
notifyInstalledPluginDetected: (detection) => {
|
||||
if (!detection.installed || !detection.path)
|
||||
return;
|
||||
electron_1.dialog.showMessageBoxSync({
|
||||
type: 'warning',
|
||||
title: 'SubMiner mpv plugin detected',
|
||||
message: buildInstalledWindowsMpvPluginMessage(detection.path, detection.version),
|
||||
});
|
||||
},
|
||||
resolveInstalledPluginBeforeLaunch: (detection, mpvPath) => promptForWindowsLegacyMpvPluginRemoval(mpvPath, detection),
|
||||
};
|
||||
}
|
||||
process.argv = (0, main_entry_runtime_1.normalizeStartupArgv)(process.argv, process.env);
|
||||
(0, main_entry_runtime_1.applyEarlyLinuxCommandLineSwitches)(electron_1.app.commandLine, process.argv);
|
||||
applySanitizedEnv((0, main_entry_runtime_1.sanitizeStartupEnv)(process.env));
|
||||
const userDataPath = (0, main_entry_runtime_1.configureEarlyAppPaths)(electron_1.app);
|
||||
const reportFatalError = (0, fatal_error_1.createFatalErrorReporter)({
|
||||
showErrorBox: (title, details) => electron_1.dialog.showErrorBox(title, details),
|
||||
consoleError: (message, error) => console.error(message, error),
|
||||
});
|
||||
(0, fatal_error_1.registerFatalErrorHandlers)({
|
||||
reportFatalError,
|
||||
exit: (code) => electron_1.app.exit(code),
|
||||
});
|
||||
function startMainProcess() {
|
||||
const gotSingleInstanceLock = (0, early_single_instance_1.requestSingleInstanceLockEarly)(electron_1.app);
|
||||
if (!gotSingleInstanceLock) {
|
||||
electron_1.app.exit(0);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
require('./main.js');
|
||||
}
|
||||
catch (error) {
|
||||
reportFatalError(error, {
|
||||
title: 'SubMiner startup failed',
|
||||
context: 'SubMiner failed while loading the main process.',
|
||||
});
|
||||
electron_1.app.exit(1);
|
||||
}
|
||||
}
|
||||
async function forwardStartupArgvViaAppControlIfAvailable() {
|
||||
if (!(0, main_entry_runtime_1.shouldForwardStartupArgvViaAppControl)(process.argv, process.env)) {
|
||||
return false;
|
||||
}
|
||||
const result = await (0, app_control_client_1.sendAppControlCommand)(process.argv, {
|
||||
configDir: userDataPath,
|
||||
timeoutMs: 500,
|
||||
});
|
||||
if (result.ok) {
|
||||
electron_1.app.exit(0);
|
||||
return true;
|
||||
}
|
||||
if (!result.unavailable) {
|
||||
console.error(`SubMiner app-control handoff failed: ${result.error ?? 'unknown error'}`);
|
||||
electron_1.app.exit(1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async function runEntryProcess() {
|
||||
if ((0, main_entry_runtime_1.shouldHandleHelpOnlyAtEntry)(process.argv, process.env)) {
|
||||
const sanitizedEnv = (0, main_entry_runtime_1.sanitizeHelpEnv)(process.env);
|
||||
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
|
||||
if (!sanitizedEnv.VK_INSTANCE_LAYERS) {
|
||||
delete process.env.VK_INSTANCE_LAYERS;
|
||||
}
|
||||
(0, help_1.printHelp)(DEFAULT_TEXTHOOKER_PORT);
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
if ((0, main_entry_runtime_1.shouldHandleLaunchMpvAtEntry)(process.argv, process.env)) {
|
||||
const sanitizedEnv = (0, main_entry_runtime_1.sanitizeLaunchMpvEnv)(process.env);
|
||||
applySanitizedEnv(sanitizedEnv);
|
||||
await electron_1.app.whenReady();
|
||||
const configuredMpvLaunch = (0, main_entry_launch_config_1.readConfiguredWindowsMpvLaunch)(userDataPath);
|
||||
const extraArgs = (0, main_entry_runtime_1.normalizeLaunchMpvExtraArgs)(process.argv);
|
||||
(0, log_files_1.applyLogFileTogglesToEnv)(configuredMpvLaunch.logFiles);
|
||||
const mpvLogPath = (0, log_files_1.isLogFileEnabled)('mpv')
|
||||
? process.env.SUBMINER_MPV_LOG?.trim() || (0, log_files_1.resolveDefaultLogFilePath)('mpv')
|
||||
: '';
|
||||
if (mpvLogPath) {
|
||||
(0, log_files_1.pruneLogDirectoryForPath)(mpvLogPath, configuredMpvLaunch.logRotation);
|
||||
}
|
||||
const result = await (0, windows_mpv_launch_1.launchWindowsMpv)((0, main_entry_runtime_1.normalizeLaunchMpvTargets)(process.argv), (0, windows_mpv_launch_1.createWindowsMpvLaunchDeps)({
|
||||
getEnv: (name) => process.env[name],
|
||||
isAppControlServerAvailable: () => (0, app_control_client_1.isAppControlServerAvailable)({
|
||||
configDir: userDataPath,
|
||||
timeoutMs: 350,
|
||||
}),
|
||||
sendAppControlCommand: (argv) => (0, app_control_client_1.sendAppControlCommand)(argv, {
|
||||
configDir: userDataPath,
|
||||
timeoutMs: 1000,
|
||||
}),
|
||||
showError: (title, content) => {
|
||||
electron_1.dialog.showErrorBox(title, content);
|
||||
},
|
||||
logInfo: (message) => appendWindowsMpvLaunchLog(message, configuredMpvLaunch.logRotation),
|
||||
}), [...extraArgs, ...(0, mpv_logging_args_1.buildMpvLoggingArgs)(configuredMpvLaunch.logLevel, mpvLogPath, extraArgs)], process.execPath, resolveBundledWindowsMpvPluginEntrypoint(), configuredMpvLaunch.executablePath, configuredMpvLaunch.launchMode, createWindowsRuntimePluginPolicy(), configuredMpvLaunch.pluginRuntimeConfig);
|
||||
electron_1.app.exit(result.ok ? 0 : 1);
|
||||
return;
|
||||
}
|
||||
if ((0, main_entry_runtime_1.shouldHandleStatsDaemonCommandAtEntry)(process.argv, process.env)) {
|
||||
await electron_1.app.whenReady();
|
||||
const exitCode = await (0, stats_daemon_entry_1.runStatsDaemonControlFromProcess)(electron_1.app.getPath('userData'));
|
||||
electron_1.app.exit(exitCode);
|
||||
return;
|
||||
}
|
||||
if (await forwardStartupArgvViaAppControlIfAvailable()) {
|
||||
return;
|
||||
}
|
||||
if ((0, main_entry_runtime_1.shouldDetachBackgroundLaunch)(process.argv, process.env)) {
|
||||
const childArgs = (0, main_entry_runtime_1.hasTransportedStartupArgs)(process.env) ? [] : process.argv.slice(1);
|
||||
const child = (0, node_child_process_1.spawn)(process.execPath, childArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: (0, main_entry_runtime_1.sanitizeBackgroundEnv)(process.env),
|
||||
});
|
||||
child.unref();
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
startMainProcess();
|
||||
}
|
||||
void runEntryProcess().catch((error) => {
|
||||
console.error('SubMiner app-control handoff failed:', error);
|
||||
startMainProcess();
|
||||
});
|
||||
//# sourceMappingURL=main-entry.js.map
|
||||
@@ -105,6 +105,8 @@ bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verif
|
||||
- For `docs-site/`, `docs/`, and doc-only edits.
|
||||
- `config`
|
||||
- For `src/config/` and config-template-sensitive edits.
|
||||
- `stats`
|
||||
- For `stats/` dashboard UI edits.
|
||||
- `core`
|
||||
- For general source changes where `typecheck` + `test:fast` is the best cheap signal.
|
||||
- `launcher-plugin`
|
||||
|
||||
@@ -108,6 +108,14 @@ for path in "${PATHS[@]}"; do
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
stats/*)
|
||||
add_lane "stats"
|
||||
add_reason "$path -> stats"
|
||||
specialized=1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
launcher/*|plugin/subminer/*|plugin/subminer.conf|scripts/test-plugin-*|scripts/get-mpv-window-*|scripts/configure-plugin-binary-path.mjs)
|
||||
add_lane "launcher-plugin"
|
||||
|
||||
@@ -255,8 +255,18 @@ write_summary_files() {
|
||||
local lane_lines
|
||||
lane_lines=$(printf '%s\n' "${SELECTED_LANES[@]}")
|
||||
printf '%s\n' "$lane_lines" >"$ARTIFACT_DIR/lanes.txt"
|
||||
printf '%s\n' "${BLOCKERS[@]}" >"$ARTIFACT_DIR/blockers.txt"
|
||||
printf '%s\n' "${PATH_ARGS[@]}" >"$ARTIFACT_DIR/requested-paths.txt"
|
||||
# bash 3.2 raises "unbound variable" under set -u when expanding an empty
|
||||
# array, so guard on length (matching the idiom used elsewhere here).
|
||||
if [[ ${#BLOCKERS[@]} -gt 0 ]]; then
|
||||
printf '%s\n' "${BLOCKERS[@]}" >"$ARTIFACT_DIR/blockers.txt"
|
||||
else
|
||||
: >"$ARTIFACT_DIR/blockers.txt"
|
||||
fi
|
||||
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
|
||||
printf '%s\n' "${PATH_ARGS[@]}" >"$ARTIFACT_DIR/requested-paths.txt"
|
||||
else
|
||||
: >"$ARTIFACT_DIR/requested-paths.txt"
|
||||
fi
|
||||
|
||||
ARTIFACT_DIR_ENV="$ARTIFACT_DIR" \
|
||||
SESSION_ID_ENV="$SESSION_ID" \
|
||||
@@ -482,6 +492,9 @@ for lane in "${SELECTED_LANES[@]}"; do
|
||||
config)
|
||||
run_step "$lane" "config" "bun run test:config" || break
|
||||
;;
|
||||
stats)
|
||||
run_step "$lane" "stats" "bun run test:stats" || break
|
||||
;;
|
||||
core)
|
||||
run_step "$lane" "typecheck" "bun run typecheck" || break
|
||||
run_step "$lane" "fast-tests" "bun run test:fast" || break
|
||||
|
||||
@@ -1,60 +1,73 @@
|
||||
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
|
||||
|
||||
<!-- prerelease-base-version: 0.17.0 -->
|
||||
<!-- prerelease-base-version: 0.18.0 -->
|
||||
|
||||
## Highlights
|
||||
### Added
|
||||
- **Watch History Browser**
|
||||
- New `subminer -H` / `--history` command lets you browse your local watch history, replay the last episode, jump to the next one, or pick an episode via fzf or rofi.
|
||||
- The rofi picker now shows AniList cover art for each show, making it easier to spot the right title at a glance.
|
||||
- **Card Audio Normalization**
|
||||
- Audio extracted for Anki cards is now volume-normalized by default, giving more consistent playback loudness across cards.
|
||||
- Prefer the original source volume? Disable it via the new `ankiConnect.media.normalizeAudio` setting.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Subtitle Delay Shortcuts:** Overlay subtitle delay controls now match mpv's native defaults.
|
||||
- `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` and show the current delay on the OSD.
|
||||
- The previous SubMiner-only adjacent-cue delay action has been removed.
|
||||
|
||||
- **Update Notifications:** New installs now default to overlay-only update notifications instead of overlay plus system notifications.
|
||||
- **New App Icon**
|
||||
- SubMiner now ships pixel-art submarine artwork contributed by an anonymous community member.
|
||||
- Applied across the app icon, tray icon, notifications, README, docs site, and stats page.
|
||||
- **Launcher Preview Layout**
|
||||
- fzf previews in the launcher now sit below the menu instead of beside it, giving long titles and metadata more horizontal room.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Anki Card Enrichment:** Fixed two issues where card fields were not populated correctly after mining.
|
||||
- Highlight Word now bolds the mined word in Kiku sentence and sentence-furigana fields even when the source Yomitan sentence has no existing bold markup.
|
||||
- Lapis and Kiku word cards enriched through SubMiner now include the word-and-sentence marker, restoring sentence context on the card front.
|
||||
|
||||
- **Windows Overlay:** Fixed shaky hover and click behavior on the subtitle bar when a video attaches to an already-running SubMiner instance.
|
||||
|
||||
- **Windows Anki & Media:** Fixed two issues affecting Windows users running SubMiner in background-launch mode.
|
||||
- Known-word cache refreshes no longer fail when no deck is configured.
|
||||
- Audio and image clipping now works correctly by recreating missing FFmpeg temp directories before processing.
|
||||
|
||||
- **Windows Character Dictionary:** The character dictionary auto-sync now correctly falls back to mpv's current video path on Windows when app media state is not yet ready.
|
||||
|
||||
- **Linux Support Assets:** Linux updates now create and refresh both managed support assets: the launcher runtime plugin copy and the rofi theme.
|
||||
- First playback on a fresh Linux install auto-installs those bundled assets before mpv starts if either one is missing.
|
||||
- Asset refreshes leave unrelated SubMiner data directories untouched and stage plugin copies before replacing the live runtime plugin.
|
||||
|
||||
- **Linux Visible Overlay Startup:** Auto-paused visible overlay startup stays fully interactive during the first measurement gap.
|
||||
- Startup subtitle cache misses paint raw text before tokenization finishes, and temporarily empty mpv subtitle reads refresh parsed cues before warm readiness resumes playback.
|
||||
|
||||
- **Playlist Transitions:** The visible overlay stays active while mpv advances to the next playlist item, including when the next episode loads after the warm transition delay.
|
||||
|
||||
- **macOS Yomitan Popup Focus:** Yomitan popup focus is restored after card mining or popup reload.
|
||||
- Clicking transparent overlay space now closes the popup and returns passthrough to mpv without a hide/reappear cycle.
|
||||
|
||||
- **Stats AniList Search:** Manual AniList linking from the stats page now strips generated `Season N` suffixes before searching, so the base anime title is used.
|
||||
|
||||
- **Desktop Notifications:** System notifications now show the SubMiner app icon when no custom notification image is provided.
|
||||
|
||||
- **Release Notes:** GitHub release `What's Changed` and `New Contributors` attribution sections are preserved when CI regenerates release notes from committed changelog output.
|
||||
|
||||
### Docs
|
||||
|
||||
- **Linux Update Flow:** Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal playback auto-installs those managed support assets if either one is missing.
|
||||
- **Character Name Highlighting in Subtitles**
|
||||
- Fixed unspaced Japanese names (e.g. 東紫乃, 渡辺真奈美) being split at the wrong point, which left surnames like 東 and 渡辺 without their character portrait or hover lookup.
|
||||
- Fixed names getting cut off or losing their highlight when caught by the subtitle scanner's punctuation handling, misclassified by grammar tagging, or swallowed entirely by a longer generic dictionary match (e.g. ヨータ disappearing inside a false とヨー match).
|
||||
- Fixed a single unrecognized word in a subtitle line (like a stray interjection) causing character-name highlighting to drop for the whole line instead of just that word.
|
||||
- No action needed — existing data upgrades automatically the next time a matching name is seen.
|
||||
- **Known-Word Highlighting**
|
||||
- Words are no longer marked "known" (green) just because they share spelling with a known Anki card that actually teaches a different reading (e.g. 床 read as とこ no longer falsely matches a known 床/ゆか card).
|
||||
- Kanji words are also no longer marked known just because a different mined word happens to share their reading (e.g. 渓谷/けいこく no longer falsely matches a known 警告/けいこく card).
|
||||
- Single-kana grammar tokens (particles like よ, え) no longer borrow an unrelated card's reading and get falsely painted as known.
|
||||
- Stats sessions now correctly reflect known-word counts again after the reading-aware matching upgrade, instead of showing 0 everywhere.
|
||||
- **Annotation Highlighting Refinements**
|
||||
- Restored frequency/JLPT highlighting and vocabulary-stat counting for words like 確かに and やはり, which were wrongly treated as grammar noise.
|
||||
- Kanji nouns that MeCab tags as "non-independent" (e.g. 日, 点, 以外) also keep their highlighting and stats counting again.
|
||||
- Suffix-only tokens (e.g. さん, れる) are now excluded from JLPT/frequency highlighting by default to match how particles and interjections are treated; known-word highlighting for them still works, and this is configurable.
|
||||
- **Unparsed Subtitle Text**
|
||||
- Subtitle text the dictionary can't recognize (like a truncated verb form) is now still hoverable for lookup and correctly counted toward a sentence's difficulty, instead of showing as dead, non-interactive text.
|
||||
- **Kiku Manual Field Grouping**
|
||||
- Fixed the field-grouping dialog getting stuck invisible behind fullscreen video on Hyprland/Wayland, and failing silently on repeated attempts after the first use.
|
||||
- Fixed a timed-out or failed grouping request leaving an invisible, stuck dialog covering the video; it now closes automatically so the overlay recovers.
|
||||
- Fixed a duplicate "Field grouping cancelled" notification appearing when grouping was cancelled via the trigger shortcut, and added a proper error message for the previously-silent case where the original card can no longer be loaded.
|
||||
- **Secondary Subtitles**
|
||||
- Karaoke-style secondary subtitles (common in opening/ending songs) no longer spam dozens of lines down the screen; repeated lines are now collapsed and the subtitle area is capped to a strip at the top.
|
||||
- **YouTube Extraction**
|
||||
- Fixed direct YouTube stream extraction occasionally corrupting the stream URL and causing failed audio/video capture.
|
||||
- **Background Stats Server**
|
||||
- Launching SubMiner in the background now correctly auto-starts the stats server when enabled, and won't start a duplicate if one's already running.
|
||||
- **Stats Trend Charts**
|
||||
- All trend chart titles now show by default, with the ability to hide specific titles (remembered across sessions) and cap how many top titles a chart displays.
|
||||
- **Stats Cover Art**
|
||||
- Cover art now loads as soon as a series starts playing instead of waiting for your first visit to its detail page, so the stats timeline shows artwork right away.
|
||||
- Existing series missing art are backfilled automatically the next time you open the stats page.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- Replace subtitle delay actions with native mpv keybindings by @ksyasuda in #120
|
||||
- fix(stats): strip Season N suffix from AniList title searches by @ksyasuda in #121
|
||||
- fix(overlay): preserve visible state across playlist item transitions by @ksyasuda in #124
|
||||
- fix(overlay): restore macOS Yomitan popup focus without breaking click-away by @ksyasuda in #125
|
||||
- fix(linux): auto-install managed plugin copy; include in asset updates by @ksyasuda in #127
|
||||
- Fix Windows Anki startup and overlay regressions by @ksyasuda in #128
|
||||
- fix(youtube): parse mpv EDL stream URLs with byte-length guards by @ksyasuda in #134
|
||||
- Normalize generated Anki audio by default by @ksyasuda in #135
|
||||
- feat(launcher): add -H/--history command to browse local watch history by @ksyasuda in #136
|
||||
- fix(overlay): prevent field grouping modal from freezing overlay on Hyprland by @ksyasuda in #138
|
||||
- fix(overlay): collapse karaoke syllable spam in secondary subtitles by @ksyasuda in #139
|
||||
- feat(stats): Trends dashboard overhaul — title visibility, ranking modes, calendar-accurate windows, tooltips by @ksyasuda in #140
|
||||
- feat(branding): replace app icon with contributed pixel-art set by @ksyasuda in #141
|
||||
- feat(anki): reading-aware known-word matching (cache v3) by @ksyasuda in #142
|
||||
- fix(stats): start stats server on background app launch by @ksyasuda in #144
|
||||
- fix(tokenizer): keep unparsed Yomitan tokens hoverable by @ksyasuda in #145
|
||||
- fix(overlay): resolve unspaced Japanese name splits and scan recovery by @ksyasuda in #146
|
||||
- fix(tokenizer): prevent grammar tokens from borrowing known-word highlight via unrelated readings by @ksyasuda in #147
|
||||
- fix(stats): fetch cover art eagerly at session start instead of on series page visit by @ksyasuda in #148
|
||||
- fix(overlay): keep frequency/JLPT highlight for kanji non-independent nouns by @ksyasuda in #150
|
||||
- fix(tokenizer): greedy name pre-pass to prevent generic matches swallowing character names by @ksyasuda in #151
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
## Highlights
|
||||
### Added
|
||||
|
||||
- **Sentence Audio Normalization**
|
||||
- Generated sentence audio is now normalized to -23 LUFS by default, giving mined clips consistent volume across shows.
|
||||
- Clips captured from playback can also mirror mpv's software volume curve, with a limiter to prevent clipping when boosted.
|
||||
- Both behaviors are controlled independently and can be turned off in the Anki Connect media settings.
|
||||
|
||||
- **Watch History Browser**
|
||||
- Added `subminer -H` / `--history` to browse local watch history, replay the last episode, continue to the next one, or jump to any past episode.
|
||||
- Works with fzf or rofi; the rofi picker shows AniList cover art already stored in the stats database.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Known-Word Highlighting Accuracy**
|
||||
- Highlighting now compares subtitle and Anki-card readings, so it no longer confuses homographs like 床/とこ vs 床/ゆか or unrelated kanji that happen to share a reading.
|
||||
- Standalone suffix words such as さん or れる are now excluded from JLPT/frequency/N+1 annotations by default, matching how particles and interjections are already treated (configurable if you'd rather keep them annotated).
|
||||
- Cards without readings still fall back to word-only matching; the highlighting cache upgrades automatically with no action needed.
|
||||
|
||||
- **Stats Trend Charts**
|
||||
- Overhauled trend charts with persisted title visibility, per-chart title limits, "top" and "most recent" ranking modes, an option to show or hide empty days, calendar-aligned periods, and sortable multi-column tooltips.
|
||||
|
||||
- **New App Icon**
|
||||
- Replaced the SubMiner icon with new pixel-art submarine artwork contributed by an anonymous community member, now used across the app icon, tray, notifications, README, docs site, and stats page.
|
||||
|
||||
- **Launcher Preview Layout**
|
||||
- fzf previews now sit below the launcher menu instead of beside it, giving long titles and metadata more horizontal room.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Character Name Recognition**
|
||||
- Character dictionaries now correctly split unspaced AniList native names and validate readings, so overlay portraits, highlights, and hover lookups work reliably even without MeCab installed.
|
||||
- Name matches survive punctuation and unmatched text and no longer get overridden by generic dictionary matches or wrongly split from longer words like 空気.
|
||||
- Existing installs regenerate automatically and upgrade to exact splits once MeCab is available — no action needed.
|
||||
|
||||
- **Frequency & JLPT Highlighting Coverage**
|
||||
- Content adverbs (確かに, やはり) and kanji nouns MeCab tags as non-independent (日, 点, 以外) are now correctly included in frequency/JLPT highlighting and vocabulary stats.
|
||||
- Lexicalized kana expressions like かといって keep their frequency annotations, while interjections, pronouns, and pure grammar fragments are still filtered out as noise.
|
||||
|
||||
- **Unparsed Text Hover Lookup**
|
||||
- Subtitle text Yomitan can't fully parse — truncated inflections like とこ戻ろ… or elongation runs like ぅ~ — is hoverable again for dictionary lookup.
|
||||
- These runs stay excluded from frequency/JLPT highlighting and vocabulary stats, same as bracketed captions and punctuation-only text.
|
||||
|
||||
- **Karaoke-Style Secondary Subtitles**
|
||||
- Secondary subtitles no longer flood the screen with dozens of one-syllable lines during karaoke-style openings and endings.
|
||||
- Repeated events now collapse into a single line, and the secondary subtitle area stays capped to a strip at the top.
|
||||
|
||||
- **Kiku Field Grouping Reliability**
|
||||
- The manual field-grouping dialog now stays above fullscreen mpv on Hyprland/Wayland and keeps working across repeated attempts.
|
||||
- Abandoned grouping windows close automatically after a timeout or failure, and each attempt now reports a clear success or error, including when the original card can no longer be loaded.
|
||||
|
||||
- **Background Stats Server Startup**
|
||||
- Background `subminer app` launches now start the stats server automatically when enabled, and skip startup if one is already running.
|
||||
|
||||
- **AniList Cover Art Timing**
|
||||
- Stats now fetches the best-match AniList cover as soon as a new series starts playing, so artwork appears in the timeline immediately instead of only after visiting the series page.
|
||||
- Existing series missing art get backfilled automatically on the next Stats page visit.
|
||||
|
||||
- **YouTube Direct Stream Playback**
|
||||
- Fixed direct YouTube stream extraction so mpv's EDL stream URLs are parsed correctly, preventing corrupted signed video URLs and the resulting ffmpeg 403 errors.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(youtube): parse mpv EDL stream URLs with byte-length guards by @ksyasuda in #134
|
||||
- Normalize generated Anki audio by default by @ksyasuda in #135
|
||||
- feat(launcher): add -H/--history command to browse local watch history by @ksyasuda in #136
|
||||
- fix(overlay): prevent field grouping modal from freezing overlay on Hyprland by @ksyasuda in #138
|
||||
- fix(overlay): collapse karaoke syllable spam in secondary subtitles by @ksyasuda in #139
|
||||
- feat(stats): Trends dashboard overhaul — title visibility, ranking modes, calendar-accurate windows, tooltips by @ksyasuda in #140
|
||||
- feat(branding): replace app icon with contributed pixel-art set by @ksyasuda in #141
|
||||
- feat(anki): reading-aware known-word matching (cache v3) by @ksyasuda in #142
|
||||
- fix(stats): start stats server on background app launch by @ksyasuda in #144
|
||||
- fix(tokenizer): keep unparsed Yomitan tokens hoverable by @ksyasuda in #145
|
||||
- fix(overlay): resolve unspaced Japanese name splits and scan recovery by @ksyasuda in #146
|
||||
- fix(tokenizer): prevent grammar tokens from borrowing known-word highlight via unrelated readings by @ksyasuda in #147
|
||||
- fix(stats): fetch cover art eagerly at session start instead of on series page visit by @ksyasuda in #148
|
||||
|
||||
## 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` and the `subminer` launcher
|
||||
|
||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||
@@ -1213,6 +1213,102 @@ test('writeChangelogArtifacts appends contributor attribution and a new-contribu
|
||||
}
|
||||
});
|
||||
|
||||
test('writeChangelogArtifacts skips contributor attribution in GitHub Actions without a token', async () => {
|
||||
const { writeChangelogArtifacts } = await loadModule();
|
||||
const workspace = createWorkspace('release-notes-actions-no-token');
|
||||
const projectRoot = path.join(workspace, 'SubMiner');
|
||||
const originalActions = process.env.GITHUB_ACTIONS;
|
||||
const originalGhToken = process.env.GH_TOKEN;
|
||||
const originalGithubToken = process.env.GITHUB_TOKEN;
|
||||
const originalPath = process.env.PATH;
|
||||
const originalWarn = console.warn;
|
||||
const warnings: string[] = [];
|
||||
|
||||
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'changes', '001.md'),
|
||||
['type: added', 'area: release', '', '- Added a feature.'].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
try {
|
||||
process.env.GITHUB_ACTIONS = 'true';
|
||||
delete process.env.GH_TOKEN;
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
process.env.PATH = workspace;
|
||||
console.warn = (message?: unknown) => {
|
||||
warnings.push(String(message));
|
||||
};
|
||||
|
||||
writeChangelogArtifacts({
|
||||
cwd: projectRoot,
|
||||
version: '0.6.0',
|
||||
date: '2026-05-06',
|
||||
deps: { runClaude: defaultStubClaude().runClaude },
|
||||
});
|
||||
|
||||
assert.deepEqual(warnings, []);
|
||||
const releaseNotes = fs.readFileSync(
|
||||
path.join(projectRoot, 'release', 'release-notes.md'),
|
||||
'utf8',
|
||||
);
|
||||
assert.doesNotMatch(releaseNotes, /## What's Changed/);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
if (originalActions === undefined) {
|
||||
delete process.env.GITHUB_ACTIONS;
|
||||
} else {
|
||||
process.env.GITHUB_ACTIONS = originalActions;
|
||||
}
|
||||
if (originalGhToken === undefined) {
|
||||
delete process.env.GH_TOKEN;
|
||||
} else {
|
||||
process.env.GH_TOKEN = originalGhToken;
|
||||
}
|
||||
if (originalGithubToken === undefined) {
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
} else {
|
||||
process.env.GITHUB_TOKEN = originalGithubToken;
|
||||
}
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('shouldSkipDefaultContributionLookup skips GitHub Actions without a gh token', async () => {
|
||||
const { shouldSkipDefaultContributionLookup } = await loadModule();
|
||||
|
||||
assert.equal(
|
||||
shouldSkipDefaultContributionLookup({
|
||||
GITHUB_ACTIONS: 'true',
|
||||
GH_TOKEN: undefined,
|
||||
GITHUB_TOKEN: undefined,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSkipDefaultContributionLookup({
|
||||
GITHUB_ACTIONS: 'true',
|
||||
GH_TOKEN: 'ghs_test',
|
||||
GITHUB_TOKEN: undefined,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSkipDefaultContributionLookup({
|
||||
GITHUB_ACTIONS: undefined,
|
||||
GH_TOKEN: undefined,
|
||||
GITHUB_TOKEN: undefined,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('writeReleaseNotesForVersion preserves committed contributor attribution before installation', async () => {
|
||||
const { writeReleaseNotesForVersion } = await loadModule();
|
||||
const workspace = createWorkspace('release-notes-preserve-attribution');
|
||||
|
||||
@@ -108,15 +108,11 @@ function renderPrereleaseBaseVersionMarker(version: string): string {
|
||||
}
|
||||
|
||||
function extractPrereleaseBaseVersionMarker(notes: string): string | null {
|
||||
return (
|
||||
/<!--\s*prerelease-base-version:\s*(\d+\.\d+\.\d+)\s*-->/u.exec(notes)?.[1] ?? null
|
||||
);
|
||||
return /<!--\s*prerelease-base-version:\s*(\d+\.\d+\.\d+)\s*-->/u.exec(notes)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function stripPrereleaseMetadata(notes: string): string {
|
||||
return notes
|
||||
.replace(/<!--\s*prerelease-base-version:\s*\d+\.\d+\.\d+\s*-->\s*/u, '')
|
||||
.trim();
|
||||
return notes.replace(/<!--\s*prerelease-base-version:\s*\d+\.\d+\.\d+\s*-->\s*/u, '').trim();
|
||||
}
|
||||
|
||||
function resolveReusablePrereleaseNotes(notes: string, version: string): string | undefined {
|
||||
@@ -349,6 +345,12 @@ function resolveFragmentRelativePath(fragmentPath: string, cwd: string): string
|
||||
return path.relative(cwd, fragmentPath).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
export function shouldSkipDefaultContributionLookup(
|
||||
env: Partial<Record<'GITHUB_ACTIONS' | 'GH_TOKEN' | 'GITHUB_TOKEN', string>> = process.env,
|
||||
): boolean {
|
||||
return env.GITHUB_ACTIONS === 'true' && !env.GH_TOKEN && !env.GITHUB_TOKEN;
|
||||
}
|
||||
|
||||
// Walks git history + the GitHub API to attribute each released fragment to the
|
||||
// PR (and author) that introduced it. One git call and one gh call per fragment,
|
||||
// plus one gh call per unique author for the first-contribution check. Best
|
||||
@@ -358,6 +360,9 @@ function defaultResolveContributions(fragmentPaths: string[], cwd: string): Cont
|
||||
if (fragmentPaths.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (shouldSkipDefaultContributionLookup()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const slug = execFileSync(
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||
|
||||
type LaneConfig = {
|
||||
roots: string[];
|
||||
include: string[];
|
||||
exclude: Set<string>;
|
||||
};
|
||||
import { collectLaneFiles } from './test-lanes';
|
||||
|
||||
type LcovRecord = {
|
||||
sourceFile: string;
|
||||
@@ -18,64 +13,6 @@ type LcovRecord = {
|
||||
|
||||
const repoRoot = resolve(__dirname, '..');
|
||||
|
||||
const lanes: Record<string, LaneConfig> = {
|
||||
'bun-src-full': {
|
||||
roots: ['src'],
|
||||
include: ['.test.ts', '.type-test.ts'],
|
||||
exclude: new Set([
|
||||
'src/core/services/anki-jimaku-ipc.test.ts',
|
||||
'src/core/services/ipc.test.ts',
|
||||
'src/core/services/overlay-manager.test.ts',
|
||||
'src/main/config-validation.test.ts',
|
||||
'src/main/runtime/registry.test.ts',
|
||||
'src/main/runtime/startup-config.test.ts',
|
||||
]),
|
||||
},
|
||||
'bun-launcher-unit': {
|
||||
roots: ['launcher'],
|
||||
include: ['.test.ts'],
|
||||
exclude: new Set(['launcher/smoke.e2e.test.ts']),
|
||||
},
|
||||
};
|
||||
|
||||
function collectFiles(
|
||||
rootDir: string,
|
||||
includeSuffixes: string[],
|
||||
excludeSet: Set<string>,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
const visit = (currentDir: string) => {
|
||||
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
||||
const fullPath = resolve(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(fullPath);
|
||||
continue;
|
||||
}
|
||||
const relPath = relative(repoRoot, fullPath).replaceAll('\\', '/');
|
||||
if (excludeSet.has(relPath)) continue;
|
||||
if (includeSuffixes.some((suffix) => relPath.endsWith(suffix))) {
|
||||
out.push(relPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
visit(resolve(repoRoot, rootDir));
|
||||
out.sort();
|
||||
return out;
|
||||
}
|
||||
|
||||
function getLaneFiles(laneName: string): string[] {
|
||||
const lane = lanes[laneName];
|
||||
if (!lane) {
|
||||
throw new Error(`Unknown coverage lane: ${laneName}`);
|
||||
}
|
||||
const files = lane.roots.flatMap((rootDir) => collectFiles(rootDir, lane.include, lane.exclude));
|
||||
if (files.length === 0) {
|
||||
throw new Error(`No test files found for coverage lane: ${laneName}`);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function parseCoverageDirArg(argv: string[]): string {
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
if (argv[index] === '--coverage-dir') {
|
||||
@@ -277,7 +214,13 @@ function runCoverageLane(): number {
|
||||
rmSync(shardRoot, { recursive: true, force: true });
|
||||
mkdirSync(shardRoot, { recursive: true });
|
||||
|
||||
const files = getLaneFiles(laneName);
|
||||
let files: string[];
|
||||
try {
|
||||
files = collectLaneFiles(repoRoot, laneName);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
|
||||
return 1;
|
||||
}
|
||||
const reports: string[] = [];
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,73 +1,153 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { relative, resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { resolve } from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { collectLaneFiles } from './test-lanes.ts';
|
||||
|
||||
// Runs a test lane with per-file process isolation: one `bun test` process per
|
||||
// test file so a hanging test or leaked global in one file cannot poison the
|
||||
// rest of the lane. Use --single-process for the old all-in-one-process mode.
|
||||
//
|
||||
// Usage: bun scripts/run-test-lane.mjs <lane> [--jobs N] [--timeout-secs N] [--single-process]
|
||||
|
||||
const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||||
|
||||
const lanes = {
|
||||
'bun-src-full': {
|
||||
roots: ['src'],
|
||||
include: ['.test.ts', '.type-test.ts'],
|
||||
exclude: new Set([
|
||||
'src/core/services/anki-jimaku-ipc.test.ts',
|
||||
'src/core/services/ipc.test.ts',
|
||||
'src/core/services/overlay-manager.test.ts',
|
||||
'src/main/config-validation.test.ts',
|
||||
'src/main/runtime/registry.test.ts',
|
||||
'src/main/runtime/startup-config.test.ts',
|
||||
]),
|
||||
},
|
||||
'bun-launcher-unit': {
|
||||
roots: ['launcher'],
|
||||
include: ['.test.ts'],
|
||||
exclude: new Set(['launcher/smoke.e2e.test.ts']),
|
||||
},
|
||||
};
|
||||
// Cap per-file buffered output so a long or noisy test cannot grow the string
|
||||
// without bound and exhaust memory.
|
||||
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
||||
|
||||
function collectFiles(rootDir, includeSuffixes, excludeSet) {
|
||||
const out = [];
|
||||
const visit = (currentDir) => {
|
||||
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
||||
const fullPath = resolve(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(fullPath);
|
||||
continue;
|
||||
// Track spawned `bun test` children so we can kill them if the runner is
|
||||
// interrupted, avoiding orphaned in-flight test processes.
|
||||
const activeChildren = new Set();
|
||||
|
||||
function terminateChildren() {
|
||||
for (const child of activeChildren) {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
activeChildren.clear();
|
||||
}
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(signal, () => {
|
||||
terminateChildren();
|
||||
process.exit(130);
|
||||
});
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { lane: undefined, jobs: 1, timeoutSecs: 300, singleProcess: false };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--jobs') {
|
||||
options.jobs = Math.max(1, Number(argv[(index += 1)]) || 1);
|
||||
} else if (arg === '--timeout-secs') {
|
||||
options.timeoutSecs = Math.max(1, Number(argv[(index += 1)]) || 300);
|
||||
} else if (arg === '--single-process') {
|
||||
options.singleProcess = true;
|
||||
} else if (!arg.startsWith('--') && options.lane === undefined) {
|
||||
options.lane = arg;
|
||||
} else {
|
||||
process.stderr.write(`Unknown argument: ${arg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function runFile(file, timeoutSecs) {
|
||||
return new Promise((resolvePromise) => {
|
||||
const child = spawn('bun', ['test', `./${file}`], { cwd: repoRoot });
|
||||
activeChildren.add(child);
|
||||
let output = '';
|
||||
let truncated = false;
|
||||
let timedOut = false;
|
||||
const append = (chunk) => {
|
||||
if (truncated) return;
|
||||
output += chunk;
|
||||
if (output.length > MAX_OUTPUT_BYTES) {
|
||||
output = `${output.slice(0, MAX_OUTPUT_BYTES)}\n[output truncated at ${MAX_OUTPUT_BYTES} bytes]\n`;
|
||||
truncated = true;
|
||||
}
|
||||
const relPath = relative(repoRoot, fullPath).replaceAll('\\', '/');
|
||||
if (excludeSet.has(relPath)) continue;
|
||||
if (includeSuffixes.some((suffix) => relPath.endsWith(suffix))) {
|
||||
out.push(relPath);
|
||||
};
|
||||
child.stdout.on('data', append);
|
||||
child.stderr.on('data', append);
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGKILL');
|
||||
}, timeoutSecs * 1000);
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
activeChildren.delete(child);
|
||||
resolvePromise({ file, code: timedOut ? 124 : (code ?? 1), output, timedOut });
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
activeChildren.delete(child);
|
||||
resolvePromise({ file, code: 1, output: String(error), timedOut: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runIsolated(files, options) {
|
||||
const failures = [];
|
||||
let nextIndex = 0;
|
||||
let completed = 0;
|
||||
|
||||
async function worker() {
|
||||
while (nextIndex < files.length) {
|
||||
const file = files[nextIndex];
|
||||
nextIndex += 1;
|
||||
const result = await runFile(file, options.timeoutSecs);
|
||||
completed += 1;
|
||||
if (result.code !== 0) {
|
||||
failures.push(result);
|
||||
const reason = result.timedOut ? `timed out after ${options.timeoutSecs}s` : 'failed';
|
||||
process.stderr.write(`\n[${completed}/${files.length}] ${file} ${reason}\n`);
|
||||
process.stderr.write(result.output);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
visit(resolve(repoRoot, rootDir));
|
||||
out.sort();
|
||||
return out;
|
||||
await Promise.all(Array.from({ length: Math.min(options.jobs, files.length) }, worker));
|
||||
|
||||
if (failures.length > 0) {
|
||||
process.stderr.write(`\n${failures.length} of ${files.length} test files failed:\n`);
|
||||
for (const failure of failures) {
|
||||
process.stderr.write(` ${failure.file}${failure.timedOut ? ' (timeout)' : ''}\n`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
process.stdout.write(`All ${files.length} test files passed.\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const lane = lanes[process.argv[2]];
|
||||
function runSingleProcess(files) {
|
||||
const result = spawnSync('bun', ['test', ...files.map((file) => `./${file}`)], {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
return result.status ?? 1;
|
||||
}
|
||||
|
||||
if (!lane) {
|
||||
process.stderr.write(`Unknown test lane: ${process.argv[2] ?? '(missing)'}\n`);
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!options.lane) {
|
||||
process.stderr.write('Missing test lane name\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = lane.roots.flatMap((rootDir) => collectFiles(rootDir, lane.include, lane.exclude));
|
||||
|
||||
if (files.length === 0) {
|
||||
process.stderr.write(`No test files found for lane: ${process.argv[2]}\n`);
|
||||
let files;
|
||||
try {
|
||||
files = collectLaneFiles(repoRoot, options.lane);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = spawnSync('bun', ['test', ...files.map((file) => `./${file}`)], {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
if (options.singleProcess) {
|
||||
process.exit(runSingleProcess(files));
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
process.exit(await runIsolated(files, options));
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { relative, resolve } from 'node:path';
|
||||
|
||||
export type TestLane = {
|
||||
roots: string[];
|
||||
include: string[];
|
||||
exclude?: string[];
|
||||
extraFiles?: string[];
|
||||
};
|
||||
|
||||
// Single source of truth for test-lane membership. Consumed by
|
||||
// scripts/run-test-lane.mjs (plain runs) and scripts/run-coverage-lane.ts
|
||||
// (per-file coverage shards). Lanes discover files by directory so new test
|
||||
// files join their lane automatically.
|
||||
export const testLanes: Record<string, TestLane> = {
|
||||
'bun-src-full': {
|
||||
roots: ['src'],
|
||||
include: ['.test.ts', '.type-test.ts'],
|
||||
// Node-compat suites; their dist builds run via test:runtime:compat.
|
||||
exclude: [
|
||||
'src/core/services/anki-jimaku-ipc.test.ts',
|
||||
'src/core/services/ipc.test.ts',
|
||||
'src/core/services/overlay-manager.test.ts',
|
||||
'src/main/config-validation.test.ts',
|
||||
'src/main/runtime/registry.test.ts',
|
||||
'src/main/runtime/startup-config.test.ts',
|
||||
],
|
||||
},
|
||||
config: {
|
||||
roots: ['src/config'],
|
||||
include: ['.test.ts'],
|
||||
extraFiles: ['src/generate-config-example.test.ts', 'src/verify-config-example.test.ts'],
|
||||
},
|
||||
launcher: {
|
||||
roots: ['launcher'],
|
||||
include: ['.test.ts'],
|
||||
},
|
||||
'bun-launcher-unit': {
|
||||
roots: ['launcher'],
|
||||
include: ['.test.ts'],
|
||||
exclude: ['launcher/smoke.e2e.test.ts'],
|
||||
},
|
||||
scripts: {
|
||||
roots: ['scripts'],
|
||||
include: ['.test.ts'],
|
||||
},
|
||||
stats: {
|
||||
roots: ['stats/src'],
|
||||
include: ['.test.ts', '.test.tsx'],
|
||||
},
|
||||
};
|
||||
|
||||
function collectFiles(
|
||||
repoRoot: string,
|
||||
rootDir: string,
|
||||
includeSuffixes: string[],
|
||||
excludeSet: Set<string>,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
const visit = (currentDir: string): void => {
|
||||
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
||||
const fullPath = resolve(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(fullPath);
|
||||
continue;
|
||||
}
|
||||
const relPath = relative(repoRoot, fullPath).replaceAll('\\', '/');
|
||||
if (excludeSet.has(relPath)) continue;
|
||||
if (includeSuffixes.some((suffix) => relPath.endsWith(suffix))) {
|
||||
out.push(relPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
visit(resolve(repoRoot, rootDir));
|
||||
out.sort();
|
||||
return out;
|
||||
}
|
||||
|
||||
export function collectLaneFiles(repoRoot: string, laneName: string): string[] {
|
||||
const lane = testLanes[laneName];
|
||||
if (!lane) {
|
||||
throw new Error(`Unknown test lane: ${laneName}`);
|
||||
}
|
||||
const excludeSet = new Set(lane.exclude ?? []);
|
||||
const files = lane.roots.flatMap((rootDir) =>
|
||||
collectFiles(repoRoot, rootDir, lane.include, excludeSet),
|
||||
);
|
||||
for (const extra of lane.extraFiles ?? []) {
|
||||
if (!files.includes(extra)) files.push(extra);
|
||||
}
|
||||
files.sort();
|
||||
if (files.length === 0) {
|
||||
throw new Error(`No test files found for lane: ${laneName}`);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { AnkiIntegration } from './anki-integration';
|
||||
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
|
||||
import type { MediaInput } from './media-input';
|
||||
import { AnkiConnectConfig } from './types';
|
||||
|
||||
type TestOverlayNotificationPayload = {
|
||||
@@ -24,6 +25,13 @@ interface IntegrationTestContext {
|
||||
stateDir: string;
|
||||
}
|
||||
|
||||
function describeMediaInputForTest(input: MediaInput): string {
|
||||
if (typeof input === 'string') {
|
||||
return input;
|
||||
}
|
||||
return `${input.path}:${input.source ?? 'raw'}`;
|
||||
}
|
||||
|
||||
function createIntegrationTestContext(
|
||||
options: {
|
||||
highlightEnabled?: boolean;
|
||||
@@ -527,6 +535,510 @@ test('AnkiIntegration marks partial update notifications as failures in OSD mode
|
||||
assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']);
|
||||
});
|
||||
|
||||
test('AnkiIntegration applies ready YouTube cache media to every queued note id', async () => {
|
||||
const osdMessages: string[] = [];
|
||||
const updatedNotes: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||
const storedMedia: string[] = [];
|
||||
const mediaInputs: string[] = [];
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
fields: {
|
||||
image: 'Picture',
|
||||
},
|
||||
media: {
|
||||
imageFormat: 'jpg',
|
||||
},
|
||||
behavior: {
|
||||
notificationType: 'osd',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{} as never,
|
||||
(text) => {
|
||||
osdMessages.push(text);
|
||||
},
|
||||
);
|
||||
|
||||
const internals = integration as unknown as {
|
||||
client: {
|
||||
notesInfo: (noteIds: number[]) => Promise<unknown[]>;
|
||||
updateNoteFields: (noteId: number, fields: Record<string, string>) => Promise<void>;
|
||||
storeMediaFile: (filename: string, data: Buffer) => Promise<void>;
|
||||
};
|
||||
mediaGenerator: {
|
||||
generateAudio: (
|
||||
path: MediaInput,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
audioPadding?: number,
|
||||
audioStreamIndex?: number,
|
||||
normalizeAudio?: boolean,
|
||||
volumeScale?: number,
|
||||
) => Promise<Buffer>;
|
||||
generateScreenshot: (path: MediaInput) => Promise<Buffer>;
|
||||
};
|
||||
queuePendingYoutubeMediaUpdate: (job: {
|
||||
sourceUrl: string;
|
||||
noteId: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
label: string | number;
|
||||
audioStreamIndex?: number;
|
||||
audioFieldName?: string;
|
||||
imageFieldName?: string;
|
||||
generateAudio: boolean;
|
||||
generateImage: boolean;
|
||||
volumeScale?: number;
|
||||
}) => void;
|
||||
};
|
||||
internals.client = {
|
||||
notesInfo: async (noteIds) =>
|
||||
noteIds.map((noteId) => ({
|
||||
noteId,
|
||||
fields: {
|
||||
SentenceAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
})),
|
||||
updateNoteFields: async (noteId, fields) => {
|
||||
updatedNotes.push({ noteId, fields });
|
||||
},
|
||||
storeMediaFile: async (filename) => {
|
||||
storedMedia.push(filename);
|
||||
},
|
||||
};
|
||||
internals.mediaGenerator = {
|
||||
generateAudio: async (
|
||||
mediaPath,
|
||||
_startTime,
|
||||
_endTime,
|
||||
_audioPadding,
|
||||
audioStreamIndex,
|
||||
_normalizeAudio,
|
||||
volumeScale,
|
||||
) => {
|
||||
mediaInputs.push(
|
||||
`audio:${describeMediaInputForTest(mediaPath)}:${audioStreamIndex ?? 'auto'}:${volumeScale}`,
|
||||
);
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async (mediaPath) => {
|
||||
mediaInputs.push(`image:${describeMediaInputForTest(mediaPath)}`);
|
||||
return Buffer.from('image');
|
||||
},
|
||||
};
|
||||
internals.queuePendingYoutubeMediaUpdate({
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
noteId: 101,
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
label: 'first',
|
||||
audioStreamIndex: 22,
|
||||
audioFieldName: 'SentenceAudio',
|
||||
imageFieldName: 'Picture',
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
volumeScale: 0.25,
|
||||
});
|
||||
internals.queuePendingYoutubeMediaUpdate({
|
||||
sourceUrl: 'https://youtu.be/abc123',
|
||||
noteId: 202,
|
||||
startTime: 20,
|
||||
endTime: 22,
|
||||
label: 'second',
|
||||
audioStreamIndex: 23,
|
||||
audioFieldName: 'SentenceAudio',
|
||||
imageFieldName: 'Picture',
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
volumeScale: 0.8,
|
||||
});
|
||||
|
||||
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
||||
|
||||
assert.deepEqual(mediaInputs, [
|
||||
'audio:/tmp/media.mkv:youtube-cache:auto:0.25',
|
||||
'image:/tmp/media.mkv:youtube-cache',
|
||||
'audio:/tmp/media.mkv:youtube-cache:auto:0.8',
|
||||
'image:/tmp/media.mkv:youtube-cache',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
updatedNotes.map((update) => update.noteId),
|
||||
[101, 202],
|
||||
);
|
||||
const firstUpdate = updatedNotes[0];
|
||||
const secondUpdate = updatedNotes[1];
|
||||
assert.ok(firstUpdate);
|
||||
assert.ok(secondUpdate);
|
||||
assert.match(firstUpdate.fields.SentenceAudio ?? '', /^\[sound:audio_/);
|
||||
assert.match(firstUpdate.fields.Picture ?? '', /^<img src="image_/);
|
||||
assert.match(secondUpdate.fields.SentenceAudio ?? '', /^\[sound:audio_/);
|
||||
assert.match(secondUpdate.fields.Picture ?? '', /^<img src="image_/);
|
||||
assert.equal(storedMedia.length, 4);
|
||||
assert.equal(
|
||||
osdMessages.some((message) =>
|
||||
message.includes('YouTube media cache ready. Adding media to 2 queued cards.'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('AnkiIntegration reports partial queued YouTube media updates separately from failures', async () => {
|
||||
const osdMessages: string[] = [];
|
||||
const updatedNotes: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||
const notifications: Array<{ noteId: number; label: string | number; suffix?: string }> = [];
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
fields: {
|
||||
image: 'Picture',
|
||||
},
|
||||
media: {
|
||||
imageFormat: 'jpg',
|
||||
},
|
||||
behavior: {
|
||||
notificationType: 'osd',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{} as never,
|
||||
(text) => {
|
||||
osdMessages.push(text);
|
||||
},
|
||||
);
|
||||
|
||||
const internals = integration as unknown as {
|
||||
client: {
|
||||
notesInfo: (noteIds: number[]) => Promise<unknown[]>;
|
||||
updateNoteFields: (noteId: number, fields: Record<string, string>) => Promise<void>;
|
||||
storeMediaFile: () => Promise<void>;
|
||||
};
|
||||
mediaGenerator: {
|
||||
generateAudio: () => Promise<Buffer>;
|
||||
generateScreenshot: () => Promise<Buffer>;
|
||||
};
|
||||
queuePendingYoutubeMediaUpdate: (job: {
|
||||
sourceUrl: string;
|
||||
noteId: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
label: string | number;
|
||||
audioFieldName?: string;
|
||||
imageFieldName?: string;
|
||||
generateAudio: boolean;
|
||||
generateImage: boolean;
|
||||
}) => void;
|
||||
showNotification: (noteId: number, label: string | number, suffix?: string) => Promise<void>;
|
||||
};
|
||||
internals.client = {
|
||||
notesInfo: async (noteIds) =>
|
||||
noteIds.map((noteId) => ({
|
||||
noteId,
|
||||
fields: {
|
||||
SentenceAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
})),
|
||||
updateNoteFields: async (noteId, fields) => {
|
||||
updatedNotes.push({ noteId, fields });
|
||||
},
|
||||
storeMediaFile: async () => undefined,
|
||||
};
|
||||
internals.mediaGenerator = {
|
||||
generateAudio: async () => {
|
||||
throw new Error('audio stream not found');
|
||||
},
|
||||
generateScreenshot: async () => Buffer.from('image'),
|
||||
};
|
||||
internals.showNotification = async (noteId, label, suffix) => {
|
||||
notifications.push({ noteId, label, suffix });
|
||||
};
|
||||
|
||||
internals.queuePendingYoutubeMediaUpdate({
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=partial',
|
||||
noteId: 303,
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
label: 'partial',
|
||||
audioFieldName: 'SentenceAudio',
|
||||
imageFieldName: 'Picture',
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
});
|
||||
|
||||
await integration.handleYoutubeMediaCacheReady('https://youtu.be/partial', '/tmp/media.mkv');
|
||||
|
||||
assert.equal(updatedNotes.length, 1);
|
||||
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
|
||||
assert.equal(updatedNotes[0]?.fields.SentenceAudio, undefined);
|
||||
assert.deepEqual(notifications, [{ noteId: 303, label: 'partial', suffix: 'audio failed' }]);
|
||||
assert.equal(
|
||||
osdMessages.some((message) =>
|
||||
message.includes('Queued YouTube media finished with 0 updated, 1 partial, and 0 failed.'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('AnkiIntegration queues YouTube media updates against recovered source URLs', async () => {
|
||||
const updatedNotes: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||
const storedMedia: string[] = [];
|
||||
const audioVolumeScales: Array<number | undefined> = [];
|
||||
let mpvVolume = 30;
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
fields: {
|
||||
image: 'Picture',
|
||||
},
|
||||
media: {
|
||||
imageFormat: 'jpg',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{
|
||||
currentVideoPath: 'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777',
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentTimePos: 11,
|
||||
requestProperty: async (name: string) => {
|
||||
assert.equal(name, 'volume');
|
||||
return mpvVolume;
|
||||
},
|
||||
} as never,
|
||||
() => undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
async () => null,
|
||||
() => true,
|
||||
() => 'https://www.youtube.com/watch?v=abc123',
|
||||
);
|
||||
|
||||
const internals = integration as unknown as {
|
||||
client: {
|
||||
notesInfo: (noteIds: number[]) => Promise<unknown[]>;
|
||||
updateNoteFields: (noteId: number, fields: Record<string, string>) => Promise<void>;
|
||||
storeMediaFile: (filename: string) => Promise<void>;
|
||||
};
|
||||
mediaGenerator: {
|
||||
generateAudio: (
|
||||
path: MediaInput,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
audioPadding?: number,
|
||||
audioStreamIndex?: number,
|
||||
normalizeAudio?: boolean,
|
||||
volumeScale?: number,
|
||||
) => Promise<Buffer>;
|
||||
generateScreenshot: () => Promise<Buffer>;
|
||||
};
|
||||
queuePendingYoutubeMediaUpdateForNote: (job: {
|
||||
noteId: number;
|
||||
noteInfo: { noteId: number; fields: Record<string, { value: string }> };
|
||||
label: string | number;
|
||||
}) => Promise<boolean>;
|
||||
showNotification: () => Promise<void>;
|
||||
};
|
||||
internals.client = {
|
||||
notesInfo: async (noteIds) =>
|
||||
noteIds.map((noteId) => ({
|
||||
noteId,
|
||||
fields: {
|
||||
SentenceAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
})),
|
||||
updateNoteFields: async (noteId, fields) => {
|
||||
updatedNotes.push({ noteId, fields });
|
||||
},
|
||||
storeMediaFile: async (filename) => {
|
||||
storedMedia.push(filename);
|
||||
},
|
||||
};
|
||||
internals.mediaGenerator = {
|
||||
generateAudio: async (
|
||||
_path,
|
||||
_startTime,
|
||||
_endTime,
|
||||
_audioPadding,
|
||||
_audioStreamIndex,
|
||||
_normalizeAudio,
|
||||
volumeScale,
|
||||
) => {
|
||||
audioVolumeScales.push(volumeScale);
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async () => Buffer.from('image'),
|
||||
};
|
||||
internals.showNotification = async () => undefined;
|
||||
|
||||
const queued = await internals.queuePendingYoutubeMediaUpdateForNote({
|
||||
noteId: 404,
|
||||
noteInfo: {
|
||||
noteId: 404,
|
||||
fields: {
|
||||
SentenceAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
},
|
||||
label: 'resolved source',
|
||||
});
|
||||
mpvVolume = 90;
|
||||
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
||||
|
||||
assert.equal(queued, true);
|
||||
assert.equal(updatedNotes.length, 1);
|
||||
assert.equal(updatedNotes[0]?.noteId, 404);
|
||||
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio_/);
|
||||
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
|
||||
assert.equal(storedMedia.length, 2);
|
||||
assert.deepEqual(audioVolumeScales, [0.3 ** 3]);
|
||||
});
|
||||
|
||||
test('AnkiIntegration passes audio normalization config for ready cached YouTube audio', async () => {
|
||||
const audioCalls: Array<{
|
||||
path: string;
|
||||
audioStreamIndex?: number;
|
||||
normalizeAudio?: boolean;
|
||||
volumeScale?: number;
|
||||
}> = [];
|
||||
const requestedProperties: string[] = [];
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
media: {
|
||||
audioPadding: 0,
|
||||
normalizeAudio: false,
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
currentAudioStreamIndex: 0,
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentTimePos: 11,
|
||||
requestProperty: async (name: string) => {
|
||||
requestedProperties.push(name);
|
||||
return 55;
|
||||
},
|
||||
} as never,
|
||||
() => undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
async () => '/tmp/subminer-youtube-media-cache/media.mkv',
|
||||
() => true,
|
||||
);
|
||||
|
||||
const internals = integration as unknown as {
|
||||
mediaGenerator: {
|
||||
generateAudio: (
|
||||
path: { path: string },
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
audioPadding?: number,
|
||||
audioStreamIndex?: number,
|
||||
normalizeAudio?: boolean,
|
||||
volumeScale?: number,
|
||||
) => Promise<Buffer>;
|
||||
};
|
||||
generateAudio: () => Promise<Buffer | null>;
|
||||
};
|
||||
internals.mediaGenerator = {
|
||||
generateAudio: async (
|
||||
path,
|
||||
_startTime,
|
||||
_endTime,
|
||||
_audioPadding,
|
||||
audioStreamIndex,
|
||||
normalizeAudio,
|
||||
volumeScale,
|
||||
) => {
|
||||
audioCalls.push({ path: path.path, audioStreamIndex, normalizeAudio, volumeScale });
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
};
|
||||
|
||||
await internals.generateAudio();
|
||||
|
||||
assert.deepEqual(requestedProperties, ['volume']);
|
||||
assert.deepEqual(audioCalls, [
|
||||
{
|
||||
path: '/tmp/subminer-youtube-media-cache/media.mkv',
|
||||
audioStreamIndex: undefined,
|
||||
normalizeAudio: false,
|
||||
volumeScale: 0.55 ** 3,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('AnkiIntegration announces ready YouTube cache when no queued notes exist', async () => {
|
||||
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
behavior: {
|
||||
notificationType: 'overlay',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{} as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
(payload) => {
|
||||
overlayNotifications.push(payload as TestOverlayNotificationPayload);
|
||||
},
|
||||
);
|
||||
|
||||
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
||||
|
||||
assert.equal(overlayNotifications.length, 1);
|
||||
assert.equal(overlayNotifications[0]?.title, 'SubMiner');
|
||||
assert.equal(overlayNotifications[0]?.body, 'YouTube media cache ready.');
|
||||
});
|
||||
|
||||
test('AnkiIntegration can let caller own no-queued YouTube cache ready notification', async () => {
|
||||
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
behavior: {
|
||||
notificationType: 'overlay',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{} as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
(payload) => {
|
||||
overlayNotifications.push(payload as TestOverlayNotificationPayload);
|
||||
},
|
||||
);
|
||||
|
||||
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv', {
|
||||
notifyNoQueued: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(overlayNotifications, []);
|
||||
});
|
||||
|
||||
test('AnkiIntegration embeds generated notification image on overlay mined-card notifications', async () => {
|
||||
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
|
||||
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
||||
|
||||
@@ -61,7 +61,19 @@ import { NoteUpdateWorkflow } from './anki-integration/note-update-workflow';
|
||||
import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow';
|
||||
import { resolveAnimatedImageLeadInSeconds } from './anki-integration/animated-image-sync';
|
||||
import { AnkiIntegrationRuntime, normalizeAnkiIntegrationConfig } from './anki-integration/runtime';
|
||||
import { resolveMediaGenerationInputPath } from './anki-integration/media-source';
|
||||
import {
|
||||
resolveAudioStreamIndexForMediaGeneration,
|
||||
resolveMediaGenerationInput,
|
||||
resolveMediaGenerationInputPath,
|
||||
type MediaGenerationInputResolverOptions,
|
||||
} from './anki-integration/media-source';
|
||||
import type { PendingYoutubeMediaUpdate } from './anki-integration/pending-youtube-media';
|
||||
import { PendingYoutubeMediaQueue } from './anki-integration/pending-youtube-media-queue';
|
||||
import type {
|
||||
PendingYoutubeMediaQueueFailedOptions,
|
||||
PendingYoutubeMediaQueueReadyOptions,
|
||||
} from './anki-integration/pending-youtube-media-queue';
|
||||
import { resolveMpvVolumeScale } from './anki-integration/mpv-volume';
|
||||
|
||||
const log = createLogger('anki').child('integration');
|
||||
|
||||
@@ -225,6 +237,13 @@ export class AnkiIntegration {
|
||||
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
|
||||
private noteIdRedirects = new Map<number, number>();
|
||||
private trackedDuplicateNoteIds = new Map<number, number[]>();
|
||||
private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null =
|
||||
null;
|
||||
private shouldRequireRemoteMediaCache: (() => boolean) | null = null;
|
||||
private getYoutubeMediaSourceUrl:
|
||||
| (() => Promise<string | null | undefined> | string | null | undefined)
|
||||
| null = null;
|
||||
private pendingYoutubeMediaQueue: PendingYoutubeMediaQueue;
|
||||
|
||||
constructor(
|
||||
config: AnkiConnectConfig,
|
||||
@@ -240,6 +259,9 @@ export class AnkiIntegration {
|
||||
aiConfig: AiConfig = {},
|
||||
recordCardsMined?: (count: number, noteIds?: number[]) => void,
|
||||
overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void,
|
||||
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'],
|
||||
shouldRequireRemoteMediaCache?: () => boolean,
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined,
|
||||
) {
|
||||
this.config = normalizeAnkiIntegrationConfig(config);
|
||||
this.aiConfig = { ...aiConfig };
|
||||
@@ -252,6 +274,10 @@ export class AnkiIntegration {
|
||||
this.overlayNotificationCallback = overlayNotificationCallback || null;
|
||||
this.fieldGroupingCallback = fieldGroupingCallback || null;
|
||||
this.recordCardsMinedCallback = recordCardsMined ?? null;
|
||||
this.getCachedMediaPath = getCachedMediaPath ?? null;
|
||||
this.shouldRequireRemoteMediaCache = shouldRequireRemoteMediaCache ?? null;
|
||||
this.getYoutubeMediaSourceUrl = getYoutubeMediaSourceUrl ?? null;
|
||||
this.pendingYoutubeMediaQueue = this.createPendingYoutubeMediaQueue();
|
||||
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
|
||||
this.pollingRunner = this.createPollingRunner();
|
||||
this.cardCreationService = this.createCardCreationService();
|
||||
@@ -295,6 +321,92 @@ export class AnkiIntegration {
|
||||
}
|
||||
}
|
||||
|
||||
private getMediaResolverOptions(): MediaGenerationInputResolverOptions {
|
||||
const options: MediaGenerationInputResolverOptions = {
|
||||
logDebug: (message) => log.debug(message),
|
||||
};
|
||||
if (this.getCachedMediaPath) {
|
||||
options.getCachedMediaPath = this.getCachedMediaPath;
|
||||
}
|
||||
if (this.shouldRequireRemoteMediaCache?.()) {
|
||||
options.remoteCacheMode = 'required';
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private createPendingYoutubeMediaQueue(): PendingYoutubeMediaQueue {
|
||||
return new PendingYoutubeMediaQueue({
|
||||
client: {
|
||||
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
|
||||
updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields),
|
||||
storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data),
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: (
|
||||
videoPath,
|
||||
startTime,
|
||||
endTime,
|
||||
audioPadding,
|
||||
audioStreamIndex,
|
||||
normalizeAudio,
|
||||
volumeScale,
|
||||
) =>
|
||||
this.mediaGenerator.generateAudio(
|
||||
videoPath,
|
||||
startTime,
|
||||
endTime,
|
||||
audioPadding,
|
||||
audioStreamIndex,
|
||||
normalizeAudio,
|
||||
volumeScale,
|
||||
),
|
||||
generateScreenshot: (videoPath, timestamp, options) =>
|
||||
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
|
||||
generateAnimatedImage: (videoPath, startTime, endTime, audioPadding, options) =>
|
||||
this.mediaGenerator.generateAnimatedImage(
|
||||
videoPath,
|
||||
startTime,
|
||||
endTime,
|
||||
audioPadding,
|
||||
options,
|
||||
),
|
||||
},
|
||||
getConfig: () => this.config,
|
||||
getCurrentVideoPath: () => this.getCurrentYoutubeMediaSourceUrl(),
|
||||
getCachedMediaPath: this.getCachedMediaPath,
|
||||
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
|
||||
getSubtitleMediaRange: (context) => this.getSubtitleMediaRange(context),
|
||||
getResolvedSentenceAudioFieldName: (noteInfo) =>
|
||||
this.getResolvedSentenceAudioFieldName(noteInfo),
|
||||
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
|
||||
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
|
||||
mergeFieldValue: (existing, newValue, overwrite) =>
|
||||
this.mergeFieldValue(existing, newValue, overwrite),
|
||||
getAnimatedImageLeadInSeconds: (noteInfo) => this.getAnimatedImageLeadInSeconds(noteInfo),
|
||||
getMpvVolumeScale: () => this.getMpvVolumeScale(),
|
||||
generateAudioFilename: () => this.generateAudioFilename(),
|
||||
generateImageFilename: () => this.generateImageFilename(),
|
||||
formatMiscInfoPatternForMediaPath: (
|
||||
fallbackFilename,
|
||||
startTimeSeconds,
|
||||
mediaPath,
|
||||
mediaTitle,
|
||||
) =>
|
||||
this.formatMiscInfoPatternForMediaPath(
|
||||
fallbackFilename,
|
||||
startTimeSeconds,
|
||||
mediaPath,
|
||||
mediaTitle,
|
||||
),
|
||||
showStatusNotification: (message) => this.showStatusNotification(message),
|
||||
showNotification: (noteId, label, errorSuffix) =>
|
||||
this.showNotification(noteId, label, errorSuffix),
|
||||
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
|
||||
logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
|
||||
logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
|
||||
});
|
||||
}
|
||||
|
||||
private createKnownWordCache(knownWordCacheStatePath?: string): KnownWordCacheManager {
|
||||
return new KnownWordCacheManager({
|
||||
client: {
|
||||
@@ -377,6 +489,10 @@ export class AnkiIntegration {
|
||||
getAiConfig: () => this.aiConfig,
|
||||
getTimingTracker: () => this.timingTracker,
|
||||
getMpvClient: () => this.mpvClient,
|
||||
...(this.getCachedMediaPath ? { getCachedMediaPath: this.getCachedMediaPath } : {}),
|
||||
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
|
||||
getYoutubeMediaSourceUrl: () => this.getCurrentYoutubeMediaSourceUrl(),
|
||||
queuePendingYoutubeMediaUpdate: (job) => this.queuePendingYoutubeMediaUpdate(job),
|
||||
getDeck: () => this.config.deck,
|
||||
client: {
|
||||
addNote: (deck, modelName, fields, tags) =>
|
||||
@@ -391,13 +507,23 @@ export class AnkiIntegration {
|
||||
retrieveMediaFile: (filename) => this.client.retrieveMediaFile(filename),
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: (videoPath, startTime, endTime, audioPadding, audioStreamIndex) =>
|
||||
generateAudio: (
|
||||
videoPath,
|
||||
startTime,
|
||||
endTime,
|
||||
audioPadding,
|
||||
audioStreamIndex,
|
||||
normalizeAudio,
|
||||
volumeScale,
|
||||
) =>
|
||||
this.mediaGenerator.generateAudio(
|
||||
videoPath,
|
||||
startTime,
|
||||
endTime,
|
||||
audioPadding,
|
||||
audioStreamIndex,
|
||||
normalizeAudio,
|
||||
volumeScale,
|
||||
),
|
||||
generateScreenshot: (videoPath, timestamp, options) =>
|
||||
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
|
||||
@@ -540,6 +666,7 @@ export class AnkiIntegration {
|
||||
formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) =>
|
||||
this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds),
|
||||
consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(),
|
||||
queuePendingYoutubeMediaUpdate: (job) => this.queuePendingYoutubeMediaUpdateForNote(job),
|
||||
addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
|
||||
showNotification: (noteId, label) => this.showNotification(noteId, label),
|
||||
showOsdNotification: (message) => this.showStatusNotification(message),
|
||||
@@ -596,8 +723,12 @@ export class AnkiIntegration {
|
||||
});
|
||||
}
|
||||
|
||||
isKnownWord(text: string): boolean {
|
||||
return this.knownWordCache.isKnownWord(text);
|
||||
isKnownWord(
|
||||
text: string,
|
||||
reading?: string,
|
||||
options?: { allowReadingOnlyMatch?: boolean },
|
||||
): boolean {
|
||||
return this.knownWordCache.isKnownWord(text, reading, options);
|
||||
}
|
||||
|
||||
getKnownWordMatchMode(): NPlusOneMatchMode {
|
||||
@@ -833,13 +964,57 @@ export class AnkiIntegration {
|
||||
};
|
||||
}
|
||||
|
||||
private queuePendingYoutubeMediaUpdate(job: PendingYoutubeMediaUpdate): void {
|
||||
this.pendingYoutubeMediaQueue.enqueue(job);
|
||||
}
|
||||
|
||||
private async queuePendingYoutubeMediaUpdateForNote(job: {
|
||||
noteId: number;
|
||||
noteInfo: NoteInfo;
|
||||
context?: SubtitleMiningContext;
|
||||
label: string | number;
|
||||
}): Promise<boolean> {
|
||||
return this.pendingYoutubeMediaQueue.queueFromNote(job);
|
||||
}
|
||||
|
||||
private async getCurrentYoutubeMediaSourceUrl(): Promise<string> {
|
||||
return (
|
||||
trimToNonEmptyString(await this.getYoutubeMediaSourceUrl?.()) ??
|
||||
trimToNonEmptyString(this.mpvClient.currentVideoPath) ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
private async getMpvVolumeScale(): Promise<number | undefined> {
|
||||
return resolveMpvVolumeScale(this.mpvClient, this.config.media?.mirrorMpvVolume !== false);
|
||||
}
|
||||
|
||||
async handleYoutubeMediaCacheReady(
|
||||
sourceUrl: string,
|
||||
cachedPath: string,
|
||||
options?: PendingYoutubeMediaQueueReadyOptions,
|
||||
): Promise<void> {
|
||||
await this.pendingYoutubeMediaQueue.handleReady(sourceUrl, cachedPath, options);
|
||||
}
|
||||
|
||||
async handleYoutubeMediaCacheFailed(
|
||||
sourceUrl: string,
|
||||
options?: PendingYoutubeMediaQueueFailedOptions,
|
||||
): Promise<void> {
|
||||
await this.pendingYoutubeMediaQueue.handleFailed(sourceUrl, options);
|
||||
}
|
||||
|
||||
private async generateAudio(context?: SubtitleMiningContext): Promise<Buffer | null> {
|
||||
const mpvClient = this.mpvClient;
|
||||
if (!mpvClient || !mpvClient.currentVideoPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'audio');
|
||||
const videoPath = await resolveMediaGenerationInput(
|
||||
mpvClient,
|
||||
'audio',
|
||||
this.getMediaResolverOptions(),
|
||||
);
|
||||
if (!videoPath) {
|
||||
return null;
|
||||
}
|
||||
@@ -850,7 +1025,9 @@ export class AnkiIntegration {
|
||||
startTime,
|
||||
endTime,
|
||||
this.config.media?.audioPadding,
|
||||
this.mpvClient.currentAudioStreamIndex,
|
||||
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
|
||||
this.config.media?.normalizeAudio !== false,
|
||||
await this.getMpvVolumeScale(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -862,7 +1039,11 @@ export class AnkiIntegration {
|
||||
return null;
|
||||
}
|
||||
|
||||
const videoPath = await resolveMediaGenerationInputPath(this.mpvClient, 'video');
|
||||
const videoPath = await resolveMediaGenerationInput(
|
||||
this.mpvClient,
|
||||
'video',
|
||||
this.getMediaResolverOptions(),
|
||||
);
|
||||
if (!videoPath) {
|
||||
return null;
|
||||
}
|
||||
@@ -896,17 +1077,30 @@ export class AnkiIntegration {
|
||||
}
|
||||
|
||||
private formatMiscInfoPattern(fallbackFilename: string, startTimeSeconds?: number): string {
|
||||
return this.formatMiscInfoPatternForMediaPath(
|
||||
fallbackFilename,
|
||||
startTimeSeconds,
|
||||
this.mpvClient.currentVideoPath || '',
|
||||
this.mpvClient.currentMediaTitle ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
private formatMiscInfoPatternForMediaPath(
|
||||
fallbackFilename: string,
|
||||
startTimeSeconds: number | undefined,
|
||||
mediaPath: string,
|
||||
mediaTitle?: string,
|
||||
): string {
|
||||
if (!this.config.metadata?.pattern) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const currentVideoPath = this.mpvClient.currentVideoPath || '';
|
||||
const videoFilename = extractFilenameFromMediaPath(currentVideoPath);
|
||||
const mediaTitle = trimToNonEmptyString(this.mpvClient.currentMediaTitle);
|
||||
const videoFilename = extractFilenameFromMediaPath(mediaPath);
|
||||
const resolvedMediaTitle = trimToNonEmptyString(mediaTitle);
|
||||
const filenameWithExt =
|
||||
(shouldPreferMediaTitleForMiscInfo(currentVideoPath, videoFilename)
|
||||
? mediaTitle || videoFilename
|
||||
: videoFilename || mediaTitle) || fallbackFilename;
|
||||
(shouldPreferMediaTitleForMiscInfo(mediaPath, videoFilename)
|
||||
? resolvedMediaTitle || videoFilename
|
||||
: videoFilename || resolvedMediaTitle) || fallbackFilename;
|
||||
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
|
||||
|
||||
const currentTimePos =
|
||||
@@ -1457,7 +1651,7 @@ export class AnkiIntegration {
|
||||
miscInfoValue?: string;
|
||||
} = {};
|
||||
|
||||
if (this.config.media?.generateAudio && this.mpvClient?.currentVideoPath) {
|
||||
if (this.config.media?.generateAudio !== false && this.mpvClient?.currentVideoPath) {
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = await this.generateAudio();
|
||||
@@ -1477,7 +1671,7 @@ export class AnkiIntegration {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.media?.generateImage && this.mpvClient?.currentVideoPath) {
|
||||
if (this.config.media?.generateImage !== false && this.mpvClient?.currentVideoPath) {
|
||||
try {
|
||||
const animatedLeadInSeconds = noteInfo
|
||||
? await this.getAnimatedImageLeadInSeconds(noteInfo)
|
||||
|
||||
@@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CardCreationService } from './card-creation';
|
||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||
import type { MediaInput } from '../media-generator';
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
|
||||
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||
@@ -266,9 +268,13 @@ test('manual clipboard subtitle update skips audio when sentence audio field is
|
||||
test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => {
|
||||
const audioPaths: string[] = [];
|
||||
const imagePaths: string[] = [];
|
||||
const recordMediaPath = (mediaInput: MediaInput): string =>
|
||||
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const edlSource = [
|
||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
||||
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
@@ -338,11 +344,11 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async (path) => {
|
||||
audioPaths.push(path);
|
||||
audioPaths.push(recordMediaPath(path));
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async (path) => {
|
||||
imagePaths.push(path);
|
||||
imagePaths.push(recordMediaPath(path));
|
||||
return Buffer.from('image');
|
||||
},
|
||||
generateAnimatedImage: async () => null,
|
||||
@@ -351,8 +357,8 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
|
||||
|
||||
await service.updateLastAddedFromClipboard('一行目\n\n二行目');
|
||||
|
||||
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
|
||||
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
|
||||
assert.deepEqual(audioPaths, [audioUrl]);
|
||||
assert.deepEqual(imagePaths, [videoUrl]);
|
||||
assert.equal(storedMedia.length, 2);
|
||||
assert.equal(updatedFields.length, 1);
|
||||
assert.equal(updatedFields[0]?.Sentence, '一行目 二行目');
|
||||
|
||||
@@ -10,6 +10,8 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
const addedFields: Record<string, string>[] = [];
|
||||
const updatedFields: Record<string, string>[] = [];
|
||||
const storedMedia: string[] = [];
|
||||
const requestedProperties: string[] = [];
|
||||
const audioVolumeScales: Array<number | undefined> = [];
|
||||
|
||||
const deps: CardCreationDeps = {
|
||||
getConfig: () =>
|
||||
@@ -24,6 +26,7 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: false,
|
||||
mirrorMpvVolume: true,
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
behavior: {},
|
||||
@@ -39,6 +42,10 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
currentSubEnd: 14,
|
||||
currentTimePos: 13,
|
||||
currentAudioStreamIndex: 0,
|
||||
requestProperty: async (name: string) => {
|
||||
requestedProperties.push(name);
|
||||
return 40;
|
||||
},
|
||||
}) as never,
|
||||
client: {
|
||||
addNote: async (_deck, _modelName, fields) => {
|
||||
@@ -68,7 +75,18 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
retrieveMediaFile: async () => '',
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => Buffer.from('audio'),
|
||||
generateAudio: async (
|
||||
_path,
|
||||
_startTime,
|
||||
_endTime,
|
||||
_audioPadding,
|
||||
_audioStreamIndex,
|
||||
_normalizeAudio,
|
||||
volumeScale,
|
||||
) => {
|
||||
audioVolumeScales.push(volumeScale);
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
@@ -124,6 +142,8 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
Expression: '字幕',
|
||||
});
|
||||
assert.equal(storedMedia.length, 1);
|
||||
assert.deepEqual(requestedProperties, ['volume']);
|
||||
assert.deepEqual(audioVolumeScales, [0.4 ** 3]);
|
||||
const mediaUpdate = updatedFields.find((fields) => 'SentenceAudio' in fields);
|
||||
assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`);
|
||||
assert.equal('ExpressionAudio' in mediaUpdate!, false);
|
||||
|
||||
@@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CardCreationService } from './card-creation';
|
||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||
import type { MediaInput } from '../media-generator';
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
|
||||
test('CardCreationService counts locally created sentence cards', async () => {
|
||||
@@ -287,9 +289,13 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
|
||||
test('CardCreationService uses stream-open-filename for remote media generation', async () => {
|
||||
const audioPaths: string[] = [];
|
||||
const imagePaths: string[] = [];
|
||||
const recordMediaPath = (mediaInput: MediaInput): string =>
|
||||
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const edlSource = [
|
||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
||||
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
@@ -345,11 +351,11 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async (path) => {
|
||||
audioPaths.push(path);
|
||||
audioPaths.push(recordMediaPath(path));
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async (path) => {
|
||||
imagePaths.push(path);
|
||||
imagePaths.push(recordMediaPath(path));
|
||||
return Buffer.from('image');
|
||||
},
|
||||
generateAnimatedImage: async () => null,
|
||||
@@ -394,8 +400,266 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
||||
const created = await service.createSentenceCard('テスト', 0, 1);
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
|
||||
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
|
||||
assert.deepEqual(audioPaths, [audioUrl]);
|
||||
assert.deepEqual(imagePaths, [videoUrl]);
|
||||
});
|
||||
|
||||
test('CardCreationService does not use mpv stream indexes for ready cached YouTube media', async () => {
|
||||
const audioCalls: Array<{ path: string; audioStreamIndex?: number }> = [];
|
||||
|
||||
const service = new CardCreationService({
|
||||
getConfig: () =>
|
||||
({
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
sentence: 'Sentence',
|
||||
audio: 'SentenceAudio',
|
||||
image: 'Picture',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: false,
|
||||
imageFormat: 'jpg',
|
||||
},
|
||||
behavior: {},
|
||||
ai: false,
|
||||
}) as AnkiConnectConfig,
|
||||
getAiConfig: () => ({}),
|
||||
getTimingTracker: () => ({}) as never,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
currentSubText: '字幕',
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentTimePos: 11,
|
||||
currentAudioStreamIndex: 0,
|
||||
}) as never,
|
||||
getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/media.mkv',
|
||||
shouldRequireRemoteMediaCache: () => true,
|
||||
client: {
|
||||
addNote: async () => 42,
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Sentence: { value: '' },
|
||||
SentenceAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
},
|
||||
],
|
||||
updateNoteFields: async () => undefined,
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async (path, _startTime, _endTime, _padding, audioStreamIndex) => {
|
||||
audioCalls.push({ path: typeof path === 'string' ? path : path.path, audioStreamIndex });
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
showOsdNotification: () => undefined,
|
||||
showUpdateResult: () => undefined,
|
||||
showStatusNotification: () => undefined,
|
||||
showNotification: async () => undefined,
|
||||
beginUpdateProgress: () => undefined,
|
||||
endUpdateProgress: () => undefined,
|
||||
withUpdateProgress: async (_message, action) => action(),
|
||||
resolveConfiguredFieldName: (noteInfo, preferredName) => {
|
||||
if (!preferredName) return null;
|
||||
return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null;
|
||||
},
|
||||
resolveNoteFieldName: (noteInfo, preferredName) => {
|
||||
if (!preferredName) return null;
|
||||
return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null;
|
||||
},
|
||||
getAnimatedImageLeadInSeconds: async () => 0,
|
||||
extractFields: () => ({}),
|
||||
processSentence: (sentence) => sentence,
|
||||
setCardTypeFields: () => undefined,
|
||||
mergeFieldValue: (_existing, newValue) => newValue,
|
||||
formatMiscInfoPattern: () => '',
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
model: 'Sentence',
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
});
|
||||
|
||||
const created = await service.createSentenceCard('テスト', 10, 12);
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.deepEqual(audioCalls, [
|
||||
{
|
||||
path: '/tmp/subminer-youtube-media-cache/media.mkv',
|
||||
audioStreamIndex: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('CardCreationService queues YouTube media when required cache is not ready', async () => {
|
||||
const mediaCalls: string[] = [];
|
||||
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||
const queuedUpdates: Array<{
|
||||
sourceUrl: string;
|
||||
noteId: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
label: string | number;
|
||||
audioFieldName?: string;
|
||||
imageFieldName?: string;
|
||||
miscInfoFieldName?: string;
|
||||
generateAudio: boolean;
|
||||
generateImage: boolean;
|
||||
volumeScale?: number;
|
||||
}> = [];
|
||||
let streamRequests = 0;
|
||||
|
||||
const service = new CardCreationService({
|
||||
getConfig: () =>
|
||||
({
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
sentence: 'Sentence',
|
||||
audio: 'SentenceAudio',
|
||||
image: 'Picture',
|
||||
miscInfo: 'MiscInfo',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
imageFormat: 'jpg',
|
||||
},
|
||||
behavior: {},
|
||||
ai: false,
|
||||
}) as AnkiConnectConfig,
|
||||
getAiConfig: () => ({}),
|
||||
getTimingTracker: () => ({}) as never,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
currentSubText: '字幕',
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentTimePos: 11,
|
||||
currentAudioStreamIndex: 2,
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'volume') {
|
||||
return 35;
|
||||
}
|
||||
streamRequests += 1;
|
||||
return 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123';
|
||||
},
|
||||
}) as never,
|
||||
getCachedMediaPath: async () => null,
|
||||
shouldRequireRemoteMediaCache: () => true,
|
||||
queuePendingYoutubeMediaUpdate: (job) => {
|
||||
queuedUpdates.push(job);
|
||||
},
|
||||
client: {
|
||||
addNote: async () => 42,
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Sentence: { value: '' },
|
||||
SentenceAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
MiscInfo: { value: '' },
|
||||
},
|
||||
},
|
||||
],
|
||||
updateNoteFields: async (noteId, fields) => {
|
||||
updates.push({ noteId, fields });
|
||||
},
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => {
|
||||
mediaCalls.push('audio');
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async () => {
|
||||
mediaCalls.push('image');
|
||||
return Buffer.from('image');
|
||||
},
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
showOsdNotification: () => undefined,
|
||||
showUpdateResult: () => undefined,
|
||||
showStatusNotification: () => undefined,
|
||||
showNotification: async () => undefined,
|
||||
beginUpdateProgress: () => undefined,
|
||||
endUpdateProgress: () => undefined,
|
||||
withUpdateProgress: async (_message, action) => action(),
|
||||
resolveConfiguredFieldName: (noteInfo, preferredName) => {
|
||||
if (!preferredName) return null;
|
||||
return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null;
|
||||
},
|
||||
resolveNoteFieldName: (noteInfo, preferredName) => {
|
||||
if (!preferredName) return null;
|
||||
return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null;
|
||||
},
|
||||
getAnimatedImageLeadInSeconds: async () => 0,
|
||||
extractFields: () => ({}),
|
||||
processSentence: (sentence) => sentence,
|
||||
setCardTypeFields: () => undefined,
|
||||
mergeFieldValue: (_existing, newValue) => newValue,
|
||||
formatMiscInfoPattern: () => '',
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
model: 'Sentence',
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
});
|
||||
|
||||
const created = await service.createSentenceCard('テスト', 10, 12);
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.equal(streamRequests, 0);
|
||||
assert.deepEqual(mediaCalls, []);
|
||||
assert.deepEqual(queuedUpdates, [
|
||||
{
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
noteId: 42,
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
label: 'テスト',
|
||||
audioFieldName: 'SentenceAudio',
|
||||
imageFieldName: 'Picture',
|
||||
miscInfoFieldName: 'MiscInfo',
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
volumeScale: 0.35 ** 3,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(updates, []);
|
||||
});
|
||||
|
||||
test('CardCreationService tracks pre-add duplicate note ids for kiku sentence cards', async () => {
|
||||
|
||||
@@ -5,15 +5,38 @@ import {
|
||||
} from '../anki-field-config';
|
||||
import { AnkiConnectConfig } from '../types/anki';
|
||||
import { createLogger } from '../logger';
|
||||
import type { MediaInput } from '../media-input';
|
||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
import { AiConfig } from '../types/integrations';
|
||||
import { MpvClient } from '../types/runtime';
|
||||
import { resolveSentenceBackText } from './ai';
|
||||
import { resolveMediaGenerationInputPath } from './media-source';
|
||||
import {
|
||||
resolveMediaGenerationInput,
|
||||
resolveAudioStreamIndexForMediaGeneration,
|
||||
type MediaGenerationInputResolverOptions,
|
||||
} from './media-source';
|
||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
||||
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
||||
import { resolveMpvVolumeScale } from './mpv-volume';
|
||||
|
||||
const log = createLogger('anki').child('integration.card-creation');
|
||||
|
||||
function shouldGenerateAudio(config: AnkiConnectConfig): boolean {
|
||||
return config.media?.generateAudio !== false;
|
||||
}
|
||||
|
||||
function shouldGenerateImage(config: AnkiConnectConfig): boolean {
|
||||
return config.media?.generateImage !== false;
|
||||
}
|
||||
|
||||
function trimToNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export interface CardCreationNoteInfo {
|
||||
noteId: number;
|
||||
fields: Record<string, { value: string }>;
|
||||
@@ -38,14 +61,16 @@ interface CardCreationClient {
|
||||
|
||||
interface CardCreationMediaGenerator {
|
||||
generateAudio(
|
||||
path: string,
|
||||
path: MediaInput,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
audioPadding?: number,
|
||||
audioStreamIndex?: number,
|
||||
normalizeAudio?: boolean,
|
||||
volumeScale?: number,
|
||||
): Promise<Buffer | null>;
|
||||
generateScreenshot(
|
||||
path: string,
|
||||
path: MediaInput,
|
||||
timestamp: number,
|
||||
options: {
|
||||
format: 'jpg' | 'png' | 'webp';
|
||||
@@ -55,7 +80,7 @@ interface CardCreationMediaGenerator {
|
||||
},
|
||||
): Promise<Buffer | null>;
|
||||
generateAnimatedImage(
|
||||
path: string,
|
||||
path: MediaInput,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
audioPadding?: number,
|
||||
@@ -74,6 +99,10 @@ interface CardCreationDeps {
|
||||
getAiConfig: () => AiConfig;
|
||||
getTimingTracker: () => SubtitleTimingTracker;
|
||||
getMpvClient: () => MpvClient;
|
||||
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'];
|
||||
shouldRequireRemoteMediaCache?: () => boolean;
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||
queuePendingYoutubeMediaUpdate?: (job: PendingYoutubeMediaUpdate) => void;
|
||||
getDeck?: () => string | undefined;
|
||||
client: CardCreationClient;
|
||||
mediaGenerator: CardCreationMediaGenerator;
|
||||
@@ -121,6 +150,19 @@ interface CardCreationDeps {
|
||||
export class CardCreationService {
|
||||
constructor(private readonly deps: CardCreationDeps) {}
|
||||
|
||||
private getMediaResolverOptions(): MediaGenerationInputResolverOptions {
|
||||
const options: MediaGenerationInputResolverOptions = {
|
||||
logDebug: (message) => log.debug(message),
|
||||
};
|
||||
if (this.deps.getCachedMediaPath) {
|
||||
options.getCachedMediaPath = this.deps.getCachedMediaPath;
|
||||
}
|
||||
if (this.deps.shouldRequireRemoteMediaCache?.()) {
|
||||
options.remoteCacheMode = 'required';
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private getConfiguredAnkiTags(): string[] {
|
||||
const tags = this.deps.getConfig().tags;
|
||||
if (!Array.isArray(tags)) {
|
||||
@@ -246,14 +288,18 @@ export class CardCreationService {
|
||||
`Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`,
|
||||
);
|
||||
|
||||
const audioSourcePath = this.deps.getConfig().media?.generateAudio
|
||||
? await resolveMediaGenerationInputPath(mpvClient, 'audio')
|
||||
const config = this.deps.getConfig();
|
||||
const generateAudio = shouldGenerateAudio(config);
|
||||
const generateImage = shouldGenerateImage(config);
|
||||
const mediaResolverOptions = this.getMediaResolverOptions();
|
||||
const audioSourcePath = generateAudio
|
||||
? await resolveMediaGenerationInput(mpvClient, 'audio', mediaResolverOptions)
|
||||
: null;
|
||||
const videoPath = this.deps.getConfig().media?.generateImage
|
||||
? await resolveMediaGenerationInputPath(mpvClient, 'video')
|
||||
const videoPath = generateImage
|
||||
? await resolveMediaGenerationInput(mpvClient, 'video', mediaResolverOptions)
|
||||
: null;
|
||||
|
||||
if (this.deps.getConfig().media?.generateAudio) {
|
||||
if (generateAudio) {
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = audioSourcePath
|
||||
@@ -281,7 +327,7 @@ export class CardCreationService {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.deps.getConfig().media?.generateImage) {
|
||||
if (generateImage) {
|
||||
try {
|
||||
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
|
||||
const imageFilename = this.generateImageFilename();
|
||||
@@ -441,7 +487,7 @@ export class CardCreationService {
|
||||
errors.push('audio');
|
||||
}
|
||||
|
||||
if (this.deps.getConfig().media?.generateImage) {
|
||||
if (shouldGenerateImage(this.deps.getConfig())) {
|
||||
try {
|
||||
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
|
||||
const imageFilename = this.generateImageFilename();
|
||||
@@ -522,9 +568,23 @@ export class CardCreationService {
|
||||
|
||||
try {
|
||||
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
||||
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'video');
|
||||
const audioSourcePath = await resolveMediaGenerationInputPath(mpvClient, 'audio');
|
||||
if (!videoPath) {
|
||||
const config = this.deps.getConfig();
|
||||
const generateAudio = shouldGenerateAudio(config);
|
||||
const generateImage = shouldGenerateImage(config);
|
||||
const mediaResolverOptions = this.getMediaResolverOptions();
|
||||
const videoPath = generateImage
|
||||
? await resolveMediaGenerationInput(mpvClient, 'video', mediaResolverOptions)
|
||||
: null;
|
||||
const audioSourcePath = generateAudio
|
||||
? await resolveMediaGenerationInput(mpvClient, 'audio', mediaResolverOptions)
|
||||
: null;
|
||||
const missingRequestedMediaInput =
|
||||
(generateImage && !videoPath) || (generateAudio && !audioSourcePath);
|
||||
const shouldQueuePendingYoutubeMedia =
|
||||
missingRequestedMediaInput &&
|
||||
this.deps.shouldRequireRemoteMediaCache?.() === true &&
|
||||
typeof this.deps.queuePendingYoutubeMediaUpdate === 'function';
|
||||
if (missingRequestedMediaInput && !shouldQueuePendingYoutubeMedia) {
|
||||
this.deps.showOsdNotification('No video loaded');
|
||||
return false;
|
||||
}
|
||||
@@ -534,13 +594,13 @@ export class CardCreationService {
|
||||
|
||||
const sentenceField = sentenceCardConfig.sentenceField;
|
||||
const audioFieldName = sentenceCardConfig.audioField || 'SentenceAudio';
|
||||
const translationField = this.deps.getConfig().fields?.translation || 'SelectionText';
|
||||
const translationField = config.fields?.translation || 'SelectionText';
|
||||
let resolvedMiscInfoField: string | null = null;
|
||||
let resolvedSentenceAudioField: string = audioFieldName;
|
||||
|
||||
fields[sentenceField] = sentence;
|
||||
|
||||
const ankiAiConfig = this.deps.getConfig().ai;
|
||||
const ankiAiConfig = config.ai;
|
||||
const ankiAiEnabled =
|
||||
typeof ankiAiConfig === 'object' && ankiAiConfig !== null
|
||||
? ankiAiConfig.enabled === true
|
||||
@@ -563,7 +623,7 @@ export class CardCreationService {
|
||||
|
||||
if (sentenceCardConfig.lapisEnabled || sentenceCardConfig.kikuEnabled) {
|
||||
fields.IsSentenceCard = 'x';
|
||||
fields[getConfiguredWordFieldName(this.deps.getConfig())] = sentence;
|
||||
fields[getConfiguredWordFieldName(config)] = sentence;
|
||||
}
|
||||
|
||||
const pendingNoteInfo = this.createPendingNoteInfo(fields);
|
||||
@@ -572,7 +632,7 @@ export class CardCreationService {
|
||||
);
|
||||
const pendingExpressionText = getPreferredWordValueFromExtractedFields(
|
||||
pendingNoteFields,
|
||||
this.deps.getConfig(),
|
||||
config,
|
||||
).trim();
|
||||
let duplicateNoteIds: number[] = [];
|
||||
if (
|
||||
@@ -590,7 +650,7 @@ export class CardCreationService {
|
||||
}
|
||||
}
|
||||
|
||||
const deck = this.deps.getConfig().deck || 'Default';
|
||||
const deck = config.deck || 'Default';
|
||||
let noteId: number;
|
||||
try {
|
||||
noteId = await this.deps.client.addNote(
|
||||
@@ -636,7 +696,7 @@ export class CardCreationService {
|
||||
this.deps.resolveNoteFieldName(createdNoteInfo, audioFieldName) || audioFieldName;
|
||||
resolvedMiscInfoField = this.deps.resolveConfiguredFieldName(
|
||||
createdNoteInfo,
|
||||
this.deps.getConfig().fields?.miscInfo,
|
||||
config.fields?.miscInfo,
|
||||
);
|
||||
|
||||
const cardTypeFields: Record<string, string> = {};
|
||||
@@ -654,41 +714,76 @@ export class CardCreationService {
|
||||
errors.push('card type fields');
|
||||
}
|
||||
|
||||
const label = sentence.length > 30 ? sentence.substring(0, 30) + '...' : sentence;
|
||||
if (shouldQueuePendingYoutubeMedia) {
|
||||
const volumeScale = generateAudio
|
||||
? await resolveMpvVolumeScale(
|
||||
mpvClient,
|
||||
this.deps.getConfig().media?.mirrorMpvVolume !== false,
|
||||
)
|
||||
: undefined;
|
||||
this.deps.queuePendingYoutubeMediaUpdate?.({
|
||||
sourceUrl:
|
||||
trimToNonEmptyString(await this.deps.getYoutubeMediaSourceUrl?.()) ??
|
||||
mpvClient.currentVideoPath,
|
||||
noteId,
|
||||
startTime,
|
||||
endTime,
|
||||
label,
|
||||
audioFieldName: resolvedSentenceAudioField,
|
||||
imageFieldName: config.fields?.image,
|
||||
miscInfoFieldName: resolvedMiscInfoField ?? undefined,
|
||||
generateAudio,
|
||||
generateImage,
|
||||
volumeScale,
|
||||
});
|
||||
await this.deps.showNotification(noteId, label, 'media queued');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (missingRequestedMediaInput) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mediaFields: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = audioSourcePath
|
||||
? await this.mediaGenerateAudio(audioSourcePath, startTime, endTime)
|
||||
: null;
|
||||
if (generateAudio) {
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = audioSourcePath
|
||||
? await this.mediaGenerateAudio(audioSourcePath, startTime, endTime)
|
||||
: null;
|
||||
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
const audioValue = `[sound:${audioFilename}]`;
|
||||
mediaFields[resolvedSentenceAudioField] = audioValue;
|
||||
miscInfoFilename = audioFilename;
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
const audioValue = `[sound:${audioFilename}]`;
|
||||
mediaFields[resolvedSentenceAudioField] = audioValue;
|
||||
miscInfoFilename = audioFilename;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to generate sentence audio:', (error as Error).message);
|
||||
errors.push('audio');
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to generate sentence audio:', (error as Error).message);
|
||||
errors.push('audio');
|
||||
}
|
||||
|
||||
try {
|
||||
const imageFilename = this.generateImageFilename();
|
||||
const imageBuffer = await this.generateImageBuffer(videoPath, startTime, endTime);
|
||||
if (generateImage) {
|
||||
try {
|
||||
const imageFilename = this.generateImageFilename();
|
||||
const imageBuffer = await this.generateImageBuffer(videoPath!, startTime, endTime);
|
||||
|
||||
const imageField = this.deps.getConfig().fields?.image;
|
||||
if (imageBuffer && imageField) {
|
||||
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
||||
mediaFields[imageField] = `<img src="${imageFilename}">`;
|
||||
miscInfoFilename = imageFilename;
|
||||
const imageField = config.fields?.image;
|
||||
if (imageBuffer && imageField) {
|
||||
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
||||
mediaFields[imageField] = `<img src="${imageFilename}">`;
|
||||
miscInfoFilename = imageFilename;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to generate sentence image:', (error as Error).message);
|
||||
errors.push('image');
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to generate sentence image:', (error as Error).message);
|
||||
errors.push('image');
|
||||
}
|
||||
|
||||
if (this.deps.getConfig().fields?.miscInfo) {
|
||||
if (config.fields?.miscInfo) {
|
||||
const miscInfo = this.deps.formatMiscInfoPattern(miscInfoFilename || '', startTime);
|
||||
if (miscInfo && resolvedMiscInfoField) {
|
||||
mediaFields[resolvedMiscInfoField] = miscInfo;
|
||||
@@ -704,7 +799,6 @@ export class CardCreationService {
|
||||
}
|
||||
}
|
||||
|
||||
const label = sentence.length > 30 ? sentence.substring(0, 30) + '...' : sentence;
|
||||
const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined;
|
||||
await this.deps.showNotification(noteId, label, errorSuffix);
|
||||
return true;
|
||||
@@ -740,7 +834,7 @@ export class CardCreationService {
|
||||
}
|
||||
|
||||
private async mediaGenerateAudio(
|
||||
videoPath: string,
|
||||
videoPath: MediaInput,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): Promise<Buffer | null> {
|
||||
@@ -754,12 +848,20 @@ export class CardCreationService {
|
||||
startTime,
|
||||
endTime,
|
||||
this.deps.getConfig().media?.audioPadding,
|
||||
mpvClient.currentAudioStreamIndex ?? undefined,
|
||||
resolveAudioStreamIndexForMediaGeneration(
|
||||
videoPath,
|
||||
mpvClient.currentAudioStreamIndex ?? undefined,
|
||||
),
|
||||
this.deps.getConfig().media?.normalizeAudio !== false,
|
||||
await resolveMpvVolumeScale(
|
||||
mpvClient,
|
||||
this.deps.getConfig().media?.mirrorMpvVolume !== false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async generateImageBuffer(
|
||||
videoPath: string,
|
||||
videoPath: MediaInput,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
animatedLeadInSeconds = 0,
|
||||
|
||||
@@ -26,6 +26,7 @@ function createWorkflowHarness() {
|
||||
const deleted: number[][] = [];
|
||||
const addedTags: Array<{ noteIds: number[]; tags: string[] }> = [];
|
||||
const statuses: string[] = [];
|
||||
const osdMessages: string[] = [];
|
||||
const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = [];
|
||||
const mergeCalls: Array<{
|
||||
keepNoteId: number;
|
||||
@@ -112,7 +113,9 @@ function createWorkflowHarness() {
|
||||
statuses.push(message);
|
||||
},
|
||||
showNotification: async () => undefined,
|
||||
showOsdNotification: () => undefined,
|
||||
showOsdNotification: (message: string) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
logError: () => undefined,
|
||||
logInfo: () => undefined,
|
||||
truncateSentence: (value: string) => value,
|
||||
@@ -125,6 +128,7 @@ function createWorkflowHarness() {
|
||||
addedTags,
|
||||
rememberedMerges,
|
||||
statuses,
|
||||
osdMessages,
|
||||
mergeCalls,
|
||||
setManualChoice: (choice: typeof manualChoice) => {
|
||||
manualChoice = choice;
|
||||
@@ -191,6 +195,50 @@ test('FieldGroupingWorkflow manual mode returns false when callback unavailable'
|
||||
assert.equal(harness.updates.length, 0);
|
||||
});
|
||||
|
||||
test('FieldGroupingWorkflow manual cancel notifies exactly once', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.setManualChoice({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
|
||||
const handled = await harness.workflow.handleManual(1, 2, {
|
||||
noteId: 2,
|
||||
fields: {
|
||||
Expression: { value: 'word-2' },
|
||||
Sentence: { value: 'line-2' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(harness.osdMessages, ['Field grouping cancelled']);
|
||||
assert.equal(harness.updates.length, 0);
|
||||
});
|
||||
|
||||
test('FieldGroupingWorkflow manual mode notifies when the original card cannot be loaded', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.setManualChoice({
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
deleteDuplicate: true,
|
||||
cancelled: false,
|
||||
});
|
||||
harness.deps.client.notesInfo = async () => [];
|
||||
|
||||
const handled = await harness.workflow.handleManual(1, 2, {
|
||||
noteId: 2,
|
||||
fields: {
|
||||
Expression: { value: 'word-2' },
|
||||
Sentence: { value: 'line-2' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(harness.osdMessages, ['Field grouping failed: original card not found']);
|
||||
});
|
||||
|
||||
test('FieldGroupingWorkflow manual keep-new uses new note as merge target and old note as source', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.setManualChoice({
|
||||
|
||||
@@ -98,6 +98,8 @@ export class FieldGroupingWorkflow {
|
||||
const originalNotesInfoResult = await this.deps.client.notesInfo([originalNoteId]);
|
||||
const originalNotesInfo = originalNotesInfoResult as FieldGroupingWorkflowNoteInfo[];
|
||||
if (!originalNotesInfo || originalNotesInfo.length === 0) {
|
||||
// handleManual owns all user-facing notifications; callers must not re-notify on false.
|
||||
this.deps.showOsdNotification('Field grouping failed: original card not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -302,7 +302,7 @@ test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fi
|
||||
assert.deepEqual(harness.manualCalls, []);
|
||||
});
|
||||
|
||||
test('triggerFieldGroupingForLastAddedCard shows a cancellation message when manual grouping is declined', async () => {
|
||||
test('triggerFieldGroupingForLastAddedCard does not re-notify when manual grouping is declined', async () => {
|
||||
const harness = createHarness({
|
||||
kikuFieldGrouping: 'manual',
|
||||
noteIds: [9],
|
||||
@@ -339,7 +339,9 @@ test('triggerFieldGroupingForLastAddedCard shows a cancellation message when man
|
||||
expression: 'word-9',
|
||||
},
|
||||
]);
|
||||
assert.equal(harness.calls.at(-1), 'osd:Field grouping cancelled');
|
||||
// The manual workflow already notifies about its outcome (cancelled/unavailable/failed);
|
||||
// the trigger wrapper re-notifying produced two "Field grouping cancelled" toasts.
|
||||
assert.equal(harness.calls.filter((call) => call === 'osd:Field grouping cancelled').length, 0);
|
||||
});
|
||||
|
||||
test('buildFieldGroupingPreview returns merged compact and full previews', async () => {
|
||||
|
||||
@@ -156,15 +156,14 @@ export class FieldGroupingService {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const handled = await this.deps.handleFieldGroupingManual(
|
||||
// The manual workflow owns all user-facing notifications for its outcomes (cancelled,
|
||||
// unavailable, failed) — re-notifying on a false return here duplicated them.
|
||||
await this.deps.handleFieldGroupingManual(
|
||||
duplicateNoteId,
|
||||
noteId,
|
||||
noteInfo,
|
||||
expressionText,
|
||||
);
|
||||
if (!handled) {
|
||||
this.deps.showOsdNotification('Field grouping cancelled');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('Error triggering field grouping:', (error as Error).message);
|
||||
|
||||
@@ -108,6 +108,55 @@ test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without i
|
||||
assert.equal(manager.isKnownWord('猫'), true);
|
||||
assert.equal(calls.findNotes, 0);
|
||||
assert.equal(calls.notesInfo, 0);
|
||||
// v2 states carry no readings, so they load usable but stale to trigger a
|
||||
// prompt upgrade refresh.
|
||||
assert.equal(
|
||||
(
|
||||
manager as unknown as {
|
||||
getMsUntilNextRefresh: () => number;
|
||||
}
|
||||
).getMsUntilNextRefresh(),
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
manager.stopLifecycle();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager startLifecycle keeps fresh v3 persisted cache without immediate refresh', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
refreshMinutes: 60,
|
||||
},
|
||||
};
|
||||
const { manager, calls, statePath, cleanup } = createKnownWordCacheHarness(config);
|
||||
const originalDateNow = Date.now;
|
||||
|
||||
try {
|
||||
Date.now = () => 120_000;
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
version: 3,
|
||||
refreshedAtMs: 120_000,
|
||||
scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":""}',
|
||||
notes: {
|
||||
'1': [{ word: '猫', reading: 'ねこ' }],
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
manager.startLifecycle();
|
||||
|
||||
assert.equal(manager.isKnownWord('猫'), true);
|
||||
assert.equal(manager.isKnownWord('猫', 'ねこ'), true);
|
||||
assert.equal(manager.isKnownWord('猫', 'びょう'), false);
|
||||
assert.equal(calls.findNotes, 0);
|
||||
assert.equal(calls.notesInfo, 0);
|
||||
assert.equal(
|
||||
(
|
||||
manager as unknown as {
|
||||
@@ -263,13 +312,11 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
|
||||
version: number;
|
||||
words: string[];
|
||||
notes?: Record<string, string[]>;
|
||||
notes?: Record<string, Array<{ word: string; reading: string | null }>>;
|
||||
};
|
||||
assert.equal(persisted.version, 2);
|
||||
assert.deepEqual(persisted.words.sort(), ['鳥']);
|
||||
assert.equal(persisted.version, 3);
|
||||
assert.deepEqual(persisted.notes, {
|
||||
'1': ['鳥'],
|
||||
'1': [{ word: '鳥', reading: null }],
|
||||
});
|
||||
} finally {
|
||||
cleanup();
|
||||
@@ -392,10 +439,10 @@ test('KnownWordCacheManager preserves cache state key captured before refresh wo
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
|
||||
scope: string;
|
||||
words: string[];
|
||||
notes: Record<string, Array<{ word: string; reading: string | null }>>;
|
||||
};
|
||||
assert.equal(persisted.scope, '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}');
|
||||
assert.deepEqual(persisted.words, ['猫']);
|
||||
assert.deepEqual(persisted.notes, { '1': [{ word: '猫', reading: null }] });
|
||||
} finally {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -648,3 +695,279 @@ test('KnownWordCacheManager skips immediate append when addMinedWordsImmediately
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager disambiguates known words by note reading', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Word',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesResult = [1];
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Word: { value: '床' },
|
||||
'Word Reading': { value: 'ゆか' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.isKnownWord('床'), true);
|
||||
assert.equal(manager.isKnownWord('床', 'ゆか'), true);
|
||||
assert.equal(manager.isKnownWord('床', 'ユカ'), true);
|
||||
// Same spelling, different word (床/とこ "bed") must not match.
|
||||
assert.equal(manager.isKnownWord('床', 'とこ'), false);
|
||||
// Note readings stay matchable as kana words.
|
||||
assert.equal(manager.isKnownWord('ゆか'), true);
|
||||
assert.equal(manager.isKnownWord('とこ'), false);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager suppresses reading-only matches when disallowed', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Word',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesResult = [1];
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Word: { value: '警告' },
|
||||
'Word Reading': { value: 'けいこく' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
// Reading-only match stays available for kana subtitle text…
|
||||
assert.equal(manager.isKnownWord('けいこく'), true);
|
||||
// …but a kanji token's reading (渓谷/けいこく) must not borrow 警告's.
|
||||
assert.equal(manager.isKnownWord('けいこく', undefined, { allowReadingOnlyMatch: false }), false);
|
||||
// Mined word texts still match regardless of the flag.
|
||||
assert.equal(manager.isKnownWord('警告', undefined, { allowReadingOnlyMatch: false }), true);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager does not match single-kana text by reading alone', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Word',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesResult = [1, 2];
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Word: { value: '夜' },
|
||||
'Word Reading': { value: 'よ' },
|
||||
},
|
||||
},
|
||||
{
|
||||
noteId: 2,
|
||||
fields: {
|
||||
Word: { value: 'え' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
// よ must not count as known just because 夜 is read よ.
|
||||
assert.equal(manager.isKnownWord('よ'), false);
|
||||
assert.equal(manager.isKnownWord('ヨ'), false);
|
||||
assert.equal(manager.isKnownWord('夜'), true);
|
||||
assert.equal(manager.isKnownWord('夜', 'よ'), true);
|
||||
// A literal single-kana word entry still matches via the word map.
|
||||
assert.equal(manager.isKnownWord('え'), true);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager probes reading fields even with per-deck word fields configured', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
decks: {
|
||||
'Kaishi 1.5k': ['Word'],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesByQuery.set('deck:"Kaishi 1.5k"', [1]);
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Word: { value: '床' },
|
||||
'Word Reading': { value: 'ゆか' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.isKnownWord('床', 'ゆか'), true);
|
||||
assert.equal(manager.isKnownWord('床', 'とこ'), false);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager matches words without readings in any reading', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Word',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesResult = [1];
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Word: { value: '床' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.isKnownWord('床'), true);
|
||||
assert.equal(manager.isKnownWord('床', 'とこ'), true);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager extracts word and reading from furigana word fields', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Word',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesResult = [1];
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Word: { value: 'お 決[き]まり' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.isKnownWord('お決まり'), true);
|
||||
assert.equal(manager.isKnownWord('お決まり', 'おきまり'), true);
|
||||
assert.equal(manager.isKnownWord('お決まり', 'おさだまり'), false);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager treats non-kana reading fields as words', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Word',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesResult = [1];
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Reading: { value: '漢字' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.isKnownWord('漢字'), true);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager keeps kana-only reading notes matchable', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Word',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesResult = [1];
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Reading: { value: 'たべる' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.isKnownWord('たべる'), true);
|
||||
assert.equal(manager.isKnownWord('タベル'), true);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,6 +5,16 @@ import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||
import { getConfiguredWordFieldName } from '../anki-field-config';
|
||||
import { AnkiConnectConfig } from '../types/anki';
|
||||
import { createLogger } from '../logger';
|
||||
import {
|
||||
DEFAULT_KNOWN_WORD_READING_FIELDS,
|
||||
KnownWordEntry,
|
||||
convertKatakanaToHiragana,
|
||||
isReadingFieldName,
|
||||
knownWordEntryListsEqual,
|
||||
normalizeKnownReadingForLookup,
|
||||
normalizeKnownWordEntryList,
|
||||
parseFuriganaAnnotatedText,
|
||||
} from './known-word-entries';
|
||||
|
||||
const log = createLogger('anki').child('integration.known-word-cache');
|
||||
|
||||
@@ -79,7 +89,16 @@ interface KnownWordCacheStateV2 {
|
||||
readonly notes: Record<string, string[]>;
|
||||
}
|
||||
|
||||
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2;
|
||||
interface KnownWordCacheStateV3 {
|
||||
readonly version: 3;
|
||||
readonly refreshedAtMs: number;
|
||||
readonly scope: string;
|
||||
readonly notes: Record<string, KnownWordEntry[]>;
|
||||
}
|
||||
|
||||
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2 | KnownWordCacheStateV3;
|
||||
|
||||
const NO_READING_KEY = '';
|
||||
|
||||
interface KnownWordCacheClient {
|
||||
findNotes: (
|
||||
@@ -106,9 +125,12 @@ type KnownWordQueryScope = {
|
||||
export class KnownWordCacheManager {
|
||||
private knownWordsLastRefreshedAtMs = 0;
|
||||
private knownWordsStateKey = '';
|
||||
private knownWords: Set<string> = new Set();
|
||||
private wordReferenceCounts = new Map<string, number>();
|
||||
private noteWordsById = new Map<number, string[]>();
|
||||
// word → (hiragana reading | NO_READING_KEY → note count). NO_READING_KEY
|
||||
// entries fail open: the word matches regardless of the token's reading.
|
||||
private wordReadingCounts = new Map<string, Map<string, number>>();
|
||||
// hiragana reading → note count, so kana tokens still match by reading alone.
|
||||
private readingCounts = new Map<string, number>();
|
||||
private noteEntriesById = new Map<number, KnownWordEntry[]>();
|
||||
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private isRefreshingKnownWords = false;
|
||||
@@ -120,13 +142,47 @@ export class KnownWordCacheManager {
|
||||
);
|
||||
}
|
||||
|
||||
isKnownWord(text: string): boolean {
|
||||
isKnownWord(
|
||||
text: string,
|
||||
reading?: string,
|
||||
options?: { allowReadingOnlyMatch?: boolean },
|
||||
): boolean {
|
||||
if (!this.isKnownWordCacheEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = this.normalizeKnownWordForLookup(text);
|
||||
return normalized.length > 0 ? this.knownWords.has(normalized) : false;
|
||||
if (normalized.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const knownReadings = this.wordReadingCounts.get(normalized);
|
||||
if (knownReadings && knownReadings.size > 0) {
|
||||
const normalizedReading =
|
||||
typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : '';
|
||||
return (
|
||||
normalizedReading.length === 0 ||
|
||||
knownReadings.has(NO_READING_KEY) ||
|
||||
knownReadings.has(normalizedReading)
|
||||
);
|
||||
}
|
||||
|
||||
// Callers that look up a kanji token's reading (not subtitle text) must
|
||||
// opt out of the reading-only fallback: readingCounts holds readings of
|
||||
// every note including kanji words, so 渓谷's けいこく would match a
|
||||
// mined 警告/けいこく.
|
||||
if (options?.allowReadingOnlyMatch === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reading-only fallback, except for single-kana text: particles and
|
||||
// interjections (よ, ね, え…) would otherwise borrow the reading of an
|
||||
// unrelated note (夜「よ」, 絵「え」) and count as known.
|
||||
const hiragana = convertKatakanaToHiragana(normalized);
|
||||
if ([...hiragana].length === 1) {
|
||||
return false;
|
||||
}
|
||||
return this.readingCounts.has(hiragana);
|
||||
}
|
||||
|
||||
refresh(force = false): Promise<void> {
|
||||
@@ -173,7 +229,7 @@ export class KnownWordCacheManager {
|
||||
let didMutateCache = false;
|
||||
const currentStateKey = this.getKnownWordCacheStateKey();
|
||||
if (this.knownWordsStateKey && this.knownWordsStateKey !== currentStateKey) {
|
||||
didMutateCache = this.knownWords.size > 0 || this.noteWordsById.size > 0;
|
||||
didMutateCache = this.wordReadingCounts.size > 0 || this.noteEntriesById.size > 0;
|
||||
this.clearKnownWordCacheState();
|
||||
}
|
||||
if (!this.knownWordsStateKey) {
|
||||
@@ -185,8 +241,8 @@ export class KnownWordCacheManager {
|
||||
return didMutateCache;
|
||||
}
|
||||
|
||||
const nextWords = this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, preferredFields);
|
||||
const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextWords);
|
||||
const nextEntries = this.extractKnownWordEntriesFromNoteInfo(noteInfo, preferredFields);
|
||||
const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextEntries);
|
||||
if (!changed) {
|
||||
return didMutateCache;
|
||||
}
|
||||
@@ -198,7 +254,7 @@ export class KnownWordCacheManager {
|
||||
log.info(
|
||||
'Known-word cache updated in-session',
|
||||
`noteId=${noteInfo.noteId}`,
|
||||
`wordCount=${nextWords.length}`,
|
||||
`wordCount=${nextEntries.length}`,
|
||||
`scope=${getKnownWordCacheScopeForConfig(this.deps.getConfig())}`,
|
||||
);
|
||||
return true;
|
||||
@@ -236,11 +292,11 @@ export class KnownWordCacheManager {
|
||||
const noteFieldsById = await this.fetchKnownWordNoteFieldsById();
|
||||
const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b);
|
||||
|
||||
if (this.noteWordsById.size === 0) {
|
||||
if (this.noteEntriesById.size === 0) {
|
||||
await this.rebuildFromCurrentNotes(currentNoteIds, noteFieldsById);
|
||||
} else {
|
||||
const currentNoteIdSet = new Set(currentNoteIds);
|
||||
for (const noteId of Array.from(this.noteWordsById.keys())) {
|
||||
for (const noteId of Array.from(this.noteEntriesById.keys())) {
|
||||
if (!currentNoteIdSet.has(noteId)) {
|
||||
this.removeNoteSnapshot(noteId);
|
||||
}
|
||||
@@ -251,7 +307,7 @@ export class KnownWordCacheManager {
|
||||
for (const noteInfo of noteInfos) {
|
||||
this.replaceNoteSnapshot(
|
||||
noteInfo.noteId,
|
||||
this.extractNormalizedKnownWordsFromNoteInfo(
|
||||
this.extractKnownWordEntriesFromNoteInfo(
|
||||
noteInfo,
|
||||
noteFieldsById.get(noteInfo.noteId),
|
||||
),
|
||||
@@ -266,7 +322,7 @@ export class KnownWordCacheManager {
|
||||
log.info(
|
||||
'Known-word cache refreshed',
|
||||
`noteCount=${currentNoteIds.length}`,
|
||||
`wordCount=${this.knownWords.size}`,
|
||||
`wordCount=${this.wordReadingCounts.size}`,
|
||||
);
|
||||
} catch (error) {
|
||||
log.warn('Failed to refresh known-word cache:', (error as Error).message);
|
||||
@@ -291,7 +347,13 @@ export class KnownWordCacheManager {
|
||||
|
||||
private getDefaultKnownWordFields(): string[] {
|
||||
const configuredWordField = getConfiguredWordFieldName(this.deps.getConfig());
|
||||
return [...new Set([configuredWordField, 'Word', 'Reading', 'Word Reading'])];
|
||||
return this.withDefaultReadingFields([configuredWordField, 'Word']);
|
||||
}
|
||||
|
||||
// Reading fields are always probed (even when a deck configures explicit
|
||||
// word fields) so entries can carry the reading their note teaches.
|
||||
private withDefaultReadingFields(fields: string[]): string[] {
|
||||
return [...new Set([...fields, ...DEFAULT_KNOWN_WORD_READING_FIELDS])];
|
||||
}
|
||||
|
||||
private getKnownWordDecks(): string[] {
|
||||
@@ -337,7 +399,9 @@ export class KnownWordCacheManager {
|
||||
.filter((field) => field.length > 0),
|
||||
),
|
||||
];
|
||||
return normalizedFields.length > 0 ? normalizedFields : this.getDefaultKnownWordFields();
|
||||
return normalizedFields.length > 0
|
||||
? this.withDefaultReadingFields(normalizedFields)
|
||||
: this.getDefaultKnownWordFields();
|
||||
}
|
||||
|
||||
const deckFields = selectedDeckEntry[1];
|
||||
@@ -351,7 +415,7 @@ export class KnownWordCacheManager {
|
||||
),
|
||||
];
|
||||
if (normalizedFields.length > 0) {
|
||||
return normalizedFields;
|
||||
return this.withDefaultReadingFields(normalizedFields);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +446,10 @@ export class KnownWordCacheManager {
|
||||
: [];
|
||||
scopes.push({
|
||||
query: `deck:"${escapeAnkiSearchValue(trimmedDeckName)}"`,
|
||||
fields: normalizedFields.length > 0 ? normalizedFields : this.getDefaultKnownWordFields(),
|
||||
fields:
|
||||
normalizedFields.length > 0
|
||||
? this.withDefaultReadingFields(normalizedFields)
|
||||
: this.getDefaultKnownWordFields(),
|
||||
});
|
||||
}
|
||||
if (scopes.length > 0) {
|
||||
@@ -490,7 +557,7 @@ export class KnownWordCacheManager {
|
||||
for (const noteInfo of noteInfos) {
|
||||
this.replaceNoteSnapshot(
|
||||
noteInfo.noteId,
|
||||
this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, noteFieldsById.get(noteInfo.noteId)),
|
||||
this.extractKnownWordEntriesFromNoteInfo(noteInfo, noteFieldsById.get(noteInfo.noteId)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -519,56 +586,77 @@ export class KnownWordCacheManager {
|
||||
return noteInfos;
|
||||
}
|
||||
|
||||
private replaceNoteSnapshot(noteId: number, nextWords: string[]): boolean {
|
||||
const normalizedWords = normalizeKnownWordList(nextWords);
|
||||
const previousWords = this.noteWordsById.get(noteId) ?? [];
|
||||
if (knownWordListsEqual(previousWords, normalizedWords)) {
|
||||
private replaceNoteSnapshot(noteId: number, nextEntries: KnownWordEntry[]): boolean {
|
||||
const normalizedEntries = normalizeKnownWordEntryList(nextEntries);
|
||||
const previousEntries = this.noteEntriesById.get(noteId) ?? [];
|
||||
if (knownWordEntryListsEqual(previousEntries, normalizedEntries)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.removeWordsFromCounts(previousWords);
|
||||
if (normalizedWords.length > 0) {
|
||||
this.noteWordsById.set(noteId, normalizedWords);
|
||||
this.addWordsToCounts(normalizedWords);
|
||||
this.removeEntriesFromCounts(previousEntries);
|
||||
if (normalizedEntries.length > 0) {
|
||||
this.noteEntriesById.set(noteId, normalizedEntries);
|
||||
this.addEntriesToCounts(normalizedEntries);
|
||||
} else {
|
||||
this.noteWordsById.delete(noteId);
|
||||
this.noteEntriesById.delete(noteId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private removeNoteSnapshot(noteId: number): void {
|
||||
const previousWords = this.noteWordsById.get(noteId);
|
||||
if (!previousWords) {
|
||||
const previousEntries = this.noteEntriesById.get(noteId);
|
||||
if (!previousEntries) {
|
||||
return;
|
||||
}
|
||||
this.noteWordsById.delete(noteId);
|
||||
this.removeWordsFromCounts(previousWords);
|
||||
this.noteEntriesById.delete(noteId);
|
||||
this.removeEntriesFromCounts(previousEntries);
|
||||
}
|
||||
|
||||
private addWordsToCounts(words: string[]): void {
|
||||
for (const word of words) {
|
||||
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) + 1;
|
||||
this.wordReferenceCounts.set(word, nextCount);
|
||||
this.knownWords.add(word);
|
||||
private addEntriesToCounts(entries: KnownWordEntry[]): void {
|
||||
for (const entry of entries) {
|
||||
const readingKey = entry.reading ?? NO_READING_KEY;
|
||||
let readings = this.wordReadingCounts.get(entry.word);
|
||||
if (!readings) {
|
||||
readings = new Map();
|
||||
this.wordReadingCounts.set(entry.word, readings);
|
||||
}
|
||||
readings.set(readingKey, (readings.get(readingKey) ?? 0) + 1);
|
||||
if (entry.reading) {
|
||||
this.readingCounts.set(entry.reading, (this.readingCounts.get(entry.reading) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private removeWordsFromCounts(words: string[]): void {
|
||||
for (const word of words) {
|
||||
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) - 1;
|
||||
if (nextCount > 0) {
|
||||
this.wordReferenceCounts.set(word, nextCount);
|
||||
} else {
|
||||
this.wordReferenceCounts.delete(word);
|
||||
this.knownWords.delete(word);
|
||||
private removeEntriesFromCounts(entries: KnownWordEntry[]): void {
|
||||
for (const entry of entries) {
|
||||
const readingKey = entry.reading ?? NO_READING_KEY;
|
||||
const readings = this.wordReadingCounts.get(entry.word);
|
||||
if (readings) {
|
||||
const nextCount = (readings.get(readingKey) ?? 0) - 1;
|
||||
if (nextCount > 0) {
|
||||
readings.set(readingKey, nextCount);
|
||||
} else {
|
||||
readings.delete(readingKey);
|
||||
if (readings.size === 0) {
|
||||
this.wordReadingCounts.delete(entry.word);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entry.reading) {
|
||||
const nextReadingCount = (this.readingCounts.get(entry.reading) ?? 0) - 1;
|
||||
if (nextReadingCount > 0) {
|
||||
this.readingCounts.set(entry.reading, nextReadingCount);
|
||||
} else {
|
||||
this.readingCounts.delete(entry.reading);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearInMemoryState(): void {
|
||||
this.knownWords = new Set();
|
||||
this.wordReferenceCounts = new Map();
|
||||
this.noteWordsById = new Map();
|
||||
this.wordReadingCounts = new Map();
|
||||
this.readingCounts = new Map();
|
||||
this.noteEntriesById = new Map();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
}
|
||||
|
||||
@@ -601,32 +689,48 @@ export class KnownWordCacheManager {
|
||||
}
|
||||
|
||||
this.clearInMemoryState();
|
||||
if (parsed.version === 3) {
|
||||
for (const [noteIdKey, entries] of Object.entries(parsed.notes)) {
|
||||
const noteId = Number.parseInt(noteIdKey, 10);
|
||||
if (!Number.isInteger(noteId) || noteId <= 0) {
|
||||
continue;
|
||||
}
|
||||
const normalizedEntries = normalizeKnownWordEntryList(entries);
|
||||
if (normalizedEntries.length === 0) {
|
||||
continue;
|
||||
}
|
||||
this.noteEntriesById.set(noteId, normalizedEntries);
|
||||
this.addEntriesToCounts(normalizedEntries);
|
||||
}
|
||||
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
|
||||
this.knownWordsStateKey = parsed.scope;
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.version === 2) {
|
||||
// Older states have no readings; load them reading-less (fail-open,
|
||||
// matching the old behavior) but leave the cache marked stale so the
|
||||
// next refresh upgrades entries with readings from Anki.
|
||||
for (const [noteIdKey, words] of Object.entries(parsed.notes)) {
|
||||
const noteId = Number.parseInt(noteIdKey, 10);
|
||||
if (!Number.isInteger(noteId) || noteId <= 0) {
|
||||
continue;
|
||||
}
|
||||
const normalizedWords = normalizeKnownWordList(words);
|
||||
if (normalizedWords.length === 0) {
|
||||
const normalizedEntries = normalizeKnownWordEntryList(
|
||||
words.map((word) => ({ word: this.normalizeKnownWordForLookup(word), reading: null })),
|
||||
);
|
||||
if (normalizedEntries.length === 0) {
|
||||
continue;
|
||||
}
|
||||
this.noteWordsById.set(noteId, normalizedWords);
|
||||
this.addWordsToCounts(normalizedWords);
|
||||
}
|
||||
} else {
|
||||
for (const value of parsed.words) {
|
||||
const normalized = this.normalizeKnownWordForLookup(value);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
this.knownWords.add(normalized);
|
||||
this.wordReferenceCounts.set(normalized, 1);
|
||||
this.noteEntriesById.set(noteId, normalizedEntries);
|
||||
this.addEntriesToCounts(normalizedEntries);
|
||||
}
|
||||
this.knownWordsStateKey = parsed.scope;
|
||||
return;
|
||||
}
|
||||
|
||||
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
|
||||
this.knownWordsStateKey = parsed.scope;
|
||||
// v1 has no per-note snapshots to convert; refetch from Anki.
|
||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||
} catch (error) {
|
||||
log.warn('Failed to load known-word cache state:', (error as Error).message);
|
||||
this.clearInMemoryState();
|
||||
@@ -636,18 +740,17 @@ export class KnownWordCacheManager {
|
||||
|
||||
private persistKnownWordCacheState(): void {
|
||||
try {
|
||||
const notes: Record<string, string[]> = {};
|
||||
for (const [noteId, words] of this.noteWordsById.entries()) {
|
||||
if (words.length > 0) {
|
||||
notes[String(noteId)] = words;
|
||||
const notes: Record<string, KnownWordEntry[]> = {};
|
||||
for (const [noteId, entries] of this.noteEntriesById.entries()) {
|
||||
if (entries.length > 0) {
|
||||
notes[String(noteId)] = entries;
|
||||
}
|
||||
}
|
||||
|
||||
const state: KnownWordCacheStateV2 = {
|
||||
version: 2,
|
||||
const state: KnownWordCacheStateV3 = {
|
||||
version: 3,
|
||||
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
|
||||
scope: this.knownWordsStateKey,
|
||||
words: Array.from(this.knownWords),
|
||||
notes,
|
||||
};
|
||||
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
|
||||
@@ -659,14 +762,18 @@ export class KnownWordCacheManager {
|
||||
private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (candidate.version !== 1 && candidate.version !== 2) return false;
|
||||
if (typeof candidate.refreshedAtMs !== 'number') return false;
|
||||
if (typeof candidate.scope !== 'string') return false;
|
||||
if (!Array.isArray(candidate.words)) return false;
|
||||
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
|
||||
if (candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3) {
|
||||
return false;
|
||||
}
|
||||
if (candidate.version === 2) {
|
||||
if (typeof candidate.refreshedAtMs !== 'number') return false;
|
||||
if (typeof candidate.scope !== 'string') return false;
|
||||
if (candidate.version !== 3) {
|
||||
if (!Array.isArray(candidate.words)) return false;
|
||||
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (candidate.version === 2 || candidate.version === 3) {
|
||||
if (
|
||||
typeof candidate.notes !== 'object' ||
|
||||
candidate.notes === null ||
|
||||
@@ -674,10 +781,18 @@ export class KnownWordCacheManager {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const isValidNoteEntry =
|
||||
candidate.version === 2
|
||||
? (entry: unknown): boolean => typeof entry === 'string'
|
||||
: (entry: unknown): boolean =>
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
typeof (entry as KnownWordEntry).word === 'string' &&
|
||||
((entry as KnownWordEntry).reading === null ||
|
||||
typeof (entry as KnownWordEntry).reading === 'string');
|
||||
if (
|
||||
!Object.values(candidate.notes as Record<string, unknown>).every(
|
||||
(entry) =>
|
||||
Array.isArray(entry) && entry.every((word: unknown) => typeof word === 'string'),
|
||||
(noteEntries) => Array.isArray(noteEntries) && noteEntries.every(isValidNoteEntry),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
@@ -686,11 +801,12 @@ export class KnownWordCacheManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractNormalizedKnownWordsFromNoteInfo(
|
||||
private extractKnownWordEntriesFromNoteInfo(
|
||||
noteInfo: KnownWordCacheNoteInfo,
|
||||
preferredFields = this.getConfiguredFields(),
|
||||
): string[] {
|
||||
const words: string[] = [];
|
||||
): KnownWordEntry[] {
|
||||
const wordValues: string[] = [];
|
||||
let noteReading: string | null = null;
|
||||
for (const preferredField of preferredFields) {
|
||||
const fieldName = resolveFieldName(Object.keys(noteInfo.fields), preferredField);
|
||||
if (!fieldName) continue;
|
||||
@@ -698,12 +814,36 @@ export class KnownWordCacheManager {
|
||||
const raw = noteInfo.fields[fieldName]?.value;
|
||||
if (!raw) continue;
|
||||
|
||||
const normalized = this.normalizeKnownWordForLookup(raw);
|
||||
if (normalized) {
|
||||
words.push(normalized);
|
||||
const cleaned = this.normalizeRawKnownWordValue(raw);
|
||||
if (!cleaned) continue;
|
||||
|
||||
if (isReadingFieldName(preferredField)) {
|
||||
const normalizedReading = normalizeKnownReadingForLookup(cleaned);
|
||||
if (normalizedReading) {
|
||||
noteReading ??= normalizedReading;
|
||||
continue;
|
||||
}
|
||||
// Non-kana content in a reading field: treat it as a word so decks
|
||||
// with repurposed reading fields keep matching (fail-open).
|
||||
}
|
||||
wordValues.push(cleaned);
|
||||
}
|
||||
return normalizeKnownWordList(words);
|
||||
|
||||
const entries: KnownWordEntry[] = [];
|
||||
for (const value of wordValues) {
|
||||
const parsed = parseFuriganaAnnotatedText(value);
|
||||
const word = parsed.text.trim().toLowerCase();
|
||||
if (!word) continue;
|
||||
const inlineReading = parsed.reading ? normalizeKnownReadingForLookup(parsed.reading) : '';
|
||||
entries.push({ word, reading: inlineReading || noteReading });
|
||||
}
|
||||
|
||||
// Kana-only notes (reading field but no word field) stay matchable.
|
||||
if (entries.length === 0 && noteReading) {
|
||||
entries.push({ word: noteReading, reading: noteReading });
|
||||
}
|
||||
|
||||
return normalizeKnownWordEntryList(entries);
|
||||
}
|
||||
|
||||
private normalizeRawKnownWordValue(value: string): string {
|
||||
@@ -718,22 +858,6 @@ export class KnownWordCacheManager {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeKnownWordList(words: string[]): string[] {
|
||||
return [...new Set(words.map((word) => word.trim()).filter((word) => word.length > 0))].sort();
|
||||
}
|
||||
|
||||
function knownWordListsEqual(left: string[], right: string[]): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null {
|
||||
const exact = availableFieldNames.find((name) => name === preferredName);
|
||||
if (exact) return exact;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// Known-word cache entries pair a word with the reading its Anki note teaches,
|
||||
// so spelling collisions across readings (e.g. 床/ゆか vs 床/とこ) don't mark
|
||||
// unrelated words as known. reading === null means the note carries no usable
|
||||
// reading and the word matches in any reading (fail-open).
|
||||
export interface KnownWordEntry {
|
||||
word: string;
|
||||
reading: string | null;
|
||||
}
|
||||
|
||||
const KATAKANA_TO_HIRAGANA_OFFSET = 0x60;
|
||||
const KATAKANA_CODEPOINT_START = 0x30a1;
|
||||
const KATAKANA_CODEPOINT_END = 0x30f6;
|
||||
const FURIGANA_SEGMENT_PATTERN = /([^\s \[\]]*)\[([^\]]*)\]/g;
|
||||
const FURIGANA_BRACKET_PATTERN = /\[[^\]]*\]/g;
|
||||
const WHITESPACE_PATTERN = /[\s ]+/g;
|
||||
|
||||
// Reading-bearing field names probed on every known-word note, in addition to
|
||||
// any configured word fields (covers Kaishi's "Word Reading" and Lapis's
|
||||
// "ExpressionReading" note types).
|
||||
export const DEFAULT_KNOWN_WORD_READING_FIELDS = [
|
||||
'Reading',
|
||||
'Word Reading',
|
||||
'ExpressionReading',
|
||||
'Expression Reading',
|
||||
];
|
||||
|
||||
export function isReadingFieldName(fieldName: string): boolean {
|
||||
return /reading/i.test(fieldName);
|
||||
}
|
||||
|
||||
export function convertKatakanaToHiragana(text: string): string {
|
||||
let converted = '';
|
||||
for (const char of text) {
|
||||
const code = char.codePointAt(0);
|
||||
if (code !== undefined && code >= KATAKANA_CODEPOINT_START && code <= KATAKANA_CODEPOINT_END) {
|
||||
converted += String.fromCodePoint(code - KATAKANA_TO_HIRAGANA_OFFSET);
|
||||
continue;
|
||||
}
|
||||
converted += char;
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
|
||||
function isHiraganaReadingChar(char: string): boolean {
|
||||
const code = char.codePointAt(0);
|
||||
if (code === undefined) {
|
||||
return false;
|
||||
}
|
||||
return (code >= 0x3041 && code <= 0x309f) || code === 0x30fc;
|
||||
}
|
||||
|
||||
// Splits Anki furigana syntax (`床[とこ]`, `お 決[き]まり`) into base text and
|
||||
// reading. Values without brackets pass through with reading null.
|
||||
export function parseFuriganaAnnotatedText(value: string): {
|
||||
text: string;
|
||||
reading: string | null;
|
||||
} {
|
||||
if (!value.includes('[')) {
|
||||
return { text: value, reading: null };
|
||||
}
|
||||
const text = value.replace(FURIGANA_BRACKET_PATTERN, '').replace(WHITESPACE_PATTERN, '');
|
||||
const reading = value.replace(FURIGANA_SEGMENT_PATTERN, '$2').replace(WHITESPACE_PATTERN, '');
|
||||
return { text, reading: reading.length > 0 ? reading : null };
|
||||
}
|
||||
|
||||
// Returns the hiragana-normalized reading, or '' when the value is not a
|
||||
// plausible kana reading (callers fall back to text-only matching then).
|
||||
export function normalizeKnownReadingForLookup(value: string): string {
|
||||
const parsed = parseFuriganaAnnotatedText(value.trim());
|
||||
const candidate = (parsed.reading ?? parsed.text).trim();
|
||||
if (!candidate) {
|
||||
return '';
|
||||
}
|
||||
const hiragana = convertKatakanaToHiragana(candidate);
|
||||
for (const char of hiragana) {
|
||||
if (!isHiraganaReadingChar(char)) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return hiragana;
|
||||
}
|
||||
|
||||
export function makeKnownWordEntryKey(entry: KnownWordEntry): string {
|
||||
return `${entry.word}\u0000${entry.reading ?? ''}`;
|
||||
}
|
||||
|
||||
export function normalizeKnownWordEntryList(entries: KnownWordEntry[]): KnownWordEntry[] {
|
||||
const byKey = new Map<string, KnownWordEntry>();
|
||||
for (const entry of entries) {
|
||||
const word = entry.word.trim();
|
||||
if (!word) {
|
||||
continue;
|
||||
}
|
||||
const reading = entry.reading?.trim() || null;
|
||||
const normalized: KnownWordEntry = { word, reading };
|
||||
byKey.set(makeKnownWordEntryKey(normalized), normalized);
|
||||
}
|
||||
return [...byKey.values()].sort((left, right) =>
|
||||
makeKnownWordEntryKey(left).localeCompare(makeKnownWordEntryKey(right)),
|
||||
);
|
||||
}
|
||||
|
||||
export function knownWordEntryListsEqual(left: KnownWordEntry[], right: KnownWordEntry[]): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (makeKnownWordEntryKey(left[index]!) !== makeKnownWordEntryKey(right[index]!)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1,7 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { resolveMediaGenerationInputPath } from './media-source';
|
||||
import * as mediaSource from './media-source';
|
||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||
|
||||
const { resolveMediaGenerationInputPath } = mediaSource;
|
||||
|
||||
type StructuredMediaInput = {
|
||||
path: string;
|
||||
source: string;
|
||||
singleResolvedStream: boolean;
|
||||
inputOptions?: {
|
||||
reconnect?: boolean;
|
||||
userAgent?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
type StructuredMediaResolver = (
|
||||
mpvClient: Parameters<typeof resolveMediaGenerationInputPath>[0],
|
||||
kind?: Parameters<typeof resolveMediaGenerationInputPath>[1],
|
||||
options?: {
|
||||
getCachedMediaPath?: (
|
||||
currentVideoPath: string,
|
||||
kind: Parameters<typeof resolveMediaGenerationInputPath>[1],
|
||||
) => Promise<string | null>;
|
||||
remoteCacheMode?: 'optional' | 'required';
|
||||
logDebug?: (message: string) => void;
|
||||
},
|
||||
) => Promise<StructuredMediaInput | null>;
|
||||
|
||||
test('resolveMediaGenerationInputPath keeps local file paths', async () => {
|
||||
const result = await resolveMediaGenerationInputPath({
|
||||
@@ -27,9 +54,11 @@ test('resolveMediaGenerationInputPath prefers stream-open-filename for remote me
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video', async () => {
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const edlSource = [
|
||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
||||
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
@@ -48,8 +77,52 @@ test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video
|
||||
'video',
|
||||
);
|
||||
|
||||
assert.equal(audioResult, 'https://audio.example/videoplayback?mime=audio%2Fwebm');
|
||||
assert.equal(videoResult, 'https://video.example/videoplayback?mime=video%2Fmp4');
|
||||
assert.equal(audioResult, audioUrl);
|
||||
assert.equal(videoResult, videoUrl);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInputPath strips mpv edl segment options from unwrapped streams', async () => {
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const signedVideoUrl =
|
||||
'https://rr1---sn.example.googlevideo.com/videoplayback?mime=video%2Fmp4&mn=sn-a,sn-b&lsig=abc%3D';
|
||||
const edlSource = [
|
||||
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(signedVideoUrl)},title=clip,length=73,timestamps=chapters`,
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
const result = await resolveMediaGenerationInputPath(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async () => edlSource,
|
||||
},
|
||||
'video',
|
||||
);
|
||||
|
||||
assert.equal(result, signedVideoUrl);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInputPath ignores length-guarded URLs in mpv edl headers', async () => {
|
||||
const initUrl = 'https://init.example/init.mp4';
|
||||
const audioUrl = 'https://audio.example/stream';
|
||||
const videoUrl = 'https://video.example/stream';
|
||||
const edlSource = [
|
||||
`edl://!mp4_dash,init=${toMpvEdlValue(initUrl)}`,
|
||||
'!new_stream',
|
||||
toMpvEdlValue(audioUrl),
|
||||
'!new_stream',
|
||||
toMpvEdlValue(videoUrl),
|
||||
].join(';');
|
||||
|
||||
const audioResult = await resolveMediaGenerationInputPath(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async () => edlSource,
|
||||
},
|
||||
'audio',
|
||||
);
|
||||
|
||||
assert.equal(audioResult, audioUrl);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInputPath falls back to currentVideoPath when stream-open-filename fails', async () => {
|
||||
@@ -62,3 +135,213 @@ test('resolveMediaGenerationInputPath falls back to currentVideoPath when stream
|
||||
|
||||
assert.equal(result, 'https://www.youtube.com/watch?v=abc123');
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput returns single-stream metadata for mpv EDL URLs', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||
}
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const edlSource = [
|
||||
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||
].join(';');
|
||||
|
||||
const result = await resolver!(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'stream-open-filename') return edlSource;
|
||||
if (name === 'user-agent') return 'Mozilla/5.0';
|
||||
if (name === 'http-header-fields') {
|
||||
return ['Cookie: SID=secret', 'Referer: https://www.youtube.com/', 'X-Test: ok'];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
'audio',
|
||||
);
|
||||
|
||||
assert.equal(result?.path, audioUrl);
|
||||
assert.equal(result?.singleResolvedStream, true);
|
||||
assert.equal(result?.inputOptions?.reconnect, true);
|
||||
assert.equal(result?.inputOptions?.userAgent, 'Mozilla/5.0');
|
||||
assert.deepEqual(result?.inputOptions?.headers, {
|
||||
Referer: 'https://www.youtube.com/',
|
||||
'X-Test': 'ok',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput reads file-local mpv request options', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||
}
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
|
||||
const result = await resolver!(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'stream-open-filename') {
|
||||
return 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123';
|
||||
}
|
||||
if (name === 'file-local-options/user-agent') return 'SubMiner Test Agent';
|
||||
if (name === 'options/http-header-fields') return ['X-Shared: ok'];
|
||||
if (name === 'file-local-options/http-header-fields') {
|
||||
return ['Cookie: SID=secret', 'Referer: https://m.youtube.com/', 'X-Local: yes'];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
'video',
|
||||
);
|
||||
|
||||
assert.equal(result?.path, 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123');
|
||||
assert.equal(result?.singleResolvedStream, true);
|
||||
assert.equal(result?.inputOptions?.userAgent, 'SubMiner Test Agent');
|
||||
assert.deepEqual(result?.inputOptions?.headers, {
|
||||
'X-Shared': 'ok',
|
||||
Referer: 'https://m.youtube.com/',
|
||||
'X-Local': 'yes',
|
||||
Origin: 'https://www.youtube.com',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput prefers a ready cached media file for YouTube extraction', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||
}
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
|
||||
const result = await resolver!(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
|
||||
},
|
||||
'video',
|
||||
{
|
||||
getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/abc123/media.mkv',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result?.path, '/tmp/subminer-youtube-media-cache/abc123/media.mkv');
|
||||
assert.equal(result?.source, 'youtube-cache');
|
||||
assert.equal(result?.singleResolvedStream, false);
|
||||
assert.equal(result?.inputOptions, undefined);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput debug-logs sanitized YouTube cache hits', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||
}
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
const logs: string[] = [];
|
||||
|
||||
const result = await resolver!(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123&signature=secret',
|
||||
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
|
||||
},
|
||||
'video',
|
||||
{
|
||||
getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/abc123/media.mkv',
|
||||
logDebug: (message) => logs.push(message),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result?.source, 'youtube-cache');
|
||||
assert.match(logs.join('\n'), /kind=video source=youtube-cache/);
|
||||
assert.match(
|
||||
logs.join('\n'),
|
||||
/input=local:\/tmp\/subminer-youtube-media-cache\/abc123\/media\.mkv/,
|
||||
);
|
||||
assert.match(logs.join('\n'), /current=remote:www\.youtube\.com/);
|
||||
assert.doesNotMatch(logs.join('\n'), /signature=secret|videoplayback/);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput does not fall back to direct remote streams when cache is required', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||
}
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
|
||||
const result = await resolver!(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
|
||||
},
|
||||
'video',
|
||||
{
|
||||
getCachedMediaPath: async () => null,
|
||||
remoteCacheMode: 'required',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput falls back when optional cache lookup fails', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||
}
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
|
||||
const result = await resolver!(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
|
||||
},
|
||||
'video',
|
||||
{
|
||||
getCachedMediaPath: async () => {
|
||||
throw new Error('cache unavailable');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result?.path, 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123');
|
||||
assert.equal(result?.source, 'stream-open-filename');
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput debug-logs sanitized required-cache misses', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||
}
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
const logs: string[] = [];
|
||||
|
||||
const result = await resolver!(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123&signature=secret',
|
||||
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
|
||||
},
|
||||
'video',
|
||||
{
|
||||
getCachedMediaPath: async () => null,
|
||||
remoteCacheMode: 'required',
|
||||
logDebug: (message) => logs.push(message),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.match(logs.join('\n'), /kind=video source=cache-miss/);
|
||||
assert.match(logs.join('\n'), /mode=required/);
|
||||
assert.match(logs.join('\n'), /current=remote:www\.youtube\.com/);
|
||||
assert.doesNotMatch(logs.join('\n'), /signature=secret|videoplayback|googlevideo/);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,58 @@
|
||||
import { isRemoteMediaPath } from '../jimaku/utils';
|
||||
import type { MediaInput, MediaInputOptions } from '../media-input';
|
||||
import type { MpvClient } from '../types/runtime';
|
||||
import { extractFileUrlsFromMpvEdlSource } from './mpv-edl';
|
||||
|
||||
export type MediaGenerationKind = 'audio' | 'video';
|
||||
export type MediaGenerationInputSource =
|
||||
| 'current-path'
|
||||
| 'stream-open-filename'
|
||||
| 'edl-stream'
|
||||
| 'youtube-cache';
|
||||
|
||||
export interface ResolvedMediaGenerationInput {
|
||||
path: string;
|
||||
kind: MediaGenerationKind;
|
||||
source: MediaGenerationInputSource;
|
||||
singleResolvedStream: boolean;
|
||||
inputOptions?: MediaInputOptions;
|
||||
}
|
||||
|
||||
export interface MediaGenerationInputResolverOptions {
|
||||
getCachedMediaPath?: (
|
||||
currentVideoPath: string,
|
||||
kind: MediaGenerationKind,
|
||||
) => Promise<string | null>;
|
||||
remoteCacheMode?: 'optional' | 'required';
|
||||
logDebug?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function resolveAudioStreamIndexForMediaGeneration(
|
||||
input: MediaInput,
|
||||
audioStreamIndex: number | null | undefined,
|
||||
): number | undefined {
|
||||
if (typeof input === 'object' && 'source' in input && input.source === 'youtube-cache') {
|
||||
return undefined;
|
||||
}
|
||||
return audioStreamIndex ?? undefined;
|
||||
}
|
||||
|
||||
const BLOCKED_HTTP_HEADER_NAMES = new Set(['authorization', 'cookie', 'proxy-authorization']);
|
||||
const HTTP_HEADER_FIELD_PROPERTY_NAMES = [
|
||||
'http-header-fields',
|
||||
'options/http-header-fields',
|
||||
'file-local-options/http-header-fields',
|
||||
] as const;
|
||||
const USER_AGENT_PROPERTY_NAMES = [
|
||||
'file-local-options/user-agent',
|
||||
'options/user-agent',
|
||||
'user-agent',
|
||||
] as const;
|
||||
const REFERRER_PROPERTY_NAMES = [
|
||||
'file-local-options/referrer',
|
||||
'options/referrer',
|
||||
'referrer',
|
||||
] as const;
|
||||
|
||||
function trimToNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
@@ -11,10 +62,20 @@ function trimToNonEmptyString(value: unknown): string | null {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeHeaderName(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
if (BLOCKED_HTTP_HEADER_NAMES.has(trimmed.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function extractUrlsFromMpvEdlSource(source: string): string[] {
|
||||
const matches = source.matchAll(/%\d+%(https?:\/\/.*?)(?=;!new_stream|;!global_tags|$)/gms);
|
||||
return [...matches]
|
||||
.map((match) => trimToNonEmptyString(match[1]))
|
||||
return extractFileUrlsFromMpvEdlSource(source)
|
||||
.map((value) => trimToNonEmptyString(value))
|
||||
.filter((value): value is string => value !== null);
|
||||
}
|
||||
|
||||
@@ -53,6 +114,317 @@ function resolvePreferredUrlFromMpvEdlSource(
|
||||
return kind === 'audio' ? (urls[0] ?? null) : (urls[urls.length - 1] ?? null);
|
||||
}
|
||||
|
||||
function getHostname(value: string): string | null {
|
||||
try {
|
||||
return new URL(value).hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function matchesHost(hostname: string, expectedHost: string): boolean {
|
||||
return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`);
|
||||
}
|
||||
|
||||
function isGoogleVideoMediaPath(value: string): boolean {
|
||||
const host = getHostname(value);
|
||||
return Boolean(host && matchesHost(host, 'googlevideo.com'));
|
||||
}
|
||||
|
||||
function describeMediaPathForDebugLog(value: string): string {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
||||
return `remote:${url.hostname.toLowerCase() || 'unknown'}`;
|
||||
}
|
||||
return `${url.protocol.replace(/:$/, '')}:`;
|
||||
} catch {
|
||||
// Not a URL; treat as a local file path below.
|
||||
}
|
||||
|
||||
if (value.startsWith('edl://')) {
|
||||
return 'edl:';
|
||||
}
|
||||
|
||||
return `local:${value}`;
|
||||
}
|
||||
|
||||
function logMediaResolutionDebug(
|
||||
options: MediaGenerationInputResolverOptions,
|
||||
message: string,
|
||||
): void {
|
||||
if (!options.logDebug) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
options.logDebug(`[media-source] ${message}`);
|
||||
} catch {
|
||||
// Debug logging should not affect media generation.
|
||||
}
|
||||
}
|
||||
|
||||
function logResolvedMediaGenerationInput(
|
||||
options: MediaGenerationInputResolverOptions,
|
||||
currentVideoPath: string,
|
||||
result: ResolvedMediaGenerationInput,
|
||||
): void {
|
||||
logMediaResolutionDebug(
|
||||
options,
|
||||
[
|
||||
`kind=${result.kind}`,
|
||||
`source=${result.source}`,
|
||||
`input=${describeMediaPathForDebugLog(result.path)}`,
|
||||
`current=${describeMediaPathForDebugLog(currentVideoPath)}`,
|
||||
`singleResolvedStream=${result.singleResolvedStream}`,
|
||||
].join(' '),
|
||||
);
|
||||
}
|
||||
|
||||
function logMediaGenerationInputMiss(
|
||||
options: MediaGenerationInputResolverOptions,
|
||||
kind: MediaGenerationKind,
|
||||
currentVideoPath: string,
|
||||
reason: string,
|
||||
): void {
|
||||
logMediaResolutionDebug(
|
||||
options,
|
||||
[
|
||||
`kind=${kind}`,
|
||||
'source=cache-miss',
|
||||
`reason=${reason}`,
|
||||
`mode=${options.remoteCacheMode ?? 'optional'}`,
|
||||
`current=${describeMediaPathForDebugLog(currentVideoPath)}`,
|
||||
].join(' '),
|
||||
);
|
||||
}
|
||||
|
||||
function setHeaderIfMissing(headers: Record<string, string>, name: string, value: string): void {
|
||||
const lowerName = name.toLowerCase();
|
||||
if (!Object.keys(headers).some((existing) => existing.toLowerCase() === lowerName)) {
|
||||
headers[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function parseMpvHeaderField(value: string): [string, string] | null {
|
||||
const separatorIndex = value.indexOf(':');
|
||||
if (separatorIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = normalizeHeaderName(value.slice(0, separatorIndex));
|
||||
const headerValue = trimToNonEmptyString(value.slice(separatorIndex + 1));
|
||||
if (!name || !headerValue) {
|
||||
return null;
|
||||
}
|
||||
return [name, headerValue.replace(/[\r\n]+/g, ' ')];
|
||||
}
|
||||
|
||||
function toHeaderFields(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry): entry is string => typeof entry === 'string');
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function requestOptionalMpvProperty(
|
||||
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
if (!mpvClient.requestProperty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await mpvClient.requestProperty(name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestFirstNonEmptyStringProperty(
|
||||
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
||||
names: readonly string[],
|
||||
): Promise<string | null> {
|
||||
for (const name of names) {
|
||||
const value = trimToNonEmptyString(await requestOptionalMpvProperty(mpvClient, name));
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveRemoteInputOptions(
|
||||
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
||||
resolvedPath: string,
|
||||
): Promise<MediaInputOptions | undefined> {
|
||||
if (!isRemoteMediaPath(resolvedPath) || !mpvClient.requestProperty) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
for (const propertyName of HTTP_HEADER_FIELD_PROPERTY_NAMES) {
|
||||
const mpvHeaderFields = toHeaderFields(
|
||||
await requestOptionalMpvProperty(mpvClient, propertyName),
|
||||
);
|
||||
for (const field of mpvHeaderFields) {
|
||||
const parsed = parseMpvHeaderField(field);
|
||||
if (parsed) {
|
||||
headers[parsed[0]] = parsed[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userAgent = await requestFirstNonEmptyStringProperty(mpvClient, USER_AGENT_PROPERTY_NAMES);
|
||||
const referrer = await requestFirstNonEmptyStringProperty(mpvClient, REFERRER_PROPERTY_NAMES);
|
||||
if (referrer) {
|
||||
setHeaderIfMissing(headers, 'Referer', referrer);
|
||||
}
|
||||
if (isGoogleVideoMediaPath(resolvedPath)) {
|
||||
setHeaderIfMissing(headers, 'Referer', 'https://www.youtube.com/');
|
||||
setHeaderIfMissing(headers, 'Origin', 'https://www.youtube.com');
|
||||
}
|
||||
|
||||
return {
|
||||
reconnect: true,
|
||||
...(userAgent ? { userAgent } : {}),
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function toResolvedMediaGenerationInput(
|
||||
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
||||
path: string,
|
||||
kind: MediaGenerationKind,
|
||||
source: MediaGenerationInputSource,
|
||||
singleResolvedStream: boolean,
|
||||
): Promise<ResolvedMediaGenerationInput> {
|
||||
const inputOptions = await resolveRemoteInputOptions(mpvClient, path);
|
||||
return {
|
||||
path,
|
||||
kind,
|
||||
source,
|
||||
singleResolvedStream,
|
||||
...(inputOptions ? { inputOptions } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveMediaGenerationInput(
|
||||
mpvClient: Pick<MpvClient, 'currentVideoPath' | 'requestProperty'> | null | undefined,
|
||||
kind: MediaGenerationKind = 'video',
|
||||
options: MediaGenerationInputResolverOptions = {},
|
||||
): Promise<ResolvedMediaGenerationInput | null> {
|
||||
const currentVideoPath = trimToNonEmptyString(mpvClient?.currentVideoPath);
|
||||
if (!currentVideoPath) {
|
||||
logMediaResolutionDebug(options, `kind=${kind} source=none reason=no-current-video`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isRemoteMediaPath(currentVideoPath)) {
|
||||
const result: ResolvedMediaGenerationInput = {
|
||||
path: currentVideoPath,
|
||||
kind,
|
||||
source: 'current-path',
|
||||
singleResolvedStream: false,
|
||||
};
|
||||
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
let cachedPath: string | null = null;
|
||||
if (options.getCachedMediaPath) {
|
||||
try {
|
||||
cachedPath = trimToNonEmptyString(await options.getCachedMediaPath(currentVideoPath, kind));
|
||||
} catch {
|
||||
cachedPath = null;
|
||||
}
|
||||
}
|
||||
if (cachedPath) {
|
||||
const result: ResolvedMediaGenerationInput = {
|
||||
path: cachedPath,
|
||||
kind,
|
||||
source: 'youtube-cache',
|
||||
singleResolvedStream: false,
|
||||
};
|
||||
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (options.remoteCacheMode === 'required') {
|
||||
logMediaGenerationInputMiss(options, kind, currentVideoPath, 'required-cache-unavailable');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!mpvClient?.requestProperty) {
|
||||
const result: ResolvedMediaGenerationInput = {
|
||||
path: currentVideoPath,
|
||||
kind,
|
||||
source: 'current-path',
|
||||
singleResolvedStream: false,
|
||||
};
|
||||
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
const streamOpenFilename = trimToNonEmptyString(
|
||||
await mpvClient.requestProperty('stream-open-filename'),
|
||||
);
|
||||
if (streamOpenFilename?.startsWith('edl://')) {
|
||||
const preferredUrl = resolvePreferredUrlFromMpvEdlSource(streamOpenFilename, kind);
|
||||
if (preferredUrl) {
|
||||
const result = await toResolvedMediaGenerationInput(
|
||||
mpvClient,
|
||||
preferredUrl,
|
||||
kind,
|
||||
'edl-stream',
|
||||
true,
|
||||
);
|
||||
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||
return result;
|
||||
}
|
||||
const result = await toResolvedMediaGenerationInput(
|
||||
mpvClient,
|
||||
streamOpenFilename,
|
||||
kind,
|
||||
'stream-open-filename',
|
||||
false,
|
||||
);
|
||||
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||
return result;
|
||||
}
|
||||
if (streamOpenFilename) {
|
||||
const result = await toResolvedMediaGenerationInput(
|
||||
mpvClient,
|
||||
streamOpenFilename,
|
||||
kind,
|
||||
'stream-open-filename',
|
||||
isRemoteMediaPath(streamOpenFilename),
|
||||
);
|
||||
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the current path when mpv does not expose a resolved stream URL.
|
||||
}
|
||||
|
||||
const result = await toResolvedMediaGenerationInput(
|
||||
mpvClient,
|
||||
currentVideoPath,
|
||||
kind,
|
||||
'current-path',
|
||||
false,
|
||||
);
|
||||
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function resolveMediaGenerationInputPath(
|
||||
mpvClient: Pick<MpvClient, 'currentVideoPath' | 'requestProperty'> | null | undefined,
|
||||
kind: MediaGenerationKind = 'video',
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function toMpvEdlValue(value: string): string {
|
||||
return `%${Buffer.byteLength(value, 'utf8')}%${value}`;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { extractFileUrlsFromMpvEdlSource } from './mpv-edl';
|
||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||
|
||||
test('extractFileUrlsFromMpvEdlSource honors length-guarded file values', () => {
|
||||
const url =
|
||||
'https://rr1---sn.example.googlevideo.com/videoplayback?mime=video%2Fmp4&mn=sn-a,sn-b&lsig=abc%3D';
|
||||
const source = `edl://!new_stream;${toMpvEdlValue(url)},title=clip,length=73`;
|
||||
|
||||
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [url]);
|
||||
});
|
||||
|
||||
test('extractFileUrlsFromMpvEdlSource reads file parameters', () => {
|
||||
const initUrl = 'https://init.example/init.mp4';
|
||||
const fileUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const source = `edl://!mp4_dash,init=${toMpvEdlValue(initUrl)};file=${toMpvEdlValue(
|
||||
fileUrl,
|
||||
)},length=42`;
|
||||
|
||||
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [fileUrl]);
|
||||
});
|
||||
|
||||
test('extractFileUrlsFromMpvEdlSource aggregates file URLs across entries', () => {
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const source = [
|
||||
'edl://!new_stream',
|
||||
toMpvEdlValue(audioUrl),
|
||||
'!new_stream',
|
||||
`file=${toMpvEdlValue(videoUrl)},length=50`,
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [audioUrl, videoUrl]);
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
const EDL_URI_PREFIX = 'edl://';
|
||||
|
||||
const BYTE_COMMA = ','.charCodeAt(0);
|
||||
const BYTE_CR = '\r'.charCodeAt(0);
|
||||
const BYTE_EQUALS = '='.charCodeAt(0);
|
||||
const BYTE_EXCLAMATION = '!'.charCodeAt(0);
|
||||
const BYTE_LF = '\n'.charCodeAt(0);
|
||||
const BYTE_PERCENT = '%'.charCodeAt(0);
|
||||
const BYTE_SEMICOLON = ';'.charCodeAt(0);
|
||||
|
||||
function isDigitByte(value: number | undefined): value is number {
|
||||
return value !== undefined && value >= 48 && value <= 57;
|
||||
}
|
||||
|
||||
function isEntrySeparator(value: number | undefined): boolean {
|
||||
return value === BYTE_SEMICOLON || value === BYTE_LF || value === BYTE_CR;
|
||||
}
|
||||
|
||||
function isParamSeparator(value: number | undefined): boolean {
|
||||
return value === BYTE_COMMA || isEntrySeparator(value);
|
||||
}
|
||||
|
||||
function decodeBytes(buffer: Buffer, start: number, end: number): string {
|
||||
return buffer.subarray(start, end).toString('utf8');
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value);
|
||||
}
|
||||
|
||||
function toEdlDataBuffer(source: string): Buffer {
|
||||
const data = source.startsWith(EDL_URI_PREFIX) ? source.slice(EDL_URI_PREFIX.length) : source;
|
||||
return Buffer.from(data, 'utf8');
|
||||
}
|
||||
|
||||
function parseLengthGuardedValue(
|
||||
buffer: Buffer,
|
||||
position: number,
|
||||
): { value: string; end: number } | null {
|
||||
if (buffer[position] !== BYTE_PERCENT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cursor = position + 1;
|
||||
if (!isDigitByte(buffer[cursor])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let byteLength = 0;
|
||||
while (true) {
|
||||
const digit = buffer[cursor];
|
||||
if (!isDigitByte(digit)) {
|
||||
break;
|
||||
}
|
||||
byteLength = byteLength * 10 + (digit - 48);
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
if (buffer[cursor] !== BYTE_PERCENT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valueStart = cursor + 1;
|
||||
const valueEnd = valueStart + byteLength;
|
||||
if (valueEnd > buffer.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
value: decodeBytes(buffer, valueStart, valueEnd),
|
||||
end: valueEnd,
|
||||
};
|
||||
}
|
||||
|
||||
function skipEntrySeparators(buffer: Buffer, position: number): number {
|
||||
let cursor = position;
|
||||
while (cursor < buffer.length && isEntrySeparator(buffer[cursor])) {
|
||||
cursor += 1;
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function skipEntry(buffer: Buffer, position: number): number {
|
||||
let cursor = position;
|
||||
while (cursor < buffer.length) {
|
||||
const guardedValue = parseLengthGuardedValue(buffer, cursor);
|
||||
if (guardedValue) {
|
||||
cursor = guardedValue.end;
|
||||
continue;
|
||||
}
|
||||
if (isEntrySeparator(buffer[cursor])) {
|
||||
break;
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function parseRawValue(buffer: Buffer, position: number): { value: string; end: number } {
|
||||
let cursor = position;
|
||||
while (
|
||||
cursor < buffer.length &&
|
||||
!isParamSeparator(buffer[cursor]) &&
|
||||
buffer[cursor] !== BYTE_EXCLAMATION
|
||||
) {
|
||||
cursor += 1;
|
||||
}
|
||||
return {
|
||||
value: decodeBytes(buffer, position, cursor),
|
||||
end: cursor,
|
||||
};
|
||||
}
|
||||
|
||||
function parseParamValue(buffer: Buffer, position: number): { value: string; end: number } {
|
||||
return parseLengthGuardedValue(buffer, position) ?? parseRawValue(buffer, position);
|
||||
}
|
||||
|
||||
function parseOptionalParamName(
|
||||
buffer: Buffer,
|
||||
position: number,
|
||||
): { name: string | null; valueStart: number } {
|
||||
let cursor = position;
|
||||
while (
|
||||
cursor < buffer.length &&
|
||||
!isParamSeparator(buffer[cursor]) &&
|
||||
buffer[cursor] !== BYTE_PERCENT &&
|
||||
buffer[cursor] !== BYTE_EXCLAMATION
|
||||
) {
|
||||
if (buffer[cursor] === BYTE_EQUALS) {
|
||||
return {
|
||||
name: decodeBytes(buffer, position, cursor),
|
||||
valueStart: cursor + 1,
|
||||
};
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
return { name: null, valueStart: position };
|
||||
}
|
||||
|
||||
function parseSegmentEntry(buffer: Buffer, position: number): { urls: string[]; end: number } {
|
||||
const urls: string[] = [];
|
||||
let cursor = position;
|
||||
let unnamedParamIndex = 0;
|
||||
|
||||
while (cursor < buffer.length && !isEntrySeparator(buffer[cursor])) {
|
||||
const { name, valueStart } = parseOptionalParamName(buffer, cursor);
|
||||
const value = parseParamValue(buffer, valueStart);
|
||||
const lowerName = name?.toLowerCase() ?? null;
|
||||
const isFileParam = lowerName === 'file' || (lowerName === null && unnamedParamIndex === 0);
|
||||
|
||||
if (isFileParam && isHttpUrl(value.value)) {
|
||||
urls.push(value.value);
|
||||
}
|
||||
|
||||
if (lowerName === null) {
|
||||
unnamedParamIndex += 1;
|
||||
}
|
||||
|
||||
cursor = value.end;
|
||||
if (buffer[cursor] === BYTE_COMMA) {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
if (!isEntrySeparator(buffer[cursor])) {
|
||||
cursor = skipEntry(buffer, cursor);
|
||||
}
|
||||
}
|
||||
|
||||
return { urls, end: cursor };
|
||||
}
|
||||
|
||||
export function extractFileUrlsFromMpvEdlSource(source: string): string[] {
|
||||
const buffer = toEdlDataBuffer(source);
|
||||
const urls: string[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < buffer.length) {
|
||||
cursor = skipEntrySeparators(buffer, cursor);
|
||||
if (cursor >= buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (buffer[cursor] === BYTE_EXCLAMATION) {
|
||||
cursor = skipEntry(buffer, cursor);
|
||||
continue;
|
||||
}
|
||||
|
||||
const segment = parseSegmentEntry(buffer, cursor);
|
||||
urls.push(...segment.urls);
|
||||
cursor = segment.end;
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { resolveMpvVolumeScale } from './mpv-volume';
|
||||
|
||||
test('resolveMpvVolumeScale converts numeric mpv volume with mpv software volume curve', async () => {
|
||||
const requested: string[] = [];
|
||||
|
||||
const scale = await resolveMpvVolumeScale(
|
||||
{
|
||||
requestProperty: async (name) => {
|
||||
requested.push(name);
|
||||
return 75;
|
||||
},
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(scale, 0.421875);
|
||||
assert.deepEqual(requested, ['volume']);
|
||||
});
|
||||
|
||||
test('resolveMpvVolumeScale skips mpv when mirroring is disabled', async () => {
|
||||
let requested = false;
|
||||
|
||||
const scale = await resolveMpvVolumeScale(
|
||||
{
|
||||
requestProperty: async () => {
|
||||
requested = true;
|
||||
return 50;
|
||||
},
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(scale, undefined);
|
||||
assert.equal(requested, false);
|
||||
});
|
||||
|
||||
test('resolveMpvVolumeScale falls back to unity for missing, failed, or invalid values', async () => {
|
||||
assert.equal(await resolveMpvVolumeScale({}, true), 1);
|
||||
assert.equal(
|
||||
await resolveMpvVolumeScale(
|
||||
{
|
||||
requestProperty: async () => {
|
||||
throw new Error('disconnected');
|
||||
},
|
||||
},
|
||||
true,
|
||||
),
|
||||
1,
|
||||
);
|
||||
assert.equal(await resolveMpvVolumeScale({ requestProperty: async () => '50' }, true), 1);
|
||||
assert.equal(await resolveMpvVolumeScale({ requestProperty: async () => Number.NaN }, true), 1);
|
||||
assert.equal(await resolveMpvVolumeScale({ requestProperty: async () => -1 }, true), 1);
|
||||
});
|
||||