Compare commits

...

16 Commits

Author SHA1 Message Date
sudacode 4b10e85053 chore(release): prepare v0.17.2 2026-06-28 23:03:18 -07:00
sudacode c942a2cf2d fFix(youtube): recover source URL for background media cache on direct mpv open (#132) 2026-06-28 22:46:11 -07:00
sudacode f65afa6046 feat(youtube): notify on manual picker open and show success after track load (#133) 2026-06-28 22:43:16 -07:00
sudacode 389d8e06e0 chore: remove stale npm artifacts 2026-06-28 20:18:08 -07:00
sudacode 0008b55b70 chore(release): prepare v0.17.1 2026-06-27 22:59:28 -07:00
sudacode d16ae9c745 chore(release): prepare v0.17.1-beta.1 2026-06-27 22:10:02 -07:00
sudacode 36f94151b8 fix(logs): use local date for log filenames and expand export redaction (#131) 2026-06-27 21:08:11 -07:00
sudacode 5326ad32f5 feat(youtube): add mediaCache mode and safer stream media extraction (#130) 2026-06-27 19:20:18 -07:00
sudacode d199376364 chore(release): prepare v0.17.0 2026-06-15 01:58:42 -07:00
sudacode 3aee88c150 chore(release): prepare v0.17.0-beta.2 2026-06-14 20:58:26 -07:00
sudacode 70da3ee8bd Fix Windows Anki startup and overlay regressions (#128) 2026-06-14 20:51:56 -07:00
sudacode aa8eb753f6 fix: scope prerelease note reuse by version 2026-06-14 18:04:03 -07:00
sudacode 8d73de8731 docs: scope 0.17.0 beta prerelease notes 2026-06-14 17:50:09 -07:00
sudacode 5a98397efe chore: prepare 0.17.0-beta.1 prerelease 2026-06-14 17:34:05 -07:00
sudacode a117c5759c fix(linux): auto-install managed plugin copy; include in asset updates (#127) 2026-06-14 17:25:28 -07:00
sudacode ae7e6f82a8 fix(overlay): restore macOS Yomitan popup focus without breaking click-away (#125) 2026-06-14 16:46:13 -07:00
139 changed files with 10436 additions and 4651 deletions
+3
View File
@@ -1,5 +1,6 @@
# Dependencies # Dependencies
node_modules/ node_modules/
/package-lock.json
# Superpowers brainstorming # Superpowers brainstorming
.superpowers/ .superpowers/
@@ -16,6 +17,8 @@ coverage/
# Launcher build artifact (produced by make build-launcher) # Launcher build artifact (produced by make build-launcher)
/subminer /subminer
/main-entry.js
/main-entry.js.map
# Logs # Logs
*.log *.log
+50
View File
@@ -1,5 +1,55 @@
# Changelog # Changelog
## v0.17.2 (2026-06-28)
### Fixed
- YouTube Background Cache: Fixed Windows YouTube background media cache startup for YouTube URLs opened directly in mpv, including resolved stream URLs when mpv still exposes the original YouTube playlist entry, so queued Anki media updates can append audio and images after the cache finishes.
- YouTube Subtitle Picker: Manual subtitle picker requests now show an immediate configured notification while SubMiner probes tracks and opens the modal. Subtitle download progress is replaced with a transient success notification after tracks load.
## v0.17.1 (2026-06-27)
### Added
- YouTube Media Cache Mode: Adds `youtube.mediaCache.mode` with `direct` and `background` options. Background mode uses a yt-dlp cache download when direct stream extraction is unreliable — creates a text-only card immediately, queues media updates for mined notes, and fills audio/image fields once the download finishes. Progress is announced via overlay/OSD notifications. Downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`). Switching back to direct mode cancels any in-flight background download.
### Fixed
- Log Export: Fixed log filenames to use the local date so exports around UTC midnight include the current day's logs rather than stale prior-day files. Expanded export redaction to mask IPs, emails, auth and cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL parameters.
- YouTube Card Media: Improved media generation reliability by sending safer ffmpeg options for resolved streams and skipping stale stream maps (including cached YouTube files). Hardened background cache downloads with IPv4 and extractor retry flags; failed downloads now notify the user and clear queued media updates instead of leaving them silently pending. Stale background cache files are cleaned on startup and before each new download.
## v0.17.0 (2026-06-15)
### 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) ## v0.16.0 (2026-06-10)
### Breaking Changes ### Breaking Changes
@@ -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,4 +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.
-5
View File
@@ -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.
-4
View File
@@ -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.
+5 -1
View File
@@ -618,7 +618,11 @@
"primarySubLanguages": [ "primarySubLanguages": [
"ja", "ja",
"jpn" "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. }, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
// ========================================== // ==========================================
+58 -3
View File
@@ -1,6 +1,61 @@
# Changelog # Changelog
## v0.16.0 (2026-06-10) ## 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>
## Previous Versions
<details>
<summary>v0.16.x</summary>
<h2>v0.16.0 (2026-06-10)</h2>
**Breaking Changes** **Breaking Changes**
@@ -24,7 +79,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. - **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. - **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. - **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** **Fixed**
@@ -48,7 +103,7 @@
</details> </details>
## Previous Versions </details>
<details> <details>
<summary>v0.15.x</summary> <summary>v0.15.x</summary>
+17 -4
View File
@@ -188,6 +188,9 @@ Control the minimum log level for runtime output:
| `files.launcher` | boolean | Write launcher command logs (default: `true`) | | `files.launcher` | boolean | Write launcher command logs (default: `true`) |
| `files.mpv` | boolean | Write mpv player logs. Enable temporarily for mpv/plugin debugging. | | `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 ### Updates
Configure automatic update checks and update notifications: Configure automatic update checks and update notifications:
@@ -1520,14 +1523,24 @@ Set defaults used by managed subtitle auto-selection and the `subminer` launcher
```json ```json
{ {
"youtube": { "youtube": {
"primarySubLanguages": ["ja", "jpn"] "primarySubLanguages": ["ja", "jpn"],
"mediaCache": {
"mode": "direct",
"maxHeight": 720
}
} }
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| --------------------- | -------- | ------------------------------------------------------------------------------------------------ | | ---------------------- | ------------------------ | ------------------------------------------------------------------------------------------------ |
| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) | | `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: Current launcher behavior:
+25 -20
View File
@@ -12,20 +12,20 @@ Three steps to get started:
Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional. Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional.
| Dependency | Status | What it does | | Dependency | Status | What it does |
| -------------------- | ----------- | --------------------------------------------------------------------------------------------------------------- | | -------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| mpv | Required | The video player SubMiner overlays on. Must support `--input-ipc-server`. | | mpv | Required | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
| ffmpeg | Recommended | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. | | ffmpeg | Recommended | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
| MeCab + mecab-ipadic | Recommended | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. | | MeCab + mecab-ipadic | Recommended | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. |
| yt-dlp | Optional | YouTube playback and subtitle extraction. | | yt-dlp | Optional | YouTube playback and subtitle extraction. |
| fzf | Optional | Terminal-based video picker in the launcher. | | fzf | Optional | Terminal-based video picker in the launcher. |
| rofi | Optional | GUI-based video picker (Linux). | | rofi | Optional | GUI-based video picker (Linux). |
| chafa | Optional | Thumbnail previews in fzf. | | chafa | Optional | Thumbnail previews in fzf. |
| ffmpegthumbnailer | Optional | Video thumbnail generation for the picker. | | ffmpegthumbnailer | Optional | Video thumbnail generation for the picker. |
| guessit | Optional | Better AniSkip title/season/episode parsing. | | guessit | Optional | Better AniSkip title/season/episode parsing. |
| alass | Optional | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. | | alass | Optional | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. |
| ffsubsync | Optional | Audio-based subtitle sync engine. Disabled without alass or ffsubsync. | | ffsubsync | Optional | Audio-based subtitle sync engine. Disabled without alass or ffsubsync. |
| fuse2 | Linux only | Required to run the AppImage. | | fuse2 | Linux only | Required to run the AppImage. |
### Linux ### Linux
@@ -300,11 +300,11 @@ subminer -u
subminer --update subminer --update
``` ```
SubMiner verifies AppImage, launcher, and rofi theme downloads against `SHA256SUMS.txt`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself. SubMiner verifies AppImage, launcher, and Linux support-asset downloads against `SHA256SUMS.txt`. On Linux those support assets include the launcher-managed runtime plugin copy under `SubMiner/plugin/subminer` plus the rofi theme at `SubMiner/themes/subminer.rasi`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself.
The tray "Check for Updates" entry installs the new app automatically on Linux, macOS, and Windows. On Linux it replaces the running `.AppImage` in place via `electron-updater`; AppImages managed by a system package (for example the AUR `/opt/SubMiner/SubMiner.AppImage`) are skipped so the package manager stays in charge. The tray "Check for Updates" entry installs the new app automatically on Linux, macOS, and Windows. On Linux it replaces the running `.AppImage` in place via `electron-updater` and refreshes the managed support assets from `subminer-assets.tar.gz`; AppImages managed by a system package (for example the AUR `/opt/SubMiner/SubMiner.AppImage`) are skipped so the package manager stays in charge.
`subminer -u` also performs the AppImage update directly from the launcher process, which is useful when SubMiner is not currently running. `subminer -u` also performs the AppImage, launcher, and managed support-asset updates directly from the launcher process, which is useful when SubMiner is not currently running.
## How It All Fits Together ## How It All Fits Together
@@ -312,13 +312,14 @@ SubMiner is an overlay that sits on top of mpv. It connects to mpv through an IP
The `subminer` launcher handles mpv IPC socket setup automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or `\\.\pipe\subminer-socket` on Windows) - without it the overlay starts but subtitles won't appear. The `subminer` launcher handles mpv IPC socket setup automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or `\\.\pipe\subminer-socket` on Windows) - without it the overlay starts but subtitles won't appear.
The bundled mpv plugin is injected at runtime automatically - you don't need to install it separately. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference. The bundled mpv plugin is injected at runtime automatically - you don't need to install it separately. On Linux, the `subminer` launcher now checks for its managed runtime plugin copy and rofi theme before every mpv-managed launch and installs those support assets from the bundled app automatically if either one is missing. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference.
## Platform Notes ## Platform Notes
### macOS ### macOS
**MeCab paths (Homebrew):** **MeCab paths (Homebrew):**
- Apple Silicon (M1/M2): `/opt/homebrew/bin/mecab` - Apple Silicon (M1/M2): `/opt/homebrew/bin/mecab`
- Intel: `/usr/local/bin/mecab` - Intel: `/usr/local/bin/mecab`
@@ -361,17 +362,21 @@ sudo chmod +x /usr/local/bin/subminer
## Optional Extras ## Optional Extras
### Rofi Theme (Linux Only) ### Linux Support Assets
SubMiner ships a custom rofi theme in the release assets: SubMiner ships the Linux rofi theme plus the launcher-managed runtime plugin copy in `subminer-assets.tar.gz`:
```bash ```bash
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
mkdir -p ~/.local/share/SubMiner/themes mkdir -p ~/.local/share/SubMiner/themes
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
mkdir -p ~/.local/share/SubMiner/plugin
cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer
``` ```
Override with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`. `subminer -u` and the tray updater keep those Linux support assets in sync automatically once the `SubMiner` data dir exists. Normal Linux launcher playback also auto-installs the managed runtime plugin copy and rofi theme from the bundled app if either support asset is missing, so manual extraction is mainly useful for pre-seeding or custom setups.
Override the theme path with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`.
Next: [Usage](/usage) - learn about the `subminer` wrapper, keybindings, and YouTube playback. Next: [Usage](/usage) - learn about the `subminer` wrapper, keybindings, and YouTube playback.
+7 -3
View File
@@ -34,15 +34,19 @@ subminer -R -r -d ~/Anime # rofi picker, recursive
subminer -R /directory # rofi picker, directory shortcut subminer -R /directory # rofi picker, directory shortcut
``` ```
rofi shows a GUI menu with icon thumbnails when available. SubMiner ships a custom rofi theme bundled in the release assets tarball: rofi shows a GUI menu with icon thumbnails when available. SubMiner ships the rofi theme plus the Linux launcher-managed runtime plugin copy in the release assets tarball:
```bash ```bash
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
mkdir -p ~/.local/share/SubMiner/themes mkdir -p ~/.local/share/SubMiner/themes
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
mkdir -p ~/.local/share/SubMiner/plugin
cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer
``` ```
Once the `SubMiner` data dir exists, `subminer -u` refreshes both assets automatically. Normal Linux launcher playback also checks for the managed runtime plugin copy and rofi theme before mpv launch and installs them from the bundled app automatically if either one is missing.
The theme is auto-detected from these paths (first match wins): The theme is auto-detected from these paths (first match wins):
- `$SUBMINER_ROFI_THEME` environment variable (absolute path) - `$SUBMINER_ROFI_THEME` environment variable (absolute path)
@@ -79,7 +83,7 @@ subminer stats -b # start background stats daemon
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows | | `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows |
| `subminer doctor` | Dependency + config + socket diagnostics | | `subminer doctor` | Dependency + config + socket diagnostics |
| `subminer settings` | Open the SubMiner settings window | | `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 path` | Print active config file path |
| `subminer config show` | Print active config contents | | `subminer config show` | Print active config contents |
| `subminer mpv status` | Check mpv socket readiness | | `subminer mpv status` | Check mpv socket readiness |
@@ -113,7 +117,7 @@ Use `subminer <subcommand> -h` for command-specific help.
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) | | `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) | | `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
On Linux, `subminer -u` updates from the launcher process itself. It can check and replace the AppImage, launcher, and rofi theme even when SubMiner is already running in the tray. On Linux, `subminer -u` updates from the launcher process itself. It can check and replace the AppImage, launcher, runtime plugin copy, and rofi theme even when SubMiner is already running in the tray.
With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary. With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary.
+2
View File
@@ -10,6 +10,8 @@ The plugin ships as a modular Lua package under `plugin/subminer/` (entry point
Launch mpv through the SubMiner app, the `subminer` launcher, or the packaged Windows SubMiner mpv shortcut. These paths pass mpv a bundled plugin path for that playback session only, leaving regular mpv playback untouched. Launch mpv through the SubMiner app, the `subminer` launcher, or the packaged Windows SubMiner mpv shortcut. These paths pass mpv a bundled plugin path for that playback session only, leaving regular mpv playback untouched.
On Linux, the launcher-managed runtime plugin copy lives under the SubMiner data dir (`$XDG_DATA_HOME/SubMiner/plugin/subminer` by default, plus `/usr/local/share/SubMiner` or `/usr/share/SubMiner` for system installs). `subminer -u` and the tray updater keep that managed copy current. This is separate from mpv's global `scripts/` directory.
If setup detects an older global SubMiner plugin in mpv's `scripts` directory, use **Remove legacy mpv plugin** in first-run setup. The global plugin is not needed once runtime loading is available. If setup detects an older global SubMiner plugin in mpv's `scripts` directory, use **Remove legacy mpv plugin** in first-run setup. The global plugin is not needed once runtime loading is available.
mpv must have IPC enabled for SubMiner to connect: mpv must have IPC enabled for SubMiner to connect:
+5 -1
View File
@@ -618,7 +618,11 @@
"primarySubLanguages": [ "primarySubLanguages": [
"ja", "ja",
"jpn" "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. }, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
// ========================================== // ==========================================
+3 -3
View File
@@ -54,7 +54,7 @@ From there, subtitles render as interactive, hoverable word spans and you mine c
| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` | | **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` |
| **mpv plugin** (all platforms) | Bundled and injected at runtime. Provides `y` chord keybindings for controlling the overlay from within mpv. No manual install needed. | Automatic when using the launcher or shortcut | | **mpv plugin** (all platforms) | Bundled and injected at runtime. Provides `y` chord keybindings for controlling the overlay from within mpv. No manual install needed. | Automatic when using the launcher or shortcut |
The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect. The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. On Linux, normal `subminer` playback auto-installs the launcher-managed runtime plugin copy from the bundled app if that managed copy is missing, so no separate plugin install is needed for standard launcher usage. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
## Live Config Reload ## Live Config Reload
@@ -148,7 +148,7 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin corre
SubMiner.AppImage --help # Show all options 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. 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 jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
- `subminer doctor`: health checks for core dependencies and runtime paths. - `subminer doctor`: health checks for core dependencies and runtime paths.
- `subminer settings`: open the SubMiner settings window (also `subminer --settings`). - `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 config`: config file helpers (`path`, `show`).
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`). - `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target. - `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
+26 -15
View File
@@ -74,20 +74,30 @@ Press **Ctrl+Alt+C** during YouTube playback to open the subtitle picker overlay
- Select different primary and secondary tracks - Select different primary and secondary tracks
- Retry track loading if the auto-load failed or picked the wrong track - Retry track loading if the auto-load failed or picked the wrong track
SubMiner shows an "Opening YouTube subtitle picker..." status through your configured notification
surface while it probes tracks and prepares the modal, then updates the subtitle download progress
card to a success notification after the selected tracks load.
The picker displays each track with its language, kind (manual/auto), and title when available. The picker displays each track with its language, kind (manual/auto), and title when available.
## Subtitle Format Handling ## Subtitle Format Handling
SubMiner handles several YouTube subtitle formats transparently: SubMiner handles several YouTube subtitle formats transparently:
| Format | Handling | | Format | Handling |
| ------ | -------- | | ---------------------- | -------------------------------------------------------- |
| `srt`, `vtt` | Used directly (preferred for manual tracks) | | `srt`, `vtt` | Used directly (preferred for manual tracks) |
| `srv1`, `srv2`, `srv3` | YouTube TimedText XML --- converted to VTT automatically | | `srv1`, `srv2`, `srv3` | YouTube TimedText XML --- converted to VTT automatically |
| Auto-generated VTT | Normalized to remove rolling-caption text duplication | | Auto-generated VTT | Normalized to remove rolling-caption text duplication |
For auto-generated tracks, SubMiner prefers `srv3` > `srv2` > `srv1` > `vtt` (TimedText XML produces cleaner output). For manual tracks, `srt` > `vtt` is preferred. For auto-generated tracks, SubMiner prefers `srv3` > `srv2` > `srv1` > `vtt` (TimedText XML produces cleaner output). For manual tracks, `srt` > `vtt` is preferred.
## Card Media Cache
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 ## Configuration Reference
### Primary Subtitle Languages ### Primary Subtitle Languages
@@ -95,13 +105,13 @@ For auto-generated tracks, SubMiner prefers `srv3` > `srv2` > `srv1` > `vtt` (Ti
```jsonc ```jsonc
{ {
"youtube": { "youtube": {
"primarySubLanguages": ["ja", "jpn"] "primarySubLanguages": ["ja", "jpn"],
} },
} }
``` ```
| Option | Type | Description | | Option | Type | Description |
| ------ | ---- | ----------- | | --------------------- | ---------- | ------------------------------------------------------------------------------------- |
| `primarySubLanguages` | `string[]` | Language priority for YouTube primary subtitle auto-loading (default `["ja", "jpn"]`) | | `primarySubLanguages` | `string[]` | Language priority for YouTube primary subtitle auto-loading (default `["ja", "jpn"]`) |
### Secondary Subtitle Languages ### Secondary Subtitle Languages
@@ -113,16 +123,16 @@ Secondary track selection uses the shared `secondarySub` config:
"secondarySub": { "secondarySub": {
"secondarySubLanguages": [], "secondarySubLanguages": [],
"autoLoadSecondarySub": false, "autoLoadSecondarySub": false,
"defaultMode": "hover" "defaultMode": "hover",
} },
} }
``` ```
| Option | Type | Description | | Option | Type | Description |
| ------ | ---- | ----------- | | ----------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track. Default is empty (`[]`). For YouTube, SubMiner always tries an English track first regardless of this list. | | `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track. Default is empty (`[]`). For YouTube, SubMiner always tries an English track first regardless of this list. |
| `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary track (default: `false`) | | `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary track (default: `false`) |
| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) | | `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
Precedence: CLI flag > environment variable > `config.jsonc` > built-in default. Precedence: CLI flag > environment variable > `config.jsonc` > built-in default.
@@ -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. - **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. - **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`. - **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. - **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. - **`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. - **Secondary subtitle fails**: Secondary track failures never block playback. The primary subtitle loads independently.
+4 -2
View File
@@ -61,8 +61,10 @@
committed file — so review it before committing. If you add more committed file — so review it before committing. If you add more
`changes/*.md` fragments for a later beta/RC, rerun `changes/*.md` fragments for a later beta/RC, rerun
`bun run changelog:prerelease-notes --version <version>`; the generator uses `bun run changelog:prerelease-notes --version <version>`; the generator uses
the existing prerelease notes as the baseline and asks Claude to merge only the existing prerelease notes as the baseline only when their hidden
the new fragment material. Do not run `bun run changelog:build`. `prerelease-base-version` marker matches the current base version, and asks
Claude to merge only the new fragment material. Do not run
`bun run changelog:build`.
6. Tag the commit: `git tag v<version>`. 6. Tag the commit: `git tag v<version>`.
7. Push commit + tag. 7. Push commit + tag.
+20 -1
View File
@@ -3,7 +3,7 @@
# Subtitle Overlay Priming # Subtitle Overlay Priming
Status: active Status: active
Last verified: 2026-06-01 Last verified: 2026-06-14
Owner: Kyle Yasuda Owner: Kyle Yasuda
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
@@ -79,9 +79,19 @@ prefetch work and re-centers prefetch around the live playback time.
window so readiness can still arrive before fallback resumes playback. window so readiness can still arrive before fallback resumes playback.
- If mpv is already on a subtitle, SubMiner still prefers the resolved current subtitle payload and - If mpv is already on a subtitle, SubMiner still prefers the resolved current subtitle payload and
waits for a fresh measured subtitle rectangle before signaling readiness. waits for a fresh measured subtitle rectangle before signaling readiness.
- If the startup subtitle has no cached annotations yet, autoplay priming emits a raw first-paint
subtitle payload before background tokenization. The tokenized payload replaces it when ready, but
the visible overlay can paint and measure the line before the mpv startup gate resumes playback.
- If startup `sub-text` is temporarily empty, autoplay priming refreshes the active subtitle source
and then awaits cue-based priming before synthetic warm readiness can proceed. A parsed current or
imminent cue is treated as the startup subtitle so the visible overlay can paint and measure it
before playback resumes.
- If mpv is before the first subtitle, SubMiner sends a synthetic warm readiness payload after - If mpv is before the first subtitle, SubMiner sends a synthetic warm readiness payload after
tokenization warmup and visible overlay content-ready. This releases playback without waiting for tokenization warmup and visible overlay content-ready. This releases playback without waiting for
a later subtitle event that cannot happen while mpv is paused. a later subtitle event that cannot happen while mpv is paused.
- After a synthetic warm readiness release, SubMiner briefly polls/refreshes the current subtitle
again. This covers Linux/mpv startup cases where `sub-text` is still empty while paused but becomes
available right after playback resumes, without waiting for the next subtitle property change.
## Linux/X11 Window Shape ## Linux/X11 Window Shape
@@ -95,6 +105,15 @@ prefetch work and re-centers prefetch around the live playback time.
overlay window remained mapped above mpv. overlay window remained mapped above mpv.
- Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and - Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and
the Linux cursor-poll fallback, not bounding-shape clipping. the Linux cursor-poll fallback, not bounding-shape clipping.
- Visible-overlay show/reset marks Linux pointer passthrough state dirty even when the logical
interaction state is already inactive. The next cursor-poll tick must still reapply
`setIgnoreMouseEvents(true, { forward: true })`; otherwise a newly shown Electron overlay can keep
full-window input capture and block both mpv and overlay controls before the first subtitle
measurement.
- Visible-overlay show also starts a short Linux input grace before the first content measurement.
Native Wayland surfaces can become inert while `setIgnoreMouseEvents(true)` is active; keeping the
overlay interactive during this startup gap lets notifications and overlay mouse bindings work
until subtitle/sidebar/notification rectangles are reported.
## Config And Migration ## Config And Migration
+30 -2
View File
@@ -7,7 +7,7 @@ import { runConfigCommand } from './config-command.js';
import { runDictionaryCommand } from './dictionary-command.js'; import { runDictionaryCommand } from './dictionary-command.js';
import { runDoctorCommand } from './doctor-command.js'; import { runDoctorCommand } from './doctor-command.js';
import { runLogsCommand } from './logs-command.js'; import { runLogsCommand } from './logs-command.js';
import { runMpvPreAppCommand } from './mpv-command.js'; import { runMpvPostAppCommand, runMpvPreAppCommand } from './mpv-command.js';
import { runAppPassthroughCommand } from './app-command.js'; import { runAppPassthroughCommand } from './app-command.js';
import { runStatsCommand } from './stats-command.js'; import { runStatsCommand } from './stats-command.js';
import { runUpdateCommand } from './update-command.js'; import { runUpdateCommand } from './update-command.js';
@@ -262,7 +262,9 @@ test('mpv pre-app command exits non-zero when socket is not ready', async () =>
await assert.rejects( await assert.rejects(
async () => { async () => {
await runMpvPreAppCommand(context, { await runMpvPreAppCommand(context, {
ensureRuntimePluginReady: async () => {},
waitForUnixSocketReady: async () => false, waitForUnixSocketReady: async () => false,
resolveRuntimePluginPath: () => null,
launchMpvIdleDetached: async () => {}, launchMpvIdleDetached: async () => {},
}); });
}, },
@@ -270,6 +272,32 @@ test('mpv pre-app command exits non-zero when socket is not ready', async () =>
); );
}); });
test('mpv idle command ensures Linux runtime plugin before detached launch', async () => {
const context = createContext();
context.args.mpvIdle = true;
const calls: string[] = [];
const handled = await runMpvPostAppCommand(context, {
ensureRuntimePluginReady: async () => {
calls.push('plugin');
},
waitForUnixSocketReady: async () => {
calls.push('wait');
return true;
},
launchMpvIdleDetached: async () => {
calls.push('launch');
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return '/tmp/plugin/main.lua';
},
});
assert.equal(handled, true);
assert.deepEqual(calls, ['plugin', 'resolve', 'launch', 'wait']);
});
test('dictionary command forwards --dictionary and target path to app binary', () => { test('dictionary command forwards --dictionary and target path to app binary', () => {
const context = createContext(); const context = createContext();
context.args.dictionary = true; context.args.dictionary = true;
@@ -361,7 +389,7 @@ test('update command runs direct Linux release update without launching Electron
'direct:/tmp/subminer.app:/tmp/subminer:stable', 'direct:/tmp/subminer.app:/tmp/subminer:stable',
'info:AppImage update: not-found', 'info:AppImage update: not-found',
'info:Launcher update: updated', 'info:Launcher update: updated',
'info:Rofi theme update: skipped', 'info:Support assets update: skipped',
]); ]);
}); });
+17 -1
View File
@@ -5,9 +5,12 @@ import {
resolveLauncherRuntimePluginPath, resolveLauncherRuntimePluginPath,
} from '../mpv.js'; } from '../mpv.js';
import type { LauncherCommandContext } from './context.js'; import type { LauncherCommandContext } from './context.js';
import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js';
interface MpvCommandDeps { interface MpvCommandDeps {
ensureRuntimePluginReady(context: LauncherCommandContext): Promise<void>;
waitForUnixSocketReady(socketPath: string, timeoutMs: number): Promise<boolean>; waitForUnixSocketReady(socketPath: string, timeoutMs: number): Promise<boolean>;
resolveRuntimePluginPath(context: LauncherCommandContext): string | null;
launchMpvIdleDetached( launchMpvIdleDetached(
socketPath: string, socketPath: string,
appPath: string, appPath: string,
@@ -18,7 +21,19 @@ interface MpvCommandDeps {
} }
const defaultDeps: MpvCommandDeps = { const defaultDeps: MpvCommandDeps = {
ensureRuntimePluginReady: async (context) => {
await ensureLinuxRuntimePluginAvailable({
appPath: context.appPath ?? undefined,
scriptPath: context.scriptPath,
logLevel: context.args.logLevel,
});
},
waitForUnixSocketReady, waitForUnixSocketReady,
resolveRuntimePluginPath: (context) =>
resolveLauncherRuntimePluginPath({
appPath: context.appPath ?? '',
scriptPath: context.scriptPath,
}),
launchMpvIdleDetached, launchMpvIdleDetached,
}; };
@@ -58,11 +73,12 @@ export async function runMpvPostAppCommand(
fail('SubMiner app binary not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.'); fail('SubMiner app binary not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
} }
await deps.ensureRuntimePluginReady(context);
await deps.launchMpvIdleDetached( await deps.launchMpvIdleDetached(
mpvSocketPath, mpvSocketPath,
appPath, appPath,
args, args,
resolveLauncherRuntimePluginPath({ appPath, scriptPath }), deps.resolveRuntimePluginPath(context),
{ {
...pluginRuntimeConfig, ...pluginRuntimeConfig,
backend: args.backend, backend: args.backend,
@@ -112,6 +112,7 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
await runPlaybackCommandWithDeps(context, { await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {}, ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }), chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }),
checkDependencies: () => {}, checkDependencies: () => {},
registerCleanup: () => {}, registerCleanup: () => {},
@@ -161,6 +162,7 @@ test('youtube app-owned playback disables mpv plugin auto-start', async () => {
await runPlaybackCommandWithDeps(context, { await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {}, ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'url' }), chooseTarget: async () => ({ target: context.args.target, kind: 'url' }),
checkDependencies: () => {}, checkDependencies: () => {},
registerCleanup: () => {}, registerCleanup: () => {},
@@ -227,6 +229,7 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
try { try {
await runPlaybackCommandWithDeps(context, { await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {}, ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }), chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {}, checkDependencies: () => {},
registerCleanup: () => {}, registerCleanup: () => {},
@@ -278,6 +281,7 @@ test('plugin auto-start playback attaches a warm background app through the laun
await runPlaybackCommandWithDeps(context, { await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {}, ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }), chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {}, checkDependencies: () => {},
registerCleanup: () => {}, registerCleanup: () => {},
@@ -351,6 +355,7 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
await runPlaybackCommandWithDeps(context, { await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {}, ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }), chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {}, checkDependencies: () => {},
registerCleanup: () => {}, registerCleanup: () => {},
@@ -420,6 +425,7 @@ test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is
await runPlaybackCommandWithDeps(context, { await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {}, ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }), chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {}, checkDependencies: () => {},
registerCleanup: () => {}, registerCleanup: () => {},
@@ -441,3 +447,34 @@ test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay']); assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay']);
}); });
test('playback command ensures Linux runtime plugin before mpv launch', async () => {
const context = createContext();
context.args = {
...context.args,
target: '/tmp/movie.mkv',
targetKind: 'file',
};
const calls: string[] = [];
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {
calls.push('plugin');
},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async () => {
calls.push('startMpv');
},
waitForUnixSocketReady: async () => true,
startOverlay: async () => {},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
});
assert.deepEqual(calls, ['plugin', 'startMpv']);
});
+11
View File
@@ -19,6 +19,7 @@ import type { Args } from '../types.js';
import { nowMs } from '../time.js'; import { nowMs } from '../time.js';
import type { LauncherCommandContext } from './context.js'; import type { LauncherCommandContext } from './context.js';
import { ensureLauncherSetupReady } from '../setup-gate.js'; import { ensureLauncherSetupReady } from '../setup-gate.js';
import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js';
import { import {
getDefaultConfigDir, getDefaultConfigDir,
getSetupStatePath, getSetupStatePath,
@@ -144,6 +145,13 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> { export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
return runPlaybackCommandWithDeps(context, { return runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady, ensurePlaybackSetupReady,
ensureRuntimePluginReady: async (commandContext) => {
await ensureLinuxRuntimePluginAvailable({
appPath: commandContext.appPath ?? undefined,
scriptPath: commandContext.scriptPath,
logLevel: commandContext.args.logLevel,
});
},
chooseTarget, chooseTarget,
checkDependencies, checkDependencies,
registerCleanup, registerCleanup,
@@ -160,6 +168,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
type PlaybackCommandDeps = { type PlaybackCommandDeps = {
ensurePlaybackSetupReady: (context: LauncherCommandContext) => Promise<void>; ensurePlaybackSetupReady: (context: LauncherCommandContext) => Promise<void>;
ensureRuntimePluginReady: (context: LauncherCommandContext) => Promise<void>;
chooseTarget: ( chooseTarget: (
args: Args, args: Args,
scriptPath: string, scriptPath: string,
@@ -253,6 +262,8 @@ export async function runPlaybackCommandWithDeps(
); );
} }
await deps.ensureRuntimePluginReady(context);
await deps.startMpv( await deps.startMpv(
selectedTarget.target, selectedTarget.target,
selectedTarget.kind, selectedTarget.kind,
+7 -3
View File
@@ -34,7 +34,10 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as
return { return {
appImage: { status: 'updated' }, appImage: { status: 'updated' },
launcher: { status: 'updated' }, launcher: { status: 'updated' },
supportAssets: [{ status: 'skipped' }], supportAssets: [
{ status: 'updated', component: 'theme', message: 'Installed theme.' },
{ status: 'skipped', component: 'plugin', message: 'Plugin already up to date.' },
],
}; };
}, },
readMainConfig: () => ({ updates: { channel: 'prerelease' } }), readMainConfig: () => ({ updates: { channel: 'prerelease' } }),
@@ -48,7 +51,8 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as
'direct:/home/kyle/.local/bin/SubMiner.AppImage:/home/kyle/.local/bin/subminer:prerelease', 'direct:/home/kyle/.local/bin/SubMiner.AppImage:/home/kyle/.local/bin/subminer:prerelease',
'info:AppImage update: updated', 'info:AppImage update: updated',
'info:Launcher update: updated', 'info:Launcher update: updated',
'info:Rofi theme update: skipped', 'info:Support assets (theme) update: updated - Installed theme.',
'info:Support assets (plugin) update: skipped - Plugin already up to date.',
]); ]);
}); });
@@ -101,7 +105,7 @@ test('runUpdateCommand skips Linux asset replacement when release is not newer',
'fetch:https://api.github.com/repos/ksyasuda/SubMiner/releases', 'fetch:https://api.github.com/repos/ksyasuda/SubMiner/releases',
'info:AppImage update: up to date', 'info:AppImage update: up to date',
'info:Launcher update: up to date', 'info:Launcher update: up to date',
'info:Rofi theme update: up to date', 'info:Support assets update: up to date',
]); ]);
} finally { } finally {
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
+21 -7
View File
@@ -39,7 +39,12 @@ type DirectReleaseUpdateRequest = {
type DirectReleaseUpdateResult = { type DirectReleaseUpdateResult = {
appImage: { status: string; command?: string; message?: string }; appImage: { status: string; command?: string; message?: string };
launcher: { status: string; command?: string; message?: string }; launcher: { status: string; command?: string; message?: string };
supportAssets: Array<{ status: string; command?: string; message?: string }>; supportAssets: Array<{
status: string;
component?: 'theme' | 'plugin';
command?: string;
message?: string;
}>;
}; };
type UpdateCommandDeps = { type UpdateCommandDeps = {
@@ -124,20 +129,29 @@ function readUpdateChannel(root: Record<string, unknown> | null): UpdateChannel
function logUpdateResult( function logUpdateResult(
label: string, label: string,
result: { status: string; command?: string; message?: string }, result: {
status: string;
component?: 'theme' | 'plugin';
command?: string;
message?: string;
},
configuredLogLevel: NonNullable<LauncherCommandContext['args']['logLevel']>, configuredLogLevel: NonNullable<LauncherCommandContext['args']['logLevel']>,
deps: Pick<UpdateCommandDeps, 'log'>, deps: Pick<UpdateCommandDeps, 'log'>,
): void { ): void {
const displayStatus = result.status === 'up-to-date' ? 'up to date' : result.status; const displayStatus = result.status === 'up-to-date' ? 'up to date' : result.status;
deps.log('info', configuredLogLevel, `${label} update: ${displayStatus}`); const componentLabel = result.component ? ` (${result.component})` : '';
const detailSuffix = result.message ? ` - ${result.message}` : '';
deps.log(
'info',
configuredLogLevel,
`${label}${componentLabel} update: ${displayStatus}${detailSuffix}`,
);
if (result.command) { if (result.command) {
deps.log( deps.log(
'warn', 'warn',
configuredLogLevel, configuredLogLevel,
`${label} update requires manual command: ${result.command}`, `${label}${componentLabel} update requires manual command: ${result.command}`,
); );
} else if (result.message) {
deps.log('warn', configuredLogLevel, `${label} update note: ${result.message}`);
} }
} }
@@ -187,7 +201,7 @@ export async function runUpdateCommand(
logUpdateResult('AppImage', result.appImage, logLevel, resolvedDeps); logUpdateResult('AppImage', result.appImage, logLevel, resolvedDeps);
logUpdateResult('Launcher', result.launcher, logLevel, resolvedDeps); logUpdateResult('Launcher', result.launcher, logLevel, resolvedDeps);
for (const supportResult of result.supportAssets) { for (const supportResult of result.supportAssets) {
logUpdateResult('Rofi theme', supportResult, logLevel, resolvedDeps); logUpdateResult('Support assets', supportResult, logLevel, resolvedDeps);
} }
return true; return true;
} }
+134
View File
@@ -0,0 +1,134 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { Args } from './types.js';
import { runJellyfinPlayMenuWithDeps } from './jellyfin.js';
function createArgs(): Args {
return {
backend: 'auto',
directory: '.',
recursive: false,
profile: '',
startOverlay: false,
youtubeMode: 'download',
whisperBin: '',
whisperModel: '',
whisperVadModel: '',
whisperThreads: 0,
youtubeSubgenOutDir: '',
youtubeSubgenAudioFormat: '',
youtubeSubgenKeepTemp: false,
youtubeFixWithAi: false,
youtubePrimarySubLangs: [],
youtubeSecondarySubLangs: [],
youtubeAudioLangs: [],
youtubeWhisperSourceLanguage: '',
aiConfig: {},
useTexthooker: false,
autoStartOverlay: false,
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
logLevel: 'info',
logRotation: 7,
passwordStore: '',
target: '',
targetKind: '',
jimakuApiKey: '',
jimakuApiKeyCommand: '',
jimakuApiBaseUrl: '',
jimakuLanguagePreference: 'ja',
jimakuMaxEntryResults: 20,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
jellyfinPlay: false,
jellyfinDiscovery: false,
dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
stats: false,
doctor: false,
doctorRefreshKnownWords: false,
logsExport: false,
version: false,
settings: false,
configPath: false,
configShow: false,
mpvIdle: false,
mpvSocket: false,
mpvStatus: false,
mpvArgs: '',
appPassthrough: false,
appArgs: [],
jellyfinServer: 'https://jellyfin.example.test',
jellyfinUsername: '',
jellyfinPassword: '',
launchMode: 'normal',
};
}
test('Jellyfin playback ensures Linux runtime plugin before detached idle mpv bootstrap', async () => {
const originalAccessToken = process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
const originalUserId = process.env.SUBMINER_JELLYFIN_USER_ID;
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = 'token';
process.env.SUBMINER_JELLYFIN_USER_ID = 'user';
const calls: string[] = [];
try {
await assert.rejects(
() =>
runJellyfinPlayMenuWithDeps(
'/tmp/SubMiner.AppImage',
createArgs(),
'/tmp/subminer',
'/tmp/subminer.sock',
{
loadLauncherJellyfinConfig: () => ({}),
findRofiTheme: () => null,
resolveJellyfinSelection: async () => 'item-123',
resolveJellyfinSelectionViaApp: async () => {
throw new Error('unexpected app-based selection');
},
hasStoredJellyfinSession: () => true,
requestJellyfinPreviewAuthFromApp: async () => null,
resolveLauncherMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
pathExists: () => false,
ensureRuntimePluginReady: async () => {
calls.push('plugin');
},
waitForUnixSocketReady: async () => {
calls.push('wait');
return true;
},
launchMpvIdleDetached: async () => {
calls.push('launch');
},
resolveLauncherRuntimePluginPath: () => {
calls.push('resolve');
return '/tmp/plugin/main.lua';
},
runAppCommandWithInheritLogged: () => {
calls.push('handoff');
throw new Error('stop after handoff');
},
log: () => {},
},
),
/stop after handoff/,
);
assert.deepEqual(calls, ['plugin', 'resolve', 'launch', 'wait', 'handoff']);
} finally {
if (originalAccessToken === undefined) {
delete process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
} else {
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = originalAccessToken;
}
if (originalUserId === undefined) {
delete process.env.SUBMINER_JELLYFIN_USER_ID;
} else {
process.env.SUBMINER_JELLYFIN_USER_ID = originalUserId;
}
}
});
+87 -19
View File
@@ -30,9 +30,54 @@ import {
resolveLauncherRuntimePluginPath, resolveLauncherRuntimePluginPath,
waitForUnixSocketReady, waitForUnixSocketReady,
} from './mpv.js'; } from './mpv.js';
import { ensureLinuxRuntimePluginAvailable } from './runtime-plugin-preflight.js';
const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*m/g; const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*m/g;
type JellyfinPlayMenuDeps = {
loadLauncherJellyfinConfig: typeof loadLauncherJellyfinConfig;
findRofiTheme: typeof findRofiTheme;
resolveJellyfinSelection: typeof resolveJellyfinSelection;
hasStoredJellyfinSession: typeof hasStoredJellyfinSession;
requestJellyfinPreviewAuthFromApp: typeof requestJellyfinPreviewAuthFromApp;
resolveLauncherMainConfigPath: typeof resolveLauncherMainConfigPath;
resolveJellyfinSelectionViaApp: typeof resolveJellyfinSelectionViaApp;
pathExists: (candidate: string) => boolean;
ensureRuntimePluginReady: (options: {
appPath: string;
scriptPath: string;
logLevel: Args['logLevel'];
}) => Promise<void>;
waitForUnixSocketReady: typeof waitForUnixSocketReady;
launchMpvIdleDetached: typeof launchMpvIdleDetached;
resolveLauncherRuntimePluginPath: typeof resolveLauncherRuntimePluginPath;
runAppCommandWithInheritLogged: typeof runAppCommandWithInheritLogged;
log: typeof log;
};
const defaultJellyfinPlayMenuDeps: JellyfinPlayMenuDeps = {
loadLauncherJellyfinConfig,
findRofiTheme,
resolveJellyfinSelection,
hasStoredJellyfinSession,
requestJellyfinPreviewAuthFromApp,
resolveLauncherMainConfigPath,
resolveJellyfinSelectionViaApp,
pathExists: (candidate) => fs.existsSync(candidate),
ensureRuntimePluginReady: async ({ appPath, scriptPath, logLevel }) => {
await ensureLinuxRuntimePluginAvailable({
appPath,
scriptPath,
logLevel,
});
},
waitForUnixSocketReady,
launchMpvIdleDetached,
resolveLauncherRuntimePluginPath,
runAppCommandWithInheritLogged,
log,
};
export function sanitizeServerUrl(value: string): string { export function sanitizeServerUrl(value: string): string {
return value.trim().replace(/\/+$/, ''); return value.trim().replace(/\/+$/, '');
} }
@@ -974,7 +1019,17 @@ export async function runJellyfinPlayMenu(
scriptPath: string, scriptPath: string,
mpvSocketPath: string, mpvSocketPath: string,
): Promise<never> { ): Promise<never> {
const config = loadLauncherJellyfinConfig(); return runJellyfinPlayMenuWithDeps(appPath, args, scriptPath, mpvSocketPath);
}
export async function runJellyfinPlayMenuWithDeps(
appPath: string,
args: Args,
scriptPath: string,
mpvSocketPath: string,
deps: JellyfinPlayMenuDeps = defaultJellyfinPlayMenuDeps,
): Promise<never> {
const config = deps.loadLauncherJellyfinConfig();
const envAccessToken = (process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN || '').trim(); const envAccessToken = (process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN || '').trim();
const envUserId = (process.env.SUBMINER_JELLYFIN_USER_ID || '').trim(); const envUserId = (process.env.SUBMINER_JELLYFIN_USER_ID || '').trim();
const session: JellyfinSessionConfig = { const session: JellyfinSessionConfig = {
@@ -986,58 +1041,71 @@ export async function runJellyfinPlayMenu(
iconCacheDir: config.iconCacheDir || '', iconCacheDir: config.iconCacheDir || '',
}; };
const rofiTheme = args.useRofi ? findRofiTheme(scriptPath) : null; const rofiTheme = args.useRofi ? deps.findRofiTheme(scriptPath) : null;
if (args.useRofi && !rofiTheme) { if (args.useRofi && !rofiTheme) {
log('warn', args.logLevel, 'Rofi theme not found for Jellyfin picker; using rofi defaults.'); deps.log(
'warn',
args.logLevel,
'Rofi theme not found for Jellyfin picker; using rofi defaults.',
);
} }
const hasDirectSession = Boolean(session.serverUrl && session.accessToken && session.userId); const hasDirectSession = Boolean(session.serverUrl && session.accessToken && session.userId);
let itemId = ''; let itemId = '';
if (hasDirectSession) { if (hasDirectSession) {
itemId = await resolveJellyfinSelection(args, session, rofiTheme); itemId = await deps.resolveJellyfinSelection(args, session, rofiTheme);
} else { } else {
const configPath = resolveLauncherMainConfigPath(); const configPath = deps.resolveLauncherMainConfigPath();
if (!hasStoredJellyfinSession(configPath)) { if (!deps.hasStoredJellyfinSession(configPath)) {
fail( fail(
'Missing Jellyfin session. Run `subminer jellyfin -l --server <url> --username <user> --password <pass>` first.', 'Missing Jellyfin session. Run `subminer jellyfin -l --server <url> --username <user> --password <pass>` first.',
); );
} }
const previewAuth = await requestJellyfinPreviewAuthFromApp(appPath, args); const previewAuth = await deps.requestJellyfinPreviewAuthFromApp(appPath, args);
if (previewAuth) { if (previewAuth) {
session.serverUrl = previewAuth.serverUrl || session.serverUrl; session.serverUrl = previewAuth.serverUrl || session.serverUrl;
session.accessToken = previewAuth.accessToken; session.accessToken = previewAuth.accessToken;
session.userId = previewAuth.userId || session.userId; session.userId = previewAuth.userId || session.userId;
log('debug', args.logLevel, 'Jellyfin preview auth bridge ready for picker image previews.'); deps.log(
'debug',
args.logLevel,
'Jellyfin preview auth bridge ready for picker image previews.',
);
} else { } else {
log( deps.log(
'debug', 'debug',
args.logLevel, args.logLevel,
'Jellyfin preview auth bridge unavailable; picker image previews may be disabled.', 'Jellyfin preview auth bridge unavailable; picker image previews may be disabled.',
); );
} }
itemId = await resolveJellyfinSelectionViaApp(appPath, args, session, rofiTheme); itemId = await deps.resolveJellyfinSelectionViaApp(appPath, args, session, rofiTheme);
} }
log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`); deps.log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`); deps.log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
let mpvReady = false; let mpvReady = false;
if (fs.existsSync(mpvSocketPath)) { if (deps.pathExists(mpvSocketPath)) {
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 250); mpvReady = await deps.waitForUnixSocketReady(mpvSocketPath, 250);
} }
if (!mpvReady) { if (!mpvReady) {
await launchMpvIdleDetached( await deps.ensureRuntimePluginReady({ appPath, scriptPath, logLevel: args.logLevel });
await deps.launchMpvIdleDetached(
mpvSocketPath, mpvSocketPath,
appPath, appPath,
args, args,
resolveLauncherRuntimePluginPath({ appPath, scriptPath }), deps.resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
); );
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000); mpvReady = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
} }
log('debug', args.logLevel, `MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`); deps.log(
'debug',
args.logLevel,
`MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`,
);
if (!mpvReady) { if (!mpvReady) {
fail(`MPV IPC socket not ready: ${mpvSocketPath}`); fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
} }
const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`]; const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel); if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore); if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play'); deps.runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
} }
+3 -2
View File
@@ -2,9 +2,10 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import path from 'node:path'; import path from 'node:path';
import { getDefaultLauncherLogFile, getDefaultMpvLogFile } from './types.js'; import { getDefaultLauncherLogFile, getDefaultMpvLogFile } from './types.js';
import { localDateKey } from '../src/shared/log-files.js';
test('getDefaultMpvLogFile uses APPDATA on windows', () => { test('getDefaultMpvLogFile uses APPDATA on windows', () => {
const today = new Date().toISOString().slice(0, 10); const today = localDateKey(new Date());
const resolved = getDefaultMpvLogFile({ const resolved = getDefaultMpvLogFile({
platform: 'win32', platform: 'win32',
homeDir: 'C:\\Users\\tester', homeDir: 'C:\\Users\\tester',
@@ -20,7 +21,7 @@ test('getDefaultMpvLogFile uses APPDATA on windows', () => {
}); });
test('getDefaultLauncherLogFile uses launcher prefix', () => { test('getDefaultLauncherLogFile uses launcher prefix', () => {
const today = new Date().toISOString().slice(0, 10); const today = localDateKey(new Date());
const resolved = getDefaultLauncherLogFile({ const resolved = getDefaultLauncherLogFile({
platform: 'linux', platform: 'linux',
homeDir: '/home/tester', homeDir: '/home/tester',
+259
View File
@@ -0,0 +1,259 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {
ensureLinuxRuntimePluginAvailable,
installManagedPluginAssetsViaApp,
} from './runtime-plugin-preflight';
test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', async () => {
const calls: string[] = [];
await ensureLinuxRuntimePluginAvailable({
platform: 'darwin',
detectInstalledPlugin: () => {
calls.push('detect');
return false;
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return null;
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
log: () => {
calls.push('log');
},
});
assert.deepEqual(calls, []);
});
test('ensureLinuxRuntimePluginAvailable skips install when installed global plugin and managed theme exist', async () => {
const calls: string[] = [];
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
detectInstalledPlugin: () => {
calls.push('detect');
return true;
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return null;
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
isManagedThemeAvailable: () => {
calls.push('theme');
return true;
},
log: () => {},
});
assert.deepEqual(calls, ['detect', 'theme']);
});
test('ensureLinuxRuntimePluginAvailable skips install when managed runtime path and theme already resolve', async () => {
const calls: string[] = [];
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
xdgDataHome: '/tmp/xdg-data',
detectInstalledPlugin: () => {
calls.push('detect');
return false;
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return '/tmp/plugin/main.lua';
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
isManagedThemeAvailable: () => {
calls.push('theme');
return true;
},
log: () => {},
});
assert.deepEqual(calls, ['detect', 'resolve', 'theme']);
});
test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme is missing', async () => {
const calls: string[] = [];
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
xdgDataHome: '/tmp/xdg-data',
detectInstalledPlugin: () => {
calls.push('detect');
return false;
},
resolveRuntimePluginPath: () => {
calls.push('resolve');
return '/tmp/plugin/main.lua';
},
isManagedThemeAvailable: () => {
calls.push('theme');
return false;
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.deepEqual(calls, [
'detect',
'resolve',
'theme',
'info:Linux runtime support assets missing; installing managed plugin/theme assets.',
'install',
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi',
'resolve',
]);
});
test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => {
const calls: string[] = [];
let resolveCount = 0;
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
xdgDataHome: '/tmp/xdg-data',
detectInstalledPlugin: () => false,
resolveRuntimePluginPath: () => {
resolveCount += 1;
calls.push(`resolve:${resolveCount}`);
return resolveCount === 1 ? null : '/tmp/plugin/main.lua';
},
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.deepEqual(calls, [
'resolve:1',
'info:Linux runtime support assets missing; installing managed plugin/theme assets.',
'install',
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi',
'resolve:2',
]);
});
test('ensureLinuxRuntimePluginAvailable fails when install result is not ok', async () => {
await assert.rejects(
() =>
ensureLinuxRuntimePluginAvailable({
platform: 'linux',
detectInstalledPlugin: () => false,
resolveRuntimePluginPath: () => null,
installManagedPluginAssets: async () => ({
ok: false,
status: 'failed',
error: 'copy failed',
}),
log: () => {},
}),
/copy failed/,
);
});
test('ensureLinuxRuntimePluginAvailable fails when runtime path remains unresolved after install', async () => {
await assert.rejects(
() =>
ensureLinuxRuntimePluginAvailable({
platform: 'linux',
detectInstalledPlugin: () => false,
resolveRuntimePluginPath: () => null,
installManagedPluginAssets: async () => ({
ok: true,
status: 'installed',
path: '/tmp/plugin/main.lua',
}),
log: () => {},
}),
/managed runtime plugin assets could not be installed/i,
);
});
test('installManagedPluginAssetsViaApp returns launch errors without waiting for a response file', async () => {
let waited = false;
const result = await installManagedPluginAssetsViaApp(
{
appPath: '/opt/SubMiner/subminer',
},
{
runAppCommandCaptureOutput: () => ({
status: 1,
stdout: '',
stderr: '',
error: new Error('spawn failed'),
}),
waitForInstallResponse: async () => {
waited = true;
return null;
},
},
);
assert.deepEqual(result, {
ok: false,
status: 'failed',
error: 'spawn failed',
});
assert.equal(waited, false);
});
test('installManagedPluginAssetsViaApp does not let temp cleanup errors mask install result', async () => {
const originalRmSync = fs.rmSync;
fs.rmSync = ((targetPath, options) => {
if (String(targetPath).includes('subminer-runtime-plugin-')) {
throw new Error('cleanup failed');
}
return originalRmSync(targetPath, options);
}) as typeof fs.rmSync;
try {
const result = await installManagedPluginAssetsViaApp(
{
appPath: '/opt/SubMiner/subminer',
},
{
runAppCommandCaptureOutput: () => ({
status: 0,
stdout: '',
stderr: '',
}),
waitForInstallResponse: async () => ({
ok: true,
status: 'installed',
path: '/tmp/plugin/main.lua',
}),
},
);
assert.deepEqual(result, {
ok: true,
status: 'installed',
path: '/tmp/plugin/main.lua',
});
} finally {
fs.rmSync = originalRmSync;
}
});
+223
View File
@@ -0,0 +1,223 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { log as launcherLog } from './log.js';
import { runAppCommandCaptureOutput, resolveLauncherRuntimePluginPath } from './mpv.js';
import { nowMs } from './time.js';
import { sleep } from './util.js';
import { detectInstalledMpvPlugin } from '../src/main/runtime/first-run-setup-plugin.js';
import {
resolveManagedLinuxRuntimePluginPaths,
type EnsureLinuxRuntimePluginAssetsResult,
} from '../src/main/runtime/linux-runtime-plugin-assets.js';
const RESPONSE_TIMEOUT_MS = 30_000;
type PreflightLog = (
level: 'debug' | 'info' | 'warn' | 'error',
configured: 'debug' | 'info' | 'warn' | 'error',
message: string,
) => void;
type EnsureLinuxRuntimePluginAvailableOptions = {
appPath?: string;
scriptPath?: string;
logLevel?: 'debug' | 'info' | 'warn' | 'error';
platform?: NodeJS.Platform;
homeDir?: string;
xdgConfigHome?: string;
xdgDataHome?: string;
appDataDir?: string;
detectInstalledPlugin?: () => boolean;
resolveRuntimePluginPath?: () => string | null;
isManagedThemeAvailable?: () => boolean;
installManagedPluginAssets?: () => Promise<EnsureLinuxRuntimePluginAssetsResult>;
log?: PreflightLog;
};
type RuntimePluginPreflightResponse = {
ok: boolean;
status: 'installed' | 'already-present' | 'failed';
path?: string;
error?: string;
};
function resolveConfiguredLogLevel(
logLevel: EnsureLinuxRuntimePluginAvailableOptions['logLevel'],
): 'debug' | 'info' | 'warn' | 'error' {
return logLevel ?? 'warn';
}
async function waitForInstallResponse(
responsePath: string,
): Promise<RuntimePluginPreflightResponse | null> {
const deadline = nowMs() + RESPONSE_TIMEOUT_MS;
while (nowMs() < deadline) {
try {
if (fs.existsSync(responsePath)) {
return JSON.parse(fs.readFileSync(responsePath, 'utf8')) as RuntimePluginPreflightResponse;
}
} catch {
// retry until timeout
}
await sleep(100);
}
return null;
}
type InstallManagedPluginAssetsViaAppDeps = {
runAppCommandCaptureOutput?: typeof runAppCommandCaptureOutput;
waitForInstallResponse?: typeof waitForInstallResponse;
};
export async function installManagedPluginAssetsViaApp(
options: {
appPath: string;
logLevel?: 'debug' | 'info' | 'warn' | 'error';
},
deps: InstallManagedPluginAssetsViaAppDeps = {},
): Promise<EnsureLinuxRuntimePluginAssetsResult> {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-runtime-plugin-'));
const responsePath = path.join(tempDir, 'response.json');
const runAppCommand = deps.runAppCommandCaptureOutput ?? runAppCommandCaptureOutput;
const waitForResponse = deps.waitForInstallResponse ?? waitForInstallResponse;
try {
const appArgs = [
'--ensure-linux-runtime-plugin-assets',
'--ensure-linux-runtime-plugin-assets-response-path',
responsePath,
];
const result = runAppCommand(options.appPath, appArgs);
if (result.error) {
return {
ok: false,
status: 'failed',
error: result.error.message,
};
}
if (result.status !== 0) {
const stderr = result.stderr.trim();
const stdout = result.stdout.trim();
return {
ok: false,
status: 'failed',
error:
stderr ||
stdout ||
`Linux runtime plugin asset install command exited with status ${result.status}.`,
};
}
const response = await waitForResponse(responsePath);
if (response) {
return response;
}
const stderr = result.stderr.trim();
const stdout = result.stdout.trim();
return {
ok: false,
status: 'failed',
error:
stderr ||
stdout ||
`Timed out waiting for Linux runtime plugin asset response after app exit status ${result.status}.`,
};
} finally {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// Avoid hiding the install failure or success result behind temp cleanup errors.
}
}
}
export async function ensureLinuxRuntimePluginAvailable(
options: EnsureLinuxRuntimePluginAvailableOptions,
): Promise<void> {
const platform = options.platform ?? process.platform;
if (platform !== 'linux') {
return;
}
const configuredLogLevel = resolveConfiguredLogLevel(options.logLevel);
const log = options.log ?? launcherLog;
const homeDir = options.homeDir ?? os.homedir();
const detectInstalledPlugin =
options.detectInstalledPlugin ??
(() =>
detectInstalledMpvPlugin({
platform,
homeDir,
xdgConfigHome: options.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
appDataDir: options.appDataDir ?? process.env.APPDATA,
}).installed);
const installedPluginAvailable = detectInstalledPlugin();
const managedPaths = resolveManagedLinuxRuntimePluginPaths({
homeDir,
xdgDataHome: options.xdgDataHome ?? process.env.XDG_DATA_HOME,
});
const resolveRuntimePluginPath =
options.resolveRuntimePluginPath ??
(() => {
if (!options.appPath) return null;
return resolveLauncherRuntimePluginPath({
appPath: options.appPath,
scriptPath: options.scriptPath,
platform,
homeDir,
env: process.env,
});
});
const isManagedThemeAvailable =
options.isManagedThemeAvailable ?? (() => fs.existsSync(managedPaths.themePath));
const runtimePluginAvailable = installedPluginAvailable || Boolean(resolveRuntimePluginPath());
if (runtimePluginAvailable && isManagedThemeAvailable()) {
return;
}
log(
'info',
configuredLogLevel,
'Linux runtime support assets missing; installing managed plugin/theme assets.',
);
const installManagedPluginAssets =
options.installManagedPluginAssets ??
(() => {
if (!options.appPath) {
throw new Error(
'Linux managed runtime plugin assets could not be installed. Launch aborted before starting mpv.',
);
}
return installManagedPluginAssetsViaApp({
appPath: options.appPath,
logLevel: options.logLevel,
});
});
const installResult = await installManagedPluginAssets();
if (!installResult.ok) {
const message = installResult.error || 'Unknown Linux runtime plugin asset install failure.';
log(
'warn',
configuredLogLevel,
`Managed Linux runtime support asset install failed: ${message}`,
);
throw new Error(message);
}
log(
'info',
configuredLogLevel,
`Managed Linux runtime support assets installed: plugin=${installResult.path ?? 'unknown path'} theme=${managedPaths.themePath}`,
);
const runtimePluginPath = resolveRuntimePluginPath();
if (runtimePluginPath) {
return;
}
const message =
`Linux managed runtime plugin assets could not be installed. ` +
`Checked path: ${managedPaths.pluginEntrypointPath}. ` +
'Launch aborted before starting mpv.';
log('warn', configuredLogLevel, message);
throw new Error(message);
}
+41 -2
View File
@@ -24,6 +24,7 @@ type SmokeCase = {
artifactsDir: string; artifactsDir: string;
binDir: string; binDir: string;
xdgConfigHome: string; xdgConfigHome: string;
xdgDataHome: string;
appDataDir: string; appDataDir: string;
localAppDataDir: string; localAppDataDir: string;
homeDir: string; homeDir: string;
@@ -64,6 +65,7 @@ function createSmokeCase(name: string): SmokeCase {
const artifactsDir = path.join(root, 'artifacts'); const artifactsDir = path.join(root, 'artifacts');
const binDir = path.join(root, 'bin'); const binDir = path.join(root, 'bin');
const xdgConfigHome = path.join(root, 'xdg'); const xdgConfigHome = path.join(root, 'xdg');
const xdgDataHome = path.join(root, 'xdg-data');
const appDataDir = path.join(root, 'AppData', 'Roaming'); const appDataDir = path.join(root, 'AppData', 'Roaming');
const localAppDataDir = path.join(root, 'AppData', 'Local'); const localAppDataDir = path.join(root, 'AppData', 'Local');
const homeDir = path.join(root, 'home'); const homeDir = path.join(root, 'home');
@@ -135,6 +137,7 @@ process.on('SIGTERM', closeAndExit);
fakeAppBasePath, fakeAppBasePath,
`#!/usr/bin/env bun `#!/usr/bin/env bun
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path');
const logPath = ${JSON.stringify(fakeAppLogPath)}; const logPath = ${JSON.stringify(fakeAppLogPath)};
const startPath = ${JSON.stringify(fakeAppStartLogPath)}; const startPath = ${JSON.stringify(fakeAppStartLogPath)};
@@ -154,6 +157,25 @@ if (entry.argv.includes('--stop')) {
if (entry.argv.includes('--app-ping')) { if (entry.argv.includes('--app-ping')) {
process.exit(process.env.SUBMINER_FAKE_APP_RUNNING === '1' ? 0 : 1); process.exit(process.env.SUBMINER_FAKE_APP_RUNNING === '1' ? 0 : 1);
} }
if (entry.argv.includes('--ensure-linux-runtime-plugin-assets')) {
const responseFlagIndex = entry.argv.indexOf('--ensure-linux-runtime-plugin-assets-response-path');
const responsePath = responseFlagIndex >= 0 ? entry.argv[responseFlagIndex + 1] : '';
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(process.env.HOME || '', '.local', 'share');
const dataDir = path.join(xdgDataHome, 'SubMiner');
const pluginDir = path.join(dataDir, 'plugin', 'subminer');
const pluginConfigPath = path.join(dataDir, 'plugin', 'subminer.conf');
const themePath = path.join(dataDir, 'themes', 'subminer.rasi');
fs.mkdirSync(pluginDir, { recursive: true });
fs.mkdirSync(path.dirname(themePath), { recursive: true });
fs.writeFileSync(path.join(pluginDir, 'main.lua'), '-- smoke plugin\\n');
fs.writeFileSync(pluginConfigPath, 'smoke=true\\n');
fs.writeFileSync(themePath, '/* smoke theme */\\n');
if (responsePath) {
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
fs.writeFileSync(responsePath, JSON.stringify({ ok: true, status: 'installed', path: path.join(pluginDir, 'main.lua') }));
}
process.exit(0);
}
process.exit(0); process.exit(0);
`, `,
@@ -164,6 +186,7 @@ process.exit(0);
artifactsDir, artifactsDir,
binDir, binDir,
xdgConfigHome, xdgConfigHome,
xdgDataHome,
appDataDir, appDataDir,
localAppDataDir, localAppDataDir,
homeDir, homeDir,
@@ -181,6 +204,7 @@ function makeTestEnv(smokeCase: SmokeCase): NodeJS.ProcessEnv {
...process.env, ...process.env,
HOME: smokeCase.homeDir, HOME: smokeCase.homeDir,
XDG_CONFIG_HOME: smokeCase.xdgConfigHome, XDG_CONFIG_HOME: smokeCase.xdgConfigHome,
XDG_DATA_HOME: smokeCase.xdgDataHome,
APPDATA: smokeCase.appDataDir, APPDATA: smokeCase.appDataDir,
LOCALAPPDATA: smokeCase.localAppDataDir, LOCALAPPDATA: smokeCase.localAppDataDir,
SUBMINER_APPIMAGE_PATH: smokeCase.fakeAppPath, SUBMINER_APPIMAGE_PATH: smokeCase.fakeAppPath,
@@ -495,7 +519,7 @@ test(
); );
test( test(
'launcher start-overlay attaches to a running background app without spawning another app command', 'launcher start-overlay attaches to a running background app without spawning another app start command',
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS }, { timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
async () => { async () => {
await withSmokeCase('overlay-borrow-background', async (smokeCase) => { await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
@@ -530,7 +554,15 @@ test(
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError); typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
assert.equal(result.status, unixSocketDenied ? 3 : 0); assert.equal(result.status, unixSocketDenied ? 3 : 0);
assert.equal(appEntries.length, 0); if (process.platform === 'linux') {
assert.equal(appEntries.length > 0, true);
assert.equal(
appEntries.every((entry) =>
(entry.argv as string[]).includes('--ensure-linux-runtime-plugin-assets'),
),
true,
);
}
assert.equal(appStartEntries.length, 0); assert.equal(appStartEntries.length, 0);
assert.equal(appStopEntries.length, 0); assert.equal(appStopEntries.length, 0);
assert.equal(controlEntries.length, 1); assert.equal(controlEntries.length, 1);
@@ -587,6 +619,13 @@ test(
/subminer-auto_start_pause_until_ready_owns_initial_pause=yes/, /subminer-auto_start_pause_until_ready_owns_initial_pause=yes/,
); );
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i); assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
if (process.platform === 'linux') {
assert.match(result.stdout, /managed plugin\/theme assets/i);
assert.equal(
fs.existsSync(path.join(smokeCase.xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi')),
true,
);
}
}); });
}, },
); );
-4095
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
File diff suppressed because one or more lines are too long
+11 -133
View File
@@ -1,147 +1,24 @@
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release. > This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
<!-- prerelease-base-version: 0.17.1 -->
## Highlights ## Highlights
### Added ### Added
- **Settings Window:** A dedicated Settings window is now available via `subminer --settings` or `subminer settings`, organized into Appearance, Behavior, Anki, Input, and Integration sections. - **YouTube Media Cache Mode**: A new `youtube.mediaCache.mode` setting (`direct` or `background`) lets you choose how SubMiner extracts audio and image from YouTube cards.
- Includes click-to-learn keybinding controls, an AnkiConnect deck dropdown that auto-fills from Yomitan's current mining deck, and AnkiConnect-backed deck, field, and note-type pickers. - In background mode, SubMiner creates a text-only card immediately, downloads a yt-dlp media cache (capped at 720p by default), and fills audio and image fields once the file is ready — with overlay and OSD notifications when the download starts and when media is available.
- Live-saves changes for subtitle CSS declarations, stats keys, logging level, Anki field mappings, sentence card model, and other annotation and runtime options; search narrows across all categories including on multi-word terms. AI and translation settings remain config-file only. - If a background download fails, SubMiner now notifies you and clears any pending media updates rather than leaving cards silently incomplete.
- **Auto-Updater:** SubMiner can now check for and apply updates from the system tray or by running `subminer -u`, with checksum verification and configurable update notifications.
- The `subminer` launcher and Linux rofi theme update automatically alongside the app.
- Set `updates.channel` to `"prerelease"` to receive beta and RC builds.
- **First-Run Setup:** A new optional setup flow installs Bun and the `subminer` command-line launcher on Linux, macOS, and Windows.
- Windows users get a `subminer.cmd` PATH shim so `subminer` works in any terminal without manually adding `SubMiner.exe` to PATH.
- Setup recognizes existing `subminer` installs in Homebrew or user PATH directories and avoids writing into Homebrew-owned paths. An Open SubMiner Settings button is included on completion; the standalone setup app quits after finishing.
- **Character Portraits:** Character-name subtitle matches can now show optional inline AniList character portraits.
- Manual AniList title overrides are scoped per media directory so separate season folders keep independent character dictionary selections.
- **Log Export:** Sanitized log ZIP archives can be exported from the tray menu or by running `subminer logs -e`, with home-directory usernames redacted from the exported contents.
- **Logging Configuration:** SubMiner's logging level is now forwarded into launcher-started and Windows shortcut-started mpv sessions, controlling mpv log verbosity and plugin script logging.
- The new `logging.rotation` config sets daily log retention (default 7 days). `logging.files` toggles let you enable or disable per-component log files; mpv logs are off by default unless explicitly enabled.
- **Yomitan Popup Visibility:** The new `subtitleStyle.primaryVisibleOnYomitanPopup` option keeps hover-mode primary subtitles visible while a Yomitan lookup popup is open.
- **Launcher:** `subminer --version` / `subminer -v` now prints the installed app version. The new `mpv.profile` config option passes an mpv profile to SubMiner-managed mpv launches, and bundled mpv plugin startup options are now configurable from SubMiner config.
### Changed
- **Subtitle Appearance:** Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`; sidebar appearance uses `subtitleSidebar.css`.
- Default font stack updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`; default text shadow is stronger, JLPT underlines are thicker, and the frequency `topX` threshold defaults to `10000`.
- Existing configs are migrated automatically: legacy appearance options and hover token colors fold into `subtitleStyle.css`, and user config files are preserved.
- **Known-Word Colors:** Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`. Legacy Anki color keys remain accepted with deprecation warnings.
- N+1 highlighting is preserved for configs that already had it enabled; new configs leave it disabled unless `ankiConnect.nPlusOne.enabled` is set explicitly.
- **Character Dictionary:** Entries are now scoped to the current AniList media and generate Japanese name aliases only, so raw romanized or English aliases no longer appear as separate results.
- A new `Ctrl/Cmd+D` manager modal lets you remove, reorder, or override loaded dictionary entries.
- The in-app AniList title selector now waits for an explicit search rather than triggering automatically; the search box is prefilled from the current filename guess.
- **Linux Updater:** Tray "Check for Updates" now installs the new AppImage automatically via `electron-updater`, matching the macOS and Windows update flow. System-package-managed AppImages and non-AppImage launches fall back to the GitHub-asset flow.
- **Subsync:** The subtitle sync dialog now always opens the manual picker; the `subsync.defaultMode` config option has been removed.
- **Jellyfin Setup:** The server presets dropdown is replaced by a single editable server URL field.
- **Defaults:** Jellyfin remote-session startup warmup and character-name subtitle highlighting now default to off.
- **Runtime:** The bundled Electron runtime is updated from 39.8.6 to 42.2.0.
### Fixed ### Fixed
- **macOS Overlay:** Significantly improved overlay focus and stability across a range of scenarios. - **Log Export**: Log filenames now use your local date, so exporting logs near midnight no longer pulls stale files from the previous UTC day. Export redaction has also been expanded to mask a broader range of sensitive data, including IP addresses, email addresses, authentication and cookie headers, yt-dlp cookie arguments, URL credentials, and signed YouTube media URLs.
- The overlay hides when mpv loses focus, is minimized, or is no longer the foreground target; stays stable through transient window-tracking misses; remains correctly layered during stats mouse passthrough; and opens over fullscreen mpv without switching Spaces.
- Passthrough is fixed so mpv controls stay clickable before hovering a subtitle bar. The compiled mpv window helper is now correctly bundled, preventing the overlay from falling back to a slower startup path on first launch.
- **Linux/Hyprland Overlay:** Overlay placement refreshes after leaving mpv fullscreen so the visible overlay stays aligned to the player. - **YouTube Card Media Reliability**: Direct stream extraction now uses safer ffmpeg options and skips stale or cached stream map entries to reduce failed media generation. Background cache downloads are hardened with IPv4 and extractor retry flags, stale cache files are cleaned up on startup and before new downloads, and in-flight background downloads are stopped automatically when switching back to direct mode.
- The overlay stays stacked above mpv after click-to-focus events and is suspended while the in-player stats window is open.
- Settings windows (SubMiner and Yomitan) now open above the subtitle overlay; the overlay hides immediately when the character dictionary modal opens, including while AniList lookup is in progress.
- **Jellyfin Playback:** Resolved a wide range of discovery and playback issues: the active item is no longer reloaded during startup, paused mpv is no longer misreported as playing, startup unpause no longer repeats after a manual pause or `y-t` toggle, and duplicate ready signals no longer re-show the overlay. ## What's Changed
- Discovery now correctly handles delayed Japanese subtitle selection and prevents later-loading foreign tracks from stealing the active Japanese track.
- Discovery resume correctly handles `StartPositionTicks: 0` for items with saved progress.
- **Jellyfin Subtitles:** Improved subtitle timing by preferring default embedded streams over external sidecars, stripping Jellyfin's server-selected stream from playback URLs, suppressing mpv auto-selection while SubMiner stages managed tracks, and automatically correcting Japanese-vs-English cue timeline offsets. - feat(youtube): add mediaCache mode and safer stream media extraction by @ksyasuda in #130
- Per-stream subtitle delay shifts are restored on load. Track selection now tolerates transient `track-list` read failures and numeric string track IDs on Linux. - fix(logs): use local date for log filenames and expand export redaction by @ksyasuda in #131
- **Jellyfin Overlay:** The visible subtitle overlay now shows automatically during Jellyfin playback so `subtitleStyle` appearance applies, and the bundled mpv plugin is injected when SubMiner auto-launches mpv so mpv-side keybindings work without overlay focus.
- The `y-t` overlay toggle is reliable and remains sticky across stream redirects.
- Passive Linux/Hyprland overlay shows no longer steal keyboard focus from mpv.
- **Jellyfin Remote Progress:** Fixed progress sync for mpv/SubMiner seek jumps, stopped sessions, startup path changes, and Linux websocket reconnect windows.
- Play and Resume are now distinct: Play starts from the beginning while Resume starts at the saved position.
- Final progress reports use SubMiner's last known position when mpv resets during stop.
- **Jellyfin Identity:** Cast device identity is now derived from the OS hostname. Multiple SubMiner installs no longer share the same remote-session identity.
- **Jellyfin Tray:** The discovery tray checkbox stays in sync on Linux after tray, CLI, or startup remote-session changes. Stale discovery sessions restart automatically when the server no longer lists the SubMiner cast target.
- **Jellyfin Setup:** Fixed the Windows login flow with an IPC bridge and immediate progress feedback; unreachable servers time out with an inline error instead of hanging.
- **AniList Progress:** Threshold checks now use fresh playback position data so updates fire correctly when playback reaches or skips past the watched threshold.
- Season-specific results are preferred for multi-season files, with a clear message when the matched season is not in Planning or Watching status.
- Repeated missing-token checks no longer exhaust AniList retry attempts or create duplicate dead-letter entries for the same episode.
- **Anki:** Sentence-audio padding is now opt-in by default; animated AVIF freeze-frame duration is correctly aligned to word audio length without double-counting padding.
- Multi-line sentence mining stays aligned for repeated subtitle text; Kiku duplicate-card detection and merge flow are fixed; clipboard card updates from YouTube use mpv's resolved stream URLs; sentence cards refresh the secondary subtitle before saving.
- Known-word cache append is fixed when no default Anki mining deck is configured but multiple known-word deck field mappings are present.
- **YouTube:** Primary subtitles are downloaded to temporary local files so the primary bar and sidebar read the same source, with cleanup on reload and quit.
- False load-failure notifications are suppressed. Launcher-managed playback creates the tray icon when attaching to an already-running process, and app-owned playback no longer lets the mpv plugin start a second SubMiner instance.
- **Character Dictionary:** Surname honorifics are now matched for Japanese localized aliases embedded in AniList alternative names; cached snapshots are regenerated to include them.
- Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests. Manager keyboard shortcuts are correctly forwarded to the mpv plugin.
- **Updater:** Update checks are more stable across platforms: Linux uses GitHub release metadata; `subminer -u` can update independently of the tray app; macOS update dialogs reliably appear in the foreground.
- Builds that cannot apply native updates show a manual-install message instead of a restart prompt. Windows retains the native NSIS update path while routing updater HTTP through the main process.
- **Setup - macOS:** First-run setup recognizes existing `subminer` installs in Homebrew or user PATH directories and avoids writing into Homebrew-owned paths.
- `subminer app --setup` opens the setup flow even when SubMiner is already running. The standalone setup app quits after completing first-run setup, and `subminer settings` exits cleanly when the window is closed.
- **Tray App:** Fixed several lifecycle issues: the tray stays running when Yomitan settings are closed; a close-only menu prevents accidentally quitting the tray app; an in-page close button is available on Hyprland where native window controls are unavailable.
- Settings loading no longer blocks other tray actions; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized; the session help modal closes correctly without mpv running.
- On Windows, "Open SubMiner Setup" now correctly opens the setup window after first-run setup is complete.
- **Launcher:** Launcher-opened videos reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused until subtitle priming and tokenization readiness complete.
- `subminer settings` on macOS no longer emits Electron menu diagnostics and exits cleanly when the window is closed. Linux first-run launcher installs build with a valid Bun shebang; `subminer app` on Linux returns control to the terminal immediately.
- On Windows, managed mpv launches from a background instance correctly retarget the new mpv socket, bind to the player window, and receive startup overlay options.
- **Playback:** The first subtitle is primed before autoplay resumes so the overlay renders text before video playback begins. Launcher-owned videos quit SubMiner when playback ends while background and tray sessions stay alive.
- The visible overlay and subtitle stream stay alive after restarting SubMiner from the `y-r` shortcut, with correct Linux bounds reapplication and user-paused playback preserved through readiness gates.
- **Subtitle Frequency:** Frequency highlighting is preserved for determiner-led noun compounds like `その場` while standalone determiners are still filtered. Annotations are corrected for Yomitan single-token compounds with internal particles like `目の前`.
- **Subtitle Annotation Prefetch:** Cached annotations and character images are ready for more live subtitle changes without delaying raw subtitle display.
- **Shortcuts:** Native mpv menu shortcuts are disabled during managed macOS playback so SubMiner shortcuts also work while mpv has focus. Session shortcuts including `stats.markWatchedKey` are correctly wired through mpv. The visible overlay receives focus when entering multi-line copy/mine selection so number keys work on macOS and Windows.
- **Stats:** In-player stats layering is fixed so delete confirmations, overlay modals, and update-check dialogs appear above the stats window. Jellyfin playback stats are grouped under item metadata so watched episodes merge with matching local library titles and display clean names.
- **Sidebar:** Yomitan lookup popups opened from the subtitle sidebar now correctly pause playback when popup auto-pause is enabled. Mined cards use audio and images from the clicked subtitle line rather than the current primary line.
- **Controller:** Config and debug shortcuts stay closed while controller support is disabled, with a notice to enable `controller.enabled`. Learn mode can be entered from the edit pencil or binding badge; remaps are saved per controller profile, and individual bindings can be reset to their defaults.
- **Discord Rich Presence:** Presence no longer falls back to Jellyfin stream URLs; Jellyfin playback titles are primed before loading tokenized streams so presence shows the show/episode title.
- **WebSocket:** The regular subtitle WebSocket now sends plain text only; annotation spans and token metadata are sent exclusively on the annotation WebSocket.
- **Windows Startup:** Fatal startup errors now show a native error dialog and write details to the app log instead of exiting silently.
- **Yomitan:** Fixed popups not opening when overlay startup races the Yomitan extension load.
- **Subtitle Sync Modal:** Fixed a macOS issue where the modal would flash and disappear on the first attempt, or leave stale state after syncing.
### Docs
- **Versioned Docs:** Stable docs are now published at the site root with current development docs under `/main/`.
- Fixed versioned docs navigation so archived pages keep local links under the selected version, the version switcher no longer nests paths incorrectly, and local dev version routes serve warmed archive files instead of redirecting to production.
- **Configuration Reference:** All previously undocumented config options are now covered, including `subtitleStyle.primaryDefaultMode`, `stats.markWatchedKey`, `immersionTracking.lifetimeSummaries.*`, and all seven `mpv.*` launcher options. Updated known-word cache docs and examples to recommend expression/word fields.
- **Architecture Docs:** Added a Playback Startup Flow diagram and a Runtime Sockets section and diagram to the IPC + Runtime Contracts page, with cross-reference pointers in the MPV Plugin and Troubleshooting pages.
## Installation ## Installation
@@ -151,6 +28,7 @@ See the README and docs/installation guide for full setup steps.
- Linux: `SubMiner.AppImage` - Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip` - macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher - 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`. Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
+28
View File
@@ -0,0 +1,28 @@
## Highlights
### Fixed
- **YouTube Background Cache:** Fixed Windows background media cache startup for YouTube URLs opened directly in mpv.
- Resolved stream URLs are now tracked even when mpv still exposes the original YouTube playlist entry.
- Queued Anki media updates can append audio and images after the cache finishes instead of staying text-only.
- **YouTube Subtitle Picker Notifications:** Manual subtitle picker requests now show immediate status while SubMiner probes tracks and opens the modal.
- Subtitle download progress is replaced with a transient success notification after tracks load.
## What's Changed
- feat(youtube): notify on manual picker open and show success after track load by @ksyasuda in #133
- fix(youtube): recover source URL for background media cache on direct mpv open by @ksyasuda in #132
## 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`.
+60
View File
@@ -605,6 +605,7 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m); assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m);
assert.match(prereleaseNotes, /<!-- prerelease-base-version: 0\.11\.3 -->/);
assert.match(prereleaseNotes, /## Highlights\n### Added\n- Polished: added entry\./); assert.match(prereleaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
assert.match(prereleaseNotes, /### Fixed\n- Polished: fixed entry\./); assert.match(prereleaseNotes, /### Fixed\n- Polished: fixed entry\./);
assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/); assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
@@ -620,6 +621,8 @@ test('writePrereleaseNotesForVersion reuses existing prerelease notes when addin
const existingNotes = [ const existingNotes = [
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.', '> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
'', '',
'<!-- prerelease-base-version: 0.11.3 -->',
'',
'## Highlights', '## Highlights',
'### Added', '### Added',
'- Overlay: Previous beta entry.', '- Overlay: Previous beta entry.',
@@ -679,6 +682,61 @@ test('writePrereleaseNotesForVersion reuses existing prerelease notes when addin
} }
}); });
test('writePrereleaseNotesForVersion ignores unmarked prerelease notes from an older release line', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-ignore-unmarked-old-notes');
const projectRoot = path.join(workspace, 'SubMiner');
const existingNotes = [
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
'',
'## Highlights',
'### Added',
'- Settings Window: Previous release line entry.',
'',
'## Installation',
'',
'See the README and docs/installation guide for full setup steps.',
'',
].join('\n');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.17.0-beta.1' }, null, 2),
'utf8',
);
fs.writeFileSync(path.join(projectRoot, 'release', 'prerelease-notes.md'), existingNotes, 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
[
'type: changed',
'area: overlay',
'',
'- Replaced subtitle delay actions with native mpv keybindings.',
].join('\n'),
'utf8',
);
try {
const stub = defaultStubClaude();
const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.17.0-beta.1',
deps: { runClaude: stub.runClaude },
});
assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
assert.doesNotMatch(stub.calls[0]!.input, /EXISTING PRERELEASE NOTES/);
assert.doesNotMatch(stub.calls[0]!.input, /Settings Window: Previous release line entry/);
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(prereleaseNotes, /### Changed\n- Polished: changed entry\./);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writePrereleaseNotesForVersion prompts Claude to revise stale prerelease bullets instead of appending fix churn', async () => { test('writePrereleaseNotesForVersion prompts Claude to revise stale prerelease bullets instead of appending fix churn', async () => {
const { writePrereleaseNotesForVersion } = await loadModule(); const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-net-outcome-prompt'); const workspace = createWorkspace('prerelease-net-outcome-prompt');
@@ -686,6 +744,8 @@ test('writePrereleaseNotesForVersion prompts Claude to revise stale prerelease b
const existingNotes = [ const existingNotes = [
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.', '> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
'', '',
'<!-- prerelease-base-version: 0.12.0 -->',
'',
'## Highlights', '## Highlights',
'### Added', '### Added',
'- Config Window: Previous beta entry.', '- Config Window: Previous beta entry.',
+36 -1
View File
@@ -93,6 +93,36 @@ function isSupportedPrereleaseVersion(version: string): boolean {
return /^\d+\.\d+\.\d+-(beta|rc)\.\d+$/u.test(normalizeVersion(version)); return /^\d+\.\d+\.\d+-(beta|rc)\.\d+$/u.test(normalizeVersion(version));
} }
function resolvePrereleaseBaseVersion(version: string): string {
const match = /^(\d+\.\d+\.\d+)-(?:beta|rc)\.\d+$/u.exec(normalizeVersion(version));
if (!match) {
throw new Error(
`Unsupported prerelease version (${version}). Expected x.y.z-beta.N or x.y.z-rc.N.`,
);
}
return match[1]!;
}
function renderPrereleaseBaseVersionMarker(version: string): string {
return `<!-- prerelease-base-version: ${resolvePrereleaseBaseVersion(version)} -->`;
}
function extractPrereleaseBaseVersionMarker(notes: string): string | 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();
}
function resolveReusablePrereleaseNotes(notes: string, version: string): string | undefined {
const existingBaseVersion = extractPrereleaseBaseVersionMarker(notes);
if (existingBaseVersion !== resolvePrereleaseBaseVersion(version)) {
return undefined;
}
return stripPrereleaseMetadata(notes);
}
function verifyRequestedVersionMatchesPackageVersion( function verifyRequestedVersionMatchesPackageVersion(
options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>, options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>,
): void { ): void {
@@ -669,13 +699,16 @@ function renderReleaseNotes(
disclaimer?: string; disclaimer?: string;
contributions?: Contribution[]; contributions?: Contribution[];
contributorSections?: string[]; contributorSections?: string[];
metadata?: string[];
}, },
): string { ): string {
const prefix = options?.disclaimer ? [options.disclaimer, ''] : []; const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
const metadata = options?.metadata?.length ? [...options.metadata, ''] : [];
const contributorSections = const contributorSections =
options?.contributorSections ?? renderContributorsSections(options?.contributions ?? []); options?.contributorSections ?? renderContributorsSections(options?.contributions ?? []);
return [ return [
...prefix, ...prefix,
...metadata,
'## Highlights', '## Highlights',
changes, changes,
'', '',
@@ -705,6 +738,7 @@ function writeReleaseNotesFile(
outputPath?: string; outputPath?: string;
contributions?: Contribution[]; contributions?: Contribution[];
contributorSections?: string[]; contributorSections?: string[];
metadata?: string[];
}, },
): string { ): string {
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync; const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
@@ -1038,7 +1072,7 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
const prereleaseNotesPath = path.join(cwd, PRERELEASE_NOTES_PATH); const prereleaseNotesPath = path.join(cwd, PRERELEASE_NOTES_PATH);
const existingReleaseNotes = existsSync(prereleaseNotesPath) const existingReleaseNotes = existsSync(prereleaseNotesPath)
? readFileSync(prereleaseNotesPath, 'utf8') ? resolveReusablePrereleaseNotes(readFileSync(prereleaseNotesPath, 'utf8'), version)
: undefined; : undefined;
const changes = polishFragmentsWithClaude(fragments, { const changes = polishFragmentsWithClaude(fragments, {
mode: 'release-notes', mode: 'release-notes',
@@ -1052,6 +1086,7 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.', '> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
outputPath: PRERELEASE_NOTES_PATH, outputPath: PRERELEASE_NOTES_PATH,
contributions, contributions,
metadata: [renderPrereleaseBaseVersionMarker(version)],
}); });
} }
+562 -1
View File
@@ -5,6 +5,7 @@ import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { AnkiIntegration } from './anki-integration'; import { AnkiIntegration } from './anki-integration';
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge'; import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
import type { MediaInput } from './media-input';
import { AnkiConnectConfig } from './types'; import { AnkiConnectConfig } from './types';
type TestOverlayNotificationPayload = { type TestOverlayNotificationPayload = {
@@ -24,6 +25,13 @@ interface IntegrationTestContext {
stateDir: string; stateDir: string;
} }
function describeMediaInputForTest(input: MediaInput): string {
if (typeof input === 'string') {
return input;
}
return `${input.path}:${input.source ?? 'raw'}`;
}
function createIntegrationTestContext( function createIntegrationTestContext(
options: { options: {
highlightEnabled?: boolean; highlightEnabled?: boolean;
@@ -95,7 +103,7 @@ function createIntegrationTestContext(
knownWordsScope: string; knownWordsScope: string;
knownWordsLastRefreshedAtMs: number; knownWordsLastRefreshedAtMs: number;
}; };
privateState.knownWordsScope = 'is:note'; privateState.knownWordsScope = 'all';
privateState.knownWordsLastRefreshedAtMs = Date.now(); privateState.knownWordsLastRefreshedAtMs = Date.now();
return { return {
@@ -324,6 +332,119 @@ test('AnkiIntegration resolves merged-away note ids to the kept note id', () =>
} }
}); });
function processSentenceWithConfig(
config: Partial<AnkiConnectConfig>,
mpvSentence: string,
noteFields: Record<string, string>,
): string {
const integration = new AnkiIntegration(config as AnkiConnectConfig, {} as never, {} as never);
return (
integration as unknown as {
processSentence: (sentence: string, fields: Record<string, string>) => string;
}
).processSentence(mpvSentence, noteFields);
}
function processSentenceFuriganaWithConfig(
config: Partial<AnkiConnectConfig>,
sentenceFurigana: string,
noteFields: Record<string, string>,
): string {
const integration = new AnkiIntegration(config as AnkiConnectConfig, {} as never, {} as never);
return (
integration as unknown as {
processSentenceFurigana: (sentence: string, fields: Record<string, string>) => string;
}
).processSentenceFurigana(sentenceFurigana, noteFields);
}
test('AnkiIntegration highlights mined word from expression field when sentence has no bold marker', () => {
const processed = processSentenceWithConfig(
{
fields: {
word: 'Expression',
sentence: 'Sentence',
},
behavior: {
highlightWord: true,
},
},
'先日 貴様らが潜入した キールダンジョンから―',
{
expression: '潜入',
sentence: '先日 貴様らが潜入した キールダンジョンから―',
},
);
assert.equal(processed, '先日 貴様らが<b>潜入</b>した キールダンジョンから―');
});
test('AnkiIntegration keeps existing Yomitan bold target when present', () => {
const processed = processSentenceWithConfig(
{
fields: {
word: 'Expression',
sentence: 'Sentence',
},
behavior: {
highlightWord: true,
},
},
'先日 貴様らが潜入した キールダンジョンから―',
{
expression: '潜入',
sentence: '<b>潜入した</b>',
},
);
assert.equal(processed, '先日 貴様らが<b>潜入した</b> キールダンジョンから―');
});
test('AnkiIntegration leaves sentence plain when word highlighting is disabled', () => {
const processed = processSentenceWithConfig(
{
fields: {
word: 'Expression',
sentence: 'Sentence',
},
behavior: {
highlightWord: false,
},
},
'先日 貴様らが潜入した キールダンジョンから―',
{
expression: '潜入',
sentence: '<b>潜入</b>',
},
);
assert.equal(processed, '先日 貴様らが潜入した キールダンジョンから―');
});
test('AnkiIntegration highlights mined word in sentence furigana field', () => {
const processed = processSentenceFuriganaWithConfig(
{
fields: {
word: 'Expression',
sentence: 'Sentence',
},
behavior: {
highlightWord: true,
},
},
'<span class="term"><ruby>不思議<rt>ふしぎ</rt></ruby></span><span class="term">な</span><span class="term"><ruby>特技<rt>とくぎ</rt></ruby></span><span class="term">を</span>',
{
expression: '特技',
sentence: '不思議な特技を',
},
);
assert.equal(
processed,
'<span class="term"><ruby>不思議<rt>ふしぎ</rt></ruby></span><span class="term">な</span><b><span class="term"><ruby>特技<rt>とくぎ</rt></ruby></span></b><span class="term">を</span>',
);
});
test('AnkiIntegration does not allocate proxy server when proxy transport is disabled', () => { test('AnkiIntegration does not allocate proxy server when proxy transport is disabled', () => {
const integration = new AnkiIntegration( const integration = new AnkiIntegration(
{ {
@@ -414,6 +535,446 @@ test('AnkiIntegration marks partial update notifications as failures in OSD mode
assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']); 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,
) => 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;
}) => 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) => {
mediaInputs.push(
`audio:${describeMediaInputForTest(mediaPath)}:${audioStreamIndex ?? 'auto'}`,
);
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,
});
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,
});
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
assert.deepEqual(mediaInputs, [
'audio:/tmp/media.mkv:youtube-cache:auto',
'image:/tmp/media.mkv:youtube-cache',
'audio:/tmp/media.mkv:youtube-cache:auto',
'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 integration = new AnkiIntegration(
{
fields: {
image: 'Picture',
},
media: {
imageFormat: 'jpg',
},
},
{} as never,
{
currentVideoPath: 'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777',
currentSubStart: 10,
currentSubEnd: 12,
currentTimePos: 11,
} as never,
() => undefined,
undefined,
undefined,
undefined,
{},
undefined,
undefined,
async () => null,
() => true,
() => 'https://www.youtube.com/watch?v=abc123',
);
const internals = integration as unknown as {
client: {
notesInfo: (noteIds: number[]) => Promise<unknown[]>;
updateNoteFields: (noteId: number, fields: Record<string, string>) => Promise<void>;
storeMediaFile: (filename: string) => Promise<void>;
};
mediaGenerator: {
generateAudio: () => Promise<Buffer>;
generateScreenshot: () => Promise<Buffer>;
};
queuePendingYoutubeMediaUpdateForNote: (job: {
noteId: number;
noteInfo: { noteId: number; fields: Record<string, { value: string }> };
label: string | number;
}) => Promise<boolean>;
showNotification: () => Promise<void>;
};
internals.client = {
notesInfo: async (noteIds) =>
noteIds.map((noteId) => ({
noteId,
fields: {
SentenceAudio: { value: '' },
Picture: { value: '' },
},
})),
updateNoteFields: async (noteId, fields) => {
updatedNotes.push({ noteId, fields });
},
storeMediaFile: async (filename) => {
storedMedia.push(filename);
},
};
internals.mediaGenerator = {
generateAudio: async () => Buffer.from('audio'),
generateScreenshot: async () => Buffer.from('image'),
};
internals.showNotification = async () => undefined;
const queued = await internals.queuePendingYoutubeMediaUpdateForNote({
noteId: 404,
noteInfo: {
noteId: 404,
fields: {
SentenceAudio: { value: '' },
Picture: { value: '' },
},
},
label: 'resolved source',
});
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
assert.equal(queued, true);
assert.equal(updatedNotes.length, 1);
assert.equal(updatedNotes[0]?.noteId, 404);
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio_/);
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
assert.equal(storedMedia.length, 2);
});
test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube audio', async () => {
const audioCalls: Array<{ path: string; audioStreamIndex?: number }> = [];
const integration = new AnkiIntegration(
{
media: {
audioPadding: 0,
},
},
{} as never,
{
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
currentAudioStreamIndex: 0,
currentSubStart: 10,
currentSubEnd: 12,
currentTimePos: 11,
} 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,
) => Promise<Buffer>;
};
generateAudio: () => Promise<Buffer | null>;
};
internals.mediaGenerator = {
generateAudio: async (path, _startTime, _endTime, _audioPadding, audioStreamIndex) => {
audioCalls.push({ path: path.path, audioStreamIndex });
return Buffer.from('audio');
},
};
await internals.generateAudio();
assert.deepEqual(audioCalls, [
{
path: '/tmp/subminer-youtube-media-cache/media.mkv',
audioStreamIndex: undefined,
},
]);
});
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 () => { test('AnkiIntegration embeds generated notification image on overlay mined-card notifications', async () => {
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = []; const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
const overlayNotifications: TestOverlayNotificationPayload[] = []; const overlayNotifications: TestOverlayNotificationPayload[] = [];
+288 -19
View File
@@ -61,7 +61,18 @@ import { NoteUpdateWorkflow } from './anki-integration/note-update-workflow';
import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow'; import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow';
import { resolveAnimatedImageLeadInSeconds } from './anki-integration/animated-image-sync'; import { resolveAnimatedImageLeadInSeconds } from './anki-integration/animated-image-sync';
import { AnkiIntegrationRuntime, normalizeAnkiIntegrationConfig } from './anki-integration/runtime'; 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';
const log = createLogger('anki').child('integration'); const log = createLogger('anki').child('integration');
@@ -70,7 +81,7 @@ interface NoteInfo {
fields: Record<string, { value: string }>; fields: Record<string, { value: string }>;
} }
type CardKind = 'sentence' | 'audio'; type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
function trimToNonEmptyString(value: unknown): string | null { function trimToNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') return null; if (typeof value !== 'string') return null;
@@ -78,6 +89,66 @@ function trimToNonEmptyString(value: unknown): string | null {
return trimmed.length > 0 ? trimmed : null; return trimmed.length > 0 ? trimmed : null;
} }
function stripRubyReadingText(value: string): string {
return value
.replace(/<rt\b[^>]*>[\s\S]*?<\/rt>/gi, '')
.replace(/<rp\b[^>]*>[\s\S]*?<\/rp>/gi, '');
}
function stripHtmlTags(value: string): string {
return value.replace(/<[^>]+>/g, '');
}
function getVisibleFuriganaText(value: string): string {
return stripHtmlTags(stripRubyReadingText(value));
}
function boldMatchingFuriganaTerms(sentenceFurigana: string, highlightedText: string): string {
if (!sentenceFurigana || !highlightedText || /<b\b/i.test(sentenceFurigana)) {
return sentenceFurigana;
}
const spanRegex = /<span\b[^>]*>[\s\S]*?<\/span>/gi;
const spans: Array<{ start: number; end: number; visibleStart: number; visibleEnd: number }> = [];
let visibleSentence = '';
let match: RegExpExecArray | null;
while ((match = spanRegex.exec(sentenceFurigana)) !== null) {
const visibleStart = visibleSentence.length;
visibleSentence += getVisibleFuriganaText(match[0] || '');
spans.push({
start: match.index,
end: match.index + match[0].length,
visibleStart,
visibleEnd: visibleSentence.length,
});
}
if (spans.length === 0) {
return sentenceFurigana.replace(highlightedText, `<b>${highlightedText}</b>`);
}
const highlightStart = visibleSentence.indexOf(highlightedText);
if (highlightStart === -1) {
return sentenceFurigana;
}
const highlightEnd = highlightStart + highlightedText.length;
const matchingSpans = spans.filter(
(span) => span.visibleEnd > highlightStart && span.visibleStart < highlightEnd,
);
if (matchingSpans.length === 0) {
return sentenceFurigana;
}
let result = sentenceFurigana;
for (const span of [...matchingSpans].reverse()) {
result = `${result.slice(0, span.start)}<b>${result.slice(
span.start,
span.end,
)}</b>${result.slice(span.end)}`;
}
return result;
}
function decodeURIComponentSafe(value: string): string { function decodeURIComponentSafe(value: string): string {
try { try {
return decodeURIComponent(value); return decodeURIComponent(value);
@@ -165,6 +236,13 @@ export class AnkiIntegration {
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null; private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
private noteIdRedirects = new Map<number, number>(); private noteIdRedirects = new Map<number, number>();
private trackedDuplicateNoteIds = 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( constructor(
config: AnkiConnectConfig, config: AnkiConnectConfig,
@@ -180,6 +258,9 @@ export class AnkiIntegration {
aiConfig: AiConfig = {}, aiConfig: AiConfig = {},
recordCardsMined?: (count: number, noteIds?: number[]) => void, recordCardsMined?: (count: number, noteIds?: number[]) => void,
overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void, overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void,
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'],
shouldRequireRemoteMediaCache?: () => boolean,
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined,
) { ) {
this.config = normalizeAnkiIntegrationConfig(config); this.config = normalizeAnkiIntegrationConfig(config);
this.aiConfig = { ...aiConfig }; this.aiConfig = { ...aiConfig };
@@ -192,6 +273,10 @@ export class AnkiIntegration {
this.overlayNotificationCallback = overlayNotificationCallback || null; this.overlayNotificationCallback = overlayNotificationCallback || null;
this.fieldGroupingCallback = fieldGroupingCallback || null; this.fieldGroupingCallback = fieldGroupingCallback || null;
this.recordCardsMinedCallback = recordCardsMined ?? 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.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
this.pollingRunner = this.createPollingRunner(); this.pollingRunner = this.createPollingRunner();
this.cardCreationService = this.createCardCreationService(); this.cardCreationService = this.createCardCreationService();
@@ -235,6 +320,81 @@ 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) =>
this.mediaGenerator.generateAudio(
videoPath,
startTime,
endTime,
audioPadding,
audioStreamIndex,
),
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),
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 { private createKnownWordCache(knownWordCacheStatePath?: string): KnownWordCacheManager {
return new KnownWordCacheManager({ return new KnownWordCacheManager({
client: { client: {
@@ -317,6 +477,10 @@ export class AnkiIntegration {
getAiConfig: () => this.aiConfig, getAiConfig: () => this.aiConfig,
getTimingTracker: () => this.timingTracker, getTimingTracker: () => this.timingTracker,
getMpvClient: () => this.mpvClient, getMpvClient: () => this.mpvClient,
...(this.getCachedMediaPath ? { getCachedMediaPath: this.getCachedMediaPath } : {}),
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
getYoutubeMediaSourceUrl: () => this.getCurrentYoutubeMediaSourceUrl(),
queuePendingYoutubeMediaUpdate: (job) => this.queuePendingYoutubeMediaUpdate(job),
getDeck: () => this.config.deck, getDeck: () => this.config.deck,
client: { client: {
addNote: (deck, modelName, fields, tags) => addNote: (deck, modelName, fields, tags) =>
@@ -461,6 +625,10 @@ export class AnkiIntegration {
handleFieldGroupingManual: (originalNoteId, newNoteId, newNoteInfo, expression) => handleFieldGroupingManual: (originalNoteId, newNoteId, newNoteInfo, expression) =>
this.handleFieldGroupingManual(originalNoteId, newNoteId, newNoteInfo, expression), this.handleFieldGroupingManual(originalNoteId, newNoteId, newNoteInfo, expression),
processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields), processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields),
processSentenceFurigana: (sentenceFurigana, noteFields) =>
this.processSentenceFurigana(sentenceFurigana, noteFields),
setCardTypeFields: (updatedFields, availableFieldNames, cardKind) =>
this.setCardTypeFields(updatedFields, availableFieldNames, cardKind),
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
this.resolveConfiguredFieldName(noteInfo, ...preferredNames), this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
getResolvedSentenceAudioFieldName: (noteInfo) => getResolvedSentenceAudioFieldName: (noteInfo) =>
@@ -476,6 +644,7 @@ export class AnkiIntegration {
formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) => formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) =>
this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds), this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds),
consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(), consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(),
queuePendingYoutubeMediaUpdate: (job) => this.queuePendingYoutubeMediaUpdateForNote(job),
addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId), addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
showNotification: (noteId, label) => this.showNotification(noteId, label), showNotification: (noteId, label) => this.showNotification(noteId, label),
showOsdNotification: (message) => this.showStatusNotification(message), showOsdNotification: (message) => this.showStatusNotification(message),
@@ -677,20 +846,25 @@ export class AnkiIntegration {
return result; return result;
} }
private getSentenceHighlightText(noteFields: Record<string, string>): string {
const sentenceFieldName = this.config.fields?.sentence?.toLowerCase() || 'sentence';
const existingSentence = noteFields[sentenceFieldName] || '';
return (
existingSentence.match(/<b>(.*?)<\/b>/)?.[1] ||
getPreferredWordValueFromExtractedFields(noteFields, this.config).trim()
);
}
private processSentence(mpvSentence: string, noteFields: Record<string, string>): string { private processSentence(mpvSentence: string, noteFields: Record<string, string>): string {
if (this.config.behavior?.highlightWord === false) { if (this.config.behavior?.highlightWord === false) {
return mpvSentence; return mpvSentence;
} }
const sentenceFieldName = this.config.fields?.sentence?.toLowerCase() || 'sentence'; const highlightedText = this.getSentenceHighlightText(noteFields);
const existingSentence = noteFields[sentenceFieldName] || ''; if (!highlightedText) {
const highlightMatch = existingSentence.match(/<b>(.*?)<\/b>/);
if (!highlightMatch || !highlightMatch[1]) {
return mpvSentence; return mpvSentence;
} }
const highlightedText = highlightMatch[1];
const index = mpvSentence.indexOf(highlightedText); const index = mpvSentence.indexOf(highlightedText);
if (index === -1) { if (index === -1) {
@@ -702,6 +876,20 @@ export class AnkiIntegration {
return `${prefix}<b>${highlightedText}</b>${suffix}`; return `${prefix}<b>${highlightedText}</b>${suffix}`;
} }
private processSentenceFurigana(
sentenceFurigana: string,
noteFields: Record<string, string>,
): string {
if (this.config.behavior?.highlightWord === false) {
return sentenceFurigana;
}
const highlightedText = this.getSentenceHighlightText(noteFields);
return highlightedText
? boldMatchingFuriganaTerms(sentenceFurigana, highlightedText)
: sentenceFurigana;
}
private consumeSubtitleMiningContext(): SubtitleMiningContext | null { private consumeSubtitleMiningContext(): SubtitleMiningContext | null {
if (!this.consumeSubtitleMiningContextCallback) { if (!this.consumeSubtitleMiningContextCallback) {
return null; return null;
@@ -750,13 +938,53 @@ 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) ??
''
);
}
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> { private async generateAudio(context?: SubtitleMiningContext): Promise<Buffer | null> {
const mpvClient = this.mpvClient; const mpvClient = this.mpvClient;
if (!mpvClient || !mpvClient.currentVideoPath) { if (!mpvClient || !mpvClient.currentVideoPath) {
return null; return null;
} }
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'audio'); const videoPath = await resolveMediaGenerationInput(
mpvClient,
'audio',
this.getMediaResolverOptions(),
);
if (!videoPath) { if (!videoPath) {
return null; return null;
} }
@@ -767,7 +995,7 @@ export class AnkiIntegration {
startTime, startTime,
endTime, endTime,
this.config.media?.audioPadding, this.config.media?.audioPadding,
this.mpvClient.currentAudioStreamIndex, resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
); );
} }
@@ -779,7 +1007,11 @@ export class AnkiIntegration {
return null; return null;
} }
const videoPath = await resolveMediaGenerationInputPath(this.mpvClient, 'video'); const videoPath = await resolveMediaGenerationInput(
this.mpvClient,
'video',
this.getMediaResolverOptions(),
);
if (!videoPath) { if (!videoPath) {
return null; return null;
} }
@@ -813,17 +1045,30 @@ export class AnkiIntegration {
} }
private formatMiscInfoPattern(fallbackFilename: string, startTimeSeconds?: number): string { 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) { if (!this.config.metadata?.pattern) {
return ''; return '';
} }
const currentVideoPath = this.mpvClient.currentVideoPath || ''; const videoFilename = extractFilenameFromMediaPath(mediaPath);
const videoFilename = extractFilenameFromMediaPath(currentVideoPath); const resolvedMediaTitle = trimToNonEmptyString(mediaTitle);
const mediaTitle = trimToNonEmptyString(this.mpvClient.currentMediaTitle);
const filenameWithExt = const filenameWithExt =
(shouldPreferMediaTitleForMiscInfo(currentVideoPath, videoFilename) (shouldPreferMediaTitleForMiscInfo(mediaPath, videoFilename)
? mediaTitle || videoFilename ? resolvedMediaTitle || videoFilename
: videoFilename || mediaTitle) || fallbackFilename; : videoFilename || resolvedMediaTitle) || fallbackFilename;
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, ''); const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
const currentTimePos = const currentTimePos =
@@ -1030,6 +1275,30 @@ export class AnkiIntegration {
): void { ): void {
const audioFlagNames = ['IsAudioCard']; const audioFlagNames = ['IsAudioCard'];
if (cardKind === 'word-and-sentence') {
const wordAndSentenceFlag = this.resolveFieldName(
availableFieldNames,
'IsWordAndSentenceCard',
);
if (!wordAndSentenceFlag) {
return;
}
updatedFields[wordAndSentenceFlag] = 'x';
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
if (sentenceFlag && sentenceFlag !== wordAndSentenceFlag) {
updatedFields[sentenceFlag] = '';
}
for (const audioFlagName of audioFlagNames) {
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
if (resolved && resolved !== wordAndSentenceFlag) {
updatedFields[resolved] = '';
}
}
return;
}
if (cardKind === 'sentence') { if (cardKind === 'sentence') {
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard'); const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
if (sentenceFlag) { if (sentenceFlag) {
@@ -1350,7 +1619,7 @@ export class AnkiIntegration {
miscInfoValue?: string; miscInfoValue?: string;
} = {}; } = {};
if (this.config.media?.generateAudio && this.mpvClient?.currentVideoPath) { if (this.config.media?.generateAudio !== false && this.mpvClient?.currentVideoPath) {
try { try {
const audioFilename = this.generateAudioFilename(); const audioFilename = this.generateAudioFilename();
const audioBuffer = await this.generateAudio(); const audioBuffer = await this.generateAudio();
@@ -1370,7 +1639,7 @@ export class AnkiIntegration {
} }
} }
if (this.config.media?.generateImage && this.mpvClient?.currentVideoPath) { if (this.config.media?.generateImage !== false && this.mpvClient?.currentVideoPath) {
try { try {
const animatedLeadInSeconds = noteInfo const animatedLeadInSeconds = noteInfo
? await this.getAnimatedImageLeadInSeconds(noteInfo) ? await this.getAnimatedImageLeadInSeconds(noteInfo)
@@ -2,10 +2,32 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { CardCreationService } from './card-creation'; import { CardCreationService } from './card-creation';
import type { MediaInput } from '../media-generator';
import type { AnkiConnectConfig } from '../types/anki'; import type { AnkiConnectConfig } from '../types/anki';
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0]; type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
function setWordAndSentenceCardTypeFields(
updatedFields: Record<string, string>,
availableFieldNames: string[],
cardKind: 'sentence' | 'audio' | 'word-and-sentence',
): void {
if (cardKind !== 'word-and-sentence') return;
const resolveFieldName = (preferredName: string): string | null =>
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
if (!wordAndSentenceFlag) return;
updatedFields[wordAndSentenceFlag] = 'x';
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
const resolved = resolveFieldName(flagName);
if (resolved && resolved !== wordAndSentenceFlag) {
updatedFields[resolved] = '';
}
}
}
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): { function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
service: CardCreationService; service: CardCreationService;
updatedFields: Record<string, string>[]; updatedFields: Record<string, string>[];
@@ -142,6 +164,72 @@ test('manual clipboard subtitle update replaces sentence audio without touching
); );
}); });
test('manual clipboard subtitle update marks Kiku word cards as word-and-sentence cards when enabled', async () => {
const { service, updatedFields } = createManualUpdateService({
getConfig: () =>
({
deck: 'Mining',
fields: {
word: 'Expression',
sentence: 'Sentence',
audio: 'ExpressionAudio',
},
media: {
generateAudio: false,
generateImage: false,
maxMediaDuration: 30,
},
behavior: {
overwriteAudio: false,
overwriteImage: false,
},
ai: false,
}) as AnkiConnectConfig,
client: {
addNote: async () => 0,
addTags: async () => undefined,
notesInfo: async () => [
{
noteId: 42,
fields: {
Expression: { value: '単語' },
Sentence: { value: '' },
IsWordAndSentenceCard: { value: '' },
IsSentenceCard: { value: '' },
IsAudioCard: { value: '' },
},
},
],
updateNoteFields: async (_noteId, fields) => {
updatedFields.push(fields);
},
storeMediaFile: async () => undefined,
findNotes: async () => [42],
retrieveMediaFile: async () => '',
},
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
}),
setCardTypeFields: setWordAndSentenceCardTypeFields,
});
await service.updateLastAddedFromClipboard('字幕');
assert.equal(updatedFields.length, 1);
assert.deepEqual(updatedFields[0], {
Sentence: '字幕',
IsWordAndSentenceCard: 'x',
IsSentenceCard: '',
IsAudioCard: '',
});
});
test('manual clipboard subtitle update skips audio when sentence audio field is missing', async () => { test('manual clipboard subtitle update skips audio when sentence audio field is missing', async () => {
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService({ const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService({
client: { client: {
@@ -179,6 +267,8 @@ 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 () => { test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => {
const audioPaths: string[] = []; const audioPaths: string[] = [];
const imagePaths: string[] = []; const imagePaths: string[] = [];
const recordMediaPath = (mediaInput: MediaInput): string =>
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
const edlSource = [ const edlSource = [
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm', 'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4', '!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
@@ -251,11 +341,11 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
}, },
mediaGenerator: { mediaGenerator: {
generateAudio: async (path) => { generateAudio: async (path) => {
audioPaths.push(path); audioPaths.push(recordMediaPath(path));
return Buffer.from('audio'); return Buffer.from('audio');
}, },
generateScreenshot: async (path) => { generateScreenshot: async (path) => {
imagePaths.push(path); imagePaths.push(recordMediaPath(path));
return Buffer.from('image'); return Buffer.from('image');
}, },
generateAnimatedImage: async () => null, generateAnimatedImage: async () => null,
+258 -2
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { CardCreationService } from './card-creation'; import { CardCreationService } from './card-creation';
import type { MediaInput } from '../media-generator';
import type { AnkiConnectConfig } from '../types/anki'; import type { AnkiConnectConfig } from '../types/anki';
test('CardCreationService counts locally created sentence cards', async () => { test('CardCreationService counts locally created sentence cards', async () => {
@@ -287,6 +288,8 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
test('CardCreationService uses stream-open-filename for remote media generation', async () => { test('CardCreationService uses stream-open-filename for remote media generation', async () => {
const audioPaths: string[] = []; const audioPaths: string[] = [];
const imagePaths: string[] = []; const imagePaths: string[] = [];
const recordMediaPath = (mediaInput: MediaInput): string =>
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
const edlSource = [ const edlSource = [
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm', 'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4', '!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
@@ -345,11 +348,11 @@ test('CardCreationService uses stream-open-filename for remote media generation'
}, },
mediaGenerator: { mediaGenerator: {
generateAudio: async (path) => { generateAudio: async (path) => {
audioPaths.push(path); audioPaths.push(recordMediaPath(path));
return Buffer.from('audio'); return Buffer.from('audio');
}, },
generateScreenshot: async (path) => { generateScreenshot: async (path) => {
imagePaths.push(path); imagePaths.push(recordMediaPath(path));
return Buffer.from('image'); return Buffer.from('image');
}, },
generateAnimatedImage: async () => null, generateAnimatedImage: async () => null,
@@ -398,6 +401,259 @@ test('CardCreationService uses stream-open-filename for remote media generation'
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']); assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
}); });
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;
}> = [];
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 () => {
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,
},
]);
assert.deepEqual(updates, []);
});
test('CardCreationService tracks pre-add duplicate note ids for kiku sentence cards', async () => { test('CardCreationService tracks pre-add duplicate note ids for kiku sentence cards', async () => {
const trackedDuplicates: Array<{ noteId: number; duplicateNoteIds: number[] }> = []; const trackedDuplicates: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
const duplicateLookupExpressions: string[] = []; const duplicateLookupExpressions: string[] = [];
+147 -51
View File
@@ -5,20 +5,43 @@ import {
} from '../anki-field-config'; } from '../anki-field-config';
import { AnkiConnectConfig } from '../types/anki'; import { AnkiConnectConfig } from '../types/anki';
import { createLogger } from '../logger'; import { createLogger } from '../logger';
import type { MediaInput } from '../media-input';
import { SubtitleTimingTracker } from '../subtitle-timing-tracker'; import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
import { AiConfig } from '../types/integrations'; import { AiConfig } from '../types/integrations';
import { MpvClient } from '../types/runtime'; import { MpvClient } from '../types/runtime';
import { resolveSentenceBackText } from './ai'; 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';
const log = createLogger('anki').child('integration.card-creation'); 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 { export interface CardCreationNoteInfo {
noteId: number; noteId: number;
fields: Record<string, { value: string }>; fields: Record<string, { value: string }>;
} }
type CardKind = 'sentence' | 'audio'; type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
interface CardCreationClient { interface CardCreationClient {
addNote( addNote(
@@ -37,14 +60,14 @@ interface CardCreationClient {
interface CardCreationMediaGenerator { interface CardCreationMediaGenerator {
generateAudio( generateAudio(
path: string, path: MediaInput,
startTime: number, startTime: number,
endTime: number, endTime: number,
audioPadding?: number, audioPadding?: number,
audioStreamIndex?: number, audioStreamIndex?: number,
): Promise<Buffer | null>; ): Promise<Buffer | null>;
generateScreenshot( generateScreenshot(
path: string, path: MediaInput,
timestamp: number, timestamp: number,
options: { options: {
format: 'jpg' | 'png' | 'webp'; format: 'jpg' | 'png' | 'webp';
@@ -54,7 +77,7 @@ interface CardCreationMediaGenerator {
}, },
): Promise<Buffer | null>; ): Promise<Buffer | null>;
generateAnimatedImage( generateAnimatedImage(
path: string, path: MediaInput,
startTime: number, startTime: number,
endTime: number, endTime: number,
audioPadding?: number, audioPadding?: number,
@@ -73,6 +96,10 @@ interface CardCreationDeps {
getAiConfig: () => AiConfig; getAiConfig: () => AiConfig;
getTimingTracker: () => SubtitleTimingTracker; getTimingTracker: () => SubtitleTimingTracker;
getMpvClient: () => MpvClient; getMpvClient: () => MpvClient;
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'];
shouldRequireRemoteMediaCache?: () => boolean;
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
queuePendingYoutubeMediaUpdate?: (job: PendingYoutubeMediaUpdate) => void;
getDeck?: () => string | undefined; getDeck?: () => string | undefined;
client: CardCreationClient; client: CardCreationClient;
mediaGenerator: CardCreationMediaGenerator; mediaGenerator: CardCreationMediaGenerator;
@@ -120,6 +147,19 @@ interface CardCreationDeps {
export class CardCreationService { export class CardCreationService {
constructor(private readonly deps: CardCreationDeps) {} 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[] { private getConfiguredAnkiTags(): string[] {
const tags = this.deps.getConfig().tags; const tags = this.deps.getConfig().tags;
if (!Array.isArray(tags)) { if (!Array.isArray(tags)) {
@@ -219,7 +259,8 @@ export class CardCreationService {
this.deps.getConfig(), this.deps.getConfig(),
); );
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo); const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo);
const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField; const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const sentenceField = sentenceCardConfig.sentenceField;
const sentence = blocks.join(' '); const sentence = blocks.join(' ');
const updatedFields: Record<string, string> = {}; const updatedFields: Record<string, string> = {};
@@ -230,6 +271,13 @@ export class CardCreationService {
if (sentenceField) { if (sentenceField) {
const processedSentence = this.deps.processSentence(sentence, fields); const processedSentence = this.deps.processSentence(sentence, fields);
updatedFields[sentenceField] = processedSentence; updatedFields[sentenceField] = processedSentence;
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
this.deps.setCardTypeFields(
updatedFields,
Object.keys(noteInfo.fields),
'word-and-sentence',
);
}
updatePerformed = true; updatePerformed = true;
} }
@@ -237,14 +285,18 @@ export class CardCreationService {
`Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`, `Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`,
); );
const audioSourcePath = this.deps.getConfig().media?.generateAudio const config = this.deps.getConfig();
? await resolveMediaGenerationInputPath(mpvClient, 'audio') const generateAudio = shouldGenerateAudio(config);
const generateImage = shouldGenerateImage(config);
const mediaResolverOptions = this.getMediaResolverOptions();
const audioSourcePath = generateAudio
? await resolveMediaGenerationInput(mpvClient, 'audio', mediaResolverOptions)
: null; : null;
const videoPath = this.deps.getConfig().media?.generateImage const videoPath = generateImage
? await resolveMediaGenerationInputPath(mpvClient, 'video') ? await resolveMediaGenerationInput(mpvClient, 'video', mediaResolverOptions)
: null; : null;
if (this.deps.getConfig().media?.generateAudio) { if (generateAudio) {
try { try {
const audioFilename = this.generateAudioFilename(); const audioFilename = this.generateAudioFilename();
const audioBuffer = audioSourcePath const audioBuffer = audioSourcePath
@@ -272,7 +324,7 @@ export class CardCreationService {
} }
} }
if (this.deps.getConfig().media?.generateImage) { if (generateImage) {
try { try {
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo); const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
const imageFilename = this.generateImageFilename(); const imageFilename = this.generateImageFilename();
@@ -432,7 +484,7 @@ export class CardCreationService {
errors.push('audio'); errors.push('audio');
} }
if (this.deps.getConfig().media?.generateImage) { if (shouldGenerateImage(this.deps.getConfig())) {
try { try {
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo); const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
const imageFilename = this.generateImageFilename(); const imageFilename = this.generateImageFilename();
@@ -513,9 +565,23 @@ export class CardCreationService {
try { try {
return await this.deps.withUpdateProgress('Creating sentence card', async () => { return await this.deps.withUpdateProgress('Creating sentence card', async () => {
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'video'); const config = this.deps.getConfig();
const audioSourcePath = await resolveMediaGenerationInputPath(mpvClient, 'audio'); const generateAudio = shouldGenerateAudio(config);
if (!videoPath) { 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'); this.deps.showOsdNotification('No video loaded');
return false; return false;
} }
@@ -525,13 +591,13 @@ export class CardCreationService {
const sentenceField = sentenceCardConfig.sentenceField; const sentenceField = sentenceCardConfig.sentenceField;
const audioFieldName = sentenceCardConfig.audioField || 'SentenceAudio'; 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 resolvedMiscInfoField: string | null = null;
let resolvedSentenceAudioField: string = audioFieldName; let resolvedSentenceAudioField: string = audioFieldName;
fields[sentenceField] = sentence; fields[sentenceField] = sentence;
const ankiAiConfig = this.deps.getConfig().ai; const ankiAiConfig = config.ai;
const ankiAiEnabled = const ankiAiEnabled =
typeof ankiAiConfig === 'object' && ankiAiConfig !== null typeof ankiAiConfig === 'object' && ankiAiConfig !== null
? ankiAiConfig.enabled === true ? ankiAiConfig.enabled === true
@@ -554,7 +620,7 @@ export class CardCreationService {
if (sentenceCardConfig.lapisEnabled || sentenceCardConfig.kikuEnabled) { if (sentenceCardConfig.lapisEnabled || sentenceCardConfig.kikuEnabled) {
fields.IsSentenceCard = 'x'; fields.IsSentenceCard = 'x';
fields[getConfiguredWordFieldName(this.deps.getConfig())] = sentence; fields[getConfiguredWordFieldName(config)] = sentence;
} }
const pendingNoteInfo = this.createPendingNoteInfo(fields); const pendingNoteInfo = this.createPendingNoteInfo(fields);
@@ -563,7 +629,7 @@ export class CardCreationService {
); );
const pendingExpressionText = getPreferredWordValueFromExtractedFields( const pendingExpressionText = getPreferredWordValueFromExtractedFields(
pendingNoteFields, pendingNoteFields,
this.deps.getConfig(), config,
).trim(); ).trim();
let duplicateNoteIds: number[] = []; let duplicateNoteIds: number[] = [];
if ( if (
@@ -581,7 +647,7 @@ export class CardCreationService {
} }
} }
const deck = this.deps.getConfig().deck || 'Default'; const deck = config.deck || 'Default';
let noteId: number; let noteId: number;
try { try {
noteId = await this.deps.client.addNote( noteId = await this.deps.client.addNote(
@@ -627,7 +693,7 @@ export class CardCreationService {
this.deps.resolveNoteFieldName(createdNoteInfo, audioFieldName) || audioFieldName; this.deps.resolveNoteFieldName(createdNoteInfo, audioFieldName) || audioFieldName;
resolvedMiscInfoField = this.deps.resolveConfiguredFieldName( resolvedMiscInfoField = this.deps.resolveConfiguredFieldName(
createdNoteInfo, createdNoteInfo,
this.deps.getConfig().fields?.miscInfo, config.fields?.miscInfo,
); );
const cardTypeFields: Record<string, string> = {}; const cardTypeFields: Record<string, string> = {};
@@ -645,41 +711,69 @@ export class CardCreationService {
errors.push('card type fields'); errors.push('card type fields');
} }
const label = sentence.length > 30 ? sentence.substring(0, 30) + '...' : sentence;
if (shouldQueuePendingYoutubeMedia) {
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,
});
await this.deps.showNotification(noteId, label, 'media queued');
return true;
}
if (missingRequestedMediaInput) {
return false;
}
const mediaFields: Record<string, string> = {}; const mediaFields: Record<string, string> = {};
try { if (generateAudio) {
const audioFilename = this.generateAudioFilename(); try {
const audioBuffer = audioSourcePath const audioFilename = this.generateAudioFilename();
? await this.mediaGenerateAudio(audioSourcePath, startTime, endTime) const audioBuffer = audioSourcePath
: null; ? await this.mediaGenerateAudio(audioSourcePath, startTime, endTime)
: null;
if (audioBuffer) { if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer); await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
const audioValue = `[sound:${audioFilename}]`; const audioValue = `[sound:${audioFilename}]`;
mediaFields[resolvedSentenceAudioField] = audioValue; mediaFields[resolvedSentenceAudioField] = audioValue;
miscInfoFilename = audioFilename; 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 { if (generateImage) {
const imageFilename = this.generateImageFilename(); try {
const imageBuffer = await this.generateImageBuffer(videoPath, startTime, endTime); const imageFilename = this.generateImageFilename();
const imageBuffer = await this.generateImageBuffer(videoPath!, startTime, endTime);
const imageField = this.deps.getConfig().fields?.image; const imageField = config.fields?.image;
if (imageBuffer && imageField) { if (imageBuffer && imageField) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer); await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
mediaFields[imageField] = `<img src="${imageFilename}">`; mediaFields[imageField] = `<img src="${imageFilename}">`;
miscInfoFilename = 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); const miscInfo = this.deps.formatMiscInfoPattern(miscInfoFilename || '', startTime);
if (miscInfo && resolvedMiscInfoField) { if (miscInfo && resolvedMiscInfoField) {
mediaFields[resolvedMiscInfoField] = miscInfo; mediaFields[resolvedMiscInfoField] = miscInfo;
@@ -695,7 +789,6 @@ export class CardCreationService {
} }
} }
const label = sentence.length > 30 ? sentence.substring(0, 30) + '...' : sentence;
const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined; const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined;
await this.deps.showNotification(noteId, label, errorSuffix); await this.deps.showNotification(noteId, label, errorSuffix);
return true; return true;
@@ -731,7 +824,7 @@ export class CardCreationService {
} }
private async mediaGenerateAudio( private async mediaGenerateAudio(
videoPath: string, videoPath: MediaInput,
startTime: number, startTime: number,
endTime: number, endTime: number,
): Promise<Buffer | null> { ): Promise<Buffer | null> {
@@ -745,12 +838,15 @@ export class CardCreationService {
startTime, startTime,
endTime, endTime,
this.deps.getConfig().media?.audioPadding, this.deps.getConfig().media?.audioPadding,
mpvClient.currentAudioStreamIndex ?? undefined, resolveAudioStreamIndexForMediaGeneration(
videoPath,
mpvClient.currentAudioStreamIndex ?? undefined,
),
); );
} }
private async generateImageBuffer( private async generateImageBuffer(
videoPath: string, videoPath: MediaInput,
startTime: number, startTime: number,
endTime: number, endTime: number,
animatedLeadInSeconds = 0, animatedLeadInSeconds = 0,
+35 -5
View File
@@ -94,7 +94,7 @@ test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without i
JSON.stringify({ JSON.stringify({
version: 2, version: 2,
refreshedAtMs: 120_000, refreshedAtMs: 120_000,
scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":""}', scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":""}',
words: ['猫'], words: ['猫'],
notes: { notes: {
'1': ['猫'], '1': ['猫'],
@@ -143,7 +143,7 @@ test('KnownWordCacheManager startLifecycle immediately refreshes stale persisted
JSON.stringify({ JSON.stringify({
version: 2, version: 2,
refreshedAtMs: 59_000, refreshedAtMs: 59_000,
scope: '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}', scope: '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}',
words: ['猫'], words: ['猫'],
notes: { notes: {
'1': ['猫'], '1': ['猫'],
@@ -229,7 +229,7 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
JSON.stringify({ JSON.stringify({
version: 2, version: 2,
refreshedAtMs: 1, refreshedAtMs: 1,
scope: '{"refreshMinutes":1440,"scope":"is:note","fieldsWord":"Word"}', scope: '{"refreshMinutes":1440,"scope":"all","fieldsWord":"Word"}',
words: ['猫', '犬'], words: ['猫', '犬'],
notes: { notes: {
'1': ['猫'], '1': ['猫'],
@@ -276,6 +276,36 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
} }
}); });
test('KnownWordCacheManager uses empty query when no known-word deck is configured', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesByQuery.set('', [1]);
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Word: { value: '猫' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('猫'), true);
} finally {
cleanup();
}
});
test('KnownWordCacheManager skips malformed note info without fields', async () => { test('KnownWordCacheManager skips malformed note info without fields', async () => {
const config: AnkiConnectConfig = { const config: AnkiConnectConfig = {
fields: { fields: {
@@ -364,7 +394,7 @@ test('KnownWordCacheManager preserves cache state key captured before refresh wo
scope: string; scope: string;
words: string[]; words: string[];
}; };
assert.equal(persisted.scope, '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}'); assert.equal(persisted.scope, '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}');
assert.deepEqual(persisted.words, ['猫']); assert.deepEqual(persisted.words, ['猫']);
} finally { } finally {
fs.rmSync(stateDir, { recursive: true, force: true }); fs.rmSync(stateDir, { recursive: true, force: true });
@@ -568,7 +598,7 @@ test('KnownWordCacheManager reports immediate append cache clears as mutations',
JSON.stringify({ JSON.stringify({
version: 2, version: 2,
refreshedAtMs: Date.now(), refreshedAtMs: Date.now(),
scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":"Expression"}', scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":"Expression"}',
words: ['猫'], words: ['猫'],
notes: { notes: {
'1': ['猫'], '1': ['猫'],
+2 -2
View File
@@ -48,7 +48,7 @@ export function getKnownWordCacheScopeForConfig(config: AnkiConnectConfig): stri
} }
const configuredDeck = trimToNonEmptyString(config.deck); const configuredDeck = trimToNonEmptyString(config.deck);
return configuredDeck ? `deck:${configuredDeck}` : 'is:note'; return configuredDeck ? `deck:${configuredDeck}` : 'all';
} }
export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string { export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string {
@@ -396,7 +396,7 @@ export class KnownWordCacheManager {
private buildKnownWordsQuery(): string { private buildKnownWordsQuery(): string {
const decks = this.getKnownWordDecks(); const decks = this.getKnownWordDecks();
if (decks.length === 0) { if (decks.length === 0) {
return 'is:note'; return '';
} }
if (decks.length === 1) { if (decks.length === 1) {
+235 -1
View File
@@ -1,7 +1,33 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { resolveMediaGenerationInputPath } from './media-source'; import * as mediaSource from './media-source';
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 () => { test('resolveMediaGenerationInputPath keeps local file paths', async () => {
const result = await resolveMediaGenerationInputPath({ const result = await resolveMediaGenerationInputPath({
@@ -62,3 +88,211 @@ test('resolveMediaGenerationInputPath falls back to currentVideoPath when stream
assert.equal(result, 'https://www.youtube.com/watch?v=abc123'); 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 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',
].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, 'https://audio.example/videoplayback?mime=audio%2Fwebm');
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/);
});
+372
View File
@@ -1,7 +1,57 @@
import { isRemoteMediaPath } from '../jimaku/utils'; import { isRemoteMediaPath } from '../jimaku/utils';
import type { MediaInput, MediaInputOptions } from '../media-input';
import type { MpvClient } from '../types/runtime'; import type { MpvClient } from '../types/runtime';
export type MediaGenerationKind = 'audio' | 'video'; 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 { function trimToNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') { if (typeof value !== 'string') {
@@ -11,6 +61,17 @@ function trimToNonEmptyString(value: unknown): string | null {
return trimmed.length > 0 ? trimmed : 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[] { function extractUrlsFromMpvEdlSource(source: string): string[] {
const matches = source.matchAll(/%\d+%(https?:\/\/.*?)(?=;!new_stream|;!global_tags|$)/gms); const matches = source.matchAll(/%\d+%(https?:\/\/.*?)(?=;!new_stream|;!global_tags|$)/gms);
return [...matches] return [...matches]
@@ -53,6 +114,317 @@ function resolvePreferredUrlFromMpvEdlSource(
return kind === 'audio' ? (urls[0] ?? null) : (urls[urls.length - 1] ?? null); 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( export async function resolveMediaGenerationInputPath(
mpvClient: Pick<MpvClient, 'currentVideoPath' | 'requestProperty'> | null | undefined, mpvClient: Pick<MpvClient, 'currentVideoPath' | 'requestProperty'> | null | undefined,
kind: MediaGenerationKind = 'video', kind: MediaGenerationKind = 'video',
+37
View File
@@ -0,0 +1,37 @@
export interface NoteFieldValueInfo {
fields: Record<string, { value: string }>;
}
export function getNoteFieldValue(
noteInfo: NoteFieldValueInfo,
preferredName: string,
): string | null {
const resolvedFieldName = Object.keys(noteInfo.fields).find(
(fieldName) => fieldName.toLowerCase() === preferredName.toLowerCase(),
);
return resolvedFieldName ? (noteInfo.fields[resolvedFieldName]?.value ?? '') : null;
}
export function hasNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: string): boolean {
return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0;
}
export function shouldMarkWordAndSentenceCard(
noteInfo: NoteFieldValueInfo,
sentenceCardConfig: { lapisEnabled: boolean; kikuEnabled: boolean },
): boolean {
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
return false;
}
const wordAndSentenceValue = getNoteFieldValue(noteInfo, 'IsWordAndSentenceCard');
if (wordAndSentenceValue === null) {
return false;
}
if (wordAndSentenceValue.trim().length > 0) {
return true;
}
return (
!hasNoteFieldValue(noteInfo, 'IsSentenceCard') && !hasNoteFieldValue(noteInfo, 'IsAudioCard')
);
}
@@ -7,6 +7,27 @@ import {
} from './note-update-workflow'; } from './note-update-workflow';
import type { SubtitleMiningContext } from '../types/subtitle'; import type { SubtitleMiningContext } from '../types/subtitle';
function setWordAndSentenceCardTypeFields(
updatedFields: Record<string, string>,
availableFieldNames: string[],
cardKind: 'word-and-sentence',
): void {
assert.equal(cardKind, 'word-and-sentence');
const resolveFieldName = (preferredName: string): string | null =>
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
if (!wordAndSentenceFlag) return;
updatedFields[wordAndSentenceFlag] = 'x';
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
const resolved = resolveFieldName(flagName);
if (resolved && resolved !== wordAndSentenceFlag) {
updatedFields[resolved] = '';
}
}
}
function createWorkflowHarness() { function createWorkflowHarness() {
const updates: Array<{ noteId: number; fields: Record<string, string> }> = []; const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
const notifications: Array<{ noteId: number; label: string | number }> = []; const notifications: Array<{ noteId: number; label: string | number }> = [];
@@ -40,6 +61,7 @@ function createWorkflowHarness() {
getCurrentSubtitleStart: () => 12.3, getCurrentSubtitleStart: () => 12.3,
getEffectiveSentenceCardConfig: () => ({ getEffectiveSentenceCardConfig: () => ({
sentenceField: 'Sentence', sentenceField: 'Sentence',
lapisEnabled: false,
kikuEnabled: false, kikuEnabled: false,
kikuFieldGrouping: 'disabled' as const, kikuFieldGrouping: 'disabled' as const,
}), }),
@@ -57,6 +79,7 @@ function createWorkflowHarness() {
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) => handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
false, false,
processSentence: (text: string, _noteFields: Record<string, string>) => text, processSentence: (text: string, _noteFields: Record<string, string>) => text,
setCardTypeFields: setWordAndSentenceCardTypeFields,
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => { resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
if (!preferred) return null; if (!preferred) return null;
const names = Object.keys(noteInfo.fields); const names = Object.keys(noteInfo.fields);
@@ -102,6 +125,118 @@ test('NoteUpdateWorkflow updates sentence field and emits notification', async (
assert.equal(harness.notifications.length, 1); assert.equal(harness.notifications.length, 1);
}); });
test('NoteUpdateWorkflow updates sentence furigana when highlight processor changes it', async () => {
const harness = createWorkflowHarness();
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'tokugi' },
Sentence: { value: '' },
SentenceFurigana: { value: '<span class="term">tokugi</span>' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
harness.deps.processSentenceFurigana = (sentenceFurigana) =>
sentenceFurigana.replace('tokugi', '<b>tokugi</b>');
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.deepEqual(harness.updates[0]?.fields, {
Sentence: 'subtitle-text',
SentenceFurigana: '<span class="term"><b>tokugi</b></span>',
});
});
test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence cards', async () => {
const harness = createWorkflowHarness();
harness.deps.getEffectiveSentenceCardConfig = () => ({
sentenceField: 'Sentence',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'manual',
});
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: '' },
IsWordAndSentenceCard: { value: '' },
IsSentenceCard: { value: '' },
IsAudioCard: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.deepEqual(harness.updates[0]?.fields, {
Sentence: 'subtitle-text',
IsWordAndSentenceCard: 'x',
IsSentenceCard: '',
IsAudioCard: '',
});
});
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
const harness = createWorkflowHarness();
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: '' },
IsWordAndSentenceCard: { value: '' },
IsSentenceCard: { value: '' },
IsAudioCard: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.deepEqual(harness.updates[0]?.fields, {
Sentence: 'subtitle-text',
});
});
test('NoteUpdateWorkflow preserves explicit sentence card type during sentence enrichment', async () => {
const harness = createWorkflowHarness();
harness.deps.getEffectiveSentenceCardConfig = () => ({
sentenceField: 'Sentence',
lapisEnabled: true,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
});
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'sentence expression' },
Sentence: { value: '' },
IsWordAndSentenceCard: { value: '' },
IsSentenceCard: { value: 'x' },
IsAudioCard: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.deepEqual(harness.updates[0]?.fields, {
Sentence: 'subtitle-text',
});
});
test('NoteUpdateWorkflow no-ops when note info is missing', async () => { test('NoteUpdateWorkflow no-ops when note info is missing', async () => {
const harness = createWorkflowHarness(); const harness = createWorkflowHarness();
harness.deps.client.notesInfo = async () => []; harness.deps.client.notesInfo = async () => [];
@@ -119,6 +254,7 @@ test('NoteUpdateWorkflow updates note before auto field grouping merge', async (
let notesInfoCallCount = 0; let notesInfoCallCount = 0;
harness.deps.getEffectiveSentenceCardConfig = () => ({ harness.deps.getEffectiveSentenceCardConfig = () => ({
sentenceField: 'Sentence', sentenceField: 'Sentence',
lapisEnabled: false,
kikuEnabled: true, kikuEnabled: true,
kikuFieldGrouping: 'auto', kikuFieldGrouping: 'auto',
}); });
@@ -273,3 +409,59 @@ test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing
assert.deepEqual(imageContext, sidebarContext); assert.deepEqual(imageContext, sidebarContext);
assert.equal(miscInfoStartTime, 10); assert.equal(miscInfoStartTime, 10);
}); });
test('NoteUpdateWorkflow queues media updates when YouTube cache is pending', async () => {
const harness = createWorkflowHarness();
const queuedUpdates: Array<{
noteId: number;
noteInfo: NoteUpdateWorkflowNoteInfo;
context?: SubtitleMiningContext;
label: string | number;
}> = [];
const mediaCalls: string[] = [];
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: '' },
SentenceAudio: { value: '' },
Picture: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
harness.deps.getConfig = () => ({
fields: {
sentence: 'Sentence',
image: 'Picture',
},
media: {
generateAudio: true,
generateImage: true,
},
behavior: {},
});
harness.deps.generateAudio = async () => {
mediaCalls.push('audio');
return Buffer.from('audio');
};
harness.deps.generateImage = async () => {
mediaCalls.push('image');
return Buffer.from('image');
};
harness.deps.queuePendingYoutubeMediaUpdate = async (job) => {
queuedUpdates.push(job);
return true;
};
await harness.workflow.execute(42);
assert.deepEqual(mediaCalls, []);
assert.equal(queuedUpdates.length, 1);
assert.equal(queuedUpdates[0]?.noteId, 42);
assert.equal(queuedUpdates[0]?.label, 'taberu');
assert.equal(queuedUpdates[0]?.context, undefined);
assert.deepEqual(harness.updates, [{ noteId: 42, fields: { Sentence: 'subtitle-text' } }]);
});
+59 -5
View File
@@ -1,6 +1,7 @@
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config'; import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
import type { SubtitleMiningContext } from '../types/subtitle'; import type { SubtitleMiningContext } from '../types/subtitle';
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
export interface NoteUpdateWorkflowNoteInfo { export interface NoteUpdateWorkflowNoteInfo {
noteId: number; noteId: number;
@@ -35,6 +36,7 @@ export interface NoteUpdateWorkflowDeps {
getCurrentSubtitleStart: () => number | undefined; getCurrentSubtitleStart: () => number | undefined;
getEffectiveSentenceCardConfig: () => { getEffectiveSentenceCardConfig: () => {
sentenceField: string; sentenceField: string;
lapisEnabled: boolean;
kikuEnabled: boolean; kikuEnabled: boolean;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
}; };
@@ -58,6 +60,15 @@ export interface NoteUpdateWorkflowDeps {
expression: string, expression: string,
) => Promise<boolean>; ) => Promise<boolean>;
processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string; processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string;
processSentenceFurigana?: (
sentenceFurigana: string,
noteFields: Record<string, string>,
) => string;
setCardTypeFields: (
updatedFields: Record<string, string>,
availableFieldNames: string[],
cardKind: 'word-and-sentence',
) => void;
resolveConfiguredFieldName: ( resolveConfiguredFieldName: (
noteInfo: NoteUpdateWorkflowNoteInfo, noteInfo: NoteUpdateWorkflowNoteInfo,
...preferredNames: (string | undefined)[] ...preferredNames: (string | undefined)[]
@@ -74,6 +85,12 @@ export interface NoteUpdateWorkflowDeps {
) => Promise<Buffer | null>; ) => Promise<Buffer | null>;
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string; formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
consumeSubtitleMiningContext?: () => SubtitleMiningContext | null; consumeSubtitleMiningContext?: () => SubtitleMiningContext | null;
queuePendingYoutubeMediaUpdate?: (job: {
noteId: number;
noteInfo: NoteUpdateWorkflowNoteInfo;
context?: SubtitleMiningContext;
label: string | number;
}) => Promise<boolean>;
addConfiguredTagsToNote: (noteId: number) => Promise<void>; addConfiguredTagsToNote: (noteId: number) => Promise<void>;
showNotification: (noteId: number, label: string | number) => Promise<void>; showNotification: (noteId: number, label: string | number) => Promise<void>;
showOsdNotification: (message: string) => void; showOsdNotification: (message: string) => void;
@@ -184,15 +201,52 @@ export class NoteUpdateWorkflow {
sentenceField, sentenceField,
config.fields?.sentence, config.fields?.sentence,
); );
const noteLabel = hasExpressionText ? expressionText : noteId;
const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText(); const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
if (sentenceField && currentSubtitleText) { if (sentenceField && currentSubtitleText) {
const processedSentence = this.deps.processSentence(currentSubtitleText, fields); const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
updatedFields[sentenceField] = processedSentence; updatedFields[sentenceField] = processedSentence;
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
this.deps.setCardTypeFields(
updatedFields,
Object.keys(noteInfo.fields),
'word-and-sentence',
);
}
updatePerformed = true; updatePerformed = true;
} }
const sentenceFuriganaField = this.deps.resolveConfiguredFieldName(
noteInfo,
'SentenceFurigana',
);
const existingSentenceFurigana = sentenceFuriganaField
? noteInfo.fields[sentenceFuriganaField]?.value || ''
: '';
if (sentenceFuriganaField && existingSentenceFurigana && this.deps.processSentenceFurigana) {
const processedSentenceFurigana = this.deps.processSentenceFurigana(
existingSentenceFurigana,
fields,
);
if (processedSentenceFurigana !== existingSentenceFurigana) {
updatedFields[sentenceFuriganaField] = processedSentenceFurigana;
updatePerformed = true;
}
}
if (config.media?.generateAudio) { const generateAudio = config.media?.generateAudio !== false;
const generateImage = config.media?.generateImage !== false;
const mediaCacheQueued =
(generateAudio || generateImage) && this.deps.queuePendingYoutubeMediaUpdate
? await this.deps.queuePendingYoutubeMediaUpdate({
noteId,
noteInfo,
context: subtitleMiningContext ?? undefined,
label: noteLabel,
})
: false;
if (!mediaCacheQueued && generateAudio) {
try { try {
const audioFilename = this.deps.generateAudioFilename(); const audioFilename = this.deps.generateAudioFilename();
const audioBuffer = await this.deps.generateAudio(subtitleMiningContext ?? undefined); const audioBuffer = await this.deps.generateAudio(subtitleMiningContext ?? undefined);
@@ -217,7 +271,7 @@ export class NoteUpdateWorkflow {
} }
} }
if (config.media?.generateImage) { if (!mediaCacheQueued && generateImage) {
try { try {
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo); const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
const imageFilename = this.deps.generateImageFilename(); const imageFilename = this.deps.generateImageFilename();
@@ -252,7 +306,7 @@ export class NoteUpdateWorkflow {
} }
} }
if (config.fields?.miscInfo) { if (!mediaCacheQueued && config.fields?.miscInfo) {
const miscInfo = this.deps.formatMiscInfoPattern( const miscInfo = this.deps.formatMiscInfoPattern(
miscInfoFilename || '', miscInfoFilename || '',
subtitleMiningContext?.startTime ?? this.deps.getCurrentSubtitleStart(), subtitleMiningContext?.startTime ?? this.deps.getCurrentSubtitleStart(),
@@ -270,8 +324,8 @@ export class NoteUpdateWorkflow {
if (updatePerformed) { if (updatePerformed) {
await this.deps.client.updateNoteFields(noteId, updatedFields); await this.deps.client.updateNoteFields(noteId, updatedFields);
await this.deps.addConfiguredTagsToNote(noteId); await this.deps.addConfiguredTagsToNote(noteId);
this.deps.logInfo('Updated card fields for:', hasExpressionText ? expressionText : noteId); this.deps.logInfo('Updated card fields for:', noteLabel);
await this.deps.showNotification(noteId, hasExpressionText ? expressionText : noteId); await this.deps.showNotification(noteId, noteLabel);
} }
if (shouldRunFieldGrouping && hasExpressionText && duplicateNoteId !== null) { if (shouldRunFieldGrouping && hasExpressionText && duplicateNoteId !== null) {
@@ -0,0 +1,193 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AnkiConnectConfig } from '../types/anki';
import {
PendingYoutubeMediaQueue,
type PendingYoutubeMediaQueueDeps,
} from './pending-youtube-media-queue';
function createDeps(
overrides: Partial<PendingYoutubeMediaQueueDeps> = {},
): PendingYoutubeMediaQueueDeps {
const warnings: unknown[][] = [];
const deps: PendingYoutubeMediaQueueDeps & { warnings: unknown[][] } = {
client: {
notesInfo: async () => [],
updateNoteFields: async () => {},
storeMediaFile: async () => {},
},
mediaGenerator: {
generateAudio: async () => Buffer.from('audio'),
generateScreenshot: async () => Buffer.from('image'),
generateAnimatedImage: async () => Buffer.from('image'),
},
getConfig: () =>
({
media: { generateAudio: true, generateImage: true },
fields: {},
}) as AnkiConnectConfig,
getCurrentVideoPath: () => 'https://www.youtube.com/watch?v=abc123',
getCachedMediaPath: async () => null,
shouldRequireRemoteMediaCache: () => true,
getSubtitleMediaRange: () => ({ startTime: 1, endTime: 2 }),
getResolvedSentenceAudioFieldName: () => 'SentenceAudio',
resolveConfiguredFieldName: () => 'Picture',
mergeFieldValue: (_existing, newValue) => newValue,
getAnimatedImageLeadInSeconds: async () => 0,
generateAudioFilename: () => 'audio.mp3',
generateImageFilename: () => 'image.webp',
formatMiscInfoPatternForMediaPath: () => '',
showStatusNotification: () => {},
showNotification: async () => {},
logInfo: () => {},
logWarn: (...args) => {
warnings.push(args);
},
logError: () => {},
warnings,
...overrides,
};
return deps;
}
test('PendingYoutubeMediaQueue treats cache lookup failures as an immediate generation fallback', async () => {
const deps = createDeps({
getCachedMediaPath: async () => {
throw new Error('cache unavailable');
},
});
const queue = new PendingYoutubeMediaQueue(deps);
const queued = await queue.queueFromNote({
noteId: 42,
noteInfo: { noteId: 42, fields: {} },
label: 'demo',
});
assert.equal(queued, false);
assert.deepEqual((deps as typeof deps & { warnings: unknown[][] }).warnings, [
[
'Failed to read YouTube cache state; falling back to immediate media generation:',
'cache unavailable',
],
]);
});
test('PendingYoutubeMediaQueue drains matching queued jobs when the cache download fails', async () => {
const statusMessages: string[] = [];
const notifications: Array<{ noteId: number; label: string | number; suffix?: string }> = [];
const updatedNotes: number[] = [];
const deps = createDeps({
client: {
notesInfo: async (noteIds) =>
noteIds.map((noteId) => ({
noteId,
fields: {
SentenceAudio: { value: '' },
Picture: { value: '' },
},
})),
updateNoteFields: async (noteId) => {
updatedNotes.push(noteId);
},
storeMediaFile: async () => {},
},
showStatusNotification: (message) => {
statusMessages.push(message);
},
showNotification: async (noteId, label, suffix) => {
notifications.push({ noteId, label, suffix });
},
});
const queue = new PendingYoutubeMediaQueue(deps);
queue.enqueue({
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
noteId: 42,
startTime: 1,
endTime: 2,
label: 'queued',
generateAudio: true,
generateImage: true,
});
await queue.handleFailed('https://youtu.be/abc123');
await queue.handleReady('https://youtu.be/abc123', '/tmp/media.mkv');
assert.deepEqual(updatedNotes, []);
assert.deepEqual(notifications, [{ noteId: 42, label: 'queued', suffix: 'media cache failed' }]);
assert.equal(
statusMessages.includes('YouTube media cache failed. Media was not added to 1 queued card.'),
true,
);
});
test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queuing from notes', async () => {
const updatedNotes: Array<{ noteId: number; fields: Record<string, string> }> = [];
const storedMedia: string[] = [];
const deps = createDeps({
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);
},
},
getConfig: () => ({ media: {}, fields: { image: 'Picture' } }) as AnkiConnectConfig,
});
const queue = new PendingYoutubeMediaQueue(deps);
const queued = await queue.queueFromNote({
noteId: 42,
noteInfo: { noteId: 42, fields: {} },
label: 'demo',
});
await queue.handleReady('https://youtu.be/abc123', '/tmp/media.mkv');
assert.equal(queued, true);
assert.equal(updatedNotes.length, 1);
assert.equal(storedMedia.length, 2);
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio\.mp3\]$/);
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image\.webp">$/);
});
test('PendingYoutubeMediaQueue only announces a download once per source while jobs collect', () => {
const statusMessages: string[] = [];
const deps = createDeps({
showStatusNotification: (message) => {
statusMessages.push(message);
},
});
const queue = new PendingYoutubeMediaQueue(deps);
queue.enqueue({
sourceUrl: 'https://youtu.be/abc123',
noteId: 1,
startTime: 1,
endTime: 2,
label: 'first',
generateAudio: true,
generateImage: false,
});
queue.enqueue({
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
noteId: 2,
startTime: 3,
endTime: 4,
label: 'second',
generateAudio: false,
generateImage: true,
});
assert.equal(statusMessages.length, 1);
});
@@ -0,0 +1,391 @@
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import type { MediaInput } from '../media-input';
import type { MediaGenerator } from '../media-generator';
import type { AnkiConnectConfig } from '../types/anki';
import type { SubtitleMiningContext } from '../types/subtitle';
import { youtubeMediaUrlsMatch, type PendingYoutubeMediaUpdate } from './pending-youtube-media';
import type { MediaGenerationInputResolverOptions } from './media-source';
type PendingYoutubeMediaUpdateResult = 'updated' | 'partial' | 'failed';
export interface PendingYoutubeMediaQueueReadyOptions {
notifyNoQueued?: boolean;
}
export interface PendingYoutubeMediaQueueFailedOptions {
notifyStatus?: boolean;
}
export interface PendingYoutubeMediaNoteInfo {
noteId: number;
fields: Record<string, { value: string }>;
}
export interface PendingYoutubeMediaQueueDeps {
client: {
notesInfo(noteIds: number[]): Promise<unknown>;
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
storeMediaFile(filename: string, data: Buffer): Promise<void>;
};
mediaGenerator: Pick<
MediaGenerator,
'generateAudio' | 'generateScreenshot' | 'generateAnimatedImage'
>;
getConfig: () => AnkiConnectConfig;
getCurrentVideoPath: () => Promise<string | undefined> | string | undefined;
getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null;
shouldRequireRemoteMediaCache: () => boolean;
getSubtitleMediaRange: (context?: SubtitleMiningContext) => {
startTime: number;
endTime: number;
};
getResolvedSentenceAudioFieldName: (noteInfo: PendingYoutubeMediaNoteInfo) => string | null;
resolveConfiguredFieldName: (
noteInfo: PendingYoutubeMediaNoteInfo,
...preferredNames: (string | undefined)[]
) => string | null;
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
getAnimatedImageLeadInSeconds: (noteInfo: PendingYoutubeMediaNoteInfo) => Promise<number>;
generateAudioFilename: () => string;
generateImageFilename: () => string;
formatMiscInfoPatternForMediaPath: (
fallbackFilename: string,
startTimeSeconds: number | undefined,
mediaPath: string,
mediaTitle?: string,
) => string;
showStatusNotification: (message: string) => void;
showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise<void>;
logInfo: (message: string, ...args: unknown[]) => void;
logWarn: (message: string, ...args: unknown[]) => void;
logError: (message: string, ...args: unknown[]) => void;
}
function trimToNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function shouldGenerateAudio(config: AnkiConnectConfig): boolean {
return config.media?.generateAudio !== false;
}
function shouldGenerateImage(config: AnkiConnectConfig): boolean {
return config.media?.generateImage !== false;
}
export class PendingYoutubeMediaQueue {
private updates: PendingYoutubeMediaUpdate[] = [];
constructor(private readonly deps: PendingYoutubeMediaQueueDeps) {}
enqueue(job: PendingYoutubeMediaUpdate): void {
if (!job.generateAudio && !job.generateImage) {
return;
}
const isFirstQueuedForSource = !this.updates.some((existing) =>
youtubeMediaUrlsMatch(existing.sourceUrl, job.sourceUrl),
);
this.updates.push(job);
this.deps.logInfo('Queued YouTube media update for note:', job.noteId);
if (isFirstQueuedForSource) {
this.deps.showStatusNotification(
'YouTube media cache is still downloading. Card media will be added when the cache is ready.',
);
}
}
async queueFromNote(job: {
noteId: number;
noteInfo: PendingYoutubeMediaNoteInfo;
context?: SubtitleMiningContext;
label: string | number;
}): Promise<boolean> {
const sourceUrl = trimToNonEmptyString(await this.deps.getCurrentVideoPath());
const getCachedMediaPath = this.deps.getCachedMediaPath;
if (!sourceUrl || this.deps.shouldRequireRemoteMediaCache() !== true || !getCachedMediaPath) {
return false;
}
let cachedPath: string | null = null;
try {
cachedPath = await getCachedMediaPath(sourceUrl, 'video');
} catch (error) {
this.deps.logWarn(
'Failed to read YouTube cache state; falling back to immediate media generation:',
error instanceof Error ? error.message : String(error),
);
return false;
}
if (cachedPath) {
return false;
}
const config = this.deps.getConfig();
const mediaRange = this.deps.getSubtitleMediaRange(job.context);
this.enqueue({
sourceUrl,
noteId: job.noteId,
startTime: mediaRange.startTime,
endTime: mediaRange.endTime,
label: job.label,
audioFieldName: this.deps.getResolvedSentenceAudioFieldName(job.noteInfo) ?? undefined,
imageFieldName:
this.deps.resolveConfiguredFieldName(
job.noteInfo,
config.fields?.image,
DEFAULT_ANKI_CONNECT_CONFIG.fields.image,
) ?? undefined,
miscInfoFieldName:
this.deps.resolveConfiguredFieldName(job.noteInfo, config.fields?.miscInfo) ?? undefined,
generateAudio: shouldGenerateAudio(config),
generateImage: shouldGenerateImage(config),
});
return true;
}
async handleReady(
sourceUrl: string,
cachedPath: string,
options: PendingYoutubeMediaQueueReadyOptions = {},
): Promise<void> {
const jobs = this.takeMatchingUpdates(sourceUrl);
if (jobs.length === 0) {
if (options.notifyNoQueued !== false) {
this.deps.showStatusNotification('YouTube media cache ready.');
}
return;
}
this.deps.showStatusNotification(
`YouTube media cache ready. Adding media to ${jobs.length} queued card${
jobs.length === 1 ? '' : 's'
}.`,
);
let updatedCount = 0;
let partialCount = 0;
let failedCount = 0;
for (const job of jobs) {
try {
const result = await this.applyUpdate(job, cachedPath);
if (result === 'updated') {
updatedCount += 1;
} else if (result === 'partial') {
partialCount += 1;
} else {
failedCount += 1;
}
} catch (error) {
failedCount += 1;
this.deps.logError(
'Failed to apply queued YouTube media update:',
error instanceof Error ? error.message : String(error),
);
}
}
if (partialCount > 0 || failedCount > 0) {
this.deps.showStatusNotification(
`Queued YouTube media finished with ${updatedCount} updated, ${partialCount} partial, and ${failedCount} failed.`,
);
}
}
async handleFailed(
sourceUrl: string,
options: PendingYoutubeMediaQueueFailedOptions = {},
): Promise<void> {
const jobs = this.takeMatchingUpdates(sourceUrl);
if (jobs.length === 0) {
if (options.notifyStatus !== false) {
this.deps.showStatusNotification('YouTube media cache failed.');
}
return;
}
if (options.notifyStatus !== false) {
this.deps.showStatusNotification(
`YouTube media cache failed. Media was not added to ${jobs.length} queued card${
jobs.length === 1 ? '' : 's'
}.`,
);
}
this.deps.logWarn('Discarding queued YouTube media updates after cache failure:', jobs.length);
for (const job of jobs) {
try {
await this.deps.showNotification(job.noteId, job.label, 'media cache failed');
} catch (error) {
this.deps.logWarn(
'Failed to show queued YouTube media failure notification:',
error instanceof Error ? error.message : String(error),
);
}
}
}
private takeMatchingUpdates(sourceUrl: string): PendingYoutubeMediaUpdate[] {
const matched: PendingYoutubeMediaUpdate[] = [];
const remaining: PendingYoutubeMediaUpdate[] = [];
for (const job of this.updates) {
if (youtubeMediaUrlsMatch(job.sourceUrl, sourceUrl)) {
matched.push(job);
} else {
remaining.push(job);
}
}
this.updates = remaining;
return matched;
}
private async applyUpdate(
job: PendingYoutubeMediaUpdate,
cachedPath: string,
): Promise<PendingYoutubeMediaUpdateResult> {
const notesInfoResult = await this.deps.client.notesInfo([job.noteId]);
const notesInfo = notesInfoResult as unknown as PendingYoutubeMediaNoteInfo[];
const noteInfo = notesInfo[0];
if (!noteInfo) {
this.deps.logWarn('Queued YouTube media target note not found:', job.noteId);
return 'failed';
}
const config = this.deps.getConfig();
const mediaFields: Record<string, string> = {};
const errors: string[] = [];
let miscInfoFilename: string | null = null;
const cachedMediaInput: MediaInput = {
path: cachedPath,
source: 'youtube-cache',
};
if (job.generateAudio) {
try {
const audioFilename = this.deps.generateAudioFilename();
const audioBuffer = await this.deps.mediaGenerator.generateAudio(
cachedMediaInput,
job.startTime,
job.endTime,
config.media?.audioPadding,
undefined,
);
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
const audioField =
job.audioFieldName || this.deps.getResolvedSentenceAudioFieldName(noteInfo) || null;
if (audioField) {
const existingAudio = noteInfo.fields[audioField]?.value || '';
mediaFields[audioField] = this.deps.mergeFieldValue(
existingAudio,
`[sound:${audioFilename}]`,
config.behavior?.overwriteAudio !== false,
);
}
miscInfoFilename = audioFilename;
}
} catch (error) {
errors.push('audio');
this.deps.logError('Failed to generate queued YouTube audio:', (error as Error).message);
}
}
if (job.generateImage) {
try {
const imageFilename = this.deps.generateImageFilename();
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
const imageBuffer = await this.generateImageFromInput(
cachedMediaInput,
job.startTime,
job.endTime,
animatedLeadInSeconds,
);
if (imageBuffer) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
const imageField =
job.imageFieldName ||
this.deps.resolveConfiguredFieldName(
noteInfo,
config.fields?.image,
DEFAULT_ANKI_CONNECT_CONFIG.fields.image,
);
if (imageField) {
const existingImage = noteInfo.fields[imageField]?.value || '';
mediaFields[imageField] = this.deps.mergeFieldValue(
existingImage,
`<img src="${imageFilename}">`,
config.behavior?.overwriteImage !== false,
);
} else {
this.deps.logWarn(
'Image field not found on queued YouTube media note, skipping image update',
);
}
miscInfoFilename = imageFilename;
}
} catch (error) {
errors.push('image');
this.deps.logError('Failed to generate queued YouTube image:', (error as Error).message);
}
}
if (config.fields?.miscInfo && miscInfoFilename) {
const miscInfoField =
job.miscInfoFieldName ||
this.deps.resolveConfiguredFieldName(noteInfo, config.fields.miscInfo);
const miscInfo = this.deps.formatMiscInfoPatternForMediaPath(
miscInfoFilename,
job.startTime,
job.sourceUrl,
);
if (miscInfoField && miscInfo) {
mediaFields[miscInfoField] = miscInfo;
}
}
if (Object.keys(mediaFields).length === 0) {
return 'failed';
}
await this.deps.client.updateNoteFields(job.noteId, mediaFields);
const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined;
await this.deps.showNotification(job.noteId, job.label, errorSuffix);
this.deps.logInfo('Applied queued YouTube media update for note:', job.noteId);
return errors.length === 0 ? 'updated' : 'partial';
}
private async generateImageFromInput(
videoPath: MediaInput,
startTime: number,
endTime: number,
animatedLeadInSeconds = 0,
): Promise<Buffer | null> {
const config = this.deps.getConfig();
if (config.media?.imageType === 'avif') {
return this.deps.mediaGenerator.generateAnimatedImage(
videoPath,
startTime,
endTime,
config.media?.audioPadding,
{
fps: config.media?.animatedFps,
maxWidth: config.media?.animatedMaxWidth,
maxHeight: config.media?.animatedMaxHeight,
crf: config.media?.animatedCrf,
leadingStillDuration: animatedLeadInSeconds,
},
);
}
const timestamp = startTime + (endTime - startTime) / 2;
return this.deps.mediaGenerator.generateScreenshot(videoPath, timestamp, {
format: config.media?.imageFormat as 'jpg' | 'png' | 'webp',
quality: config.media?.imageQuality,
maxWidth: config.media?.imageMaxWidth,
maxHeight: config.media?.imageMaxHeight,
});
}
}
@@ -0,0 +1,53 @@
export interface PendingYoutubeMediaUpdate {
sourceUrl: string;
noteId: number;
startTime: number;
endTime: number;
label: string | number;
audioFieldName?: string;
imageFieldName?: string;
miscInfoFieldName?: string;
generateAudio: boolean;
generateImage: boolean;
}
function trimToNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function getYoutubeVideoId(rawUrl: string): string | null {
try {
const parsed = new URL(rawUrl);
const host = parsed.hostname.toLowerCase();
if (host === 'youtu.be' || host.endsWith('.youtu.be')) {
return trimToNonEmptyString(parsed.pathname.replace(/^\/+/, '').split('/')[0]);
}
if (host === 'youtube.com' || host.endsWith('.youtube.com')) {
const watchId = trimToNonEmptyString(parsed.searchParams.get('v'));
if (watchId) {
return watchId;
}
const parts = parsed.pathname.split('/').filter(Boolean);
if (parts[0] === 'shorts' || parts[0] === 'embed' || parts[0] === 'live') {
return trimToNonEmptyString(parts[1]);
}
}
} catch {
return null;
}
return null;
}
export function youtubeMediaUrlsMatch(a: string, b: string): boolean {
if (a.trim() === b.trim()) {
return true;
}
const aVideoId = getYoutubeVideoId(a);
const bVideoId = getYoutubeVideoId(b);
return Boolean(aVideoId && bVideoId && aVideoId === bVideoId);
}
+19
View File
@@ -69,6 +69,25 @@ test('parseArgs captures update command and internal launcher paths', () => {
assert.equal(shouldRunYomitanOnlyStartup(args), false); assert.equal(shouldRunYomitanOnlyStartup(args), false);
}); });
test('parseArgs captures hidden Linux runtime plugin asset ensure command', () => {
const args = parseArgs([
'--ensure-linux-runtime-plugin-assets',
'--ensure-linux-runtime-plugin-assets-response-path',
'/tmp/subminer-plugin-response.json',
]);
assert.equal(args.ensureLinuxRuntimePluginAssets, true);
assert.equal(
args.ensureLinuxRuntimePluginAssetsResponsePath,
'/tmp/subminer-plugin-response.json',
);
assert.equal(hasExplicitCommand(args), true);
assert.equal(shouldStartApp(args), true);
assert.equal(isHeadlessInitialCommand(args), true);
assert.equal(commandNeedsOverlayRuntime(args), false);
assert.equal(shouldRunYomitanOnlyStartup(args), false);
});
test('parseArgs captures launch-mpv targets and keeps it out of app startup', () => { test('parseArgs captures launch-mpv targets and keeps it out of app startup', () => {
const args = parseArgs(['--launch-mpv', 'C:\\a.mkv', 'C:\\b.mkv']); const args = parseArgs(['--launch-mpv', 'C:\\a.mkv', 'C:\\b.mkv']);
assert.equal(args.launchMpv, true); assert.equal(args.launchMpv, true);
+21 -3
View File
@@ -80,6 +80,8 @@ export interface CliArgs {
update?: boolean; update?: boolean;
updateLauncherPath?: string; updateLauncherPath?: string;
updateResponsePath?: string; updateResponsePath?: string;
ensureLinuxRuntimePluginAssets?: boolean;
ensureLinuxRuntimePluginAssetsResponsePath?: string;
autoStartOverlay: boolean; autoStartOverlay: boolean;
generateConfig: boolean; generateConfig: boolean;
configPath?: string; configPath?: string;
@@ -178,6 +180,8 @@ export function parseArgs(argv: string[]): CliArgs {
update: false, update: false,
updateLauncherPath: undefined, updateLauncherPath: undefined,
updateResponsePath: undefined, updateResponsePath: undefined,
ensureLinuxRuntimePluginAssets: false,
ensureLinuxRuntimePluginAssetsResponsePath: undefined,
autoStartOverlay: false, autoStartOverlay: false,
generateConfig: false, generateConfig: false,
backupOverwrite: false, backupOverwrite: false,
@@ -379,7 +383,15 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--open-browser') args.texthookerOpenBrowser = true; else if (arg === '--open-browser') args.texthookerOpenBrowser = true;
else if (arg === '--app-ping') args.appPing = true; else if (arg === '--app-ping') args.appPing = true;
else if (arg === '--update') args.update = true; else if (arg === '--update') args.update = true;
else if (arg.startsWith('--update-launcher-path=')) { else if (arg === '--ensure-linux-runtime-plugin-assets') {
args.ensureLinuxRuntimePluginAssets = true;
} else if (arg.startsWith('--ensure-linux-runtime-plugin-assets-response-path=')) {
const value = arg.split('=', 2)[1];
if (value) args.ensureLinuxRuntimePluginAssetsResponsePath = value;
} else if (arg === '--ensure-linux-runtime-plugin-assets-response-path') {
const value = readValue(argv[i + 1]);
if (value) args.ensureLinuxRuntimePluginAssetsResponsePath = value;
} else if (arg.startsWith('--update-launcher-path=')) {
const value = arg.split('=', 2)[1]; const value = arg.split('=', 2)[1];
if (value) args.updateLauncherPath = value; if (value) args.updateLauncherPath = value;
} else if (arg === '--update-launcher-path') { } else if (arg === '--update-launcher-path') {
@@ -581,13 +593,16 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.texthooker || args.texthooker ||
args.appPing || args.appPing ||
args.update || args.update ||
args.ensureLinuxRuntimePluginAssets === true ||
args.generateConfig || args.generateConfig ||
args.help args.help
); );
} }
export function isHeadlessInitialCommand(args: CliArgs): boolean { export function isHeadlessInitialCommand(args: CliArgs): boolean {
return args.refreshKnownWords || args.update === true; return (
args.refreshKnownWords || args.update === true || args.ensureLinuxRuntimePluginAssets === true
);
} }
export function isStandaloneTexthookerCommand(args: CliArgs): boolean { export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
@@ -654,6 +669,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.jellyfinPreviewAuth && !args.jellyfinPreviewAuth &&
!args.appPing && !args.appPing &&
!args.update && !args.update &&
!args.ensureLinuxRuntimePluginAssets &&
!args.help && !args.help &&
!args.autoStartOverlay && !args.autoStartOverlay &&
!args.generateConfig !args.generateConfig
@@ -707,7 +723,8 @@ export function shouldStartApp(args: CliArgs): boolean {
args.jellyfin || args.jellyfin ||
args.jellyfinPlay || args.jellyfinPlay ||
args.texthooker || args.texthooker ||
args.update args.update ||
args.ensureLinuxRuntimePluginAssets
) { ) {
if (args.launchMpv) { if (args.launchMpv) {
return false; return false;
@@ -780,6 +797,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
!args.texthooker && !args.texthooker &&
!args.appPing && !args.appPing &&
!args.update && !args.update &&
!args.ensureLinuxRuntimePluginAssets &&
!args.help && !args.help &&
!args.autoStartOverlay && !args.autoStartOverlay &&
!args.generateConfig && !args.generateConfig &&
+52
View File
@@ -80,6 +80,8 @@ test('loads defaults when config is missing', () => {
assert.equal('remoteControlDeviceName' in config.jellyfin, false); assert.equal('remoteControlDeviceName' in config.jellyfin, false);
assert.equal('deviceId' in config.jellyfin, false); assert.equal('deviceId' in config.jellyfin, false);
assert.equal('clientVersion' in config.jellyfin, false); assert.equal('clientVersion' in config.jellyfin, false);
assert.equal(config.youtube.mediaCache.mode, 'direct');
assert.equal(config.youtube.mediaCache.maxHeight, 720);
assert.equal(config.ai.enabled, false); assert.equal(config.ai.enabled, false);
assert.equal(config.ai.apiKeyCommand, ''); assert.equal(config.ai.apiKeyCommand, '');
assert.equal(config.texthooker.openBrowser, false); assert.equal(config.texthooker.openBrowser, false);
@@ -1750,6 +1752,56 @@ test('parses global shortcuts and startup settings', () => {
assert.equal(config.youtubeSubgen.fixWithAi, true); assert.equal(config.youtubeSubgen.fixWithAi, true);
}); });
test('parses YouTube media cache config and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"youtube": {
"mediaCache": {
"mode": "background",
"maxHeight": 480
}
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().youtube.mediaCache.mode, 'background');
assert.equal(validService.getConfig().youtube.mediaCache.maxHeight, 480);
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"youtube": {
"mediaCache": {
"mode": "always",
"maxHeight": -1
}
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
invalidService.getConfig().youtube.mediaCache.mode,
DEFAULT_CONFIG.youtube.mediaCache.mode,
);
assert.equal(
invalidService.getConfig().youtube.mediaCache.maxHeight,
DEFAULT_CONFIG.youtube.mediaCache.maxHeight,
);
assert.ok(
invalidService.getWarnings().some((warning) => warning.path === 'youtube.mediaCache.mode'),
);
assert.ok(
invalidService.getWarnings().some((warning) => warning.path === 'youtube.mediaCache.maxHeight'),
);
});
test('parses controller settings with logical bindings and tuning knobs', () => { test('parses controller settings with logical bindings and tuning knobs', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
+4
View File
@@ -111,6 +111,10 @@ export const CORE_DEFAULT_CONFIG: Pick<
}, },
youtube: { youtube: {
primarySubLanguages: ['ja', 'jpn'], primarySubLanguages: ['ja', 'jpn'],
mediaCache: {
mode: 'direct',
maxHeight: 720,
},
}, },
subsync: { subsync: {
alass_path: '', alass_path: '',
+18
View File
@@ -119,6 +119,24 @@ export function buildCoreConfigOptionRegistry(
description: description:
'Comma-separated primary subtitle language priority for managed subtitle auto-selection.', 'Comma-separated primary subtitle language priority for managed subtitle auto-selection.',
}, },
{
path: 'youtube.mediaCache.mode',
kind: 'enum',
enumValues: ['direct', 'background'],
enumLabels: {
direct: 'Direct stream extraction',
background: 'Background media cache',
},
defaultValue: defaultConfig.youtube.mediaCache.mode,
description: 'How YouTube card audio/images are extracted.',
},
{
path: 'youtube.mediaCache.maxHeight',
kind: 'number',
defaultValue: defaultConfig.youtube.mediaCache.maxHeight,
description:
'Maximum video height downloaded for the YouTube background media cache. Set to 0 for unlimited.',
},
{ {
path: 'controller.enabled', path: 'controller.enabled',
kind: 'boolean', kind: 'boolean',
+1
View File
@@ -32,6 +32,7 @@ export interface ConfigOptionRegistryEntry {
* `osd` and `osd-system` in NOTIFICATION_TYPE_VALUES. * `osd` and `osd-system` in NOTIFICATION_TYPE_VALUES.
*/ */
enumValues?: readonly string[]; enumValues?: readonly string[];
enumLabels?: Record<string, string>;
/** /**
* Optional settings UI subset when legacy/runtime-valid enum options should remain * Optional settings UI subset when legacy/runtime-valid enum options should remain
* editable in config files but hidden from new UI choices, for example * editable in config files but hidden from new UI choices, for example
+33
View File
@@ -297,6 +297,39 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
'Expected string array.', 'Expected string array.',
); );
} }
if (isObject(src.youtube.mediaCache)) {
const mode = src.youtube.mediaCache.mode;
if (mode === 'direct' || mode === 'background') {
resolved.youtube.mediaCache.mode = mode;
} else if (mode !== undefined) {
warn(
'youtube.mediaCache.mode',
mode,
resolved.youtube.mediaCache.mode,
"Expected 'direct' or 'background'.",
);
}
const maxHeight = asNumber(src.youtube.mediaCache.maxHeight);
if (maxHeight !== undefined && Number.isInteger(maxHeight) && maxHeight >= 0) {
resolved.youtube.mediaCache.maxHeight = maxHeight;
} else if (src.youtube.mediaCache.maxHeight !== undefined) {
warn(
'youtube.mediaCache.maxHeight',
src.youtube.mediaCache.maxHeight,
resolved.youtube.mediaCache.maxHeight,
'Expected a whole number at least 0.',
);
}
} else if (src.youtube.mediaCache !== undefined) {
warn(
'youtube.mediaCache',
src.youtube.mediaCache,
resolved.youtube.mediaCache,
'Expected object.',
);
}
} }
if (isObject(src.subsync)) { if (isObject(src.subsync)) {
+14
View File
@@ -165,6 +165,20 @@ test('settings registry exposes specialized controls for config-assisted inputs'
assert.equal(field('discordPresence.presenceStyle').control, 'select'); assert.equal(field('discordPresence.presenceStyle').control, 'select');
}); });
test('settings registry exposes YouTube media cache mode as a labeled select', () => {
const mediaCacheMode = field('youtube.mediaCache.mode');
const mediaCacheMaxHeight = field('youtube.mediaCache.maxHeight');
assert.equal(mediaCacheMode.control, 'select');
assert.deepEqual(mediaCacheMode.enumValues, ['direct', 'background']);
assert.deepEqual(mediaCacheMode.enumLabels, {
direct: 'Direct stream extraction',
background: 'Background media cache',
});
assert.equal(mediaCacheMaxHeight.control, 'number');
assert.equal(mediaCacheMaxHeight.defaultValue, 720);
});
test('settings registry exposes css declaration editor for primary and secondary subtitle appearance', () => { test('settings registry exposes css declaration editor for primary and secondary subtitle appearance', () => {
const primaryVisible = fields const primaryVisible = fields
.filter( .filter(
+1
View File
@@ -720,6 +720,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
...(option?.settingsEnumValues || option?.enumValues ...(option?.settingsEnumValues || option?.enumValues
? { enumValues: option.settingsEnumValues ?? option.enumValues } ? { enumValues: option.settingsEnumValues ?? option.enumValues }
: {}), : {}),
...(option?.enumLabels ? { enumLabels: option.enumLabels } : {}),
restartBehavior: restartBehaviorForPath(leaf.path), restartBehavior: restartBehaviorForPath(leaf.path),
advanced: advanced:
leaf.path.startsWith('controller.') || leaf.path.startsWith('controller.') ||
@@ -2035,6 +2035,76 @@ Aligned English subtitle
}); });
}); });
it('POST /api/stats/mine-card marks Kiku word mining notes as word-and-sentence cards when enabled', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
await withFakeAnkiConnect(
async (requests, url) => {
const app = createStatsApp(createMockTracker(), {
addYomitanNote: async () => 777,
createMediaGenerator: () => ({
generateAudio: async () => null,
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
}),
ankiConnectConfig: {
url,
deck: 'Mining',
fields: {
image: 'Picture',
sentence: 'Sentence',
},
media: {
generateAudio: false,
generateImage: false,
},
isKiku: {
enabled: true,
fieldGrouping: 'disabled',
deleteDuplicateInAuto: true,
},
},
});
const res = await app.request('/api/stats/mine-card?mode=word', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourcePath,
startMs: 1_000,
endMs: 2_000,
sentence: '猫を見た',
word: '猫',
videoTitle: 'Episode 1',
}),
});
const body = await res.json();
assert.equal(res.status, 200, JSON.stringify(body));
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
const fields = updateRequest?.params?.note?.fields ?? {};
assert.equal(fields.Sentence, '<b>猫</b>を見た');
assert.equal(fields.IsWordAndSentenceCard, 'x');
assert.equal(fields.IsSentenceCard, '');
assert.equal(fields.IsAudioCard, '');
},
{
notesInfoFields: {
Expression: { value: '猫' },
Sentence: { value: '' },
Picture: { value: '' },
IsWordAndSentenceCard: { value: '' },
IsSentenceCard: { value: '' },
IsAudioCard: { value: '' },
},
},
);
});
});
it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => { it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
await withTempDir(async (dir) => { await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv'); const sourcePath = path.join(dir, 'episode.mkv');
+9
View File
@@ -40,6 +40,12 @@ export interface AnkiJimakuIpcRuntimeOptions {
getAnkiIntegration: () => AnkiIntegration | null; getAnkiIntegration: () => AnkiIntegration | null;
setAnkiIntegration: (integration: AnkiIntegration | null) => void; setAnkiIntegration: (integration: AnkiIntegration | null) => void;
getKnownWordCacheStatePath: () => string; getKnownWordCacheStatePath: () => string;
getCachedMediaPath?: (
currentVideoPath: string,
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
createFieldGroupingCallback: () => ( createFieldGroupingCallback: () => (
@@ -107,6 +113,9 @@ export function registerAnkiJimakuIpcRuntime(
mergeAiConfig(config.ai, config.ankiConnect?.ai) as AiConfig, mergeAiConfig(config.ai, config.ankiConnect?.ai) as AiConfig,
undefined, undefined,
options.showOverlayNotification, options.showOverlayNotification,
options.getCachedMediaPath,
options.shouldRequireRemoteMediaCache,
options.getYoutubeMediaSourceUrl,
); );
integration.start(); integration.start();
options.setAnkiIntegration(integration); options.setAnkiIntegration(integration);
+4
View File
@@ -243,6 +243,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
runUpdateCommand: async (args) => { runUpdateCommand: async (args) => {
calls.push(`runUpdateCommand:${args.updateLauncherPath ?? ''}`); calls.push(`runUpdateCommand:${args.updateLauncherPath ?? ''}`);
}, },
runEnsureLinuxRuntimePluginAssetsCommand: async () => {
calls.push('runEnsureLinuxRuntimePluginAssetsCommand');
},
printHelp: () => { printHelp: () => {
calls.push('printHelp'); calls.push('printHelp');
}, },
@@ -624,6 +627,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
stop: () => {}, stop: () => {},
hasMainWindow: () => true, hasMainWindow: () => true,
runUpdateCommand: async () => {}, runUpdateCommand: async () => {},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
runYoutubePlaybackFlow: async () => {}, runYoutubePlaybackFlow: async () => {},
}, },
dispatchSessionAction: async () => {}, dispatchSessionAction: async () => {},
+19
View File
@@ -97,6 +97,10 @@ export interface CliCommandServiceDeps {
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>; runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
runJellyfinCommand: (args: CliArgs) => Promise<void>; runJellyfinCommand: (args: CliArgs) => Promise<void>;
runUpdateCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>; runUpdateCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
runEnsureLinuxRuntimePluginAssetsCommand: (
args: CliArgs,
source: CliCommandSource,
) => Promise<void>;
runYoutubePlaybackFlow: (request: { runYoutubePlaybackFlow: (request: {
url: string; url: string;
mode: NonNullable<CliArgs['youtubeMode']>; mode: NonNullable<CliArgs['youtubeMode']>;
@@ -182,6 +186,7 @@ interface AppCliRuntime {
stop: () => void; stop: () => void;
hasMainWindow: () => boolean; hasMainWindow: () => boolean;
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand']; runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandServiceDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow']; runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
} }
@@ -292,6 +297,7 @@ export function createCliCommandDepsRuntime(
runStatsCommand: options.jellyfin.runStatsCommand, runStatsCommand: options.jellyfin.runStatsCommand,
runJellyfinCommand: options.jellyfin.runCommand, runJellyfinCommand: options.jellyfin.runCommand,
runUpdateCommand: options.app.runUpdateCommand, runUpdateCommand: options.app.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: options.app.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow, runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
printHelp: options.ui.printHelp, printHelp: options.ui.printHelp,
hasMainWindow: options.app.hasMainWindow, hasMainWindow: options.app.hasMainWindow,
@@ -454,6 +460,19 @@ export function handleCliCommand(
deps.stopApp(); deps.stopApp();
} }
}); });
} else if (args.ensureLinuxRuntimePluginAssets) {
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
deps
.runEnsureLinuxRuntimePluginAssetsCommand(args, source)
.catch((err) => {
deps.error('runEnsureLinuxRuntimePluginAssetsCommand failed:', err);
deps.showMpvOsd(`Linux runtime plugin install failed: ${(err as Error).message}`);
})
.finally(() => {
if (shouldStopAfterRun) {
deps.stopApp();
}
});
} else if (args.toggleSecondarySub) { } else if (args.toggleSecondarySub) {
deps.cycleSecondarySubMode(); deps.cycleSecondarySubMode();
} else if (args.triggerFieldGrouping) { } else if (args.triggerFieldGrouping) {
+28
View File
@@ -25,6 +25,12 @@ type CreateAnkiIntegrationArgs = {
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
knownWordCacheStatePath: string; knownWordCacheStatePath: string;
getCachedMediaPath?: (
currentVideoPath: string,
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
}; };
export type OverlayWindowTrackerOptions = { export type OverlayWindowTrackerOptions = {
@@ -65,6 +71,9 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
args.aiConfig, args.aiConfig,
undefined, undefined,
args.showOverlayNotification, args.showOverlayNotification,
args.getCachedMediaPath,
args.shouldRequireRemoteMediaCache,
args.getYoutubeMediaSourceUrl,
); );
} }
@@ -132,6 +141,12 @@ export function initializeOverlayRuntime(
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string; getKnownWordCacheStatePath: () => string;
getCachedMediaPath?: (
currentVideoPath: string,
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
shouldStartAnkiIntegration?: () => boolean; shouldStartAnkiIntegration?: () => boolean;
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike; createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
backendOverride: string | null; backendOverride: string | null;
@@ -166,6 +181,12 @@ export function initializeOverlayAnkiIntegration(options: {
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string; getKnownWordCacheStatePath: () => string;
getCachedMediaPath?: (
currentVideoPath: string,
kind: 'audio' | 'video',
) => Promise<string | null>;
shouldRequireRemoteMediaCache?: () => boolean;
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
shouldStartAnkiIntegration?: () => boolean; shouldStartAnkiIntegration?: () => boolean;
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike; createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
}): boolean { }): boolean {
@@ -200,6 +221,13 @@ export function initializeOverlayAnkiIntegration(options: {
showOverlayNotification: options.showOverlayNotification, showOverlayNotification: options.showOverlayNotification,
createFieldGroupingCallback: options.createFieldGroupingCallback, createFieldGroupingCallback: options.createFieldGroupingCallback,
knownWordCacheStatePath: options.getKnownWordCacheStatePath(), knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
...(options.getCachedMediaPath ? { getCachedMediaPath: options.getCachedMediaPath } : {}),
...(options.shouldRequireRemoteMediaCache
? { shouldRequireRemoteMediaCache: options.shouldRequireRemoteMediaCache }
: {}),
...(options.getYoutubeMediaSourceUrl
? { getYoutubeMediaSourceUrl: options.getYoutubeMediaSourceUrl }
: {}),
}); });
if (options.shouldStartAnkiIntegration?.() !== false) { if (options.shouldStartAnkiIntegration?.() !== false) {
integration.start(); integration.start();
+29 -1
View File
@@ -204,6 +204,29 @@ function getStatsWordMiningAudioFieldName(
); );
} }
function shouldUseStatsLapisKikuCardFields(ankiConfig: AnkiConnectConfig): boolean {
return ankiConfig.isLapis?.enabled === true || ankiConfig.isKiku?.enabled === true;
}
function applyStatsWordAndSentenceCardFields(
fields: Record<string, string>,
noteInfo: StatsServerNoteInfo | null,
ankiConfig: AnkiConnectConfig,
): void {
if (!shouldUseStatsLapisKikuCardFields(ankiConfig) || !noteInfo) return;
const wordAndSentenceFlag = resolveStatsNoteFieldName(noteInfo, 'IsWordAndSentenceCard');
if (!wordAndSentenceFlag) return;
fields[wordAndSentenceFlag] = 'x';
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
const resolved = resolveStatsNoteFieldName(noteInfo, flagName);
if (resolved && resolved !== wordAndSentenceFlag) {
fields[resolved] = '';
}
}
}
function getStatsDirectMiningAudioFieldNames( function getStatsDirectMiningAudioFieldNames(
ankiConfig: AnkiConnectConfig, ankiConfig: AnkiConnectConfig,
noteInfo: StatsServerNoteInfo | null, noteInfo: StatsServerNoteInfo | null,
@@ -1299,7 +1322,11 @@ export function createStatsApp(
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null; let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
let noteInfo: StatsServerNoteInfo | null = null; let noteInfo: StatsServerNoteInfo | null = null;
if (audioBuffer || (syncAnimatedImageToWordAudio && generateImage)) { if (
audioBuffer ||
(syncAnimatedImageToWordAudio && generateImage) ||
shouldUseStatsLapisKikuCardFields(ankiConfig)
) {
try { try {
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[]; const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
noteInfo = noteInfoResult[0] ?? null; noteInfo = noteInfoResult[0] ?? null;
@@ -1339,6 +1366,7 @@ export function createStatsApp(
const imageFieldName = ankiConfig.fields?.image ?? 'Picture'; const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
mediaFields[sentenceFieldName] = highlightedSentence; mediaFields[sentenceFieldName] = highlightedSentence;
applyStatsWordAndSentenceCardFields(mediaFields, noteInfo, ankiConfig);
if (audioBuffer) { if (audioBuffer) {
const audioFilename = `subminer_audio_${timestamp}.mp3`; const audioFilename = `subminer_audio_${timestamp}.mp3`;
@@ -0,0 +1,540 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import test from 'node:test';
import { createYoutubeMediaCacheService } from './media-cache';
class FakeYtDlpProcess extends EventEmitter {
killed = false;
stdout = new EventEmitter();
stderr = new EventEmitter();
kill(): boolean {
this.killed = true;
return true;
}
}
type SpawnCall = {
command: string;
args: string[];
options?: { stdio?: Array<'ignore' | 'pipe'> };
};
function makeTempCacheRoot(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-media-cache-test-'));
}
test('YouTube media cache does nothing in direct mode', async () => {
const cacheRoot = makeTempCacheRoot();
const spawnCalls: SpawnCall[] = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
spawn: (command, args) => {
spawnCalls.push({ command, args });
return new FakeYtDlpProcess();
},
});
cache.start('https://youtu.be/demo', { mode: 'direct' });
assert.deepEqual(spawnCalls, []);
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache exposes the downloaded file after the background job completes', async () => {
const cacheRoot = makeTempCacheRoot();
const spawnedProcesses: FakeYtDlpProcess[] = [];
const spawnCalls: SpawnCall[] = [];
const readyEvents: Array<{ url: string; path: string }> = [];
const startedEvents: Array<{ url: string }> = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
onDownloadStarted: (event) => {
startedEvents.push(event);
},
onReady: (event) => {
readyEvents.push(event);
},
spawn: (command, args, options) => {
spawnCalls.push({ command, args, options });
const proc = new FakeYtDlpProcess();
spawnedProcesses.push(proc);
return proc;
},
});
cache.start('https://youtu.be/demo', { mode: 'background' });
assert.deepEqual(startedEvents, [{ url: 'https://youtu.be/demo' }]);
assert.equal(spawnCalls.length, 1);
assert.equal(spawnCalls[0]?.command, 'yt-dlp');
assert.ok(spawnCalls[0]?.args.includes('--no-playlist'));
assert.ok(spawnCalls[0]?.args.includes('--force-ipv4'));
assert.equal(spawnCalls[0]?.args[spawnCalls[0].args.indexOf('--retries') + 1], '5');
assert.equal(spawnCalls[0]?.args[spawnCalls[0].args.indexOf('--fragment-retries') + 1], '5');
assert.equal(spawnCalls[0]?.args[spawnCalls[0].args.indexOf('--extractor-retries') + 1], '5');
assert.ok(spawnCalls[0]?.args.includes('--merge-output-format'));
assert.equal(
spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-f') + 1],
'bestvideo*[height<=720]+bestaudio/best[height<=720]',
);
assert.deepEqual(spawnCalls[0]?.options?.stdio, ['ignore', 'ignore', 'ignore']);
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null);
const outputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1];
assert.equal(typeof outputTemplate, 'string');
const outputDir = path.dirname(outputTemplate!);
const outputPath = path.join(outputDir, 'media.mkv');
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(outputPath, 'cached media');
spawnedProcesses[0]?.emit('close', 0);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), outputPath);
assert.deepEqual(readyEvents, [{ url: 'https://youtu.be/demo', path: outputPath }]);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache clears the active cache when direct mode starts', async () => {
const cacheRoot = makeTempCacheRoot();
const spawnedProcesses: FakeYtDlpProcess[] = [];
const spawnCalls: SpawnCall[] = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
spawn: (command, args, options) => {
spawnCalls.push({ command, args, options });
const proc = new FakeYtDlpProcess();
spawnedProcesses.push(proc);
return proc;
},
});
cache.start('https://youtu.be/background', { mode: 'background' });
const outputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1];
assert.equal(typeof outputTemplate, 'string');
const outputDir = path.dirname(outputTemplate!);
const outputPath = path.join(outputDir, 'media.mkv');
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(outputPath, 'cached media');
spawnedProcesses[0]?.emit('close', 0);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(await cache.getActiveCachedMediaPath(), outputPath);
cache.start('https://youtu.be/direct', { mode: 'direct' });
assert.equal(await cache.getActiveCachedMediaPath(), null);
assert.equal(await cache.getCachedMediaPath('https://youtu.be/background'), outputPath);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache cancels the active download when direct mode starts', async () => {
const cacheRoot = makeTempCacheRoot();
const spawnedProcesses: FakeYtDlpProcess[] = [];
const spawnCalls: SpawnCall[] = [];
const readyEvents: Array<{ url: string; path: string }> = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
onReady: (event) => {
readyEvents.push(event);
},
spawn: (command, args, options) => {
spawnCalls.push({ command, args, options });
const proc = new FakeYtDlpProcess();
spawnedProcesses.push(proc);
return proc;
},
});
cache.start('https://youtu.be/background', { mode: 'background' });
const outputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1];
assert.equal(typeof outputTemplate, 'string');
const outputDir = path.dirname(outputTemplate!);
const outputPath = path.join(outputDir, 'media.mkv');
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(outputPath, 'cached media');
cache.start('https://youtu.be/direct', { mode: 'direct' });
spawnedProcesses[0]?.emit('close', 0);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(spawnedProcesses[0]?.killed, true);
assert.deepEqual(readyEvents, []);
assert.equal(await cache.getActiveCachedMediaPath(), null);
assert.equal(await cache.getCachedMediaPath('https://youtu.be/background'), null);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache reports failed background downloads', async () => {
const cacheRoot = makeTempCacheRoot();
const spawnedProcesses: FakeYtDlpProcess[] = [];
const failedEvents: Array<{ url: string }> = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
onFailed: (event) => {
failedEvents.push(event);
},
spawn: () => {
const proc = new FakeYtDlpProcess();
spawnedProcesses.push(proc);
return proc;
},
});
cache.start('https://youtu.be/demo', { mode: 'background' });
spawnedProcesses[0]?.emit('close', 1);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(failedEvents, [{ url: 'https://youtu.be/demo' }]);
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache only reports a failed download once when error is followed by close', async () => {
const cacheRoot = makeTempCacheRoot();
const spawnedProcesses: FakeYtDlpProcess[] = [];
const failedEvents: Array<{ url: string }> = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
onFailed: (event) => {
failedEvents.push(event);
},
spawn: () => {
const proc = new FakeYtDlpProcess();
spawnedProcesses.push(proc);
return proc;
},
});
cache.start('https://youtu.be/demo', { mode: 'background' });
spawnedProcesses[0]?.emit('error', new Error('spawn failed'));
spawnedProcesses[0]?.emit('close', 1);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(failedEvents, [{ url: 'https://youtu.be/demo' }]);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache can disable the download height cap', () => {
const cacheRoot = makeTempCacheRoot();
const spawnCalls: SpawnCall[] = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
spawn: (command, args, options) => {
spawnCalls.push({ command, args, options });
return new FakeYtDlpProcess();
},
});
cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 0 });
assert.equal(spawnCalls.length, 1);
assert.equal(
spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-f') + 1],
'bestvideo*+bestaudio/best',
);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache applies the configured download height cap', () => {
const cacheRoot = makeTempCacheRoot();
const spawnCalls: SpawnCall[] = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
spawn: (command, args, options) => {
spawnCalls.push({ command, args, options });
return new FakeYtDlpProcess();
},
});
cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 480 });
assert.equal(spawnCalls.length, 1);
assert.equal(
spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-f') + 1],
'bestvideo*[height<=480]+bestaudio/best[height<=480]',
);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache restarts ready sessions when the height cap changes', async () => {
const cacheRoot = makeTempCacheRoot();
const spawnedProcesses: FakeYtDlpProcess[] = [];
const spawnCalls: SpawnCall[] = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
spawn: (command, args, options) => {
spawnCalls.push({ command, args, options });
const proc = new FakeYtDlpProcess();
spawnedProcesses.push(proc);
return proc;
},
});
cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 720 });
const firstOutputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1];
assert.equal(typeof firstOutputTemplate, 'string');
const firstOutputDir = path.dirname(firstOutputTemplate!);
const firstOutputPath = path.join(firstOutputDir, 'media.mkv');
fs.mkdirSync(firstOutputDir, { recursive: true });
fs.writeFileSync(firstOutputPath, 'cached media');
spawnedProcesses[0]?.emit('close', 0);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), firstOutputPath);
cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 480 });
assert.equal(spawnCalls.length, 2);
assert.equal(fs.existsSync(firstOutputPath), false);
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null);
assert.equal(
spawnCalls[1]?.args[spawnCalls[1].args.indexOf('-f') + 1],
'bestvideo*[height<=480]+bestaudio/best[height<=480]',
);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache restarts running sessions when the height cap changes', () => {
const cacheRoot = makeTempCacheRoot();
const spawnedProcesses: FakeYtDlpProcess[] = [];
const spawnCalls: SpawnCall[] = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
spawn: (command, args, options) => {
spawnCalls.push({ command, args, options });
const proc = new FakeYtDlpProcess();
spawnedProcesses.push(proc);
return proc;
},
});
cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 720 });
cache.start('https://youtu.be/demo', { mode: 'background', maxHeight: 480 });
assert.equal(spawnedProcesses[0]?.killed, true);
assert.equal(spawnCalls.length, 2);
assert.equal(
spawnCalls[1]?.args[spawnCalls[1].args.indexOf('-f') + 1],
'bestvideo*[height<=480]+bestaudio/best[height<=480]',
);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache removes stale files from previous runs on startup', () => {
const cacheRoot = makeTempCacheRoot();
const staleDir = path.join(cacheRoot, 'stale-session');
const spawnCalls: SpawnCall[] = [];
try {
fs.mkdirSync(staleDir, { recursive: true });
fs.writeFileSync(path.join(staleDir, 'media.mkv'), 'stale cached media');
createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
spawn: (command, args) => {
spawnCalls.push({ command, args });
return new FakeYtDlpProcess();
},
});
assert.equal(fs.existsSync(staleDir), false);
assert.deepEqual(spawnCalls, []);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache restarts when a ready cached file was deleted externally', async () => {
const cacheRoot = makeTempCacheRoot();
const spawnedProcesses: FakeYtDlpProcess[] = [];
const spawnCalls: Array<{ command: string; args: string[] }> = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
spawn: (command, args) => {
spawnCalls.push({ command, args });
const proc = new FakeYtDlpProcess();
spawnedProcesses.push(proc);
return proc;
},
});
cache.start('https://youtu.be/demo', { mode: 'background' });
const outputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1];
assert.equal(typeof outputTemplate, 'string');
const outputDir = path.dirname(outputTemplate!);
const outputPath = path.join(outputDir, 'media.mkv');
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(outputPath, 'cached media');
spawnedProcesses[0]?.emit('close', 0);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), outputPath);
fs.rmSync(outputPath);
cache.start('https://youtu.be/demo', { mode: 'background' });
assert.equal(spawnCalls.length, 2);
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache removes stale disk siblings before starting a new cache', () => {
const cacheRoot = makeTempCacheRoot();
const staleDir = path.join(cacheRoot, 'stale-sibling');
const spawnCalls: Array<{ command: string; args: string[] }> = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
spawn: (command, args) => {
spawnCalls.push({ command, args });
return new FakeYtDlpProcess();
},
});
fs.mkdirSync(staleDir, { recursive: true });
fs.writeFileSync(path.join(staleDir, 'media.mkv'), 'stale cached media');
cache.start('https://youtu.be/demo', { mode: 'background' });
assert.equal(fs.existsSync(staleDir), false);
assert.equal(spawnCalls.length, 1);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache drops old sessions when a new background cache starts', async () => {
const cacheRoot = makeTempCacheRoot();
const spawnedProcesses: FakeYtDlpProcess[] = [];
const spawnCalls: Array<{ command: string; args: string[] }> = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
spawn: (command, args) => {
spawnCalls.push({ command, args });
const proc = new FakeYtDlpProcess();
spawnedProcesses.push(proc);
return proc;
},
});
cache.start('https://youtu.be/first', { mode: 'background' });
const firstOutputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1];
assert.equal(typeof firstOutputTemplate, 'string');
const firstOutputDir = path.dirname(firstOutputTemplate!);
fs.mkdirSync(firstOutputDir, { recursive: true });
fs.writeFileSync(path.join(firstOutputDir, 'media.mkv'), 'cached media');
cache.start('https://youtu.be/second', { mode: 'background' });
assert.equal(spawnedProcesses[0]?.killed, true);
assert.equal(fs.existsSync(firstOutputDir), false);
assert.equal(await cache.getCachedMediaPath('https://youtu.be/first'), null);
assert.equal(spawnCalls.length, 2);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
test('YouTube media cache cleanup kills downloads and removes temp files', async () => {
const cacheRoot = makeTempCacheRoot();
const spawnedProcesses: FakeYtDlpProcess[] = [];
const spawnCalls: Array<{ command: string; args: string[] }> = [];
try {
const cache = createYoutubeMediaCacheService({
cacheRoot,
getYtDlpCommand: () => 'yt-dlp',
spawn: (command, args) => {
spawnCalls.push({ command, args });
const proc = new FakeYtDlpProcess();
spawnedProcesses.push(proc);
return proc;
},
});
cache.start('https://youtu.be/demo', { mode: 'background' });
const outputTemplate = spawnCalls[0]?.args[spawnCalls[0].args.indexOf('-o') + 1];
assert.equal(typeof outputTemplate, 'string');
const outputDir = path.dirname(outputTemplate!);
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(path.join(outputDir, 'media.mkv'), 'cached media');
cache.cleanup();
assert.equal(spawnedProcesses[0]?.killed, true);
assert.equal(fs.existsSync(outputDir), false);
assert.equal(await cache.getCachedMediaPath('https://youtu.be/demo'), null);
} finally {
fs.rmSync(cacheRoot, { recursive: true, force: true });
}
});
+307
View File
@@ -0,0 +1,307 @@
import { spawn as spawnProcess } from 'node:child_process';
import * as crypto from 'node:crypto';
import { EventEmitter } from 'node:events';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import type { YoutubeMediaCacheMode } from '../../../types/integrations';
import { getYoutubeYtDlpCommand } from './ytdlp-command';
type MediaCacheSessionState = 'running' | 'ready' | 'failed';
type SpawnedProcess = EventEmitter & {
killed?: boolean;
kill?: (signal?: NodeJS.Signals | number) => boolean;
};
type SpawnProcess = (
command: string,
args: string[],
options?: { stdio?: Array<'ignore' | 'pipe'> },
) => SpawnedProcess;
interface MediaCacheSession {
url: string;
dir: string;
maxHeight: number;
process: SpawnedProcess | null;
readyPath: string | null;
state: MediaCacheSessionState;
}
export interface YoutubeMediaCacheStartOptions {
mode: YoutubeMediaCacheMode;
maxHeight?: number;
}
export interface YoutubeMediaCacheServiceDeps {
cacheRoot?: string;
getYtDlpCommand?: () => string;
spawn?: SpawnProcess;
onDownloadStarted?: (event: { url: string }) => void;
onReady?: (event: { url: string; path: string }) => void;
onFailed?: (event: { url: string }) => void;
logInfo?: (message: string) => void;
logWarn?: (message: string) => void;
}
const MEDIA_FILE_EXTENSIONS = new Set(['.mkv', '.mp4', '.webm', '.m4a', '.mp3', '.opus']);
const DEFAULT_MAX_HEIGHT = 720;
function cacheKeyForUrl(url: string): string {
return crypto.createHash('sha256').update(url).digest('hex').slice(0, 24);
}
function isFinalMediaFile(fileName: string): boolean {
if (!fileName.startsWith('media.')) {
return false;
}
if (fileName.endsWith('.part') || fileName.endsWith('.ytdl') || fileName.endsWith('.tmp')) {
return false;
}
return MEDIA_FILE_EXTENSIONS.has(path.extname(fileName).toLowerCase());
}
function findReadyMediaPath(dir: string): string | null {
try {
const files = fs.readdirSync(dir);
const mediaFile = files.find(isFinalMediaFile);
return mediaFile ? path.join(dir, mediaFile) : null;
} catch {
return null;
}
}
function getFormatSelector(maxHeight: number): string {
return maxHeight > 0
? `bestvideo*[height<=${maxHeight}]+bestaudio/best[height<=${maxHeight}]`
: 'bestvideo*+bestaudio/best';
}
function normalizeMaxHeight(maxHeight: number | undefined): number {
if (maxHeight === undefined) {
return DEFAULT_MAX_HEIGHT;
}
return Number.isInteger(maxHeight) && maxHeight >= 0 ? maxHeight : DEFAULT_MAX_HEIGHT;
}
function createYtDlpArgs(url: string, outputTemplate: string, maxHeight?: number): string[] {
return [
'--no-playlist',
'--no-warnings',
'--force-ipv4',
'--retries',
'5',
'--fragment-retries',
'5',
'--extractor-retries',
'5',
'-f',
getFormatSelector(normalizeMaxHeight(maxHeight)),
'--merge-output-format',
'mkv',
'-o',
outputTemplate,
url,
];
}
export function createYoutubeMediaCacheService(deps: YoutubeMediaCacheServiceDeps = {}) {
const cacheRoot = deps.cacheRoot ?? path.join(os.tmpdir(), 'subminer-youtube-media-cache');
const getYtDlpCommand = deps.getYtDlpCommand ?? getYoutubeYtDlpCommand;
const spawn: SpawnProcess =
deps.spawn ??
((command, args, options) =>
spawnProcess(command, args, options ?? {}) as unknown as SpawnedProcess);
const sessions = new Map<string, MediaCacheSession>();
let activeKey: string | null = null;
const getSessionDir = (url: string): string => path.join(cacheRoot, cacheKeyForUrl(url));
const removeCacheDir = (dir: string): void => {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// Temp cache cleanup should not block shutdown or playback startup.
}
};
const removeCacheRootEntriesExcept = (dirsToKeep: string[]): void => {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(cacheRoot, { withFileTypes: true });
} catch {
return;
}
const keepDirs = new Set(dirsToKeep.map((dir) => path.resolve(dir)));
for (const entry of entries) {
const entryPath = path.join(cacheRoot, entry.name);
if (keepDirs.has(path.resolve(entryPath))) {
continue;
}
removeCacheDir(entryPath);
}
};
const removeSession = (key: string): void => {
const session = sessions.get(key);
if (!session) {
return;
}
if (session.state === 'running' && session.process?.kill && !session.process.killed) {
session.process.kill();
}
sessions.delete(key);
if (activeKey === key) {
activeKey = null;
}
removeCacheDir(session.dir);
};
const removeInactiveSessions = (keyToKeep: string): void => {
for (const key of [...sessions.keys()]) {
if (key !== keyToKeep) {
removeSession(key);
}
}
};
removeCacheRootEntriesExcept([]);
const getCachedMediaPath = async (url: string): Promise<string | null> => {
const key = cacheKeyForUrl(url);
const session = sessions.get(key);
if (session?.readyPath && fs.existsSync(session.readyPath)) {
return session.readyPath;
}
const readyPath = findReadyMediaPath(session?.dir ?? getSessionDir(url));
if (readyPath) {
sessions.set(key, {
url,
dir: path.dirname(readyPath),
maxHeight: session?.maxHeight ?? DEFAULT_MAX_HEIGHT,
process: null,
readyPath,
state: 'ready',
});
return readyPath;
}
return null;
};
const getActiveCachedMediaPath = async (): Promise<string | null> => {
if (!activeKey) {
return null;
}
const session = sessions.get(activeKey);
return session ? getCachedMediaPath(session.url) : null;
};
const start = (url: string, options: YoutubeMediaCacheStartOptions): void => {
if (options.mode !== 'background') {
if (activeKey) {
const activeSession = sessions.get(activeKey);
if (activeSession?.state === 'running') {
removeSession(activeKey);
}
}
activeKey = null;
return;
}
const key = cacheKeyForUrl(url);
const maxHeight = normalizeMaxHeight(options.maxHeight);
activeKey = key;
const dir = getSessionDir(url);
const existingSession = sessions.get(key);
const canReuseExistingSession = existingSession?.maxHeight === maxHeight;
if (existingSession?.state === 'running' && canReuseExistingSession) {
removeInactiveSessions(key);
removeCacheRootEntriesExcept([existingSession.dir]);
return;
}
if (existingSession) {
if (
canReuseExistingSession &&
existingSession.state === 'ready' &&
((existingSession.readyPath && fs.existsSync(existingSession.readyPath)) ||
findReadyMediaPath(existingSession.dir))
) {
removeInactiveSessions(key);
removeCacheRootEntriesExcept([existingSession.dir]);
return;
}
removeSession(key);
activeKey = key;
}
removeInactiveSessions(key);
removeCacheRootEntriesExcept([dir]);
fs.mkdirSync(dir, { recursive: true });
const outputTemplate = path.join(dir, 'media.%(ext)s');
const args = createYtDlpArgs(url, outputTemplate, maxHeight);
const child = spawn(getYtDlpCommand(), args, { stdio: ['ignore', 'ignore', 'ignore'] });
const session: MediaCacheSession = {
url,
dir,
maxHeight,
process: child,
readyPath: null,
state: 'running',
};
sessions.set(key, session);
deps.logInfo?.(`Started YouTube media cache download for ${url}`);
deps.onDownloadStarted?.({ url });
child.once('error', (error) => {
const currentSession = sessions.get(key);
if (currentSession !== session) {
return;
}
session.state = 'failed';
session.process = null;
deps.logWarn?.(
`YouTube media cache download failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
deps.onFailed?.({ url });
});
child.once('close', (code) => {
const currentSession = sessions.get(key);
if (currentSession !== session) {
return;
}
if (session.state === 'failed') {
return;
}
session.process = null;
if (code === 0) {
const readyPath = findReadyMediaPath(dir);
if (readyPath) {
session.state = 'ready';
session.readyPath = readyPath;
deps.logInfo?.(`YouTube media cache ready at ${readyPath}`);
deps.onReady?.({ url, path: readyPath });
return;
}
}
session.state = 'failed';
deps.logWarn?.(`YouTube media cache download exited without a usable media file.`);
deps.onFailed?.({ url });
});
};
const cleanup = (): void => {
for (const key of [...sessions.keys()]) {
removeSession(key);
}
activeKey = null;
};
return {
cleanup,
getActiveCachedMediaPath,
getCachedMediaPath,
start,
};
}
+69
View File
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveDefaultNotificationIconPath } from './notification';
test('default notification icon resolves packaged SubMiner asset when no per-notification icon is provided', () => {
const path = resolveDefaultNotificationIconPath({
platform: 'linux',
resourcesPath: '/opt/SubMiner/resources',
appPath: '/opt/SubMiner/resources/app.asar',
dirname: '/opt/SubMiner/resources/app.asar/dist/core/utils',
cwd: '/opt/SubMiner',
joinPath: (...parts) => parts.join('/'),
fileExists: (candidate) => candidate === '/opt/SubMiner/resources/assets/SubMiner.png',
});
assert.equal(path, '/opt/SubMiner/resources/assets/SubMiner.png');
});
test('default notification icon prefers the square app icon when bundled images are available', () => {
const path = resolveDefaultNotificationIconPath({
platform: 'linux',
resourcesPath: '/opt/SubMiner/resources',
appPath: '/opt/SubMiner/resources/app.asar',
dirname: '/opt/SubMiner/resources/app.asar/dist/core/utils',
cwd: '/opt/SubMiner',
joinPath: (...parts) => parts.join('/'),
fileExists: (candidate) =>
candidate === '/opt/SubMiner/resources/assets/SubMiner.png' ||
candidate === '/opt/SubMiner/resources/assets/SubMiner-square.png',
});
assert.equal(path, '/opt/SubMiner/resources/assets/SubMiner-square.png');
});
test('default notification icon avoids macOS tray template assets', () => {
const seen: string[] = [];
const path = resolveDefaultNotificationIconPath({
platform: 'darwin',
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
dirname: '/Applications/SubMiner.app/Contents/Resources/app.asar/dist/core/utils',
cwd: '/Applications/SubMiner.app/Contents/Resources',
joinPath: (...parts) => parts.join('/'),
fileExists: (candidate) => {
seen.push(candidate);
return candidate.endsWith('/assets/SubMiner-square.png');
},
});
assert.equal(path, '/Applications/SubMiner.app/Contents/Resources/assets/SubMiner-square.png');
assert.equal(
seen.some((candidate) => candidate.includes('SubMinerTemplate')),
false,
);
});
test('default notification icon resolves cwd fallback through injected deps', () => {
const resolvedPath = resolveDefaultNotificationIconPath({
platform: 'linux',
resourcesPath: '/missing/resources',
appPath: '/missing/app',
dirname: '/missing/dist/core/utils',
cwd: '/portable/SubMiner',
joinPath: (...parts) => parts.join('/'),
fileExists: (candidate) => candidate === '/portable/SubMiner/assets/SubMiner-square.png',
});
assert.equal(resolvedPath, '/portable/SubMiner/assets/SubMiner-square.png');
});
+57 -9
View File
@@ -1,10 +1,57 @@
import electron from 'electron'; import electron from 'electron';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '../../logger'; import { createLogger } from '../../logger';
const { Notification, nativeImage } = electron; const { Notification, nativeImage } = electron;
const logger = createLogger('core:notification'); const logger = createLogger('core:notification');
export function resolveDefaultNotificationIconPath(deps: {
platform: string;
resourcesPath: string;
appPath: string;
dirname: string;
cwd: string;
joinPath: (...parts: string[]) => string;
fileExists: (path: string) => boolean;
}): string | null {
const iconNames =
deps.platform === 'win32'
? ['SubMiner.ico', 'SubMiner-square.png', 'SubMiner.png']
: ['SubMiner-square.png', 'SubMiner.png'];
const baseDirs = [
deps.joinPath(deps.resourcesPath, 'assets'),
deps.joinPath(deps.appPath, 'assets'),
deps.joinPath(deps.dirname, '..', 'assets'),
deps.joinPath(deps.dirname, '..', '..', 'assets'),
deps.joinPath(deps.cwd, 'assets'),
];
for (const baseDir of baseDirs) {
for (const iconName of iconNames) {
const candidate = deps.joinPath(baseDir, iconName);
if (deps.fileExists(candidate)) {
return candidate;
}
}
}
return null;
}
function resolveRuntimeDefaultNotificationIconPath(): string | null {
return resolveDefaultNotificationIconPath({
platform: process.platform,
resourcesPath: process.resourcesPath,
appPath: electron.app?.getAppPath?.() ?? process.cwd(),
dirname: __dirname,
cwd: process.cwd(),
joinPath: (...parts) => path.join(...parts),
fileExists: (candidate) => fs.existsSync(candidate),
});
}
export function showDesktopNotification( export function showDesktopNotification(
title: string, title: string,
options: { body?: string; icon?: string }, options: { body?: string; icon?: string },
@@ -19,19 +66,20 @@ export function showDesktopNotification(
notificationOptions.body = options.body; notificationOptions.body = options.body;
} }
if (options.icon) { const icon = options.icon ?? resolveRuntimeDefaultNotificationIconPath() ?? undefined;
if (icon) {
const isFilePath = const isFilePath =
typeof options.icon === 'string' && typeof icon === 'string' && (icon.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(icon));
(options.icon.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(options.icon));
if (isFilePath) { if (isFilePath) {
if (fs.existsSync(options.icon)) { if (fs.existsSync(icon)) {
notificationOptions.icon = options.icon; notificationOptions.icon = icon;
} else { } else {
logger.warn('Notification icon file not found', options.icon); logger.warn('Notification icon file not found', icon);
} }
} else if (typeof options.icon === 'string' && options.icon.startsWith('data:image/')) { } else if (typeof icon === 'string' && icon.startsWith('data:image/')) {
const base64Data = options.icon.replace(/^data:image\/\w+;base64,/, ''); const base64Data = icon.replace(/^data:image\/\w+;base64,/, '');
try { try {
const image = nativeImage.createFromBuffer(Buffer.from(base64Data, 'base64')); const image = nativeImage.createFromBuffer(Buffer.from(base64Data, 'base64'));
if (image.isEmpty()) { if (image.isEmpty()) {
@@ -45,7 +93,7 @@ export function showDesktopNotification(
logger.error('Failed to create notification icon from base64', err); logger.error('Failed to create notification icon from base64', err);
} }
} else { } else {
notificationOptions.icon = options.icon; notificationOptions.icon = icon;
} }
} }
+4 -3
View File
@@ -2,9 +2,10 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import path from 'node:path'; import path from 'node:path';
import { resolveDefaultLogFilePath, setLogRotation } from './logger'; import { resolveDefaultLogFilePath, setLogRotation } from './logger';
import { localDateKey } from './shared/log-files';
test('resolveDefaultLogFilePath uses APPDATA on windows', () => { test('resolveDefaultLogFilePath uses APPDATA on windows', () => {
const today = new Date().toISOString().slice(0, 10); const today = localDateKey(new Date());
const resolved = resolveDefaultLogFilePath({ const resolved = resolveDefaultLogFilePath({
platform: 'win32', platform: 'win32',
homeDir: 'C:\\Users\\tester', homeDir: 'C:\\Users\\tester',
@@ -20,7 +21,7 @@ test('resolveDefaultLogFilePath uses APPDATA on windows', () => {
}); });
test('resolveDefaultLogFilePath uses .config on linux', () => { test('resolveDefaultLogFilePath uses .config on linux', () => {
const today = new Date().toISOString().slice(0, 10); const today = localDateKey(new Date());
const resolved = resolveDefaultLogFilePath({ const resolved = resolveDefaultLogFilePath({
platform: 'linux', platform: 'linux',
homeDir: '/home/tester', homeDir: '/home/tester',
@@ -34,7 +35,7 @@ test('resolveDefaultLogFilePath uses .config on linux', () => {
test('setLogRotation accepts numeric retention days', () => { test('setLogRotation accepts numeric retention days', () => {
const previous = process.env.SUBMINER_LOG_ROTATION; const previous = process.env.SUBMINER_LOG_ROTATION;
const today = new Date().toISOString().slice(0, 10); const today = localDateKey(new Date());
setLogRotation(14); setLogRotation(14);
try { try {
const resolved = resolveDefaultLogFilePath({ const resolved = resolveDefaultLogFilePath({
+219 -7
View File
@@ -332,6 +332,7 @@ import {
acquireYoutubeSubtitleTrack, acquireYoutubeSubtitleTrack,
acquireYoutubeSubtitleTracks, acquireYoutubeSubtitleTracks,
} from './core/services/youtube/generate'; } from './core/services/youtube/generate';
import { createYoutubeMediaCacheService } from './core/services/youtube/media-cache';
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve'; import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
import { probeYoutubeTracks } from './core/services/youtube/track-probe'; import { probeYoutubeTracks } from './core/services/youtube/track-probe';
import { import {
@@ -347,12 +348,16 @@ import {
shouldAutoOpenFirstRunSetup, shouldAutoOpenFirstRunSetup,
} from './main/runtime/first-run-setup-service'; } from './main/runtime/first-run-setup-service';
import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow'; import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow';
import { createYoutubeMediaCachePlaybackRuntime } from './main/runtime/youtube-media-cache-playback';
import { createYoutubePlaybackRuntime } from './main/runtime/youtube-playback-runtime'; import { createYoutubePlaybackRuntime } from './main/runtime/youtube-playback-runtime';
import { import {
clearYoutubePrimarySubtitleNotificationTimer, clearYoutubePrimarySubtitleNotificationTimer,
createYoutubePrimarySubtitleNotificationRuntime, createYoutubePrimarySubtitleNotificationRuntime,
} from './main/runtime/youtube-primary-subtitle-notification'; } from './main/runtime/youtube-primary-subtitle-notification';
import { createAutoplayReadyGate } from './main/runtime/autoplay-ready-gate'; import {
createAutoplayReadyGate,
type AutoplayReadySignal,
} from './main/runtime/autoplay-ready-gate';
import { createAutoplaySubtitlePrimingRuntime } from './main/runtime/autoplay-subtitle-priming-runtime'; import { createAutoplaySubtitlePrimingRuntime } from './main/runtime/autoplay-subtitle-priming-runtime';
import { createAutoplayTokenizationWarmRelease } from './main/runtime/autoplay-tokenization-warm-release'; import { createAutoplayTokenizationWarmRelease } from './main/runtime/autoplay-tokenization-warm-release';
import { isVisibleOverlayAutoplayTargetReady } from './main/runtime/visible-overlay-autoplay-readiness'; import { isVisibleOverlayAutoplayTargetReady } from './main/runtime/visible-overlay-autoplay-readiness';
@@ -508,6 +513,11 @@ import {
runUpdateCliCommand, runUpdateCliCommand,
writeUpdateCliCommandResponse, writeUpdateCliCommandResponse,
} from './main/runtime/update/update-cli-command'; } from './main/runtime/update/update-cli-command';
import {
runEnsureLinuxRuntimePluginAssetsCliCommand,
writeEnsureLinuxRuntimePluginAssetsCliCommandResponse,
} from './main/runtime/linux-runtime-plugin-assets-cli-command';
import { ensureLinuxRuntimePluginAssets } from './main/runtime/linux-runtime-plugin-assets';
import { createUpdateServiceRuntime } from './main/runtime/update/update-service-runtime'; import { createUpdateServiceRuntime } from './main/runtime/update/update-service-runtime';
import { import {
createRefreshSubtitlePrefetchFromActiveTrackHandler, createRefreshSubtitlePrefetchFromActiveTrackHandler,
@@ -524,7 +534,6 @@ import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './main/runti
import { import {
isSameYoutubeMediaPath, isSameYoutubeMediaPath,
isYoutubeMediaPath, isYoutubeMediaPath,
isYoutubePlaybackActive,
shouldUseCachedYoutubeParsedCues, shouldUseCachedYoutubeParsedCues,
} from './main/runtime/youtube-playback'; } from './main/runtime/youtube-playback';
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy'; import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
@@ -1164,11 +1173,97 @@ const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({
}, },
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
}); });
const youtubeMediaCache = createYoutubeMediaCacheService({
onDownloadStarted: (event) => {
showConfiguredStatusNotification('YouTube media cache is downloading.', {
id: 'youtube-media-cache-status',
title: 'YouTube media cache',
variant: 'progress',
persistent: true,
});
logger.info(`YouTube media cache download notification shown for ${event.url}`);
},
onReady: (event) => {
showConfiguredStatusNotification('YouTube media cache ready.', {
id: 'youtube-media-cache-status',
title: 'YouTube media cache',
variant: 'success',
persistent: false,
});
void appState.ankiIntegration
?.handleYoutubeMediaCacheReady(event.url, event.path, { notifyNoQueued: false })
.catch((error) => {
logger.warn(
`Failed to apply queued YouTube media updates: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
},
onFailed: (event) => {
showConfiguredStatusNotification('YouTube media cache failed.', {
id: 'youtube-media-cache-status',
title: 'YouTube media cache',
variant: 'error',
persistent: false,
});
void appState.ankiIntegration
?.handleYoutubeMediaCacheFailed(event.url, { notifyStatus: false })
.catch((error) => {
logger.warn(
`Failed to drain queued YouTube media updates after cache failure: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
},
logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message),
});
const youtubeMediaCachePlaybackRuntime = createYoutubeMediaCachePlaybackRuntime({
getMediaCacheConfig: () => getResolvedConfig().youtube.mediaCache,
requestMpvProperty: async (name) => {
const client = appState.mpvClient;
if (!client) return null;
return await client.requestProperty(name);
},
startYoutubeMediaCache: (url, options) => {
youtubeMediaCache.start(url, options);
},
logWarn: (message) => logger.warn(message),
});
const waitForYoutubeMpvConnected = createWaitForMpvConnectedHandler({ const waitForYoutubeMpvConnected = createWaitForMpvConnectedHandler({
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
now: () => Date.now(), now: () => Date.now(),
sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)), sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
}); });
const POST_WARM_AUTOPLAY_SUBTITLE_PRIME_DELAYS_MS = [0, 75, 200, 500, 1_000] as const;
function schedulePostWarmAutoplaySubtitlePrime(signal: AutoplayReadySignal): void {
if (signal.payload.text.trim() !== '__warm__') {
return;
}
const mediaPath = signal.mediaPath;
for (const delayMs of POST_WARM_AUTOPLAY_SUBTITLE_PRIME_DELAYS_MS) {
const timer = setTimeout(() => {
const currentMediaPath =
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null;
if (currentMediaPath !== mediaPath || !overlayManager.getVisibleOverlayVisible()) {
return;
}
void primeCurrentSubtitleForAutoplay(mediaPath).catch((error) => {
logger.debug(
`[autoplay-subtitle-prime] failed to prime current subtitle after warm readiness: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
}, delayMs);
timer.unref?.();
}
}
const autoplayReadyGate = createAutoplayReadyGate({ const autoplayReadyGate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => youtubePrimarySubtitleNotificationRuntime.isAppOwnedFlowInFlight(), isAppOwnedFlowInFlight: () => youtubePrimarySubtitleNotificationRuntime.isAppOwnedFlowInFlight(),
getCurrentMediaPath: () => appState.currentMediaPath, getCurrentMediaPath: () => appState.currentMediaPath,
@@ -1194,6 +1289,9 @@ const autoplayReadyGate = createAutoplayReadyGate({
}); });
broadcastToOverlayWindows(IPC_CHANNELS.event.overlayPointerRecoveryRequest); broadcastToOverlayWindows(IPC_CHANNELS.event.overlayPointerRecoveryRequest);
}, },
onAutoplayReadyReleased: (signal) => {
schedulePostWarmAutoplaySubtitlePrime(signal);
},
isSignalTargetReady: (signal) => isSignalTargetReady: (signal) =>
isVisibleOverlayAutoplayTargetReady( isVisibleOverlayAutoplayTargetReady(
{ {
@@ -1278,6 +1376,12 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
}, },
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs), waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request), prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
startYoutubeMediaCache: (url) => {
youtubeMediaCache.start(url, {
mode: getResolvedConfig().youtube.mediaCache.mode,
maxHeight: getResolvedConfig().youtube.mediaCache.maxHeight,
});
},
runYoutubePlaybackFlow: (request) => youtubeFlowRuntime.runYoutubePlaybackFlow(request), runYoutubePlaybackFlow: (request) => youtubeFlowRuntime.runYoutubePlaybackFlow(request),
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message), logWarn: (message) => logger.warn(message),
@@ -1591,9 +1695,61 @@ const youtubePrimarySubtitleNotificationRuntime = createYoutubePrimarySubtitleNo
}); });
function isYoutubePlaybackActiveNow(): boolean { function isYoutubePlaybackActiveNow(): boolean {
return isYoutubePlaybackActive( return Boolean(getCurrentYoutubeMediaCacheSourceUrlSnapshot());
appState.currentMediaPath, }
appState.mpvClient?.currentVideoPath ?? null,
function getCurrentYoutubeMediaCacheSourceUrlSnapshot(): string | null {
const currentMediaPath = appState.currentMediaPath?.trim() || null;
if (isYoutubeMediaPath(currentMediaPath)) {
return currentMediaPath;
}
const currentVideoPath = appState.mpvClient?.currentVideoPath?.trim() || null;
if (isYoutubeMediaPath(currentVideoPath)) {
return currentVideoPath;
}
return youtubeMediaCachePlaybackRuntime.getActiveYoutubeSourceUrlSnapshot();
}
async function getCurrentYoutubeMediaCacheSourceUrl(): Promise<string | null> {
const currentMediaPath = appState.currentMediaPath?.trim() || null;
if (isYoutubeMediaPath(currentMediaPath)) {
return currentMediaPath;
}
const currentVideoPath = appState.mpvClient?.currentVideoPath?.trim() || null;
if (isYoutubeMediaPath(currentVideoPath)) {
return currentVideoPath;
}
return await youtubeMediaCachePlaybackRuntime.getActiveYoutubeSourceUrl();
}
function shouldRequireYoutubeMediaCacheForCurrentPlayback(): boolean {
return (
getResolvedConfig().youtube.mediaCache.mode === 'background' && isYoutubePlaybackActiveNow()
);
}
async function getCachedYoutubeMediaPathForCurrentPlayback(
currentVideoPath: string,
_kind: 'audio' | 'video',
): Promise<string | null> {
if (getResolvedConfig().youtube.mediaCache.mode !== 'background') {
return null;
}
const cacheSourceUrl = isYoutubeMediaPath(currentVideoPath)
? currentVideoPath
: await getCurrentYoutubeMediaCacheSourceUrl();
if (!cacheSourceUrl) {
return null;
}
// mpv can expose the resolved stream URL here while the cache key uses the original page URL.
// Keep the active-cache fallback so current playback can still resolve the ready cached file.
return (
(await youtubeMediaCache.getCachedMediaPath(cacheSourceUrl)) ??
(await youtubeMediaCache.getActiveCachedMediaPath())
); );
} }
@@ -1758,6 +1914,7 @@ const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({
}, },
getCurrentSubText: () => appState.currentSubText, getCurrentSubText: () => appState.currentSubText,
getCurrentSubtitleData: () => appState.currentSubtitleData, getCurrentSubtitleData: () => appState.currentSubtitleData,
getActiveParsedSubtitleCues: () => appState.activeParsedSubtitleCues,
setActiveParsedSubtitleMediaPath: (mediaPath) => { setActiveParsedSubtitleMediaPath: (mediaPath) => {
appState.activeParsedSubtitleMediaPath = mediaPath; appState.activeParsedSubtitleMediaPath = mediaPath;
}, },
@@ -1781,6 +1938,10 @@ function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
return autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForVisibleOverlay(); return autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForVisibleOverlay();
} }
function primeCurrentSubtitleForAutoplay(mediaPath: string): Promise<void> {
return autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForAutoplay(mediaPath);
}
function cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(): void { function cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(): void {
autoplaySubtitlePrimingRuntime.cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); autoplaySubtitlePrimingRuntime.cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
} }
@@ -2201,6 +2362,7 @@ const mediaRuntime = createMediaRuntimeService(
const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({ const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
userDataPath: USER_DATA_PATH, userDataPath: USER_DATA_PATH,
getCurrentMediaPath: () => appState.currentMediaPath, getCurrentMediaPath: () => appState.currentMediaPath,
getCurrentVideoPath: () => appState.mpvClient?.currentVideoPath,
getCurrentMediaTitle: () => appState.currentMediaTitle, getCurrentMediaTitle: () => appState.currentMediaTitle,
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath), resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle), guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
@@ -2518,6 +2680,10 @@ function clearWindowsVisibleOverlayForegroundPollLoop(): void {
visibleOverlayInteractionRuntime.clearWindowsVisibleOverlayForegroundPollLoop(); visibleOverlayInteractionRuntime.clearWindowsVisibleOverlayForegroundPollLoop();
} }
function tickWindowsOverlayPointerInteractionNow(): void {
visibleOverlayInteractionRuntime.tickWindowsOverlayPointerInteractionNow();
}
function scheduleVisibleOverlayBlurRefresh(): void { function scheduleVisibleOverlayBlurRefresh(): void {
visibleOverlayInteractionRuntime.scheduleVisibleOverlayBlurRefresh(); visibleOverlayInteractionRuntime.scheduleVisibleOverlayBlurRefresh();
} }
@@ -2526,6 +2692,10 @@ function resetLinuxVisibleOverlayStartupInputPrimer(): void {
visibleOverlayInteractionRuntime.resetLinuxVisibleOverlayStartupInputPrimer(); visibleOverlayInteractionRuntime.resetLinuxVisibleOverlayStartupInputPrimer();
} }
function startLinuxVisibleOverlayStartupInputGrace(): void {
visibleOverlayInteractionRuntime.startLinuxVisibleOverlayStartupInputGrace();
}
function applyLinuxOverlayInputShapeFromLatestMeasurement(): boolean { function applyLinuxOverlayInputShapeFromLatestMeasurement(): boolean {
return visibleOverlayInteractionRuntime.applyLinuxOverlayInputShapeFromLatestMeasurement(); return visibleOverlayInteractionRuntime.applyLinuxOverlayInputShapeFromLatestMeasurement();
} }
@@ -2584,6 +2754,7 @@ const overlayNotificationsRuntime = createOverlayNotificationsRuntime({
}); });
const { const {
flushQueuedOverlayNotifications, flushQueuedOverlayNotifications,
flushQueuedMpvOsdNotifications,
openAnkiCardFromNotification, openAnkiCardFromNotification,
toggleNotificationHistoryPanel, toggleNotificationHistoryPanel,
showConfiguredPlaybackFeedback, showConfiguredPlaybackFeedback,
@@ -3749,6 +3920,7 @@ const {
}, },
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(), stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(), cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
cleanupYoutubeMediaCache: () => youtubeMediaCache.cleanup(),
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(), cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => { stopDiscordPresenceService: () => {
void appState.discordPresenceService?.stop(); void appState.discordPresenceService?.stop();
@@ -4234,6 +4406,7 @@ const {
}, },
onMpvConnected: () => { onMpvConnected: () => {
maybeStartOverlayLoadingOsd(); maybeStartOverlayLoadingOsd();
flushQueuedMpvOsdNotifications();
if (appState.sessionBindingsInitialized) { if (appState.sessionBindingsInitialized) {
sendMpvCommandRuntime(appState.mpvClient, [ sendMpvCommandRuntime(appState.mpvClient, [
'script-message', 'script-message',
@@ -4309,6 +4482,7 @@ const {
subtitlePrefetchRuntime.cancelPendingInit(); subtitlePrefetchRuntime.cancelPendingInit();
} }
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path); youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
void youtubeMediaCachePlaybackRuntime.handleMediaPathChange(path);
if (path) { if (path) {
ensureImmersionTrackerStarted(); ensureImmersionTrackerStarted();
void subtitlePrefetchRuntime.refreshSubtitlePrefetchFromActiveTrack(); void subtitlePrefetchRuntime.refreshSubtitlePrefetchFromActiveTrack();
@@ -5307,6 +5481,15 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
focusMainWindow: () => { focusMainWindow: () => {
const mainWindow = overlayManager.getMainWindow(); const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return; if (!mainWindow || mainWindow.isDestroyed()) return;
if (process.platform === 'darwin') {
focusMacOSOverlayWindow({
platform: process.platform,
getOverlayWindow: () => mainWindow,
stealAppFocus: () => app.focus({ steal: true }),
warn: (message, details) => logger.warn(message, details),
});
return;
}
if (!mainWindow.isFocused()) { if (!mainWindow.isFocused()) {
mainWindow.focus(); mainWindow.focus();
} }
@@ -5352,13 +5535,15 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
if (!mainWindow || senderWindow !== mainWindow) { if (!mainWindow || senderWindow !== mainWindow) {
return; return;
} }
if (visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive() === active) { const previousActive =
visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive();
visibleOverlayInteractionRuntime.setVisibleOverlayInteractionActive(active);
if (previousActive === active) {
if (active && process.platform === 'darwin' && !mainWindow.isFocused()) { if (active && process.platform === 'darwin' && !mainWindow.isFocused()) {
overlayVisibilityRuntime.updateVisibleOverlayVisibility(); overlayVisibilityRuntime.updateVisibleOverlayVisibility();
} }
return; return;
} }
visibleOverlayInteractionRuntime.setVisibleOverlayInteractionActive(active);
overlayVisibilityRuntime.updateVisibleOverlayVisibility(); overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}, },
onOverlayInteractiveHint: (interactive, senderWindow) => { onOverlayInteractiveHint: (interactive, senderWindow) => {
@@ -5558,6 +5743,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
reportOverlayContentBounds: (payload: unknown) => { reportOverlayContentBounds: (payload: unknown) => {
if (overlayContentMeasurementStore.report(payload)) { if (overlayContentMeasurementStore.report(payload)) {
tickLinuxOverlayPointerInteractionNow(); tickLinuxOverlayPointerInteractionNow();
tickWindowsOverlayPointerInteractionNow();
primeLinuxOverlayPointerInteractionAfterFirstMeasurement(); primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
autoplayReadyGate.flushPendingAutoplayReadySignal(); autoplayReadyGate.flushPendingAutoplayReadySignal();
scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(); scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();
@@ -5667,6 +5853,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
); );
}, },
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
getCachedMediaPath: (currentVideoPath, kind) =>
getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind),
shouldRequireRemoteMediaCache: () => shouldRequireYoutubeMediaCacheForCurrentPlayback(),
getYoutubeMediaSourceUrl: () => getCurrentYoutubeMediaCacheSourceUrl(),
showDesktopNotification, showDesktopNotification,
showOverlayNotification, showOverlayNotification,
createFieldGroupingCallback: () => createFieldGroupingCallback(), createFieldGroupingCallback: () => createFieldGroupingCallback(),
@@ -5756,6 +5946,21 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
logWarn: (message, error) => logger.warn(message, error), logWarn: (message, error) => logger.warn(message, error),
}); });
}, },
runEnsureLinuxRuntimePluginAssetsCommand: async (
argsFromCommand: CliArgs,
source: CliCommandSource,
) => {
await runEnsureLinuxRuntimePluginAssetsCliCommand(
argsFromCommand,
{
ensureLinuxRuntimePluginAssets: () => ensureLinuxRuntimePluginAssets(),
writeResponse: (responsePath, payload) =>
writeEnsureLinuxRuntimePluginAssetsCliCommandResponse(responsePath, payload),
logWarn: (message, error) => logger.warn(message, error),
},
source,
);
},
runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request), runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request),
openYomitanSettings: () => openYomitanSettings(), openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => openConfigSettingsWindow(), openConfigSettingsWindow: () => openConfigSettingsWindow(),
@@ -6128,6 +6333,10 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
showOverlayNotification, showOverlayNotification,
createFieldGroupingCallback: () => createFieldGroupingCallback(), createFieldGroupingCallback: () => createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
getCachedMediaPath: (currentVideoPath, kind) =>
getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind),
shouldRequireRemoteMediaCache: () => shouldRequireYoutubeMediaCacheForCurrentPlayback(),
getYoutubeMediaSourceUrl: () => getCurrentYoutubeMediaCacheSourceUrl(),
shouldStartAnkiIntegration: () => shouldStartAnkiIntegration: () =>
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)), !(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
}, },
@@ -6226,6 +6435,7 @@ function setVisibleOverlayVisible(visible: boolean): void {
if (visible) { if (visible) {
maybeStartOverlayLoadingOsd(); maybeStartOverlayLoadingOsd();
resetLinuxVisibleOverlayStartupInputPrimer(); resetLinuxVisibleOverlayStartupInputPrimer();
startLinuxVisibleOverlayStartupInputGrace();
restoreVisibleOverlayWindowShapeForShow(); restoreVisibleOverlayWindowShapeForShow();
void ensureOverlayMpvSubtitlesHidden(); void ensureOverlayMpvSubtitlesHidden();
void primeCurrentSubtitleForVisibleOverlay(); void primeCurrentSubtitleForVisibleOverlay();
@@ -6247,6 +6457,7 @@ function toggleVisibleOverlay(): void {
} else { } else {
maybeStartOverlayLoadingOsd(); maybeStartOverlayLoadingOsd();
resetLinuxVisibleOverlayStartupInputPrimer(); resetLinuxVisibleOverlayStartupInputPrimer();
startLinuxVisibleOverlayStartupInputGrace();
restoreVisibleOverlayWindowShapeForShow(); restoreVisibleOverlayWindowShapeForShow();
void ensureOverlayMpvSubtitlesHidden(); void ensureOverlayMpvSubtitlesHidden();
void primeCurrentSubtitleForVisibleOverlay(); void primeCurrentSubtitleForVisibleOverlay();
@@ -6266,6 +6477,7 @@ function setOverlayVisible(visible: boolean): void {
if (visible) { if (visible) {
maybeStartOverlayLoadingOsd(); maybeStartOverlayLoadingOsd();
resetLinuxVisibleOverlayStartupInputPrimer(); resetLinuxVisibleOverlayStartupInputPrimer();
startLinuxVisibleOverlayStartupInputGrace();
restoreVisibleOverlayWindowShapeForShow(); restoreVisibleOverlayWindowShapeForShow();
void ensureOverlayMpvSubtitlesHidden(); void ensureOverlayMpvSubtitlesHidden();
void primeCurrentSubtitleForVisibleOverlay(); void primeCurrentSubtitleForVisibleOverlay();
@@ -2386,6 +2386,36 @@ test('buildMergedDictionary rebuilds snapshots written with an older format vers
} }
}); });
test('getManualSelectionSnapshot falls back to mpv current video path when app media path is not ready', async () => {
const userDataPath = makeTempDir();
const mpvPath =
'C:\\Videos\\KonoSuba - Gods blessing on this wonderful world!! (2016) - S02E05.mkv';
const calls: Array<{ mediaPath: string | null; mediaTitle: string | null }> = [];
const runtime = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () => null,
getCurrentVideoPath: () => mpvPath,
getCurrentMediaTitle: () => null,
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async (mediaPath, mediaTitle) => {
calls.push({ mediaPath, mediaTitle });
return {
title: 'KonoSuba - Gods blessing on this wonderful world!!',
season: 2,
episode: 5,
source: 'fallback',
};
},
now: () => 1_700_000_000_000,
});
const snapshot = await runtime.getManualSelectionSnapshot(undefined, '');
assert.deepEqual(calls, [{ mediaPath: mpvPath, mediaTitle: null }]);
assert.equal(snapshot.guessTitle, 'KonoSuba - Gods blessing on this wonderful world!!');
assert.equal(snapshot.candidates.length, 0);
});
test('buildMergedDictionary reapplies collapsible open states from current config', async () => { test('buildMergedDictionary reapplies collapsible open states from current config', async () => {
const userDataPath = makeTempDir(); const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
+8 -2
View File
@@ -76,6 +76,11 @@ function expandUserPath(input: string): string {
return input; return input;
} }
function trimToNull(input: string | null | undefined): string | null {
const trimmed = typeof input === 'string' ? input.trim() : '';
return trimmed.length > 0 ? trimmed : null;
}
function isVideoFile(filePath: string): boolean { function isVideoFile(filePath: string): boolean {
return hasVideoExtension(path.extname(filePath)); return hasVideoExtension(path.extname(filePath));
} }
@@ -195,8 +200,9 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
return dictionaryTarget.length > 0 return dictionaryTarget.length > 0
? resolveDictionaryGuessInputs(dictionaryTarget) ? resolveDictionaryGuessInputs(dictionaryTarget)
: { : {
mediaPath: deps.getCurrentMediaPath(), mediaPath:
mediaTitle: deps.getCurrentMediaTitle(), trimToNull(deps.getCurrentMediaPath()) ?? trimToNull(deps.getCurrentVideoPath?.()),
mediaTitle: trimToNull(deps.getCurrentMediaTitle()),
}; };
}; };
@@ -137,6 +137,7 @@ export type CharacterDictionaryManualSelectionResult = {
export interface CharacterDictionaryRuntimeDeps { export interface CharacterDictionaryRuntimeDeps {
userDataPath: string; userDataPath: string;
getCurrentMediaPath: () => string | null; getCurrentMediaPath: () => string | null;
getCurrentVideoPath?: () => string | null | undefined;
getCurrentMediaTitle: () => string | null; getCurrentMediaTitle: () => string | null;
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null; resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
guessAnilistMediaInfo: ( guessAnilistMediaInfo: (
+2
View File
@@ -45,6 +45,7 @@ export interface CliCommandRuntimeServiceContext {
runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand']; runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand'];
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand']; runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
runUpdateCommand: CliCommandRuntimeServiceDepsParams['app']['runUpdateCommand']; runUpdateCommand: CliCommandRuntimeServiceDepsParams['app']['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandRuntimeServiceDepsParams['app']['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow']; runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
openYomitanSettings: () => void; openYomitanSettings: () => void;
openConfigSettingsWindow: () => void; openConfigSettingsWindow: () => void;
@@ -124,6 +125,7 @@ function createCliCommandDepsFromContext(
stop: context.stopApp, stop: context.stopApp,
hasMainWindow: context.hasMainWindow, hasMainWindow: context.hasMainWindow,
runUpdateCommand: context.runUpdateCommand, runUpdateCommand: context.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: context.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow, runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
}, },
dispatchSessionAction: context.dispatchSessionAction, dispatchSessionAction: context.dispatchSessionAction,
+12
View File
@@ -126,6 +126,9 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams {
getAnkiIntegration: AnkiJimakuIpcRuntimeOptions['getAnkiIntegration']; getAnkiIntegration: AnkiJimakuIpcRuntimeOptions['getAnkiIntegration'];
setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration']; setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration'];
getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath']; getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath'];
getCachedMediaPath?: AnkiJimakuIpcRuntimeOptions['getCachedMediaPath'];
shouldRequireRemoteMediaCache?: AnkiJimakuIpcRuntimeOptions['shouldRequireRemoteMediaCache'];
getYoutubeMediaSourceUrl?: AnkiJimakuIpcRuntimeOptions['getYoutubeMediaSourceUrl'];
showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification']; showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification'];
showOverlayNotification?: (payload: OverlayNotificationPayload) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback']; createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback'];
@@ -198,6 +201,7 @@ export interface CliCommandRuntimeServiceDepsParams {
stop: CliCommandDepsRuntimeOptions['app']['stop']; stop: CliCommandDepsRuntimeOptions['app']['stop'];
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow']; hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
runUpdateCommand: CliCommandDepsRuntimeOptions['app']['runUpdateCommand']; runUpdateCommand: CliCommandDepsRuntimeOptions['app']['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandDepsRuntimeOptions['app']['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow']; runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
}; };
dispatchSessionAction: CliCommandDepsRuntimeOptions['dispatchSessionAction']; dispatchSessionAction: CliCommandDepsRuntimeOptions['dispatchSessionAction'];
@@ -316,6 +320,13 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
getAnkiIntegration: params.getAnkiIntegration, getAnkiIntegration: params.getAnkiIntegration,
setAnkiIntegration: params.setAnkiIntegration, setAnkiIntegration: params.setAnkiIntegration,
getKnownWordCacheStatePath: params.getKnownWordCacheStatePath, getKnownWordCacheStatePath: params.getKnownWordCacheStatePath,
...(params.getCachedMediaPath ? { getCachedMediaPath: params.getCachedMediaPath } : {}),
...(params.shouldRequireRemoteMediaCache
? { shouldRequireRemoteMediaCache: params.shouldRequireRemoteMediaCache }
: {}),
...(params.getYoutubeMediaSourceUrl
? { getYoutubeMediaSourceUrl: params.getYoutubeMediaSourceUrl }
: {}),
showDesktopNotification: params.showDesktopNotification, showDesktopNotification: params.showDesktopNotification,
showOverlayNotification: params.showOverlayNotification, showOverlayNotification: params.showOverlayNotification,
createFieldGroupingCallback: params.createFieldGroupingCallback, createFieldGroupingCallback: params.createFieldGroupingCallback,
@@ -392,6 +403,7 @@ export function createCliCommandRuntimeServiceDeps(
stop: params.app.stop, stop: params.app.stop,
hasMainWindow: params.app.hasMainWindow, hasMainWindow: params.app.hasMainWindow,
runUpdateCommand: params.app.runUpdateCommand, runUpdateCommand: params.app.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: params.app.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow, runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
}, },
dispatchSessionAction: params.dispatchSessionAction, dispatchSessionAction: params.dispatchSessionAction,
+144 -4
View File
@@ -50,6 +50,16 @@ test('media path changes clear rendered subtitle state without clearing same-you
); );
}); });
test('media path changes start the YouTube media cache coordinator', () => {
const source = readMainSource();
const actionBlock = source.match(
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)\n restoreMpvSubVisibility:/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /youtubeMediaCachePlaybackRuntime\.handleMediaPathChange\(path\);/);
});
test('same media path updates do not reset autoplay ready fallback state', () => { test('same media path updates do not reset autoplay ready fallback state', () => {
const source = readMainSource(); const source = readMainSource();
const actionBlock = source.match( const actionBlock = source.match(
@@ -232,6 +242,31 @@ test('autoplay subtitle prime emits cached annotations and avoids raw fallback o
); );
}); });
test('autoplay subtitle prime reuses active parsed cues before synthetic warm release', () => {
const source = readMainSource();
const runtimeDepsBlock = source.match(
/const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime\(\{(?<body>[\s\S]*?)\n\}\);/,
)?.groups?.body;
const primeSource = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts');
const emptyTextBlock = primeSource.match(
/if \(!text\.trim\(\) && isCurrentAutoplayMediaPath\(mediaPath\)\) \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body;
assert.ok(runtimeDepsBlock);
assert.match(
runtimeDepsBlock,
/getActiveParsedSubtitleCues:\s*\(\) => appState\.activeParsedSubtitleCues/,
);
assert.ok(emptyTextBlock);
assert.ok(
emptyTextBlock.indexOf('await deps.refreshSubtitlePrefetchFromActiveTrack()') <
emptyTextBlock.indexOf(
'await primeAutoplaySubtitleFromParsedCues(mediaPath, deps.getActiveParsedSubtitleCues())',
),
);
});
test('startup autoplay release is tied to visible overlay measurement readiness', () => { test('startup autoplay release is tied to visible overlay measurement readiness', () => {
const source = readMainSource(); const source = readMainSource();
const gateBlock = source.match( const gateBlock = source.match(
@@ -283,7 +318,7 @@ test('visible overlay content-ready does not tokenize before first measurement',
); );
}); });
test('accepted visible overlay measurement immediately refreshes Linux pointer interaction', () => { test('accepted visible overlay measurement immediately refreshes pointer interaction', () => {
const source = readMainSource(); const source = readMainSource();
const measurementBlock = source.match( const measurementBlock = source.match(
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/, /reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
@@ -292,6 +327,7 @@ test('accepted visible overlay measurement immediately refreshes Linux pointer i
assert.ok(measurementBlock); assert.ok(measurementBlock);
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/); assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/); assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/);
assert.match(measurementBlock, /tickWindowsOverlayPointerInteractionNow\(\)/);
assert.match(measurementBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/); assert.match(measurementBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/);
assert.ok( assert.ok(
measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') < measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') <
@@ -299,6 +335,10 @@ test('accepted visible overlay measurement immediately refreshes Linux pointer i
); );
assert.ok( assert.ok(
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();') < measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();') <
measurementBlock.indexOf('tickWindowsOverlayPointerInteractionNow();'),
);
assert.ok(
measurementBlock.indexOf('tickWindowsOverlayPointerInteractionNow();') <
measurementBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'), measurementBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'),
); );
}); });
@@ -484,6 +524,48 @@ test('configured overlay notifications require visible ready overlay window', ()
assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/); assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/);
}); });
test('YouTube media cache lifecycle routes through configured status notifications', () => {
const source = readMainSource();
const cacheBlock = source.match(
/const youtubeMediaCache = createYoutubeMediaCacheService\(\{(?<body>[\s\S]*?)\n\}\);\nconst waitForYoutubeMpvConnected/,
)?.groups?.body;
const startCacheBlock = source.match(
/startYoutubeMediaCache:\s*\(url\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n runYoutubePlaybackFlow/,
)?.groups?.body;
assert.ok(cacheBlock);
assert.ok(startCacheBlock);
assert.match(
cacheBlock,
/onDownloadStarted:\s*\(event\)\s*=>\s*\{[\s\S]*showConfiguredStatusNotification\(\s*'YouTube media cache is downloading\.'/,
);
assert.match(cacheBlock, /id:\s*'youtube-media-cache-status'/);
assert.match(cacheBlock, /variant:\s*'progress'/);
assert.match(cacheBlock, /persistent:\s*true/);
assert.match(
cacheBlock,
/onReady:\s*\(event\)\s*=>\s*\{[\s\S]*showConfiguredStatusNotification\(\s*'YouTube media cache ready\.'/,
);
assert.match(cacheBlock, /variant:\s*'success'/);
assert.match(cacheBlock, /notifyNoQueued:\s*false/);
assert.match(startCacheBlock, /mode:\s*getResolvedConfig\(\)\.youtube\.mediaCache\.mode/);
assert.match(
startCacheBlock,
/maxHeight:\s*getResolvedConfig\(\)\.youtube\.mediaCache\.maxHeight/,
);
});
test('mpv connection flushes queued configured OSD notifications', () => {
const source = readMainSource();
const connectedBlock = source.match(
/onMpvConnected:\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n maybeRunAnilistPostWatchUpdate:/,
)?.groups?.body;
assert.ok(connectedBlock);
assert.match(source, /flushQueuedMpvOsdNotifications/);
assert.match(connectedBlock, /flushQueuedMpvOsdNotifications\(\);/);
});
test('manual visible overlay show primes current subtitle from mpv before relying on live events', () => { test('manual visible overlay show primes current subtitle from mpv before relying on live events', () => {
const source = readMainSource(); const source = readMainSource();
const setBlock = source.match( const setBlock = source.match(
@@ -497,11 +579,11 @@ test('manual visible overlay show primes current subtitle from mpv before relyin
assert.ok(toggleBlock); assert.ok(toggleBlock);
assert.match( assert.match(
setBlock, setBlock,
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/, /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
); );
assert.match( assert.match(
toggleBlock, toggleBlock,
/else \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/, /else \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
); );
}); });
@@ -522,10 +604,68 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape'
assert.doesNotMatch(runtimeSource, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/); assert.doesNotMatch(runtimeSource, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
assert.match( assert.match(
setBlock, setBlock,
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/, /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
); );
}); });
test('Linux visible overlay startup reapplies passive passthrough after input reset', () => {
const pointerSource = readSource('src/main/runtime/linux-overlay-pointer-interaction.ts');
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
const resetPrimerBlock = runtimeSource.match(
/function resetLinuxVisibleOverlayStartupInputPrimer\(\): void \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body;
const startGraceBlock = runtimeSource.match(
/function startLinuxVisibleOverlayStartupInputGrace\(\): void \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body;
const depsBlock = runtimeSource.match(
/const linuxOverlayPointerInteractionDeps = \{(?<body>[\s\S]*?)\n \};/,
)?.groups?.body;
assert.ok(resetPrimerBlock);
assert.ok(startGraceBlock);
assert.ok(depsBlock);
assert.match(resetPrimerBlock, /visibleOverlayInteractionActive = false;/);
assert.match(resetPrimerBlock, /linuxOverlayPointerInteractionStateApplied = false;/);
assert.match(
startGraceBlock,
/linuxVisibleOverlayStartupInputGraceUntilMs =\s+Date\.now\(\) \+ LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS;/,
);
assert.match(startGraceBlock, /linuxOverlayPointerInteractionStateApplied = false;/);
assert.match(
depsBlock,
/isInteractionStateApplied:\s*\(\) => linuxOverlayPointerInteractionStateApplied/,
);
assert.match(
pointerSource,
/deps\.getInteractionActive\(\) === desired && deps\.isInteractionStateApplied\?\.\(\) !== false/,
);
});
test('Linux visible overlay show starts input grace before first measurement', () => {
const source = readMainSource();
const setVisibleBlock = source.match(
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const toggleBlock = source.match(
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const setOverlayBlock = source.match(
/function setOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
for (const block of [setVisibleBlock, toggleBlock, setOverlayBlock]) {
assert.ok(block);
assert.ok(
block.indexOf('resetLinuxVisibleOverlayStartupInputPrimer();') <
block.indexOf('startLinuxVisibleOverlayStartupInputGrace();'),
);
assert.ok(
block.indexOf('startLinuxVisibleOverlayStartupInputGrace();') <
block.indexOf('void primeCurrentSubtitleForVisibleOverlay();'),
);
}
});
test('Linux visible overlay bounds refresh restores X11 shape after applying mpv geometry', () => { test('Linux visible overlay bounds refresh restores X11 shape after applying mpv geometry', () => {
const source = readSource('src/main/runtime/overlay-geometry-runtime.ts'); const source = readSource('src/main/runtime/overlay-geometry-runtime.ts');
const afterBoundsBlock = source.match( const afterBoundsBlock = source.match(
@@ -41,18 +41,20 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'), clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'), stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
}); });
cleanup(); cleanup();
assert.equal(calls.length, 32); assert.equal(calls.length, 33);
assert.equal(calls[0], 'destroy-tray'); assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence'); assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.includes('cleanup-jellyfin-subtitles')); assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
assert.ok(calls.includes('clear-windows-visible-overlay-poll')); assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts')); assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
assert.ok(calls.includes('cleanup-youtube-subtitles')); assert.ok(calls.includes('cleanup-youtube-subtitles'));
assert.ok(calls.includes('cleanup-youtube-media'));
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket')); assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
}); });
@@ -92,6 +94,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
throw new Error('stop failed'); throw new Error('stop failed');
}, },
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'), stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
}); });
@@ -29,6 +29,7 @@ export function createOnWillQuitCleanupHandler(deps: {
clearYomitanSettingsWindow: () => void; clearYomitanSettingsWindow: () => void;
stopJellyfinRemoteSession: () => void; stopJellyfinRemoteSession: () => void;
cleanupYoutubeSubtitleTempDirs: () => void; cleanupYoutubeSubtitleTempDirs: () => void;
cleanupYoutubeMediaCache: () => void;
cleanupJellyfinSubtitleCache: () => void; cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void; stopDiscordPresenceService: () => void;
}) { }) {
@@ -67,6 +68,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.cleanupJellyfinSubtitleCache(); deps.cleanupJellyfinSubtitleCache();
} }
deps.cleanupYoutubeSubtitleTempDirs(); deps.cleanupYoutubeSubtitleTempDirs();
deps.cleanupYoutubeMediaCache();
deps.stopDiscordPresenceService(); deps.stopDiscordPresenceService();
}; };
} }
@@ -70,6 +70,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'), stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
}); });
@@ -92,6 +93,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
assert.ok(calls.includes('destroy-yomitan-settings-window')); assert.ok(calls.includes('destroy-yomitan-settings-window'));
assert.ok(calls.includes('stop-jellyfin-remote')); assert.ok(calls.includes('stop-jellyfin-remote'));
assert.ok(calls.includes('cleanup-youtube-subtitles')); assert.ok(calls.includes('cleanup-youtube-subtitles'));
assert.ok(calls.includes('cleanup-youtube-media'));
assert.ok(calls.includes('cleanup-jellyfin-subtitles')); assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
assert.ok(calls.includes('stop-discord-presence')); assert.ok(calls.includes('stop-discord-presence'));
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop')); assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
@@ -147,6 +149,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
clearYomitanSettingsWindow: () => {}, clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {}, stopJellyfinRemoteSession: () => {},
cleanupYoutubeSubtitleTempDirs: () => {}, cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupJellyfinSubtitleCache: () => {}, cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {}, stopDiscordPresenceService: () => {},
}); });
@@ -197,6 +200,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
clearYomitanSettingsWindow: () => {}, clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {}, stopJellyfinRemoteSession: () => {},
cleanupYoutubeSubtitleTempDirs: () => {}, cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupJellyfinSubtitleCache: () => {}, cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {}, stopDiscordPresenceService: () => {},
}); });
@@ -58,6 +58,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
stopJellyfinRemoteSession: () => void; stopJellyfinRemoteSession: () => void;
cleanupYoutubeSubtitleTempDirs: () => void; cleanupYoutubeSubtitleTempDirs: () => void;
cleanupYoutubeMediaCache: () => void;
cleanupJellyfinSubtitleCache: () => void; cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void; stopDiscordPresenceService: () => void;
}) { }) {
@@ -142,6 +143,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(), clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(), stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(), cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
cleanupYoutubeMediaCache: () => deps.cleanupYoutubeMediaCache(),
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(), cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(), stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
}); });
@@ -137,6 +137,46 @@ test('autoplay ready gate requests overlay pointer recovery when media readiness
assert.equal(pointerRecoveryRequests, 1); assert.equal(pointerRecoveryRequests, 1);
}); });
test('autoplay ready gate reports the released autoplay signal once', async () => {
const releasedSignals: string[] = [];
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: () => {},
}) as never,
signalPluginAutoplayReady: () => {},
onAutoplayReadyReleased: (signal) => {
releasedSignals.push(signal.payload.text);
},
schedule: (callback) => {
queueMicrotask(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady(
{ text: '__warm__', tokens: null },
{ forceWhilePaused: true },
);
await new Promise((resolve) => setTimeout(resolve, 0));
gate.maybeSignalPluginAutoplayReady(
{ text: '次の字幕', tokens: null },
{ forceWhilePaused: true },
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(releasedSignals, ['__warm__']);
});
test('autoplay ready gate does not unpause again after a later manual pause on the same media', async () => { test('autoplay ready gate does not unpause again after a later manual pause on the same media', async () => {
const commands: Array<Array<string | boolean>> = []; const commands: Array<Array<string | boolean>> = [];
let playbackPaused = true; let playbackPaused = true;
+2
View File
@@ -27,6 +27,7 @@ export type AutoplayReadyGateDeps = {
getMpvClient: () => MpvClientLike | null; getMpvClient: () => MpvClientLike | null;
signalPluginAutoplayReady: () => void; signalPluginAutoplayReady: () => void;
requestOverlayPointerRecovery?: () => void; requestOverlayPointerRecovery?: () => void;
onAutoplayReadyReleased?: (signal: AutoplayReadySignal) => void;
isSignalTargetReady?: (signal: AutoplayReadySignal) => boolean; isSignalTargetReady?: (signal: AutoplayReadySignal) => boolean;
now?: () => number; now?: () => number;
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>; schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
@@ -182,6 +183,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
const playbackGeneration = ++autoPlayReadySignalGeneration; const playbackGeneration = ++autoPlayReadySignalGeneration;
deps.signalPluginAutoplayReady(); deps.signalPluginAutoplayReady();
deps.requestOverlayPointerRecovery?.(); deps.requestOverlayPointerRecovery?.();
deps.onAutoplayReadyReleased?.(signal);
attemptRelease(playbackGeneration, 0); attemptRelease(playbackGeneration, 0);
}; };
@@ -38,6 +38,7 @@ test('scheduleSubtitlePrefetchRefresh logs refresh failures from timer callback'
setCurrentSubText: () => {}, setCurrentSubText: () => {},
getCurrentSubText: () => '', getCurrentSubText: () => '',
getCurrentSubtitleData: () => null, getCurrentSubtitleData: () => null,
getActiveParsedSubtitleCues: () => [],
setActiveParsedSubtitleMediaPath: () => {}, setActiveParsedSubtitleMediaPath: () => {},
subtitleProcessingController: { subtitleProcessingController: {
consumeCachedSubtitle: () => null, consumeCachedSubtitle: () => null,
@@ -63,3 +64,118 @@ test('scheduleSubtitlePrefetchRefresh logs refresh failures from timer callback'
'[autoplay-subtitle-prime] subtitle prefetch refresh failed: refresh failed', '[autoplay-subtitle-prime] subtitle prefetch refresh failed: refresh failed',
]); ]);
}); });
test('primeCurrentSubtitleForAutoplay refreshes active subtitle cues when mpv sub-text is empty', async () => {
const calls: string[] = [];
let currentSubText = '';
let activeParsedSubtitleCues: Array<{ startTime: number; endTime: number; text: string }> = [];
const mediaPath = '/media/video.mkv';
const runtime = createAutoplaySubtitlePrimingRuntime({
getCurrentMediaPath: () => mediaPath,
getMpvClient: () => ({
connected: true,
currentVideoPath: mediaPath,
requestProperty: async (name) => {
calls.push(`request:${name}`);
if (name === 'sub-text') return '';
if (name === 'time-pos') return 12;
return null;
},
}),
setCurrentSubText: (text) => {
currentSubText = text;
calls.push(`set:${text}`);
},
getCurrentSubText: () => currentSubText,
getCurrentSubtitleData: () => null,
getActiveParsedSubtitleCues: () => activeParsedSubtitleCues,
setActiveParsedSubtitleMediaPath: () => {},
subtitleProcessingController: {
consumeCachedSubtitle: () => null,
onSubtitleChange: (text) => calls.push(`change:${text}`),
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text ?? ''}`),
},
emitSubtitlePayload: (payload) => calls.push(`emit:${payload.text}`),
getSubtitlePrefetchService: () => ({
pause: () => calls.push('prefetch:pause'),
onSeek: (timePos) => calls.push(`prefetch:seek:${timePos}`),
}),
getLastObservedTimePos: () => 12,
getVisibleOverlayVisible: () => true,
emitSecondarySubtitle: () => {},
initSubtitlePrefetch: async () => {},
refreshSubtitlePrefetchFromActiveTrack: async () => {
calls.push('refresh-active-track');
activeParsedSubtitleCues = [{ startTime: 10, endTime: 20, text: '起動字幕' }];
},
logDebug: (message) => calls.push(`debug:${message}`),
});
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
assert.deepEqual(calls, [
'request:sub-text',
'refresh-active-track',
'request:time-pos',
'set:起動字幕',
'prefetch:pause',
'emit:起動字幕',
'change:起動字幕',
]);
});
test('primeCurrentSubtitleForAutoplay emits raw first paint on cache miss before tokenization', async () => {
const calls: string[] = [];
let currentSubText = '';
const mediaPath = '/media/video.mkv';
const runtime = createAutoplaySubtitlePrimingRuntime({
getCurrentMediaPath: () => mediaPath,
getMpvClient: () => ({
connected: true,
currentVideoPath: mediaPath,
requestProperty: async (name) => {
calls.push(`request:${name}`);
if (name === 'sub-text') return '起動字幕';
return null;
},
}),
setCurrentSubText: (text) => {
currentSubText = text;
calls.push(`set:${text}`);
},
getCurrentSubText: () => currentSubText,
getCurrentSubtitleData: () => null,
getActiveParsedSubtitleCues: () => [],
setActiveParsedSubtitleMediaPath: () => {},
subtitleProcessingController: {
consumeCachedSubtitle: () => null,
onSubtitleChange: (text) => calls.push(`change:${text}`),
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text ?? ''}`),
},
emitSubtitlePayload: (payload) => calls.push(`emit:${payload.text}`),
getSubtitlePrefetchService: () => ({
pause: () => calls.push('prefetch:pause'),
onSeek: (timePos) => calls.push(`prefetch:seek:${timePos}`),
}),
getLastObservedTimePos: () => 12,
getVisibleOverlayVisible: () => true,
emitSecondarySubtitle: () => {},
initSubtitlePrefetch: async () => {},
refreshSubtitlePrefetchFromActiveTrack: async () => {
calls.push('refresh-active-track');
},
logDebug: (message) => calls.push(`debug:${message}`),
});
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
assert.deepEqual(calls, [
'request:sub-text',
'set:起動字幕',
'prefetch:pause',
'emit:起動字幕',
'change:起動字幕',
]);
});
@@ -26,6 +26,7 @@ export interface AutoplaySubtitlePrimingRuntimeDeps {
setCurrentSubText: (text: string) => void; setCurrentSubText: (text: string) => void;
getCurrentSubText: () => string; getCurrentSubText: () => string;
getCurrentSubtitleData: () => SubtitleData | null; getCurrentSubtitleData: () => SubtitleData | null;
getActiveParsedSubtitleCues: () => SubtitleCue[];
setActiveParsedSubtitleMediaPath: (mediaPath: string | null) => void; setActiveParsedSubtitleMediaPath: (mediaPath: string | null) => void;
subtitleProcessingController: { subtitleProcessingController: {
consumeCachedSubtitle: (text: string) => SubtitleData | null; consumeCachedSubtitle: (text: string) => SubtitleData | null;
@@ -107,6 +108,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
return true; return true;
} }
emitSubtitlePayload({ text, tokens: null });
subtitleProcessingController.onSubtitleChange(text); subtitleProcessingController.onSubtitleChange(text);
return true; return true;
} }
@@ -126,7 +128,20 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
return null; return null;
}); });
const text = typeof subTextRaw === 'string' ? subTextRaw : ''; const text = typeof subTextRaw === 'string' ? subTextRaw : '';
emitAutoplayPrimedSubtitle(mediaPath, text); if (emitAutoplayPrimedSubtitle(mediaPath, text)) {
return;
}
if (!text.trim() && isCurrentAutoplayMediaPath(mediaPath)) {
await deps.refreshSubtitlePrefetchFromActiveTrack().catch((error) => {
deps.logDebug(
`[autoplay-subtitle-prime] active subtitle refresh failed after empty sub-text: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
await primeAutoplaySubtitleFromParsedCues(mediaPath, deps.getActiveParsedSubtitleCues());
}
} }
async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> { async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
@@ -66,6 +66,9 @@ test('build cli command context deps maps handlers and values', () => {
runUpdateCommand: async () => { runUpdateCommand: async () => {
calls.push('run-update'); calls.push('run-update');
}, },
runEnsureLinuxRuntimePluginAssetsCommand: async () => {
calls.push('run-ensure-linux-runtime-plugin-assets');
},
runYoutubePlaybackFlow: async () => { runYoutubePlaybackFlow: async () => {
calls.push('run-youtube-playback'); calls.push('run-youtube-playback');
}, },
@@ -43,6 +43,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand']; runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>; runJellyfinCommand: (args: CliArgs) => Promise<void>;
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand']; runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandContextFactoryDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow']; runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
openYomitanSettings: () => void; openYomitanSettings: () => void;
openConfigSettingsWindow: () => void; openConfigSettingsWindow: () => void;
@@ -100,6 +101,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
runStatsCommand: deps.runStatsCommand, runStatsCommand: deps.runStatsCommand,
runJellyfinCommand: deps.runJellyfinCommand, runJellyfinCommand: deps.runJellyfinCommand,
runUpdateCommand: deps.runUpdateCommand, runUpdateCommand: deps.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: deps.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow, runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
openYomitanSettings: deps.openYomitanSettings, openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow, openConfigSettingsWindow: deps.openConfigSettingsWindow,
@@ -72,6 +72,7 @@ test('cli command context factory composes main deps and context handlers', () =
runStatsCommand: async () => {}, runStatsCommand: async () => {},
runJellyfinCommand: async () => {}, runJellyfinCommand: async () => {},
runUpdateCommand: async () => {}, runUpdateCommand: async () => {},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
runYoutubePlaybackFlow: async () => {}, runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {}, openYomitanSettings: () => {},
openConfigSettingsWindow: () => {}, openConfigSettingsWindow: () => {},
@@ -97,6 +97,9 @@ test('cli command context main deps builder maps state and callbacks', async ()
runUpdateCommand: async () => { runUpdateCommand: async () => {
calls.push('run-update'); calls.push('run-update');
}, },
runEnsureLinuxRuntimePluginAssetsCommand: async () => {
calls.push('run-ensure-linux-runtime-plugin-assets');
},
runYoutubePlaybackFlow: async () => { runYoutubePlaybackFlow: async () => {
calls.push('run-youtube-playback'); calls.push('run-youtube-playback');
}, },
@@ -56,6 +56,10 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand']; runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>; runJellyfinCommand: (args: CliArgs) => Promise<void>;
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand']; runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: (
args: CliArgs,
source: CliCommandSource,
) => Promise<void>;
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow']; runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
openYomitanSettings: () => void; openYomitanSettings: () => void;
@@ -133,6 +137,8 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args), runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
runUpdateCommand: (args: CliArgs, source: CliCommandSource) => runUpdateCommand: (args: CliArgs, source: CliCommandSource) =>
deps.runUpdateCommand(args, source), deps.runUpdateCommand(args, source),
runEnsureLinuxRuntimePluginAssetsCommand: (args: CliArgs, source: CliCommandSource) =>
deps.runEnsureLinuxRuntimePluginAssetsCommand(args, source),
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request), runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
openYomitanSettings: () => deps.openYomitanSettings(), openYomitanSettings: () => deps.openYomitanSettings(),
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(), openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
@@ -54,6 +54,7 @@ function createDeps() {
runStatsCommand: async () => {}, runStatsCommand: async () => {},
runJellyfinCommand: async () => {}, runJellyfinCommand: async () => {},
runUpdateCommand: async () => {}, runUpdateCommand: async () => {},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
runYoutubePlaybackFlow: async () => {}, runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {}, openYomitanSettings: () => {},
openConfigSettingsWindow: () => {}, openConfigSettingsWindow: () => {},
+2
View File
@@ -48,6 +48,7 @@ export type CliCommandContextFactoryDeps = {
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand']; runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>; runJellyfinCommand: (args: CliArgs) => Promise<void>;
runUpdateCommand: CliCommandRuntimeServiceContext['runUpdateCommand']; runUpdateCommand: CliCommandRuntimeServiceContext['runUpdateCommand'];
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandRuntimeServiceContext['runEnsureLinuxRuntimePluginAssetsCommand'];
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow']; runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
openYomitanSettings: () => void; openYomitanSettings: () => void;
openConfigSettingsWindow: () => void; openConfigSettingsWindow: () => void;
@@ -127,6 +128,7 @@ export function createCliCommandContext(
runStatsCommand: deps.runStatsCommand, runStatsCommand: deps.runStatsCommand,
runJellyfinCommand: deps.runJellyfinCommand, runJellyfinCommand: deps.runJellyfinCommand,
runUpdateCommand: deps.runUpdateCommand, runUpdateCommand: deps.runUpdateCommand,
runEnsureLinuxRuntimePluginAssetsCommand: deps.runEnsureLinuxRuntimePluginAssetsCommand,
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow, runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
openYomitanSettings: deps.openYomitanSettings, openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow, openConfigSettingsWindow: deps.openConfigSettingsWindow,
@@ -48,6 +48,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
runJellyfinCommand: async () => {}, runJellyfinCommand: async () => {},
runStatsCommand: async () => {}, runStatsCommand: async () => {},
runUpdateCommand: async () => {}, runUpdateCommand: async () => {},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
runYoutubePlaybackFlow: async () => {}, runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {}, openYomitanSettings: () => {},
openConfigSettingsWindow: () => {}, openConfigSettingsWindow: () => {},
@@ -49,6 +49,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
clearYomitanSettingsWindow: () => {}, clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: async () => {}, stopJellyfinRemoteSession: async () => {},
cleanupYoutubeSubtitleTempDirs: () => {}, cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {},
cleanupJellyfinSubtitleCache: () => {}, cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {}, stopDiscordPresenceService: () => {},
}, },
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
getPlaybackFeedbackNotificationOptions, getPlaybackFeedbackNotificationOptions,
getYoutubeFlowStatusNotificationOptions,
notifyConfiguredStatus, notifyConfiguredStatus,
} from './configured-status-notification'; } from './configured-status-notification';
import { createOverlayNotificationDelivery } from './overlay-notification-delivery'; import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
@@ -28,7 +29,7 @@ test('notifyConfiguredStatus routes both to overlay and system without osd', ()
]); ]);
}); });
test('notifyConfiguredStatus falls back to desktop for pre-overlay both status', () => { test('notifyConfiguredStatus queues overlay for pre-overlay both status and preserves desktop', () => {
const calls: string[] = []; const calls: string[] = [];
notifyConfiguredStatus('Overlay loading...', { notifyConfiguredStatus('Overlay loading...', {
@@ -43,10 +44,10 @@ test('notifyConfiguredStatus falls back to desktop for pre-overlay both status',
calls.push(`desktop:${title}:${options.body ?? ''}`), calls.push(`desktop:${title}:${options.body ?? ''}`),
}); });
assert.deepEqual(calls, ['desktop:SubMiner:Overlay loading...']); assert.deepEqual(calls, ['overlay::Overlay loading...', 'desktop:SubMiner:Overlay loading...']);
}); });
test('notifyConfiguredStatus falls back to desktop for pre-overlay overlay-only status', () => { test('notifyConfiguredStatus queues overlay for pre-overlay overlay-only status', () => {
const calls: string[] = []; const calls: string[] = [];
notifyConfiguredStatus('Overlay loading...', { notifyConfiguredStatus('Overlay loading...', {
@@ -61,7 +62,7 @@ test('notifyConfiguredStatus falls back to desktop for pre-overlay overlay-only
calls.push(`desktop:${title}:${options.body ?? ''}`), calls.push(`desktop:${title}:${options.body ?? ''}`),
}); });
assert.deepEqual(calls, ['desktop:SubMiner:Overlay loading...']); assert.deepEqual(calls, ['overlay::Overlay loading...']);
}); });
test('notifyConfiguredStatus routes pre-overlay system status to desktop only', () => { test('notifyConfiguredStatus routes pre-overlay system status to desktop only', () => {
@@ -97,6 +98,37 @@ test('notifyConfiguredStatus keeps osd-system on legacy surfaces', () => {
assert.deepEqual(calls, ['osd:Overlay loading...', 'desktop:SubMiner:Overlay loading...']); assert.deepEqual(calls, ['osd:Overlay loading...', 'desktop:SubMiner:Overlay loading...']);
}); });
test('notifyConfiguredStatus queues osd status when mpv osd is unavailable', () => {
const calls: string[] = [];
notifyConfiguredStatus(
'YouTube media cache is downloading.',
{
getNotificationType: () => 'osd',
showOsd: (message) => {
calls.push(`osd:${message}`);
return false;
},
queueOsd: (message, options) => {
calls.push(`queue:${options.id ?? ''}:${message}`);
},
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
},
{
id: 'youtube-media-cache-status',
title: 'YouTube media cache',
variant: 'progress',
persistent: true,
},
);
assert.deepEqual(calls, [
'osd:YouTube media cache is downloading.',
'queue:youtube-media-cache-status:YouTube media cache is downloading.',
]);
});
test('notifyConfiguredStatus can suppress desktop delivery for progress ticks', () => { test('notifyConfiguredStatus can suppress desktop delivery for progress ticks', () => {
const calls: string[] = []; const calls: string[] = [];
@@ -195,6 +227,36 @@ test('playback feedback options reuse subtitle mode notification ids', () => {
assert.deepEqual(getPlaybackFeedbackNotificationOptions('Secondary subtitle track: English'), {}); assert.deepEqual(getPlaybackFeedbackNotificationOptions('Secondary subtitle track: English'), {});
}); });
test('youtube flow status options route picker opening as one-shot configured status', () => {
assert.deepEqual(getYoutubeFlowStatusNotificationOptions('Opening YouTube subtitle picker...'), {
id: 'youtube-subtitles-status',
title: 'YouTube subtitles',
variant: 'info',
persistent: false,
desktop: true,
});
});
test('youtube flow status options route loaded messages as transient success', () => {
assert.deepEqual(getYoutubeFlowStatusNotificationOptions('Subtitles loaded.'), {
id: 'youtube-subtitles-status',
title: 'YouTube subtitles',
variant: 'success',
persistent: false,
desktop: true,
});
assert.deepEqual(
getYoutubeFlowStatusNotificationOptions('Primary and secondary subtitles loaded.'),
{
id: 'youtube-subtitles-status',
title: 'YouTube subtitles',
variant: 'success',
persistent: false,
desktop: true,
},
);
});
test('notifyConfiguredStatus falls back to desktop if overlay is unavailable', () => { test('notifyConfiguredStatus falls back to desktop if overlay is unavailable', () => {
const calls: string[] = []; const calls: string[] = [];
@@ -5,6 +5,7 @@ export interface ConfiguredStatusNotificationDeps {
getNotificationType: () => NotificationType | undefined; getNotificationType: () => NotificationType | undefined;
isOverlayReady?: () => boolean; isOverlayReady?: () => boolean;
showOsd: (message: string) => boolean | void; showOsd: (message: string) => boolean | void;
queueOsd?: (message: string, options: ConfiguredStatusNotificationOptions) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
showDesktopNotification: (title: string, options: { body?: string }) => void; showDesktopNotification: (title: string, options: { body?: string }) => void;
} }
@@ -30,6 +31,25 @@ export function getPlaybackFeedbackNotificationOptions(
return {}; return {};
} }
export function getYoutubeFlowStatusNotificationOptions(
message: string,
): ConfiguredStatusNotificationOptions {
const success =
message === 'Subtitles loaded.' || message === 'Primary and secondary subtitles loaded.';
const progress =
message.startsWith('Downloading subtitles') ||
message.startsWith('Loading subtitles') ||
message.startsWith('Getting subtitles') ||
message === 'Opening YouTube video';
return {
id: 'youtube-subtitles-status',
title: 'YouTube subtitles',
variant: success ? 'success' : progress ? 'progress' : 'info',
persistent: progress,
desktop: !progress,
};
}
export function notifyConfiguredStatus( export function notifyConfiguredStatus(
message: string, message: string,
deps: ConfiguredStatusNotificationDeps, deps: ConfiguredStatusNotificationDeps,
@@ -50,8 +70,7 @@ export function notifyConfiguredStatus(
} }
if (showOverlay) { if (showOverlay) {
const overlayReady = deps.isOverlayReady?.() ?? true; if (deps.showOverlayNotification) {
if (deps.showOverlayNotification && overlayReady) {
deps.showOverlayNotification({ deps.showOverlayNotification({
id: options.id, id: options.id,
title: options.title ?? 'SubMiner', title: options.title ?? 'SubMiner',
@@ -65,7 +84,10 @@ export function notifyConfiguredStatus(
} }
if (showOsd) { if (showOsd) {
deps.showOsd(message); const shown = deps.showOsd(message);
if (shown === false && delivery !== 'feedback') {
deps.queueOsd?.(message, options);
}
} }
if (desktopEnabled && shouldShowDesktop(type)) { if (desktopEnabled && shouldShowDesktop(type)) {
@@ -341,6 +341,21 @@ test('tick only writes interaction state on change', () => {
assert.deepEqual(calls, [true]); assert.deepEqual(calls, [true]);
}); });
test('tick reapplies an unchanged inactive state when the window passthrough state is dirty', () => {
const calls: boolean[] = [];
const { deps, state } = makeDeps({
getCursorScreenPoint: () => ({ x: 200, y: 200 }),
isInteractionStateApplied: () => false,
setInteractionActive: (active) => {
calls.push(active);
state.active = active;
},
});
tickLinuxOverlayPointerInteraction(deps);
assert.deepEqual(calls, [false]);
});
test('tick does not flip state when suspended (returns null)', () => { test('tick does not flip state when suspended (returns null)', () => {
const calls: boolean[] = []; const calls: boolean[] = [];
const { deps } = makeDeps({ const { deps } = makeDeps({
@@ -53,6 +53,7 @@ export type LinuxOverlayPointerInteractionDeps = {
shouldSuppressInteraction?: () => boolean; shouldSuppressInteraction?: () => boolean;
shouldUseInputShape?: () => boolean; shouldUseInputShape?: () => boolean;
getInteractionActive: () => boolean; getInteractionActive: () => boolean;
isInteractionStateApplied?: () => boolean;
setInteractionActive: (active: boolean) => void; setInteractionActive: (active: boolean) => void;
}; };
@@ -273,7 +274,9 @@ export function tickLinuxOverlayPointerInteraction(deps: LinuxOverlayPointerInte
if (deps.shouldUseInputShape?.()) return; if (deps.shouldUseInputShape?.()) return;
const desired = resolveDesiredOverlayInteractive(deps); const desired = resolveDesiredOverlayInteractive(deps);
if (desired === null) return; if (desired === null) return;
if (deps.getInteractionActive() === desired) return; if (deps.getInteractionActive() === desired && deps.isInteractionStateApplied?.() !== false) {
return;
}
deps.setInteractionActive(desired); deps.setInteractionActive(desired);
} }

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