mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 07:21:33 -07:00
Compare commits
13 Commits
v0.17.0
...
b14f977e33
| Author | SHA1 | Date | |
|---|---|---|---|
| b14f977e33 | |||
| eef4500599 | |||
|
73af1451b7
|
|||
| 36a3704815 | |||
| 359cb0a301 | |||
|
4b10e85053
|
|||
| c942a2cf2d | |||
| f65afa6046 | |||
|
389d8e06e0
|
|||
|
0008b55b70
|
|||
|
d16ae9c745
|
|||
| 36f94151b8 | |||
| 5326ad32f5 |
@@ -1,5 +1,6 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
/package-lock.json
|
||||
|
||||
# Superpowers brainstorming
|
||||
.superpowers/
|
||||
@@ -16,6 +17,8 @@ coverage/
|
||||
|
||||
# Launcher build artifact (produced by make build-launcher)
|
||||
/subminer
|
||||
/main-entry.js
|
||||
/main-entry.js.map
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# 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
|
||||
|
||||
@@ -213,6 +213,7 @@ On **Windows**, just run `SubMiner.exe` and the setup will open automatically on
|
||||
subminer video.mkv # launch mpv with SubMiner
|
||||
subminer /path/to/dir # pick a file with fzf
|
||||
subminer -R /path/to/dir # pick a file with rofi (Linux only)
|
||||
subminer -H # browse local watch history (replay / next episode / browse)
|
||||
```
|
||||
|
||||
On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: mining
|
||||
|
||||
- Normalized generated card audio by default during media extraction, with `ankiConnect.media.normalizeAudio` available to keep raw source loudness when needed.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: launcher
|
||||
|
||||
- Show cover art icons in the rofi watch-history picker, reusing AniList covers already stored in the stats database (extracted to `~/.cache/subminer/covers`).
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed never-mined compound words (e.g. 待ち合わせてる) being highlighted green as known: subtitle tokens now carry complete readings instead of kanji-only furigana joins, and the known-word reading fallback rejects readings that don't cover the token surface. Stored word readings in the stats database are no longer truncated for new lines.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: launcher
|
||||
|
||||
- Added `subminer -H` / `--history` to browse local watch history, replay the last watched episode, continue to the next episode, or browse episodes with fzf/rofi.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: youtube
|
||||
|
||||
- Fixed direct YouTube stream media extraction by parsing mpv EDL stream URLs with their byte-length guards, preventing trailing EDL segment options from corrupting signed googlevideo URLs and causing ffmpeg 403 errors.
|
||||
@@ -559,6 +559,7 @@
|
||||
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
|
||||
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Values: true | false
|
||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
@@ -618,7 +619,11 @@
|
||||
"primarySubLanguages": [
|
||||
"ja",
|
||||
"jpn"
|
||||
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||
], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||
"mediaCache": {
|
||||
"mode": "direct", // How YouTube card audio/images are extracted. Values: direct | background
|
||||
"maxHeight": 720 // Maximum video height downloaded for the YouTube background media cache. Set to 0 for unlimited.
|
||||
} // Media cache setting.
|
||||
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -161,13 +161,14 @@ Audio is extracted from the video file using the subtitle's start and end timest
|
||||
"ankiConnect": {
|
||||
"media": {
|
||||
"generateAudio": true,
|
||||
"normalizeAudio": true, // normalize generated clip loudness
|
||||
"audioPadding": 0, // optional seconds before and after subtitle timing
|
||||
"maxMediaDuration": 30 // cap total duration in seconds
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream.
|
||||
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream. Generated sentence audio is loudness-normalized by default during extraction; set `normalizeAudio` to `false` to keep raw source loudness.
|
||||
|
||||
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
|
||||
|
||||
@@ -347,6 +348,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
||||
"imageType": "static",
|
||||
"imageFormat": "jpg",
|
||||
"imageQuality": 92,
|
||||
"normalizeAudio": true,
|
||||
"audioPadding": 0,
|
||||
"maxMediaDuration": 30,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# 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**
|
||||
|
||||
@@ -188,6 +188,9 @@ Control the minimum log level for runtime output:
|
||||
| `files.launcher` | boolean | Write launcher command logs (default: `true`) |
|
||||
| `files.mpv` | boolean | Write mpv player logs. Enable temporarily for mpv/plugin debugging. |
|
||||
|
||||
Log filenames use the local calendar date, for example `app-YYYY-MM-DD.log`, `launcher-YYYY-MM-DD.log`, and `mpv-YYYY-MM-DD.log`.
|
||||
Log export creates a sanitized copy of those files; it does not rewrite the original log files on disk.
|
||||
|
||||
### Updates
|
||||
|
||||
Configure automatic update checks and update notifications:
|
||||
@@ -948,6 +951,7 @@ Enable automatic Anki card creation and updates with media generation:
|
||||
"animatedMaxWidth": 640,
|
||||
"animatedMaxHeight": 0,
|
||||
"animatedCrf": 35,
|
||||
"normalizeAudio": true,
|
||||
"audioPadding": 0,
|
||||
"fallbackDuration": 3,
|
||||
"maxMediaDuration": 30
|
||||
@@ -998,6 +1002,7 @@ This example is intentionally compact. The option table below documents availabl
|
||||
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
|
||||
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
|
||||
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
||||
| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. |
|
||||
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
||||
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||
@@ -1520,14 +1525,24 @@ Set defaults used by managed subtitle auto-selection and the `subminer` launcher
|
||||
```json
|
||||
{
|
||||
"youtube": {
|
||||
"primarySubLanguages": ["ja", "jpn"]
|
||||
"primarySubLanguages": ["ja", "jpn"],
|
||||
"mediaCache": {
|
||||
"mode": "direct",
|
||||
"maxHeight": 720
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| --------------------- | -------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) |
|
||||
| Option | Values | Description |
|
||||
| ---------------------- | ------------------------ | ------------------------------------------------------------------------------------------------ |
|
||||
| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) |
|
||||
| `mediaCache.mode` | `direct` \| `background` | YouTube card audio/image extraction mode (default `direct`) |
|
||||
| `mediaCache.maxHeight` | number | Maximum background cache download height. Set `0` for unlimited (default `720`) |
|
||||
|
||||
`mediaCache.mode: "direct"` extracts card media from the active YouTube stream URL. `mediaCache.mode: "background"` starts a separate yt-dlp media download after YouTube playback has loaded, including YouTube URLs opened directly in mpv and resolved stream URLs when mpv still exposes the original YouTube playlist entry. Playback and subtitle loading do not wait for that download. Use background mode if direct card media generation hits YouTube `403` errors from expiring stream URLs.
|
||||
|
||||
Background cache downloads are capped by `mediaCache.maxHeight`, which defaults to 720p; set it to `0` to let yt-dlp choose the best available height. Downloads use IPv4 and yt-dlp retry flags to reduce YouTube throttling failures. SubMiner announces when the background cache download starts and when the cache is ready, using the configured notification surface; overlay and OSD messages queue until the overlay or mpv is ready. If you mine cards before the cache is ready, SubMiner creates the text fields immediately, queues the audio/image work for those note IDs, shows a status notification, and fills the media fields once the cached file is ready. If the cache download fails, SubMiner shows a failure notification, shows queued-card failure notifications, and clears the pending updates.
|
||||
|
||||
Current launcher behavior:
|
||||
|
||||
|
||||
@@ -61,6 +61,23 @@ Override with the `SUBMINER_ROFI_THEME` environment variable:
|
||||
SUBMINER_ROFI_THEME=/path/to/custom-theme.rasi subminer -R
|
||||
```
|
||||
|
||||
## Watch History
|
||||
|
||||
`subminer -H` (or `--history`) browses your local watch history, sourced from the immersion tracker database. It works with both pickers: fzf by default, rofi with `-R -H`.
|
||||
|
||||
```bash
|
||||
subminer -H # fzf history browser
|
||||
subminer -R -H # rofi history browser
|
||||
```
|
||||
|
||||
The first menu lists every locally watched series, most recently watched first, using the parsed media title (e.g. the anime title) when available and the directory name otherwise. Selecting a series opens an action menu:
|
||||
|
||||
- **Replay last watched** — replays the most recently watched episode
|
||||
- **Next episode** — plays the episode after the last watched one (continues into the next season directory when the season ends)
|
||||
- **Browse episodes** — lists the video files in the series directory in episode order, using the same fzf/rofi episode picker as directory browsing; if the series has multiple season directories, a season menu is shown first
|
||||
|
||||
Series whose directories are not currently accessible (e.g. an unmounted network share) are hidden from the list. Watch history requires the immersion tracker database (`immersionTracking.dbPath`, default `<config dir>/immersion.sqlite`), which SubMiner populates during playback.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
@@ -83,7 +100,7 @@ subminer stats -b # start background stats daemon
|
||||
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows |
|
||||
| `subminer doctor` | Dependency + config + socket diagnostics |
|
||||
| `subminer settings` | Open the SubMiner settings window |
|
||||
| `subminer logs -e` | Export a sanitized log ZIP and print its path |
|
||||
| `subminer logs -e` | Export a sanitized local-date log ZIP and print its path |
|
||||
| `subminer config path` | Print active config file path |
|
||||
| `subminer config show` | Print active config contents |
|
||||
| `subminer mpv status` | Check mpv socket readiness |
|
||||
@@ -105,6 +122,7 @@ Use `subminer <subcommand> -h` for command-specific help.
|
||||
| `-d, --directory` | Video search directory (default: cwd) |
|
||||
| `-r, --recursive` | Search directories recursively |
|
||||
| `-R, --rofi` | Use rofi instead of fzf |
|
||||
| `-H, --history` | Browse local watch history (see [Watch History](#watch-history)) |
|
||||
| `--setup` | Open first-run setup popup manually |
|
||||
| `-v, --version` | Print installed SubMiner version |
|
||||
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
|
||||
|
||||
@@ -559,6 +559,7 @@
|
||||
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
|
||||
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Values: true | false
|
||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
@@ -618,7 +619,11 @@
|
||||
"primarySubLanguages": [
|
||||
"ja",
|
||||
"jpn"
|
||||
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||
], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||
"mediaCache": {
|
||||
"mode": "direct", // How YouTube card audio/images are extracted. Values: direct | background
|
||||
"maxHeight": 720 // Maximum video height downloaded for the YouTube background media cache. Set to 0 for unlimited.
|
||||
} // Media cache setting.
|
||||
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||
|
||||
// ==========================================
|
||||
|
||||
+2
-2
@@ -148,7 +148,7 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin corre
|
||||
SubMiner.AppImage --help # Show all options
|
||||
```
|
||||
|
||||
The tray menu includes `Export Logs`, which creates the same sanitized log ZIP as `subminer logs -e` and shows the archive path when complete.
|
||||
The tray menu includes `Export Logs`, which creates the same sanitized local-date log ZIP as `subminer logs -e` and shows the archive path when complete. Export sanitization masks common PII and secrets, including home-directory usernames, IP addresses, emails, auth/cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL query strings. The exported copy is sanitized; source log files remain unredacted on disk.
|
||||
|
||||
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
|
||||
|
||||
@@ -191,7 +191,7 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
|
||||
- `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
|
||||
- `subminer doctor`: health checks for core dependencies and runtime paths.
|
||||
- `subminer settings`: open the SubMiner settings window (also `subminer --settings`).
|
||||
- `subminer logs -e`: export a sanitized ZIP of today's logs, or the most recent logs when no current-day log exists.
|
||||
- `subminer logs -e`: export a sanitized ZIP of today's local-date logs, or the most recent logs when no current-day log exists. The exported copy masks common PII and secrets; on-disk logs are unchanged.
|
||||
- `subminer config`: config file helpers (`path`, `show`).
|
||||
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
|
||||
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
|
||||
|
||||
@@ -74,20 +74,30 @@ Press **Ctrl+Alt+C** during YouTube playback to open the subtitle picker overlay
|
||||
- Select different primary and secondary tracks
|
||||
- Retry track loading if the auto-load failed or picked the wrong track
|
||||
|
||||
SubMiner shows an "Opening YouTube subtitle picker..." status through your configured notification
|
||||
surface while it probes tracks and prepares the modal, then updates the subtitle download progress
|
||||
card to a success notification after the selected tracks load.
|
||||
|
||||
The picker displays each track with its language, kind (manual/auto), and title when available.
|
||||
|
||||
## Subtitle Format Handling
|
||||
|
||||
SubMiner handles several YouTube subtitle formats transparently:
|
||||
|
||||
| Format | Handling |
|
||||
| ------ | -------- |
|
||||
| `srt`, `vtt` | Used directly (preferred for manual tracks) |
|
||||
| Format | Handling |
|
||||
| ---------------------- | -------------------------------------------------------- |
|
||||
| `srt`, `vtt` | Used directly (preferred for manual tracks) |
|
||||
| `srv1`, `srv2`, `srv3` | YouTube TimedText XML --- converted to VTT automatically |
|
||||
| Auto-generated VTT | Normalized to remove rolling-caption text duplication |
|
||||
| Auto-generated VTT | Normalized to remove rolling-caption text duplication |
|
||||
|
||||
For auto-generated tracks, SubMiner prefers `srv3` > `srv2` > `srv1` > `vtt` (TimedText XML produces cleaner output). For manual tracks, `srt` > `vtt` is preferred.
|
||||
|
||||
## Card Media Cache
|
||||
|
||||
By default, YouTube card audio and screenshots are extracted directly from mpv's active stream URLs. If generated card media fails with YouTube `403` errors, set `youtube.mediaCache.mode` to `"background"`. Background mode starts a separate `yt-dlp` media download after playback loads, including YouTube URLs opened directly in mpv and resolved stream URLs when mpv still exposes the original YouTube playlist entry. It creates text fields immediately, queues audio/image work for mined notes, and fills those fields once the local cache file is ready.
|
||||
|
||||
Background cache downloads use IPv4 and retry flags to reduce YouTube throttling failures. If the background download still fails, SubMiner shows a cache failure notification, shows queued-card failure notifications, and clears those pending updates so cards are not left waiting silently.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### Primary Subtitle Languages
|
||||
@@ -95,13 +105,13 @@ For auto-generated tracks, SubMiner prefers `srv3` > `srv2` > `srv1` > `vtt` (Ti
|
||||
```jsonc
|
||||
{
|
||||
"youtube": {
|
||||
"primarySubLanguages": ["ja", "jpn"]
|
||||
}
|
||||
"primarySubLanguages": ["ja", "jpn"],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| Option | Type | Description |
|
||||
| --------------------- | ---------- | ------------------------------------------------------------------------------------- |
|
||||
| `primarySubLanguages` | `string[]` | Language priority for YouTube primary subtitle auto-loading (default `["ja", "jpn"]`) |
|
||||
|
||||
### Secondary Subtitle Languages
|
||||
@@ -113,16 +123,16 @@ Secondary track selection uses the shared `secondarySub` config:
|
||||
"secondarySub": {
|
||||
"secondarySubLanguages": [],
|
||||
"autoLoadSecondarySub": false,
|
||||
"defaultMode": "hover"
|
||||
}
|
||||
"defaultMode": "hover",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track. Default is empty (`[]`). For YouTube, SubMiner always tries an English track first regardless of this list. |
|
||||
| `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary track (default: `false`) |
|
||||
| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
|
||||
| Option | Type | Description |
|
||||
| ----------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track. Default is empty (`[]`). For YouTube, SubMiner always tries an English track first regardless of this list. |
|
||||
| `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary track (default: `false`) |
|
||||
| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
|
||||
|
||||
Precedence: CLI flag > environment variable > `config.jsonc` > built-in default.
|
||||
|
||||
@@ -131,6 +141,7 @@ Precedence: CLI flag > environment variable > `config.jsonc` > built-in default.
|
||||
- **No subtitles found**: The video may not have Japanese subtitles. Open the picker with `Ctrl+Alt+C` to see all available tracks.
|
||||
- **yt-dlp not found**: Install `yt-dlp` and ensure it is on `PATH`, or set `SUBMINER_YTDLP_BIN` to the binary path.
|
||||
- **Probe timeout**: `yt-dlp` has a 15-second timeout per operation. Slow connections or rate-limited IPs may hit this. Retry or update `yt-dlp`.
|
||||
- **Card media `403` errors**: Switch `youtube.mediaCache.mode` from `"direct"` to `"background"` so card media is generated from a local `yt-dlp` cache instead of ffmpeg reading an expiring YouTube stream URL.
|
||||
- **Auto-caption quality**: YouTube auto-generated captions vary in quality. Manual subtitles (when available) are always preferred.
|
||||
- **`ytsearch:` targets**: `subminer ytsearch:"keyword"` plays the first search result. Subtitle availability depends on the matched video.
|
||||
- **Secondary subtitle fails**: Secondary track failures never block playback. The primary subtitle loads independently.
|
||||
|
||||
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
// Minimal ambient typing for bun:sqlite. The launcher always runs under bun
|
||||
// (see the build banner in package.json), but the repo typechecks with plain
|
||||
// tsc which has no bun type definitions.
|
||||
declare module 'bun:sqlite' {
|
||||
export interface RunResult {
|
||||
changes: number;
|
||||
lastInsertRowid: number | bigint;
|
||||
}
|
||||
|
||||
export interface Statement<ReturnType = unknown, ParamsType extends unknown[] = unknown[]> {
|
||||
all(...params: ParamsType): ReturnType[];
|
||||
get(...params: ParamsType): ReturnType | undefined;
|
||||
run(...params: ParamsType): RunResult;
|
||||
}
|
||||
|
||||
export class Database {
|
||||
constructor(
|
||||
filename: string,
|
||||
options?: { readonly?: boolean; readwrite?: boolean; create?: boolean },
|
||||
);
|
||||
query<ReturnType = unknown, ParamsType extends unknown[] = unknown[]>(
|
||||
sql: string,
|
||||
): Statement<ReturnType, ParamsType>;
|
||||
prepare<ReturnType = unknown, ParamsType extends unknown[] = unknown[]>(
|
||||
sql: string,
|
||||
): Statement<ReturnType, ParamsType>;
|
||||
run(sql: string, ...params: unknown[]): RunResult;
|
||||
close(throwOnError?: boolean): void;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fail, log } from '../log.js';
|
||||
import { commandExists } from '../util.js';
|
||||
import {
|
||||
collectVideos,
|
||||
findRofiTheme,
|
||||
formatPickerLaunchError,
|
||||
showFzfMenu,
|
||||
showRofiMenu,
|
||||
} from '../picker.js';
|
||||
import {
|
||||
findNextEpisode,
|
||||
groupHistoryBySeries,
|
||||
listSeasonDirs,
|
||||
materializeCoverArt,
|
||||
queryLocalWatchHistory,
|
||||
resolveImmersionDbPath,
|
||||
sortVideosByEpisode,
|
||||
type HistorySeriesEntry,
|
||||
} from '../history.js';
|
||||
import type { Args } from '../types.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
function checkPickerDependencies(args: Args): void {
|
||||
if (args.useRofi) {
|
||||
if (!commandExists('rofi')) fail('Missing dependency: rofi');
|
||||
return;
|
||||
}
|
||||
if (!commandExists('fzf')) fail('Missing dependency: fzf');
|
||||
}
|
||||
|
||||
function showRofiIndexMenu(
|
||||
labels: string[],
|
||||
prompt: string,
|
||||
themePath: string | null,
|
||||
icons: Array<string | null> = [],
|
||||
): number {
|
||||
const rofiArgs = ['-dmenu', '-i', '-matching', 'fuzzy', '-format', 'i', '-p', prompt];
|
||||
const hasIcons = icons.some(Boolean);
|
||||
if (hasIcons) rofiArgs.push('-show-icons');
|
||||
if (themePath) {
|
||||
rofiArgs.push('-theme', themePath);
|
||||
} else {
|
||||
rofiArgs.push('-theme-str', 'configuration { font: "Noto Sans CJK JP Regular 8";}');
|
||||
}
|
||||
if (hasIcons) {
|
||||
rofiArgs.push('-theme-str', 'configuration { show-icons: true; }');
|
||||
rofiArgs.push('-theme-str', 'element-icon { enabled: true; size: 3em; }');
|
||||
}
|
||||
const lines = labels.map((label, index) =>
|
||||
icons[index] ? `${label}\u0000icon\u001f${icons[index]}` : label,
|
||||
);
|
||||
const result = spawnSync('rofi', rofiArgs, {
|
||||
input: `${lines.join('\n')}\n`,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
});
|
||||
if (result.error) {
|
||||
fail(formatPickerLaunchError('rofi', result.error as NodeJS.ErrnoException));
|
||||
}
|
||||
const out = (result.stdout || '').trim();
|
||||
if (!out) return -1;
|
||||
const idx = Number.parseInt(out, 10);
|
||||
return Number.isInteger(idx) && idx >= 0 && idx < labels.length ? idx : -1;
|
||||
}
|
||||
|
||||
function showFzfIndexMenu(labels: string[], prompt: string): number {
|
||||
const lines = labels.map((label, index) => `${index}\t${label}`);
|
||||
const result = spawnSync(
|
||||
'fzf',
|
||||
[
|
||||
'--ansi',
|
||||
'--reverse',
|
||||
'--ignore-case',
|
||||
`--prompt=${prompt}: `,
|
||||
'--delimiter=\t',
|
||||
'--with-nth=2..',
|
||||
],
|
||||
{
|
||||
input: `${lines.join('\n')}\n`,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
},
|
||||
);
|
||||
if (result.error) {
|
||||
fail(formatPickerLaunchError('fzf', result.error as NodeJS.ErrnoException));
|
||||
}
|
||||
const picked = (result.stdout || '').trim();
|
||||
const tab = picked.indexOf('\t');
|
||||
if (tab === -1) return -1;
|
||||
const idx = Number.parseInt(picked.slice(0, tab), 10);
|
||||
return Number.isInteger(idx) && idx >= 0 && idx < labels.length ? idx : -1;
|
||||
}
|
||||
|
||||
function pickIndex(
|
||||
labels: string[],
|
||||
prompt: string,
|
||||
useRofi: boolean,
|
||||
themePath: string | null,
|
||||
icons: Array<string | null> = [],
|
||||
): number {
|
||||
if (labels.length === 0) return -1;
|
||||
return useRofi
|
||||
? showRofiIndexMenu(labels, prompt, themePath, icons)
|
||||
: showFzfIndexMenu(labels, prompt);
|
||||
}
|
||||
|
||||
function formatEpisodeLabel(entry: HistorySeriesEntry): string {
|
||||
const { parsedSeason, parsedEpisode } = entry.lastWatched;
|
||||
if (parsedEpisode === null) return '';
|
||||
return parsedSeason !== null ? `S${parsedSeason}E${parsedEpisode}` : `E${parsedEpisode}`;
|
||||
}
|
||||
|
||||
function formatSeriesLabel(entry: HistorySeriesEntry): string {
|
||||
const episodeLabel = formatEpisodeLabel(entry);
|
||||
return episodeLabel ? `${entry.displayName} [last: ${episodeLabel}]` : entry.displayName;
|
||||
}
|
||||
|
||||
function pickEpisodeFromDir(dir: string, context: LauncherCommandContext): string | null {
|
||||
const { args, scriptPath } = context;
|
||||
const videos = sortVideosByEpisode(collectVideos(dir, false));
|
||||
if (videos.length === 0) {
|
||||
fail(`No video files found in: ${dir}`);
|
||||
}
|
||||
const selected = args.useRofi
|
||||
? showRofiMenu(videos, dir, false, scriptPath, args.logLevel)
|
||||
: showFzfMenu(videos);
|
||||
return selected || null;
|
||||
}
|
||||
|
||||
function browseEpisodes(
|
||||
entry: HistorySeriesEntry,
|
||||
context: LauncherCommandContext,
|
||||
themePath: string | null,
|
||||
): string | null {
|
||||
const { args } = context;
|
||||
const seasons = listSeasonDirs(entry.seriesRoot);
|
||||
let dir = entry.seriesRoot;
|
||||
|
||||
if (seasons.length > 1) {
|
||||
const idx = pickIndex(
|
||||
seasons.map((season) => season.name),
|
||||
`${entry.displayName} — Season`,
|
||||
args.useRofi,
|
||||
themePath,
|
||||
);
|
||||
if (idx < 0) return null;
|
||||
dir = seasons[idx]!.path;
|
||||
} else if (seasons.length === 1 && collectVideos(dir, false).length === 0) {
|
||||
dir = seasons[0]!.path;
|
||||
}
|
||||
|
||||
return pickEpisodeFromDir(dir, context);
|
||||
}
|
||||
|
||||
export async function runHistoryCommand(context: LauncherCommandContext): Promise<string | null> {
|
||||
const { args, scriptPath } = context;
|
||||
|
||||
checkPickerDependencies(args);
|
||||
const themePath = args.useRofi ? findRofiTheme(scriptPath) : null;
|
||||
|
||||
const dbPath = resolveImmersionDbPath();
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
fail(`Watch history database not found: ${dbPath}`);
|
||||
}
|
||||
|
||||
const rows = queryLocalWatchHistory(dbPath);
|
||||
const series = groupHistoryBySeries(rows);
|
||||
if (series.length === 0) {
|
||||
fail('No local watch history found (or watched directories are not accessible).');
|
||||
}
|
||||
|
||||
log('info', args.logLevel, `Watch history: ${series.length} series found in ${dbPath}`);
|
||||
|
||||
const coverPaths = args.useRofi
|
||||
? materializeCoverArt(
|
||||
dbPath,
|
||||
series.map((seriesEntry) => seriesEntry.coverBlobHash),
|
||||
)
|
||||
: new Map<string, string>();
|
||||
const seriesIcons = series.map((seriesEntry) =>
|
||||
seriesEntry.coverBlobHash ? (coverPaths.get(seriesEntry.coverBlobHash) ?? null) : null,
|
||||
);
|
||||
|
||||
const seriesIdx = pickIndex(
|
||||
series.map(formatSeriesLabel),
|
||||
'Watch History',
|
||||
args.useRofi,
|
||||
themePath,
|
||||
seriesIcons,
|
||||
);
|
||||
if (seriesIdx < 0) return null;
|
||||
const entry = series[seriesIdx]!;
|
||||
|
||||
const lastPath = path.resolve(entry.lastWatched.sourcePath);
|
||||
const lastExists = fs.existsSync(lastPath);
|
||||
const nextEpisode = findNextEpisode(lastPath);
|
||||
|
||||
const actions: Array<{ kind: 'replay' | 'next' | 'browse'; label: string }> = [];
|
||||
if (lastExists) {
|
||||
actions.push({ kind: 'replay', label: `Replay last watched — ${path.basename(lastPath)}` });
|
||||
}
|
||||
if (nextEpisode) {
|
||||
actions.push({ kind: 'next', label: `Next episode — ${path.basename(nextEpisode)}` });
|
||||
}
|
||||
actions.push({ kind: 'browse', label: 'Browse episodes' });
|
||||
|
||||
const entryIcon = seriesIcons[seriesIdx] ?? null;
|
||||
const actionIdx = pickIndex(
|
||||
actions.map((action) => action.label),
|
||||
entry.displayName,
|
||||
args.useRofi,
|
||||
themePath,
|
||||
actions.map(() => entryIcon),
|
||||
);
|
||||
if (actionIdx < 0) return null;
|
||||
|
||||
switch (actions[actionIdx]!.kind) {
|
||||
case 'replay':
|
||||
return lastPath;
|
||||
case 'next':
|
||||
return nextEpisode;
|
||||
case 'browse':
|
||||
return browseEpisodes(entry, context, themePath);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ function createContext(): LauncherCommandContext {
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
history: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
|
||||
@@ -198,6 +198,7 @@ export function createDefaultArgs(
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
history: false,
|
||||
logLevel: loggingConfig.level ?? 'warn',
|
||||
logRotation: loggingConfig.rotation ?? 7,
|
||||
passwordStore: '',
|
||||
@@ -231,6 +232,7 @@ export function applyRootOptionsToArgs(
|
||||
if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel);
|
||||
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
|
||||
if (options.rofi === true) parsed.useRofi = true;
|
||||
if (options.history === true) parsed.history = true;
|
||||
if (options.update === true) parsed.update = true;
|
||||
if (options.version === true) parsed.version = true;
|
||||
if (options.settings === true) parsed.settings = true;
|
||||
|
||||
@@ -64,6 +64,7 @@ function applyRootOptions(program: Command): void {
|
||||
.option('--settings', 'Open settings window')
|
||||
.option('-u, --update', 'Check for updates')
|
||||
.option('-R, --rofi', 'Use rofi picker')
|
||||
.option('-H, --history', 'Browse local watch history')
|
||||
.option('-S, --start-overlay', 'Auto-start overlay')
|
||||
.option('-T, --no-texthooker', 'Disable texthooker-ui server');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { withReadonlyWalRetry } from './history-db.js';
|
||||
|
||||
const COVER_EXTENSIONS = ['.jpg', '.png', '.webp', '.gif'] as const;
|
||||
const SAFE_COVER_HASH_PATTERN = /^[a-z0-9_-]+$/i;
|
||||
|
||||
export function getDefaultCoverCacheDir(): string {
|
||||
return path.join(os.homedir(), '.cache', 'subminer', 'covers');
|
||||
}
|
||||
|
||||
export function detectImageExtension(blob: Buffer): string {
|
||||
if (blob.length >= 8 && blob.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) {
|
||||
return '.png';
|
||||
}
|
||||
if (blob.length >= 3 && blob[0] === 0xff && blob[1] === 0xd8 && blob[2] === 0xff) {
|
||||
return '.jpg';
|
||||
}
|
||||
if (
|
||||
blob.length >= 12 &&
|
||||
blob.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
blob.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return '.webp';
|
||||
}
|
||||
if (blob.length >= 4 && blob.subarray(0, 3).toString('ascii') === 'GIF') {
|
||||
return '.gif';
|
||||
}
|
||||
return '.jpg';
|
||||
}
|
||||
|
||||
function findCachedCover(cacheDir: string, hash: string): string | null {
|
||||
for (const ext of COVER_EXTENSIONS) {
|
||||
const candidate = path.join(cacheDir, `${hash}${ext}`);
|
||||
try {
|
||||
if (fs.statSync(candidate).size > 0) return candidate;
|
||||
} catch {
|
||||
// not cached with this extension
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function queryCoverBlobs(
|
||||
dbPath: string,
|
||||
hashes: string[],
|
||||
options: { readonly?: boolean; readwrite?: boolean; create?: boolean },
|
||||
): Map<string, Buffer> {
|
||||
const blobs = new Map<string, Buffer>();
|
||||
const db = new Database(dbPath, options);
|
||||
try {
|
||||
const hasBlobTable = db
|
||||
.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'imm_cover_art_blobs'`)
|
||||
.get();
|
||||
if (!hasBlobTable) return blobs;
|
||||
|
||||
const stmt = db.query<{ cover_blob: Uint8Array | null }>(
|
||||
'SELECT cover_blob FROM imm_cover_art_blobs WHERE blob_hash = ?',
|
||||
);
|
||||
for (const hash of hashes) {
|
||||
const row = stmt.get(hash);
|
||||
if (row?.cover_blob && row.cover_blob.length > 0) {
|
||||
blobs.set(hash, Buffer.from(row.cover_blob));
|
||||
}
|
||||
}
|
||||
return blobs;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeCoverHash(hash: string | null | undefined): hash is string {
|
||||
return typeof hash === 'string' && SAFE_COVER_HASH_PATTERN.test(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures cover art blobs referenced by hash exist as image files in the cache
|
||||
* directory, extracting missing ones from the stats database. Returns a map of
|
||||
* blob hash to on-disk image path for every cover that could be materialized.
|
||||
*/
|
||||
export function materializeCoverArt(
|
||||
dbPath: string,
|
||||
hashes: Array<string | null | undefined>,
|
||||
cacheDir: string = getDefaultCoverCacheDir(),
|
||||
): Map<string, string> {
|
||||
const wanted = Array.from(new Set(hashes.filter(isSafeCoverHash)));
|
||||
const resolved = new Map<string, string>();
|
||||
if (wanted.length === 0) return resolved;
|
||||
|
||||
const missing: string[] = [];
|
||||
for (const hash of wanted) {
|
||||
const cached = findCachedCover(cacheDir, hash);
|
||||
if (cached) {
|
||||
resolved.set(hash, cached);
|
||||
} else {
|
||||
missing.push(hash);
|
||||
}
|
||||
}
|
||||
if (missing.length === 0) return resolved;
|
||||
|
||||
let blobs: Map<string, Buffer>;
|
||||
try {
|
||||
blobs = withReadonlyWalRetry(dbPath, (options) => queryCoverBlobs(dbPath, missing, options));
|
||||
} catch {
|
||||
return resolved;
|
||||
}
|
||||
if (blobs.size === 0) return resolved;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
} catch {
|
||||
return resolved;
|
||||
}
|
||||
for (const [hash, blob] of blobs) {
|
||||
const target = path.join(cacheDir, `${hash}${detectImageExtension(blob)}`);
|
||||
try {
|
||||
fs.writeFileSync(target, blob);
|
||||
resolved.set(hash, target);
|
||||
} catch {
|
||||
// cache write failure just means no icon for this entry
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { resolveConfigDir } from '../src/config/path-resolution.js';
|
||||
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
|
||||
import type { HistoryVideoRow } from './history-types.js';
|
||||
import { resolvePathMaybe } from './util.js';
|
||||
|
||||
export function resolveImmersionDbPath(): string {
|
||||
const root = readLauncherMainConfigObject();
|
||||
const tracking =
|
||||
root?.immersionTracking &&
|
||||
typeof root.immersionTracking === 'object' &&
|
||||
!Array.isArray(root.immersionTracking)
|
||||
? (root.immersionTracking as Record<string, unknown>)
|
||||
: null;
|
||||
const configured = typeof tracking?.dbPath === 'string' ? tracking.dbPath.trim() : '';
|
||||
if (configured) return resolvePathMaybe(configured);
|
||||
|
||||
const configDir = resolveConfigDir({
|
||||
platform: process.platform,
|
||||
appDataDir: process.env.APPDATA,
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
existsSync: fs.existsSync,
|
||||
});
|
||||
return path.join(configDir, 'immersion.sqlite');
|
||||
}
|
||||
|
||||
interface RawHistoryRow {
|
||||
video_id: number;
|
||||
source_path: string | null;
|
||||
parsed_title: string | null;
|
||||
parsed_season: number | null;
|
||||
parsed_episode: number | null;
|
||||
anime_title: string | null;
|
||||
last_watched_ms: number | bigint | null;
|
||||
cover_blob_hash: string | null;
|
||||
}
|
||||
|
||||
export function queryLocalWatchHistory(dbPath: string): HistoryVideoRow[] {
|
||||
return withReadonlyWalRetry(dbPath, (options) => readHistoryRows(dbPath, options));
|
||||
}
|
||||
|
||||
export function withReadonlyWalRetry<T>(
|
||||
dbPath: string,
|
||||
query: (options: { readonly?: boolean; readwrite?: boolean; create?: boolean }) => T,
|
||||
): T {
|
||||
try {
|
||||
return query({ readonly: true });
|
||||
} catch (error) {
|
||||
if (!isReadonlyWalRetryError(error, dbPath)) throw error;
|
||||
return query({ readwrite: true, create: false });
|
||||
}
|
||||
}
|
||||
|
||||
export function isReadonlyWalRetryError(error: unknown, dbPath: string): boolean {
|
||||
if (!isWalModeSqliteDatabase(dbPath)) return false;
|
||||
const code =
|
||||
typeof error === 'object' && error !== null && 'code' in error
|
||||
? String((error as { code?: unknown }).code ?? '')
|
||||
: '';
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const text = `${code} ${message}`.toLowerCase();
|
||||
return (
|
||||
text.includes('readonly') ||
|
||||
text.includes('read-only') ||
|
||||
text.includes('attempt to write a readonly database') ||
|
||||
text.includes('sqlite_cantopen') ||
|
||||
text.includes('unable to open database file')
|
||||
);
|
||||
}
|
||||
|
||||
function isWalModeSqliteDatabase(dbPath: string): boolean {
|
||||
const header = Buffer.alloc(20);
|
||||
let fd: number | null = null;
|
||||
try {
|
||||
fd = fs.openSync(dbPath, 'r');
|
||||
if (fs.readSync(fd, header, 0, header.length, 0) < header.length) return false;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
if (fd !== null) fs.closeSync(fd);
|
||||
}
|
||||
return header.subarray(0, 16).toString('ascii') === 'SQLite format 3\0' && header[18] === 2;
|
||||
}
|
||||
|
||||
function tableExists(db: Database, tableName: string): boolean {
|
||||
return Boolean(
|
||||
db.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(tableName),
|
||||
);
|
||||
}
|
||||
|
||||
function readHistoryRows(
|
||||
dbPath: string,
|
||||
options: { readonly?: boolean; readwrite?: boolean; create?: boolean },
|
||||
): HistoryVideoRow[] {
|
||||
const db = new Database(dbPath, options);
|
||||
try {
|
||||
const hasMediaArt = tableExists(db, 'imm_media_art');
|
||||
const coverSelect = hasMediaArt
|
||||
? `COALESCE(
|
||||
ma.cover_blob_hash,
|
||||
(SELECT ma2.cover_blob_hash
|
||||
FROM imm_media_art ma2
|
||||
JOIN imm_videos v2 ON v2.video_id = ma2.video_id
|
||||
WHERE v2.anime_id = v.anime_id AND ma2.cover_blob_hash IS NOT NULL
|
||||
LIMIT 1)
|
||||
) AS cover_blob_hash`
|
||||
: 'NULL AS cover_blob_hash';
|
||||
const coverJoin = hasMediaArt ? 'LEFT JOIN imm_media_art ma ON ma.video_id = v.video_id' : '';
|
||||
const rows = db
|
||||
.query<RawHistoryRow>(
|
||||
`
|
||||
SELECT
|
||||
v.video_id,
|
||||
v.source_path,
|
||||
v.parsed_title,
|
||||
v.parsed_season,
|
||||
v.parsed_episode,
|
||||
COALESCE(a.title_romaji, a.canonical_title) AS anime_title,
|
||||
MAX(CAST(s.started_at_ms AS INTEGER)) AS last_watched_ms,
|
||||
${coverSelect}
|
||||
FROM imm_sessions s
|
||||
JOIN imm_videos v ON v.video_id = s.video_id
|
||||
LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||
${coverJoin}
|
||||
WHERE v.source_type = 1 AND v.source_path IS NOT NULL AND v.source_path != ''
|
||||
GROUP BY v.video_id
|
||||
ORDER BY last_watched_ms DESC
|
||||
`,
|
||||
)
|
||||
.all();
|
||||
|
||||
return rows
|
||||
.filter((row) => typeof row.source_path === 'string' && row.source_path.length > 0)
|
||||
.map((row) => ({
|
||||
videoId: row.video_id,
|
||||
sourcePath: row.source_path!,
|
||||
parsedTitle: row.parsed_title,
|
||||
parsedSeason: row.parsed_season,
|
||||
parsedEpisode: row.parsed_episode,
|
||||
animeTitle: row.anime_title,
|
||||
lastWatchedMs: Number(row.last_watched_ms ?? 0),
|
||||
coverBlobHash: row.cover_blob_hash,
|
||||
}));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseMediaInfo } from '../src/jimaku/utils.js';
|
||||
import { collectVideos } from './picker.js';
|
||||
import type { HistorySeriesEntry, HistoryVideoRow, SeasonDirEntry } from './history-types.js';
|
||||
|
||||
const SEASON_DIR_PATTERN = /^(?:season|s)[\s._-]*(\d{1,3})\b/i;
|
||||
|
||||
export function seasonNumberFromDirName(name: string): number | null {
|
||||
const match = name.trim().match(SEASON_DIR_PATTERN);
|
||||
if (!match) return null;
|
||||
const parsed = Number.parseInt(match[1]!, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function resolveSeriesRoot(filePath: string): string {
|
||||
const parent = path.dirname(filePath);
|
||||
if (seasonNumberFromDirName(path.basename(parent)) !== null) {
|
||||
return path.dirname(parent);
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
export function groupHistoryBySeries(
|
||||
rows: HistoryVideoRow[],
|
||||
existsFn: (candidate: string) => boolean = fs.existsSync,
|
||||
): HistorySeriesEntry[] {
|
||||
const byRoot = new Map<string, HistorySeriesEntry>();
|
||||
const sorted = [...rows].sort((a, b) => b.lastWatchedMs - a.lastWatchedMs);
|
||||
|
||||
for (const row of sorted) {
|
||||
const seriesRoot = resolveSeriesRoot(row.sourcePath);
|
||||
const existing = byRoot.get(seriesRoot);
|
||||
if (existing) {
|
||||
if (existing.coverBlobHash === null && row.coverBlobHash !== null) {
|
||||
existing.coverBlobHash = row.coverBlobHash;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!existsFn(seriesRoot)) continue;
|
||||
const displayName =
|
||||
row.parsedTitle?.trim() || row.animeTitle?.trim() || path.basename(seriesRoot);
|
||||
byRoot.set(seriesRoot, {
|
||||
seriesRoot,
|
||||
displayName,
|
||||
lastWatched: row,
|
||||
coverBlobHash: row.coverBlobHash,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(byRoot.values());
|
||||
}
|
||||
|
||||
function compareNatural(a: string, b: string): number {
|
||||
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
export function sortVideosByEpisode(videos: string[]): string[] {
|
||||
const parsed = videos.map((video) => ({ video, info: parseMediaInfo(video) }));
|
||||
parsed.sort((a, b) => {
|
||||
if (a.info.episode !== null && b.info.episode !== null) {
|
||||
const seasonA = a.info.season ?? 0;
|
||||
const seasonB = b.info.season ?? 0;
|
||||
if (seasonA !== seasonB) return seasonA - seasonB;
|
||||
if (a.info.episode !== b.info.episode) return a.info.episode - b.info.episode;
|
||||
}
|
||||
return compareNatural(a.video, b.video);
|
||||
});
|
||||
return parsed.map((entry) => entry.video);
|
||||
}
|
||||
|
||||
function dirContainsVideo(dir: string): boolean {
|
||||
return collectVideos(dir, true).length > 0;
|
||||
}
|
||||
|
||||
export function listSeasonDirs(seriesRoot: string): SeasonDirEntry[] {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(seriesRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dirs = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: path.join(seriesRoot, entry.name),
|
||||
season: seasonNumberFromDirName(entry.name),
|
||||
}))
|
||||
.filter((entry) => dirContainsVideo(entry.path));
|
||||
|
||||
dirs.sort((a, b) => {
|
||||
if (a.season !== null && b.season !== null && a.season !== b.season) {
|
||||
return a.season - b.season;
|
||||
}
|
||||
return compareNatural(a.name, b.name);
|
||||
});
|
||||
return dirs;
|
||||
}
|
||||
|
||||
function findFirstEpisodeInNextSeason(resolvedLast: string, dir: string): string | null {
|
||||
const seriesRoot = resolveSeriesRoot(resolvedLast);
|
||||
if (seriesRoot === dir) return null;
|
||||
const seasons = listSeasonDirs(seriesRoot);
|
||||
const currentIdx = seasons.findIndex((season) => path.resolve(season.path) === dir);
|
||||
if (currentIdx < 0 || currentIdx + 1 >= seasons.length) return null;
|
||||
const nextSeason = sortVideosByEpisode(collectVideos(seasons[currentIdx + 1]!.path, false));
|
||||
return nextSeason[0] ?? null;
|
||||
}
|
||||
|
||||
export function findNextEpisode(lastPath: string): string | null {
|
||||
const resolvedLast = path.resolve(lastPath);
|
||||
const dir = path.dirname(resolvedLast);
|
||||
const episodes = sortVideosByEpisode(collectVideos(dir, false));
|
||||
const idx = episodes.indexOf(resolvedLast);
|
||||
|
||||
if (idx >= 0) {
|
||||
if (idx + 1 < episodes.length) return episodes[idx + 1]!;
|
||||
} else {
|
||||
const lastInfo = parseMediaInfo(resolvedLast);
|
||||
if (lastInfo.episode !== null) {
|
||||
const candidate = episodes.find((episode) => {
|
||||
const info = parseMediaInfo(episode);
|
||||
return info.episode !== null && info.episode > lastInfo.episode!;
|
||||
});
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return findFirstEpisodeInNextSeason(resolvedLast, dir);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface HistoryVideoRow {
|
||||
videoId: number;
|
||||
sourcePath: string;
|
||||
parsedTitle: string | null;
|
||||
parsedSeason: number | null;
|
||||
parsedEpisode: number | null;
|
||||
animeTitle: string | null;
|
||||
lastWatchedMs: number;
|
||||
coverBlobHash: string | null;
|
||||
}
|
||||
|
||||
export interface HistorySeriesEntry {
|
||||
seriesRoot: string;
|
||||
displayName: string;
|
||||
lastWatched: HistoryVideoRow;
|
||||
coverBlobHash: string | null;
|
||||
}
|
||||
|
||||
export interface SeasonDirEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
season: number | null;
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import {
|
||||
detectImageExtension,
|
||||
findNextEpisode,
|
||||
groupHistoryBySeries,
|
||||
isReadonlyWalRetryError,
|
||||
listSeasonDirs,
|
||||
materializeCoverArt,
|
||||
queryLocalWatchHistory,
|
||||
resolveSeriesRoot,
|
||||
seasonNumberFromDirName,
|
||||
sortVideosByEpisode,
|
||||
type HistoryVideoRow,
|
||||
} from './history.js';
|
||||
|
||||
function makeRow(overrides: Partial<HistoryVideoRow> = {}): HistoryVideoRow {
|
||||
return {
|
||||
videoId: 1,
|
||||
sourcePath: '/media/anime/Show/Season-1/Show - S01E01.mkv',
|
||||
parsedTitle: 'Show',
|
||||
parsedSeason: 1,
|
||||
parsedEpisode: 1,
|
||||
animeTitle: null,
|
||||
lastWatchedMs: 1000,
|
||||
coverBlobHash: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('seasonNumberFromDirName detects common season directory names', () => {
|
||||
assert.equal(seasonNumberFromDirName('Season-1'), 1);
|
||||
assert.equal(seasonNumberFromDirName('Season 2'), 2);
|
||||
assert.equal(seasonNumberFromDirName('S03'), 3);
|
||||
assert.equal(seasonNumberFromDirName('season_04'), 4);
|
||||
assert.equal(seasonNumberFromDirName('Specials'), null);
|
||||
assert.equal(seasonNumberFromDirName('Show Name'), null);
|
||||
});
|
||||
|
||||
test('resolveSeriesRoot skips season directories', () => {
|
||||
assert.equal(
|
||||
resolveSeriesRoot('/media/anime/Show/Season-1/Show - S01E01.mkv'),
|
||||
'/media/anime/Show',
|
||||
);
|
||||
assert.equal(resolveSeriesRoot('/media/anime/Show/Show - 01.mkv'), '/media/anime/Show');
|
||||
});
|
||||
|
||||
test('groupHistoryBySeries keeps most recent entry per series root', () => {
|
||||
const rows = [
|
||||
makeRow({ videoId: 1, parsedEpisode: 1, lastWatchedMs: 1000 }),
|
||||
makeRow({
|
||||
videoId: 2,
|
||||
sourcePath: '/media/anime/Show/Season-1/Show - S01E02.mkv',
|
||||
parsedEpisode: 2,
|
||||
lastWatchedMs: 3000,
|
||||
}),
|
||||
makeRow({
|
||||
videoId: 3,
|
||||
sourcePath: '/media/anime/Other/Other - 05.mkv',
|
||||
parsedTitle: 'Other',
|
||||
parsedSeason: null,
|
||||
parsedEpisode: 5,
|
||||
lastWatchedMs: 2000,
|
||||
}),
|
||||
];
|
||||
|
||||
const series = groupHistoryBySeries(rows, () => true);
|
||||
|
||||
assert.equal(series.length, 2);
|
||||
assert.equal(series[0]?.displayName, 'Show');
|
||||
assert.equal(series[0]?.seriesRoot, '/media/anime/Show');
|
||||
assert.equal(series[0]?.lastWatched.parsedEpisode, 2);
|
||||
assert.equal(series[1]?.displayName, 'Other');
|
||||
});
|
||||
|
||||
test('groupHistoryBySeries filters series roots that no longer exist', () => {
|
||||
const rows = [
|
||||
makeRow({ videoId: 1 }),
|
||||
makeRow({
|
||||
videoId: 2,
|
||||
sourcePath: '/gone/anime/Missing/Season-1/Missing - S01E01.mkv',
|
||||
parsedTitle: 'Missing',
|
||||
lastWatchedMs: 5000,
|
||||
}),
|
||||
];
|
||||
|
||||
const series = groupHistoryBySeries(rows, (candidate) => !candidate.startsWith('/gone/'));
|
||||
|
||||
assert.equal(series.length, 1);
|
||||
assert.equal(series[0]?.displayName, 'Show');
|
||||
});
|
||||
|
||||
test('groupHistoryBySeries falls back to directory name for display', () => {
|
||||
const rows = [
|
||||
makeRow({
|
||||
sourcePath: '/media/anime/Some Show Dir/video.mkv',
|
||||
parsedTitle: null,
|
||||
animeTitle: null,
|
||||
}),
|
||||
];
|
||||
|
||||
const series = groupHistoryBySeries(rows, () => true);
|
||||
|
||||
assert.equal(series[0]?.displayName, 'Some Show Dir');
|
||||
});
|
||||
|
||||
test('sortVideosByEpisode orders by parsed episode with natural fallback', () => {
|
||||
const videos = [
|
||||
'/media/Show/Show - S01E10 - Ten.mkv',
|
||||
'/media/Show/Show - S01E02 - Two.mkv',
|
||||
'/media/Show/Show - S01E01 - One.mkv',
|
||||
];
|
||||
|
||||
assert.deepEqual(sortVideosByEpisode(videos), [
|
||||
'/media/Show/Show - S01E01 - One.mkv',
|
||||
'/media/Show/Show - S01E02 - Two.mkv',
|
||||
'/media/Show/Show - S01E10 - Ten.mkv',
|
||||
]);
|
||||
});
|
||||
|
||||
function createSeriesTree(): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-'));
|
||||
const seriesRoot = path.join(root, 'Show');
|
||||
const season1 = path.join(seriesRoot, 'Season-1');
|
||||
const season2 = path.join(seriesRoot, 'Season-2');
|
||||
fs.mkdirSync(season1, { recursive: true });
|
||||
fs.mkdirSync(season2, { recursive: true });
|
||||
fs.mkdirSync(path.join(seriesRoot, 'extras-empty'), { recursive: true });
|
||||
for (const name of ['Show - S01E01.mkv', 'Show - S01E02.mkv', 'Show - S01E03.mkv']) {
|
||||
fs.writeFileSync(path.join(season1, name), '');
|
||||
}
|
||||
fs.writeFileSync(path.join(season2, 'Show - S02E01.mkv'), '');
|
||||
fs.writeFileSync(path.join(season1, 'notes.txt'), '');
|
||||
return seriesRoot;
|
||||
}
|
||||
|
||||
test('listSeasonDirs returns only video-bearing directories in season order', () => {
|
||||
const seriesRoot = createSeriesTree();
|
||||
try {
|
||||
const seasons = listSeasonDirs(seriesRoot);
|
||||
assert.deepEqual(
|
||||
seasons.map((entry) => entry.name),
|
||||
['Season-1', 'Season-2'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
seasons.map((entry) => entry.season),
|
||||
[1, 2],
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findNextEpisode advances within a season and across seasons', () => {
|
||||
const seriesRoot = createSeriesTree();
|
||||
try {
|
||||
const season1 = path.join(seriesRoot, 'Season-1');
|
||||
const season2 = path.join(seriesRoot, 'Season-2');
|
||||
|
||||
assert.equal(
|
||||
findNextEpisode(path.join(season1, 'Show - S01E02.mkv')),
|
||||
path.join(season1, 'Show - S01E03.mkv'),
|
||||
);
|
||||
assert.equal(
|
||||
findNextEpisode(path.join(season1, 'Show - S01E03.mkv')),
|
||||
path.join(season2, 'Show - S02E01.mkv'),
|
||||
);
|
||||
assert.equal(findNextEpisode(path.join(season2, 'Show - S02E01.mkv')), null);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findNextEpisode falls back to episode numbers when file was removed', () => {
|
||||
const seriesRoot = createSeriesTree();
|
||||
try {
|
||||
const season1 = path.join(seriesRoot, 'Season-1');
|
||||
const missing = path.join(season1, 'Show - S01E02 - Deleted Cut.mkv');
|
||||
assert.equal(findNextEpisode(missing), path.join(season1, 'Show - S01E03.mkv'));
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findNextEpisode advances seasons when a deleted file was the last episode', () => {
|
||||
const seriesRoot = createSeriesTree();
|
||||
try {
|
||||
const season1 = path.join(seriesRoot, 'Season-1');
|
||||
const season2 = path.join(seriesRoot, 'Season-2');
|
||||
const missing = path.join(season1, 'Show - S01E03 - Deleted Cut.mkv');
|
||||
assert.equal(findNextEpisode(missing), path.join(season2, 'Show - S02E01.mkv'));
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const PNG_MAGIC = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
|
||||
|
||||
function createHistoryDb(
|
||||
dbPath: string,
|
||||
options: { wal?: boolean; coverArt?: boolean } = {},
|
||||
): void {
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
if (options.wal) db.run('PRAGMA journal_mode = WAL;');
|
||||
db.run(`
|
||||
CREATE TABLE imm_anime(
|
||||
anime_id INTEGER PRIMARY KEY,
|
||||
canonical_title TEXT,
|
||||
title_romaji TEXT
|
||||
);
|
||||
`);
|
||||
db.run(`
|
||||
CREATE TABLE imm_videos(
|
||||
video_id INTEGER PRIMARY KEY,
|
||||
anime_id INTEGER,
|
||||
source_type INTEGER,
|
||||
source_path TEXT,
|
||||
parsed_title TEXT,
|
||||
parsed_season INTEGER,
|
||||
parsed_episode INTEGER
|
||||
);
|
||||
`);
|
||||
db.run(`
|
||||
CREATE TABLE imm_sessions(
|
||||
session_id INTEGER PRIMARY KEY,
|
||||
video_id INTEGER,
|
||||
started_at_ms TEXT
|
||||
);
|
||||
`);
|
||||
db.run(`INSERT INTO imm_anime VALUES (1, 'Show Season 1', 'Show Romaji');`);
|
||||
db.run(`
|
||||
INSERT INTO imm_videos VALUES
|
||||
(1, 1, 1, '/media/Show/Season-1/Show - S01E01.mkv', 'Show', 1, 1),
|
||||
(2, 1, 1, '/media/Show/Season-1/Show - S01E02.mkv', 'Show', 1, 2),
|
||||
(3, NULL, 2, NULL, 'Remote Show', NULL, NULL),
|
||||
(4, NULL, 1, '', 'Empty Path', NULL, NULL);
|
||||
`);
|
||||
db.run(`
|
||||
INSERT INTO imm_sessions VALUES
|
||||
(1, 1, '1000'),
|
||||
(2, 1, '5000'),
|
||||
(3, 2, '3000'),
|
||||
(4, 3, '9000');
|
||||
`);
|
||||
if (options.coverArt) {
|
||||
db.run(`
|
||||
CREATE TABLE imm_media_art(
|
||||
video_id INTEGER PRIMARY KEY,
|
||||
cover_blob_hash TEXT
|
||||
);
|
||||
`);
|
||||
db.run(`
|
||||
CREATE TABLE imm_cover_art_blobs(
|
||||
blob_hash TEXT PRIMARY KEY,
|
||||
cover_blob BLOB NOT NULL
|
||||
);
|
||||
`);
|
||||
// Art only on video 1; video 2 resolves it through the shared anime_id.
|
||||
db.run(`INSERT INTO imm_media_art VALUES (1, 'hash-1');`);
|
||||
db.query('INSERT INTO imm_cover_art_blobs VALUES (?, ?)').run('hash-1', PNG_MAGIC);
|
||||
}
|
||||
if (options.wal) db.run('PRAGMA wal_checkpoint(TRUNCATE);');
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function assertHistoryRows(dbPath: string): void {
|
||||
const rows = queryLocalWatchHistory(dbPath);
|
||||
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0]?.videoId, 1);
|
||||
assert.equal(rows[0]?.lastWatchedMs, 5000);
|
||||
assert.equal(rows[0]?.animeTitle, 'Show Romaji');
|
||||
assert.equal(rows[1]?.videoId, 2);
|
||||
assert.equal(rows[1]?.lastWatchedMs, 3000);
|
||||
}
|
||||
|
||||
test('queryLocalWatchHistory returns local files ordered by most recent session', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-db-'));
|
||||
const dbPath = path.join(dir, 'immersion.sqlite');
|
||||
try {
|
||||
createHistoryDb(dbPath);
|
||||
assertHistoryRows(dbPath);
|
||||
const rows = queryLocalWatchHistory(dbPath);
|
||||
assert.equal(rows[0]?.coverBlobHash, null);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('queryLocalWatchHistory resolves cover hashes directly and via shared anime', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-art-'));
|
||||
const dbPath = path.join(dir, 'immersion.sqlite');
|
||||
try {
|
||||
createHistoryDb(dbPath, { coverArt: true });
|
||||
const rows = queryLocalWatchHistory(dbPath);
|
||||
assert.equal(rows[0]?.videoId, 1);
|
||||
assert.equal(rows[0]?.coverBlobHash, 'hash-1');
|
||||
assert.equal(rows[1]?.videoId, 2);
|
||||
assert.equal(rows[1]?.coverBlobHash, 'hash-1');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('materializeCoverArt extracts blobs to the cache dir and reuses cached files', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-covers-'));
|
||||
const dbPath = path.join(dir, 'immersion.sqlite');
|
||||
const cacheDir = path.join(dir, 'covers');
|
||||
try {
|
||||
createHistoryDb(dbPath, { coverArt: true });
|
||||
|
||||
const covers = materializeCoverArt(
|
||||
dbPath,
|
||||
['hash-1', 'hash-1', null, 'hash-missing'],
|
||||
cacheDir,
|
||||
);
|
||||
const coverPath = covers.get('hash-1');
|
||||
assert.ok(coverPath);
|
||||
assert.equal(path.extname(coverPath!), '.png');
|
||||
assert.ok(fs.statSync(coverPath!).size > 0);
|
||||
assert.equal(covers.has('hash-missing'), false);
|
||||
|
||||
// Cached file is reused even when the database has disappeared.
|
||||
fs.rmSync(dbPath);
|
||||
const cachedCovers = materializeCoverArt(dbPath, ['hash-1'], cacheDir);
|
||||
assert.equal(cachedCovers.get('hash-1'), coverPath);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('materializeCoverArt rejects cover hashes that escape the cache dir', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-cover-safety-'));
|
||||
const dbPath = path.join(dir, 'immersion.sqlite');
|
||||
const cacheDir = path.join(dir, 'covers');
|
||||
const unsafeHash = '../escape';
|
||||
try {
|
||||
createHistoryDb(dbPath, { coverArt: true });
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
db.query('INSERT INTO imm_cover_art_blobs VALUES (?, ?)').run(unsafeHash, PNG_MAGIC);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
const covers = materializeCoverArt(dbPath, [unsafeHash], cacheDir);
|
||||
|
||||
assert.equal(covers.has(unsafeHash), false);
|
||||
assert.equal(fs.existsSync(path.join(dir, 'escape.png')), false);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('detectImageExtension identifies common cover formats', () => {
|
||||
assert.equal(detectImageExtension(PNG_MAGIC), '.png');
|
||||
assert.equal(detectImageExtension(Buffer.from([0xff, 0xd8, 0xff, 0xe0])), '.jpg');
|
||||
assert.equal(detectImageExtension(Buffer.from('RIFF0000WEBPVP8 ', 'ascii')), '.webp');
|
||||
assert.equal(detectImageExtension(Buffer.from('GIF89a', 'ascii')), '.gif');
|
||||
assert.equal(detectImageExtension(Buffer.from('unknown', 'ascii')), '.jpg');
|
||||
});
|
||||
|
||||
test('groupHistoryBySeries backfills cover hash from older rows of the same series', () => {
|
||||
const rows = [
|
||||
makeRow({ videoId: 2, parsedEpisode: 2, lastWatchedMs: 3000, coverBlobHash: null }),
|
||||
makeRow({ videoId: 1, parsedEpisode: 1, lastWatchedMs: 1000, coverBlobHash: 'hash-1' }),
|
||||
];
|
||||
|
||||
const series = groupHistoryBySeries(rows, () => true);
|
||||
|
||||
assert.equal(series.length, 1);
|
||||
assert.equal(series[0]?.lastWatched.videoId, 2);
|
||||
assert.equal(series[0]?.coverBlobHash, 'hash-1');
|
||||
});
|
||||
|
||||
test('queryLocalWatchHistory reads a cleanly-closed WAL database', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-wal-'));
|
||||
const dbPath = path.join(dir, 'immersion.sqlite');
|
||||
try {
|
||||
createHistoryDb(dbPath, { wal: true });
|
||||
// Reproduce the state after the app shuts down cleanly: WAL journal mode
|
||||
// with no -wal/-shm sidecar files on disk. A read-only connection then
|
||||
// fails at query time because it cannot recreate them.
|
||||
fs.rmSync(`${dbPath}-wal`, { force: true });
|
||||
fs.rmSync(`${dbPath}-shm`, { force: true });
|
||||
assertHistoryRows(dbPath);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('isReadonlyWalRetryError only accepts readonly errors from WAL-mode databases', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-retry-'));
|
||||
const walDbPath = path.join(dir, 'wal.sqlite');
|
||||
const rollbackDbPath = path.join(dir, 'rollback.sqlite');
|
||||
try {
|
||||
createHistoryDb(walDbPath, { wal: true });
|
||||
createHistoryDb(rollbackDbPath);
|
||||
|
||||
assert.equal(
|
||||
isReadonlyWalRetryError(
|
||||
Object.assign(new Error('attempt to write a readonly database'), {
|
||||
code: 'SQLITE_READONLY',
|
||||
}),
|
||||
walDbPath,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isReadonlyWalRetryError(
|
||||
Object.assign(new Error('unable to open database file'), {
|
||||
code: 'SQLITE_CANTOPEN',
|
||||
}),
|
||||
walDbPath,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isReadonlyWalRetryError(new Error('no such table: imm_sessions'), walDbPath),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isReadonlyWalRetryError(
|
||||
Object.assign(new Error('attempt to write a readonly database'), {
|
||||
code: 'SQLITE_READONLY',
|
||||
}),
|
||||
rollbackDbPath,
|
||||
),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './history-art.js';
|
||||
export * from './history-db.js';
|
||||
export * from './history-navigation.js';
|
||||
export type { HistorySeriesEntry, HistoryVideoRow, SeasonDirEntry } from './history-types.js';
|
||||
@@ -29,6 +29,7 @@ function createArgs(): Args {
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
history: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
|
||||
@@ -2,9 +2,10 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { getDefaultLauncherLogFile, getDefaultMpvLogFile } from './types.js';
|
||||
import { localDateKey } from '../src/shared/log-files.js';
|
||||
|
||||
test('getDefaultMpvLogFile uses APPDATA on windows', () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = localDateKey(new Date());
|
||||
const resolved = getDefaultMpvLogFile({
|
||||
platform: 'win32',
|
||||
homeDir: 'C:\\Users\\tester',
|
||||
@@ -20,7 +21,7 @@ test('getDefaultMpvLogFile uses APPDATA on windows', () => {
|
||||
});
|
||||
|
||||
test('getDefaultLauncherLogFile uses launcher prefix', () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = localDateKey(new Date());
|
||||
const resolved = getDefaultLauncherLogFile({
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
|
||||
@@ -21,6 +21,7 @@ import { runDictionaryCommand } from './commands/dictionary-command.js';
|
||||
import { runLogsCommand } from './commands/logs-command.js';
|
||||
import { runStatsCommand } from './commands/stats-command.js';
|
||||
import { runJellyfinCommand } from './commands/jellyfin-command.js';
|
||||
import { runHistoryCommand } from './commands/history-command.js';
|
||||
import { runPlaybackCommand } from './commands/playback-command.js';
|
||||
import { runUpdateCommand } from './commands/update-command.js';
|
||||
|
||||
@@ -142,6 +143,16 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (appContext.args.history) {
|
||||
const selected = await runHistoryCommand(appContext);
|
||||
if (!selected) {
|
||||
log('info', args.logLevel, 'No watch history selection made, exiting');
|
||||
return;
|
||||
}
|
||||
appContext.args.target = selected;
|
||||
appContext.args.targetKind = 'file';
|
||||
}
|
||||
|
||||
await runPlaybackCommand(appContext);
|
||||
}
|
||||
|
||||
|
||||
@@ -570,6 +570,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
history: false,
|
||||
logLevel: 'error',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
|
||||
@@ -42,6 +42,19 @@ test('parseArgs maps root settings window option', () => {
|
||||
assert.equal(parsed.settings, true);
|
||||
});
|
||||
|
||||
test('parseArgs maps root watch history flags', () => {
|
||||
const shortParsed = parseArgs(['-H'], 'subminer', {});
|
||||
const longParsed = parseArgs(['--history'], 'subminer', {});
|
||||
const rofiParsed = parseArgs(['-R', '-H'], 'subminer', {});
|
||||
const defaultParsed = parseArgs([], 'subminer', {});
|
||||
|
||||
assert.equal(shortParsed.history, true);
|
||||
assert.equal(longParsed.history, true);
|
||||
assert.equal(rofiParsed.history, true);
|
||||
assert.equal(rofiParsed.useRofi, true);
|
||||
assert.equal(defaultParsed.history, false);
|
||||
});
|
||||
|
||||
test('parseArgs maps root update flags without conflicting with jellyfin username', () => {
|
||||
const shortParsed = parseArgs(['-u'], 'subminer', {});
|
||||
const longParsed = parseArgs(['--update'], 'subminer', {});
|
||||
|
||||
@@ -112,6 +112,7 @@ export interface Args {
|
||||
texthookerOnly: boolean;
|
||||
texthookerOpenBrowser: boolean;
|
||||
useRofi: boolean;
|
||||
history: boolean;
|
||||
logLevel: LogLevel;
|
||||
logRotation: LogRotation;
|
||||
passwordStore: string;
|
||||
|
||||
-233
@@ -1,233 +0,0 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const node_os_1 = __importDefault(require("node:os"));
|
||||
const node_child_process_1 = require("node:child_process");
|
||||
const electron_1 = require("electron");
|
||||
const help_1 = require("./cli/help");
|
||||
const main_entry_runtime_1 = require("./main-entry-runtime");
|
||||
const early_single_instance_1 = require("./main/early-single-instance");
|
||||
const main_entry_launch_config_1 = require("./main-entry-launch-config");
|
||||
const app_control_client_1 = require("./shared/app-control-client");
|
||||
const first_run_setup_plugin_1 = require("./main/runtime/first-run-setup-plugin");
|
||||
const windows_mpv_launch_1 = require("./main/runtime/windows-mpv-launch");
|
||||
const stats_daemon_entry_1 = require("./stats-daemon-entry");
|
||||
const fatal_error_1 = require("./main/fatal-error");
|
||||
const mpv_logging_args_1 = require("./shared/mpv-logging-args");
|
||||
const log_files_1 = require("./shared/log-files");
|
||||
const DEFAULT_TEXTHOOKER_PORT = 5174;
|
||||
function appendWindowsMpvLaunchLog(message, logRotation) {
|
||||
if (!(0, log_files_1.isLogFileEnabled)('app')) {
|
||||
return;
|
||||
}
|
||||
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||
(0, log_files_1.appendLogLine)(process.env.SUBMINER_APP_LOG?.trim() || (0, log_files_1.resolveDefaultLogFilePath)('app'), `[subminer] - ${timestamp} - INFO - [main:windows-mpv-launch] ${message}`, { rotation: logRotation });
|
||||
}
|
||||
function applySanitizedEnv(sanitizedEnv) {
|
||||
if (sanitizedEnv.NODE_NO_WARNINGS) {
|
||||
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
|
||||
}
|
||||
if (sanitizedEnv.VK_INSTANCE_LAYERS) {
|
||||
process.env.VK_INSTANCE_LAYERS = sanitizedEnv.VK_INSTANCE_LAYERS;
|
||||
}
|
||||
else {
|
||||
delete process.env.VK_INSTANCE_LAYERS;
|
||||
}
|
||||
}
|
||||
function resolveBundledWindowsMpvPluginEntrypoint() {
|
||||
return ((0, first_run_setup_plugin_1.resolvePackagedRuntimePluginPath)({
|
||||
dirname: __dirname,
|
||||
appPath: electron_1.app.getAppPath(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
}) ?? undefined);
|
||||
}
|
||||
function buildInstalledWindowsMpvPluginMessage(pathValue, version) {
|
||||
return [
|
||||
'SubMiner detected an installed mpv plugin at:',
|
||||
pathValue,
|
||||
'',
|
||||
"This mpv session will use the installed plugin. Remove it to use SubMiner's bundled runtime plugin automatically.",
|
||||
`Detected plugin version: ${version ?? 'unknown or legacy'}`,
|
||||
].join('\n');
|
||||
}
|
||||
async function promptForWindowsLegacyMpvPluginRemoval(mpvPath, detection) {
|
||||
const response = await electron_1.dialog.showMessageBox({
|
||||
type: 'warning',
|
||||
title: 'SubMiner mpv plugin detected',
|
||||
message: buildInstalledWindowsMpvPluginMessage(detection.path ?? 'unknown path', detection.version),
|
||||
detail: 'Remove the legacy SubMiner mpv plugin files from mpv before launching this video? This moves the files to the OS trash. SubMiner-managed playback will then use the bundled runtime plugin.',
|
||||
buttons: ['Remove legacy plugin', 'Continue with installed plugin', 'Cancel'],
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
});
|
||||
if (response.response === 2) {
|
||||
return 'cancel';
|
||||
}
|
||||
if (response.response === 1) {
|
||||
return 'continue';
|
||||
}
|
||||
const candidates = (0, first_run_setup_plugin_1.detectInstalledFirstRunPluginCandidates)({
|
||||
platform: 'win32',
|
||||
homeDir: node_os_1.default.homedir(),
|
||||
appDataDir: electron_1.app.getPath('appData'),
|
||||
mpvExecutablePath: mpvPath,
|
||||
});
|
||||
const result = await (0, first_run_setup_plugin_1.removeLegacyMpvPluginCandidates)({
|
||||
candidates,
|
||||
trashItem: (candidatePath) => electron_1.shell.trashItem(candidatePath),
|
||||
});
|
||||
if (result.ok) {
|
||||
await electron_1.dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Legacy mpv plugin removed',
|
||||
message: 'Legacy mpv plugin removed. SubMiner-managed playback will use the bundled runtime plugin.',
|
||||
});
|
||||
return 'removed';
|
||||
}
|
||||
await electron_1.dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Could not remove legacy mpv plugin',
|
||||
message: 'Some legacy SubMiner mpv plugin files could not be moved to the trash.',
|
||||
detail: result.failedPaths.map((failure) => `${failure.path}: ${failure.message}`).join('\n'),
|
||||
});
|
||||
return 'cancel';
|
||||
}
|
||||
function createWindowsRuntimePluginPolicy() {
|
||||
return {
|
||||
detectInstalledMpvPlugin: (mpvPath) => (0, first_run_setup_plugin_1.detectInstalledMpvPlugin)({
|
||||
platform: 'win32',
|
||||
homeDir: node_os_1.default.homedir(),
|
||||
appDataDir: electron_1.app.getPath('appData'),
|
||||
mpvExecutablePath: mpvPath,
|
||||
}),
|
||||
notifyInstalledPluginDetected: (detection) => {
|
||||
if (!detection.installed || !detection.path)
|
||||
return;
|
||||
electron_1.dialog.showMessageBoxSync({
|
||||
type: 'warning',
|
||||
title: 'SubMiner mpv plugin detected',
|
||||
message: buildInstalledWindowsMpvPluginMessage(detection.path, detection.version),
|
||||
});
|
||||
},
|
||||
resolveInstalledPluginBeforeLaunch: (detection, mpvPath) => promptForWindowsLegacyMpvPluginRemoval(mpvPath, detection),
|
||||
};
|
||||
}
|
||||
process.argv = (0, main_entry_runtime_1.normalizeStartupArgv)(process.argv, process.env);
|
||||
(0, main_entry_runtime_1.applyEarlyLinuxCommandLineSwitches)(electron_1.app.commandLine, process.argv);
|
||||
applySanitizedEnv((0, main_entry_runtime_1.sanitizeStartupEnv)(process.env));
|
||||
const userDataPath = (0, main_entry_runtime_1.configureEarlyAppPaths)(electron_1.app);
|
||||
const reportFatalError = (0, fatal_error_1.createFatalErrorReporter)({
|
||||
showErrorBox: (title, details) => electron_1.dialog.showErrorBox(title, details),
|
||||
consoleError: (message, error) => console.error(message, error),
|
||||
});
|
||||
(0, fatal_error_1.registerFatalErrorHandlers)({
|
||||
reportFatalError,
|
||||
exit: (code) => electron_1.app.exit(code),
|
||||
});
|
||||
function startMainProcess() {
|
||||
const gotSingleInstanceLock = (0, early_single_instance_1.requestSingleInstanceLockEarly)(electron_1.app);
|
||||
if (!gotSingleInstanceLock) {
|
||||
electron_1.app.exit(0);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
require('./main.js');
|
||||
}
|
||||
catch (error) {
|
||||
reportFatalError(error, {
|
||||
title: 'SubMiner startup failed',
|
||||
context: 'SubMiner failed while loading the main process.',
|
||||
});
|
||||
electron_1.app.exit(1);
|
||||
}
|
||||
}
|
||||
async function forwardStartupArgvViaAppControlIfAvailable() {
|
||||
if (!(0, main_entry_runtime_1.shouldForwardStartupArgvViaAppControl)(process.argv, process.env)) {
|
||||
return false;
|
||||
}
|
||||
const result = await (0, app_control_client_1.sendAppControlCommand)(process.argv, {
|
||||
configDir: userDataPath,
|
||||
timeoutMs: 500,
|
||||
});
|
||||
if (result.ok) {
|
||||
electron_1.app.exit(0);
|
||||
return true;
|
||||
}
|
||||
if (!result.unavailable) {
|
||||
console.error(`SubMiner app-control handoff failed: ${result.error ?? 'unknown error'}`);
|
||||
electron_1.app.exit(1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async function runEntryProcess() {
|
||||
if ((0, main_entry_runtime_1.shouldHandleHelpOnlyAtEntry)(process.argv, process.env)) {
|
||||
const sanitizedEnv = (0, main_entry_runtime_1.sanitizeHelpEnv)(process.env);
|
||||
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
|
||||
if (!sanitizedEnv.VK_INSTANCE_LAYERS) {
|
||||
delete process.env.VK_INSTANCE_LAYERS;
|
||||
}
|
||||
(0, help_1.printHelp)(DEFAULT_TEXTHOOKER_PORT);
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
if ((0, main_entry_runtime_1.shouldHandleLaunchMpvAtEntry)(process.argv, process.env)) {
|
||||
const sanitizedEnv = (0, main_entry_runtime_1.sanitizeLaunchMpvEnv)(process.env);
|
||||
applySanitizedEnv(sanitizedEnv);
|
||||
await electron_1.app.whenReady();
|
||||
const configuredMpvLaunch = (0, main_entry_launch_config_1.readConfiguredWindowsMpvLaunch)(userDataPath);
|
||||
const extraArgs = (0, main_entry_runtime_1.normalizeLaunchMpvExtraArgs)(process.argv);
|
||||
(0, log_files_1.applyLogFileTogglesToEnv)(configuredMpvLaunch.logFiles);
|
||||
const mpvLogPath = (0, log_files_1.isLogFileEnabled)('mpv')
|
||||
? process.env.SUBMINER_MPV_LOG?.trim() || (0, log_files_1.resolveDefaultLogFilePath)('mpv')
|
||||
: '';
|
||||
if (mpvLogPath) {
|
||||
(0, log_files_1.pruneLogDirectoryForPath)(mpvLogPath, configuredMpvLaunch.logRotation);
|
||||
}
|
||||
const result = await (0, windows_mpv_launch_1.launchWindowsMpv)((0, main_entry_runtime_1.normalizeLaunchMpvTargets)(process.argv), (0, windows_mpv_launch_1.createWindowsMpvLaunchDeps)({
|
||||
getEnv: (name) => process.env[name],
|
||||
isAppControlServerAvailable: () => (0, app_control_client_1.isAppControlServerAvailable)({
|
||||
configDir: userDataPath,
|
||||
timeoutMs: 350,
|
||||
}),
|
||||
sendAppControlCommand: (argv) => (0, app_control_client_1.sendAppControlCommand)(argv, {
|
||||
configDir: userDataPath,
|
||||
timeoutMs: 1000,
|
||||
}),
|
||||
showError: (title, content) => {
|
||||
electron_1.dialog.showErrorBox(title, content);
|
||||
},
|
||||
logInfo: (message) => appendWindowsMpvLaunchLog(message, configuredMpvLaunch.logRotation),
|
||||
}), [...extraArgs, ...(0, mpv_logging_args_1.buildMpvLoggingArgs)(configuredMpvLaunch.logLevel, mpvLogPath, extraArgs)], process.execPath, resolveBundledWindowsMpvPluginEntrypoint(), configuredMpvLaunch.executablePath, configuredMpvLaunch.launchMode, createWindowsRuntimePluginPolicy(), configuredMpvLaunch.pluginRuntimeConfig);
|
||||
electron_1.app.exit(result.ok ? 0 : 1);
|
||||
return;
|
||||
}
|
||||
if ((0, main_entry_runtime_1.shouldHandleStatsDaemonCommandAtEntry)(process.argv, process.env)) {
|
||||
await electron_1.app.whenReady();
|
||||
const exitCode = await (0, stats_daemon_entry_1.runStatsDaemonControlFromProcess)(electron_1.app.getPath('userData'));
|
||||
electron_1.app.exit(exitCode);
|
||||
return;
|
||||
}
|
||||
if (await forwardStartupArgvViaAppControlIfAvailable()) {
|
||||
return;
|
||||
}
|
||||
if ((0, main_entry_runtime_1.shouldDetachBackgroundLaunch)(process.argv, process.env)) {
|
||||
const childArgs = (0, main_entry_runtime_1.hasTransportedStartupArgs)(process.env) ? [] : process.argv.slice(1);
|
||||
const child = (0, node_child_process_1.spawn)(process.execPath, childArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: (0, main_entry_runtime_1.sanitizeBackgroundEnv)(process.env),
|
||||
});
|
||||
child.unref();
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
startMainProcess();
|
||||
}
|
||||
void runEntryProcess().catch((error) => {
|
||||
console.error('SubMiner app-control handoff failed:', error);
|
||||
startMainProcess();
|
||||
});
|
||||
//# sourceMappingURL=main-entry.js.map
|
||||
Generated
-4095
File diff suppressed because it is too large
Load Diff
+4
-4
File diff suppressed because one or more lines are too long
@@ -1,60 +1,24 @@
|
||||
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
|
||||
|
||||
<!-- prerelease-base-version: 0.17.0 -->
|
||||
<!-- prerelease-base-version: 0.17.1 -->
|
||||
|
||||
## Highlights
|
||||
### Changed
|
||||
### Added
|
||||
|
||||
- **Subtitle Delay Shortcuts:** Overlay subtitle delay controls now match mpv's native defaults.
|
||||
- `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` and show the current delay on the OSD.
|
||||
- The previous SubMiner-only adjacent-cue delay action has been removed.
|
||||
|
||||
- **Update Notifications:** New installs now default to overlay-only update notifications instead of overlay plus system notifications.
|
||||
- **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.
|
||||
- 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.
|
||||
- If a background download fails, SubMiner now notifies you and clears any pending media updates rather than leaving cards silently incomplete.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Anki Card Enrichment:** Fixed two issues where card fields were not populated correctly after mining.
|
||||
- Highlight Word now bolds the mined word in Kiku sentence and sentence-furigana fields even when the source Yomitan sentence has no existing bold markup.
|
||||
- Lapis and Kiku word cards enriched through SubMiner now include the word-and-sentence marker, restoring sentence context on the card front.
|
||||
- **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.
|
||||
|
||||
- **Windows Overlay:** Fixed shaky hover and click behavior on the subtitle bar when a video attaches to an already-running SubMiner instance.
|
||||
|
||||
- **Windows Anki & Media:** Fixed two issues affecting Windows users running SubMiner in background-launch mode.
|
||||
- Known-word cache refreshes no longer fail when no deck is configured.
|
||||
- Audio and image clipping now works correctly by recreating missing FFmpeg temp directories before processing.
|
||||
|
||||
- **Windows Character Dictionary:** The character dictionary auto-sync now correctly falls back to mpv's current video path on Windows when app media state is not yet ready.
|
||||
|
||||
- **Linux Support Assets:** Linux updates now create and refresh both managed support assets: the launcher runtime plugin copy and the rofi theme.
|
||||
- First playback on a fresh Linux install auto-installs those bundled assets before mpv starts if either one is missing.
|
||||
- Asset refreshes leave unrelated SubMiner data directories untouched and stage plugin copies before replacing the live runtime plugin.
|
||||
|
||||
- **Linux Visible Overlay Startup:** Auto-paused visible overlay startup stays fully interactive during the first measurement gap.
|
||||
- Startup subtitle cache misses paint raw text before tokenization finishes, and temporarily empty mpv subtitle reads refresh parsed cues before warm readiness resumes playback.
|
||||
|
||||
- **Playlist Transitions:** The visible overlay stays active while mpv advances to the next playlist item, including when the next episode loads after the warm transition delay.
|
||||
|
||||
- **macOS Yomitan Popup Focus:** Yomitan popup focus is restored after card mining or popup reload.
|
||||
- Clicking transparent overlay space now closes the popup and returns passthrough to mpv without a hide/reappear cycle.
|
||||
|
||||
- **Stats AniList Search:** Manual AniList linking from the stats page now strips generated `Season N` suffixes before searching, so the base anime title is used.
|
||||
|
||||
- **Desktop Notifications:** System notifications now show the SubMiner app icon when no custom notification image is provided.
|
||||
|
||||
- **Release Notes:** GitHub release `What's Changed` and `New Contributors` attribution sections are preserved when CI regenerates release notes from committed changelog output.
|
||||
|
||||
### Docs
|
||||
|
||||
- **Linux Update Flow:** Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal playback auto-installs those managed support assets if either one is missing.
|
||||
- **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.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- Replace subtitle delay actions with native mpv keybindings by @ksyasuda in #120
|
||||
- fix(stats): strip Season N suffix from AniList title searches by @ksyasuda in #121
|
||||
- fix(overlay): preserve visible state across playlist item transitions by @ksyasuda in #124
|
||||
- fix(overlay): restore macOS Yomitan popup focus without breaking click-away by @ksyasuda in #125
|
||||
- fix(linux): auto-install managed plugin copy; include in asset updates by @ksyasuda in #127
|
||||
- Fix Windows Anki startup and overlay regressions by @ksyasuda in #128
|
||||
- feat(youtube): add mediaCache mode and safer stream media extraction by @ksyasuda in #130
|
||||
- fix(logs): use local date for log filenames and expand export redaction by @ksyasuda in #131
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
## Highlights
|
||||
### Changed
|
||||
|
||||
- **Subtitle Delay Keybindings:** Overlay subtitle delay controls now match mpv's native bindings.
|
||||
- `z`, `Z`, and `x` adjust subtitle delay; `Ctrl+Shift+Left/Right` step to the adjacent subtitle and show the current delay on the OSD.
|
||||
- Removes the old SubMiner-specific adjacent-cue delay action in favor of mpv's built-in `sub-step`.
|
||||
|
||||
- **Update Notifications:** New installs now default to overlay-only update notifications instead of also sending a system notification.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Anki — Highlight Word:** The mined word is now correctly bolded in Kiku sentence and sentence-furigana fields even when the source Yomitan sentence did not already contain bold markup.
|
||||
|
||||
- **Anki — Lapis/Kiku Word Cards:** Word cards enriched through SubMiner now correctly include the word-and-sentence marker, restoring sentence context on the card front.
|
||||
|
||||
- **Anki — Windows:** Fixed two issues that surfaced after background app launches on Windows.
|
||||
- Audio clip and image generation now works correctly by recreating missing FFmpeg temp directories before export.
|
||||
- Known-word cache refreshes no longer fail when no deck is configured.
|
||||
|
||||
- **Desktop Notifications:** Restored the SubMiner app icon on system notifications that do not supply their own image.
|
||||
|
||||
- **Dictionary — Windows:** The character dictionary auto-sync on `SubMiner mpv` shortcut launches can now fall back to mpv's current video path when app media state is not yet ready.
|
||||
|
||||
- **Overlay — macOS Yomitan Popup:** Fixed focus and dismiss behavior for the Yomitan popup on macOS.
|
||||
- Popup focus is correctly restored after mining a card or reloading the popup.
|
||||
- Clicking on transparent overlay space now properly closes the popup and passes the click through to mpv, with no hide/reappear cycle.
|
||||
|
||||
- **Overlay — Linux Startup:** Fixed several edge cases that could leave the overlay unresponsive or drop subtitles at startup when auto-pause was active.
|
||||
- The overlay stays interactive during the initial render measurement gap.
|
||||
- Subtitles paint as plain text immediately on cache misses, before tokenization finishes.
|
||||
- Temporarily empty subtitle state is now re-parsed correctly before warm readiness resumes playback.
|
||||
|
||||
- **Overlay — Playlist Advance:** The visible overlay now stays interactive when mpv advances to the next playlist item, including when the next episode loads after the warm transition delay.
|
||||
|
||||
- **Overlay — Windows:** Fixed shaky subtitle-bar hover and click behavior when a video connects to an already-running background SubMiner instance.
|
||||
|
||||
- **Stats — AniList Search:** Manual AniList linking from the stats anime page now searches only the anime title, dropping any generated "Season N" suffix that was causing failed lookups.
|
||||
|
||||
- **Updates — Linux:** Improved Linux update reliability for managed support assets.
|
||||
- Updates now correctly install and refresh both the launcher runtime plugin copy and the rofi theme alongside AppImage and launcher updates.
|
||||
- Support-asset refreshes no longer touch unrelated SubMiner data directories, and plugin copies are staged safely before replacing the live runtime plugin.
|
||||
- Fresh installs now auto-install the managed runtime plugin and rofi theme from the bundled app on first launcher playback if either asset is missing.
|
||||
|
||||
### Docs
|
||||
|
||||
- **Linux Updates:** Documented how Linux update flows manage the launcher runtime plugin and rofi theme, and that the first launcher playback auto-installs any missing managed support assets from the bundled app.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- Replace subtitle delay actions with native mpv keybindings by @ksyasuda in #120
|
||||
- fix(stats): strip Season N suffix from AniList title searches by @ksyasuda in #121
|
||||
- fix(overlay): preserve visible state across playlist item transitions by @ksyasuda in #124
|
||||
- fix(overlay): restore macOS Yomitan popup focus without breaking click-away by @ksyasuda in #125
|
||||
- fix(linux): auto-install managed plugin copy; include in asset updates by @ksyasuda in #127
|
||||
- Fix Windows Anki startup and overlay regressions by @ksyasuda in #128
|
||||
|
||||
## 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`.
|
||||
@@ -108,15 +108,11 @@ function renderPrereleaseBaseVersionMarker(version: string): string {
|
||||
}
|
||||
|
||||
function extractPrereleaseBaseVersionMarker(notes: string): string | null {
|
||||
return (
|
||||
/<!--\s*prerelease-base-version:\s*(\d+\.\d+\.\d+)\s*-->/u.exec(notes)?.[1] ?? null
|
||||
);
|
||||
return /<!--\s*prerelease-base-version:\s*(\d+\.\d+\.\d+)\s*-->/u.exec(notes)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function stripPrereleaseMetadata(notes: string): string {
|
||||
return notes
|
||||
.replace(/<!--\s*prerelease-base-version:\s*\d+\.\d+\.\d+\s*-->\s*/u, '')
|
||||
.trim();
|
||||
return notes.replace(/<!--\s*prerelease-base-version:\s*\d+\.\d+\.\d+\s*-->\s*/u, '').trim();
|
||||
}
|
||||
|
||||
function resolveReusablePrereleaseNotes(notes: string, version: string): string | undefined {
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { AnkiIntegration } from './anki-integration';
|
||||
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
|
||||
import type { MediaInput } from './media-input';
|
||||
import { AnkiConnectConfig } from './types';
|
||||
|
||||
type TestOverlayNotificationPayload = {
|
||||
@@ -24,6 +25,13 @@ interface IntegrationTestContext {
|
||||
stateDir: string;
|
||||
}
|
||||
|
||||
function describeMediaInputForTest(input: MediaInput): string {
|
||||
if (typeof input === 'string') {
|
||||
return input;
|
||||
}
|
||||
return `${input.path}:${input.source ?? 'raw'}`;
|
||||
}
|
||||
|
||||
function createIntegrationTestContext(
|
||||
options: {
|
||||
highlightEnabled?: boolean;
|
||||
@@ -527,6 +535,460 @@ test('AnkiIntegration marks partial update notifications as failures in OSD mode
|
||||
assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']);
|
||||
});
|
||||
|
||||
test('AnkiIntegration applies ready YouTube cache media to every queued note id', async () => {
|
||||
const osdMessages: string[] = [];
|
||||
const updatedNotes: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||
const storedMedia: string[] = [];
|
||||
const mediaInputs: string[] = [];
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
fields: {
|
||||
image: 'Picture',
|
||||
},
|
||||
media: {
|
||||
imageFormat: 'jpg',
|
||||
},
|
||||
behavior: {
|
||||
notificationType: 'osd',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{} as never,
|
||||
(text) => {
|
||||
osdMessages.push(text);
|
||||
},
|
||||
);
|
||||
|
||||
const internals = integration as unknown as {
|
||||
client: {
|
||||
notesInfo: (noteIds: number[]) => Promise<unknown[]>;
|
||||
updateNoteFields: (noteId: number, fields: Record<string, string>) => Promise<void>;
|
||||
storeMediaFile: (filename: string, data: Buffer) => Promise<void>;
|
||||
};
|
||||
mediaGenerator: {
|
||||
generateAudio: (
|
||||
path: MediaInput,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
audioPadding?: number,
|
||||
audioStreamIndex?: number,
|
||||
) => 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 passes audio normalization config for ready cached YouTube audio', async () => {
|
||||
const audioCalls: Array<{
|
||||
path: string;
|
||||
audioStreamIndex?: number;
|
||||
normalizeAudio?: boolean;
|
||||
}> = [];
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
media: {
|
||||
audioPadding: 0,
|
||||
normalizeAudio: false,
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
currentAudioStreamIndex: 0,
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentTimePos: 11,
|
||||
} as never,
|
||||
() => undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
async () => '/tmp/subminer-youtube-media-cache/media.mkv',
|
||||
() => true,
|
||||
);
|
||||
|
||||
const internals = integration as unknown as {
|
||||
mediaGenerator: {
|
||||
generateAudio: (
|
||||
path: { path: string },
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
audioPadding?: number,
|
||||
audioStreamIndex?: number,
|
||||
normalizeAudio?: boolean,
|
||||
) => Promise<Buffer>;
|
||||
};
|
||||
generateAudio: () => Promise<Buffer | null>;
|
||||
};
|
||||
internals.mediaGenerator = {
|
||||
generateAudio: async (
|
||||
path,
|
||||
_startTime,
|
||||
_endTime,
|
||||
_audioPadding,
|
||||
audioStreamIndex,
|
||||
normalizeAudio,
|
||||
) => {
|
||||
audioCalls.push({ path: path.path, audioStreamIndex, normalizeAudio });
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
};
|
||||
|
||||
await internals.generateAudio();
|
||||
|
||||
assert.deepEqual(audioCalls, [
|
||||
{
|
||||
path: '/tmp/subminer-youtube-media-cache/media.mkv',
|
||||
audioStreamIndex: undefined,
|
||||
normalizeAudio: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('AnkiIntegration announces ready YouTube cache when no queued notes exist', async () => {
|
||||
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
behavior: {
|
||||
notificationType: 'overlay',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{} as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
(payload) => {
|
||||
overlayNotifications.push(payload as TestOverlayNotificationPayload);
|
||||
},
|
||||
);
|
||||
|
||||
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
||||
|
||||
assert.equal(overlayNotifications.length, 1);
|
||||
assert.equal(overlayNotifications[0]?.title, 'SubMiner');
|
||||
assert.equal(overlayNotifications[0]?.body, 'YouTube media cache ready.');
|
||||
});
|
||||
|
||||
test('AnkiIntegration can let caller own no-queued YouTube cache ready notification', async () => {
|
||||
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
behavior: {
|
||||
notificationType: 'overlay',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{} as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
(payload) => {
|
||||
overlayNotifications.push(payload as TestOverlayNotificationPayload);
|
||||
},
|
||||
);
|
||||
|
||||
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv', {
|
||||
notifyNoQueued: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(overlayNotifications, []);
|
||||
});
|
||||
|
||||
test('AnkiIntegration embeds generated notification image on overlay mined-card notifications', async () => {
|
||||
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
|
||||
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
||||
|
||||
+177
-12
@@ -61,7 +61,18 @@ import { NoteUpdateWorkflow } from './anki-integration/note-update-workflow';
|
||||
import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow';
|
||||
import { resolveAnimatedImageLeadInSeconds } from './anki-integration/animated-image-sync';
|
||||
import { AnkiIntegrationRuntime, normalizeAnkiIntegrationConfig } from './anki-integration/runtime';
|
||||
import { resolveMediaGenerationInputPath } from './anki-integration/media-source';
|
||||
import {
|
||||
resolveAudioStreamIndexForMediaGeneration,
|
||||
resolveMediaGenerationInput,
|
||||
resolveMediaGenerationInputPath,
|
||||
type MediaGenerationInputResolverOptions,
|
||||
} from './anki-integration/media-source';
|
||||
import type { PendingYoutubeMediaUpdate } from './anki-integration/pending-youtube-media';
|
||||
import { PendingYoutubeMediaQueue } from './anki-integration/pending-youtube-media-queue';
|
||||
import type {
|
||||
PendingYoutubeMediaQueueFailedOptions,
|
||||
PendingYoutubeMediaQueueReadyOptions,
|
||||
} from './anki-integration/pending-youtube-media-queue';
|
||||
|
||||
const log = createLogger('anki').child('integration');
|
||||
|
||||
@@ -225,6 +236,13 @@ export class AnkiIntegration {
|
||||
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
|
||||
private noteIdRedirects = new Map<number, number>();
|
||||
private trackedDuplicateNoteIds = new Map<number, number[]>();
|
||||
private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null =
|
||||
null;
|
||||
private shouldRequireRemoteMediaCache: (() => boolean) | null = null;
|
||||
private getYoutubeMediaSourceUrl:
|
||||
| (() => Promise<string | null | undefined> | string | null | undefined)
|
||||
| null = null;
|
||||
private pendingYoutubeMediaQueue: PendingYoutubeMediaQueue;
|
||||
|
||||
constructor(
|
||||
config: AnkiConnectConfig,
|
||||
@@ -240,6 +258,9 @@ export class AnkiIntegration {
|
||||
aiConfig: AiConfig = {},
|
||||
recordCardsMined?: (count: number, noteIds?: number[]) => void,
|
||||
overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void,
|
||||
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'],
|
||||
shouldRequireRemoteMediaCache?: () => boolean,
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined,
|
||||
) {
|
||||
this.config = normalizeAnkiIntegrationConfig(config);
|
||||
this.aiConfig = { ...aiConfig };
|
||||
@@ -252,6 +273,10 @@ export class AnkiIntegration {
|
||||
this.overlayNotificationCallback = overlayNotificationCallback || null;
|
||||
this.fieldGroupingCallback = fieldGroupingCallback || null;
|
||||
this.recordCardsMinedCallback = recordCardsMined ?? null;
|
||||
this.getCachedMediaPath = getCachedMediaPath ?? null;
|
||||
this.shouldRequireRemoteMediaCache = shouldRequireRemoteMediaCache ?? null;
|
||||
this.getYoutubeMediaSourceUrl = getYoutubeMediaSourceUrl ?? null;
|
||||
this.pendingYoutubeMediaQueue = this.createPendingYoutubeMediaQueue();
|
||||
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
|
||||
this.pollingRunner = this.createPollingRunner();
|
||||
this.cardCreationService = this.createCardCreationService();
|
||||
@@ -295,6 +320,82 @@ 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,
|
||||
this.config.media?.normalizeAudio !== false,
|
||||
),
|
||||
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 {
|
||||
return new KnownWordCacheManager({
|
||||
client: {
|
||||
@@ -377,6 +478,10 @@ export class AnkiIntegration {
|
||||
getAiConfig: () => this.aiConfig,
|
||||
getTimingTracker: () => this.timingTracker,
|
||||
getMpvClient: () => this.mpvClient,
|
||||
...(this.getCachedMediaPath ? { getCachedMediaPath: this.getCachedMediaPath } : {}),
|
||||
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
|
||||
getYoutubeMediaSourceUrl: () => this.getCurrentYoutubeMediaSourceUrl(),
|
||||
queuePendingYoutubeMediaUpdate: (job) => this.queuePendingYoutubeMediaUpdate(job),
|
||||
getDeck: () => this.config.deck,
|
||||
client: {
|
||||
addNote: (deck, modelName, fields, tags) =>
|
||||
@@ -398,6 +503,7 @@ export class AnkiIntegration {
|
||||
endTime,
|
||||
audioPadding,
|
||||
audioStreamIndex,
|
||||
this.config.media?.normalizeAudio !== false,
|
||||
),
|
||||
generateScreenshot: (videoPath, timestamp, options) =>
|
||||
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
|
||||
@@ -540,6 +646,7 @@ export class AnkiIntegration {
|
||||
formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) =>
|
||||
this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds),
|
||||
consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(),
|
||||
queuePendingYoutubeMediaUpdate: (job) => this.queuePendingYoutubeMediaUpdateForNote(job),
|
||||
addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
|
||||
showNotification: (noteId, label) => this.showNotification(noteId, label),
|
||||
showOsdNotification: (message) => this.showStatusNotification(message),
|
||||
@@ -833,13 +940,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> {
|
||||
const mpvClient = this.mpvClient;
|
||||
if (!mpvClient || !mpvClient.currentVideoPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'audio');
|
||||
const videoPath = await resolveMediaGenerationInput(
|
||||
mpvClient,
|
||||
'audio',
|
||||
this.getMediaResolverOptions(),
|
||||
);
|
||||
if (!videoPath) {
|
||||
return null;
|
||||
}
|
||||
@@ -850,7 +997,8 @@ export class AnkiIntegration {
|
||||
startTime,
|
||||
endTime,
|
||||
this.config.media?.audioPadding,
|
||||
this.mpvClient.currentAudioStreamIndex,
|
||||
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
|
||||
this.config.media?.normalizeAudio !== false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -862,7 +1010,11 @@ export class AnkiIntegration {
|
||||
return null;
|
||||
}
|
||||
|
||||
const videoPath = await resolveMediaGenerationInputPath(this.mpvClient, 'video');
|
||||
const videoPath = await resolveMediaGenerationInput(
|
||||
this.mpvClient,
|
||||
'video',
|
||||
this.getMediaResolverOptions(),
|
||||
);
|
||||
if (!videoPath) {
|
||||
return null;
|
||||
}
|
||||
@@ -896,17 +1048,30 @@ export class AnkiIntegration {
|
||||
}
|
||||
|
||||
private formatMiscInfoPattern(fallbackFilename: string, startTimeSeconds?: number): string {
|
||||
return this.formatMiscInfoPatternForMediaPath(
|
||||
fallbackFilename,
|
||||
startTimeSeconds,
|
||||
this.mpvClient.currentVideoPath || '',
|
||||
this.mpvClient.currentMediaTitle ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
private formatMiscInfoPatternForMediaPath(
|
||||
fallbackFilename: string,
|
||||
startTimeSeconds: number | undefined,
|
||||
mediaPath: string,
|
||||
mediaTitle?: string,
|
||||
): string {
|
||||
if (!this.config.metadata?.pattern) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const currentVideoPath = this.mpvClient.currentVideoPath || '';
|
||||
const videoFilename = extractFilenameFromMediaPath(currentVideoPath);
|
||||
const mediaTitle = trimToNonEmptyString(this.mpvClient.currentMediaTitle);
|
||||
const videoFilename = extractFilenameFromMediaPath(mediaPath);
|
||||
const resolvedMediaTitle = trimToNonEmptyString(mediaTitle);
|
||||
const filenameWithExt =
|
||||
(shouldPreferMediaTitleForMiscInfo(currentVideoPath, videoFilename)
|
||||
? mediaTitle || videoFilename
|
||||
: videoFilename || mediaTitle) || fallbackFilename;
|
||||
(shouldPreferMediaTitleForMiscInfo(mediaPath, videoFilename)
|
||||
? resolvedMediaTitle || videoFilename
|
||||
: videoFilename || resolvedMediaTitle) || fallbackFilename;
|
||||
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
|
||||
|
||||
const currentTimePos =
|
||||
@@ -1457,7 +1622,7 @@ export class AnkiIntegration {
|
||||
miscInfoValue?: string;
|
||||
} = {};
|
||||
|
||||
if (this.config.media?.generateAudio && this.mpvClient?.currentVideoPath) {
|
||||
if (this.config.media?.generateAudio !== false && this.mpvClient?.currentVideoPath) {
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = await this.generateAudio();
|
||||
@@ -1477,7 +1642,7 @@ export class AnkiIntegration {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.media?.generateImage && this.mpvClient?.currentVideoPath) {
|
||||
if (this.config.media?.generateImage !== false && this.mpvClient?.currentVideoPath) {
|
||||
try {
|
||||
const animatedLeadInSeconds = noteInfo
|
||||
? await this.getAnimatedImageLeadInSeconds(noteInfo)
|
||||
|
||||
@@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CardCreationService } from './card-creation';
|
||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||
import type { MediaInput } from '../media-generator';
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
|
||||
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||
@@ -266,9 +268,13 @@ test('manual clipboard subtitle update skips audio when sentence audio field is
|
||||
test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => {
|
||||
const audioPaths: string[] = [];
|
||||
const imagePaths: string[] = [];
|
||||
const recordMediaPath = (mediaInput: MediaInput): string =>
|
||||
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const edlSource = [
|
||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
||||
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
@@ -338,11 +344,11 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async (path) => {
|
||||
audioPaths.push(path);
|
||||
audioPaths.push(recordMediaPath(path));
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async (path) => {
|
||||
imagePaths.push(path);
|
||||
imagePaths.push(recordMediaPath(path));
|
||||
return Buffer.from('image');
|
||||
},
|
||||
generateAnimatedImage: async () => null,
|
||||
@@ -351,8 +357,8 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
|
||||
|
||||
await service.updateLastAddedFromClipboard('一行目\n\n二行目');
|
||||
|
||||
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
|
||||
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
|
||||
assert.deepEqual(audioPaths, [audioUrl]);
|
||||
assert.deepEqual(imagePaths, [videoUrl]);
|
||||
assert.equal(storedMedia.length, 2);
|
||||
assert.equal(updatedFields.length, 1);
|
||||
assert.equal(updatedFields[0]?.Sentence, '一行目 二行目');
|
||||
|
||||
@@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CardCreationService } from './card-creation';
|
||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||
import type { MediaInput } from '../media-generator';
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
|
||||
test('CardCreationService counts locally created sentence cards', async () => {
|
||||
@@ -287,9 +289,13 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
|
||||
test('CardCreationService uses stream-open-filename for remote media generation', async () => {
|
||||
const audioPaths: string[] = [];
|
||||
const imagePaths: string[] = [];
|
||||
const recordMediaPath = (mediaInput: MediaInput): string =>
|
||||
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const edlSource = [
|
||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
||||
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
@@ -345,11 +351,11 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async (path) => {
|
||||
audioPaths.push(path);
|
||||
audioPaths.push(recordMediaPath(path));
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async (path) => {
|
||||
imagePaths.push(path);
|
||||
imagePaths.push(recordMediaPath(path));
|
||||
return Buffer.from('image');
|
||||
},
|
||||
generateAnimatedImage: async () => null,
|
||||
@@ -394,8 +400,261 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
||||
const created = await service.createSentenceCard('テスト', 0, 1);
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
|
||||
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
|
||||
assert.deepEqual(audioPaths, [audioUrl]);
|
||||
assert.deepEqual(imagePaths, [videoUrl]);
|
||||
});
|
||||
|
||||
test('CardCreationService does not use mpv stream indexes for ready cached YouTube media', async () => {
|
||||
const audioCalls: Array<{ path: string; audioStreamIndex?: number }> = [];
|
||||
|
||||
const service = new CardCreationService({
|
||||
getConfig: () =>
|
||||
({
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
sentence: 'Sentence',
|
||||
audio: 'SentenceAudio',
|
||||
image: 'Picture',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: false,
|
||||
imageFormat: 'jpg',
|
||||
},
|
||||
behavior: {},
|
||||
ai: false,
|
||||
}) as AnkiConnectConfig,
|
||||
getAiConfig: () => ({}),
|
||||
getTimingTracker: () => ({}) as never,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
currentSubText: '字幕',
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentTimePos: 11,
|
||||
currentAudioStreamIndex: 0,
|
||||
}) as never,
|
||||
getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/media.mkv',
|
||||
shouldRequireRemoteMediaCache: () => true,
|
||||
client: {
|
||||
addNote: async () => 42,
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Sentence: { value: '' },
|
||||
SentenceAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
},
|
||||
],
|
||||
updateNoteFields: async () => undefined,
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async (path, _startTime, _endTime, _padding, audioStreamIndex) => {
|
||||
audioCalls.push({ path: typeof path === 'string' ? path : path.path, audioStreamIndex });
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
showOsdNotification: () => undefined,
|
||||
showUpdateResult: () => undefined,
|
||||
showStatusNotification: () => undefined,
|
||||
showNotification: async () => undefined,
|
||||
beginUpdateProgress: () => undefined,
|
||||
endUpdateProgress: () => undefined,
|
||||
withUpdateProgress: async (_message, action) => action(),
|
||||
resolveConfiguredFieldName: (noteInfo, preferredName) => {
|
||||
if (!preferredName) return null;
|
||||
return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null;
|
||||
},
|
||||
resolveNoteFieldName: (noteInfo, preferredName) => {
|
||||
if (!preferredName) return null;
|
||||
return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null;
|
||||
},
|
||||
getAnimatedImageLeadInSeconds: async () => 0,
|
||||
extractFields: () => ({}),
|
||||
processSentence: (sentence) => sentence,
|
||||
setCardTypeFields: () => undefined,
|
||||
mergeFieldValue: (_existing, newValue) => newValue,
|
||||
formatMiscInfoPattern: () => '',
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
model: 'Sentence',
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
});
|
||||
|
||||
const created = await service.createSentenceCard('テスト', 10, 12);
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.deepEqual(audioCalls, [
|
||||
{
|
||||
path: '/tmp/subminer-youtube-media-cache/media.mkv',
|
||||
audioStreamIndex: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('CardCreationService queues YouTube media when required cache is not ready', async () => {
|
||||
const mediaCalls: string[] = [];
|
||||
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||
const queuedUpdates: Array<{
|
||||
sourceUrl: string;
|
||||
noteId: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
label: string | number;
|
||||
audioFieldName?: string;
|
||||
imageFieldName?: string;
|
||||
miscInfoFieldName?: string;
|
||||
generateAudio: boolean;
|
||||
generateImage: boolean;
|
||||
}> = [];
|
||||
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 () => {
|
||||
|
||||
@@ -5,15 +5,37 @@ import {
|
||||
} from '../anki-field-config';
|
||||
import { AnkiConnectConfig } from '../types/anki';
|
||||
import { createLogger } from '../logger';
|
||||
import type { MediaInput } from '../media-input';
|
||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
import { AiConfig } from '../types/integrations';
|
||||
import { MpvClient } from '../types/runtime';
|
||||
import { resolveSentenceBackText } from './ai';
|
||||
import { resolveMediaGenerationInputPath } from './media-source';
|
||||
import {
|
||||
resolveMediaGenerationInput,
|
||||
resolveAudioStreamIndexForMediaGeneration,
|
||||
type MediaGenerationInputResolverOptions,
|
||||
} from './media-source';
|
||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
||||
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
||||
|
||||
const log = createLogger('anki').child('integration.card-creation');
|
||||
|
||||
function shouldGenerateAudio(config: AnkiConnectConfig): boolean {
|
||||
return config.media?.generateAudio !== false;
|
||||
}
|
||||
|
||||
function shouldGenerateImage(config: AnkiConnectConfig): boolean {
|
||||
return config.media?.generateImage !== false;
|
||||
}
|
||||
|
||||
function trimToNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export interface CardCreationNoteInfo {
|
||||
noteId: number;
|
||||
fields: Record<string, { value: string }>;
|
||||
@@ -38,14 +60,15 @@ interface CardCreationClient {
|
||||
|
||||
interface CardCreationMediaGenerator {
|
||||
generateAudio(
|
||||
path: string,
|
||||
path: MediaInput,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
audioPadding?: number,
|
||||
audioStreamIndex?: number,
|
||||
normalizeAudio?: boolean,
|
||||
): Promise<Buffer | null>;
|
||||
generateScreenshot(
|
||||
path: string,
|
||||
path: MediaInput,
|
||||
timestamp: number,
|
||||
options: {
|
||||
format: 'jpg' | 'png' | 'webp';
|
||||
@@ -55,7 +78,7 @@ interface CardCreationMediaGenerator {
|
||||
},
|
||||
): Promise<Buffer | null>;
|
||||
generateAnimatedImage(
|
||||
path: string,
|
||||
path: MediaInput,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
audioPadding?: number,
|
||||
@@ -74,6 +97,10 @@ interface CardCreationDeps {
|
||||
getAiConfig: () => AiConfig;
|
||||
getTimingTracker: () => SubtitleTimingTracker;
|
||||
getMpvClient: () => MpvClient;
|
||||
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'];
|
||||
shouldRequireRemoteMediaCache?: () => boolean;
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||
queuePendingYoutubeMediaUpdate?: (job: PendingYoutubeMediaUpdate) => void;
|
||||
getDeck?: () => string | undefined;
|
||||
client: CardCreationClient;
|
||||
mediaGenerator: CardCreationMediaGenerator;
|
||||
@@ -121,6 +148,19 @@ interface CardCreationDeps {
|
||||
export class CardCreationService {
|
||||
constructor(private readonly deps: CardCreationDeps) {}
|
||||
|
||||
private getMediaResolverOptions(): MediaGenerationInputResolverOptions {
|
||||
const options: MediaGenerationInputResolverOptions = {
|
||||
logDebug: (message) => log.debug(message),
|
||||
};
|
||||
if (this.deps.getCachedMediaPath) {
|
||||
options.getCachedMediaPath = this.deps.getCachedMediaPath;
|
||||
}
|
||||
if (this.deps.shouldRequireRemoteMediaCache?.()) {
|
||||
options.remoteCacheMode = 'required';
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private getConfiguredAnkiTags(): string[] {
|
||||
const tags = this.deps.getConfig().tags;
|
||||
if (!Array.isArray(tags)) {
|
||||
@@ -246,14 +286,18 @@ export class CardCreationService {
|
||||
`Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`,
|
||||
);
|
||||
|
||||
const audioSourcePath = this.deps.getConfig().media?.generateAudio
|
||||
? await resolveMediaGenerationInputPath(mpvClient, 'audio')
|
||||
const config = this.deps.getConfig();
|
||||
const generateAudio = shouldGenerateAudio(config);
|
||||
const generateImage = shouldGenerateImage(config);
|
||||
const mediaResolverOptions = this.getMediaResolverOptions();
|
||||
const audioSourcePath = generateAudio
|
||||
? await resolveMediaGenerationInput(mpvClient, 'audio', mediaResolverOptions)
|
||||
: null;
|
||||
const videoPath = this.deps.getConfig().media?.generateImage
|
||||
? await resolveMediaGenerationInputPath(mpvClient, 'video')
|
||||
const videoPath = generateImage
|
||||
? await resolveMediaGenerationInput(mpvClient, 'video', mediaResolverOptions)
|
||||
: null;
|
||||
|
||||
if (this.deps.getConfig().media?.generateAudio) {
|
||||
if (generateAudio) {
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = audioSourcePath
|
||||
@@ -281,7 +325,7 @@ export class CardCreationService {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.deps.getConfig().media?.generateImage) {
|
||||
if (generateImage) {
|
||||
try {
|
||||
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
|
||||
const imageFilename = this.generateImageFilename();
|
||||
@@ -441,7 +485,7 @@ export class CardCreationService {
|
||||
errors.push('audio');
|
||||
}
|
||||
|
||||
if (this.deps.getConfig().media?.generateImage) {
|
||||
if (shouldGenerateImage(this.deps.getConfig())) {
|
||||
try {
|
||||
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
|
||||
const imageFilename = this.generateImageFilename();
|
||||
@@ -522,9 +566,23 @@ export class CardCreationService {
|
||||
|
||||
try {
|
||||
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
||||
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'video');
|
||||
const audioSourcePath = await resolveMediaGenerationInputPath(mpvClient, 'audio');
|
||||
if (!videoPath) {
|
||||
const config = this.deps.getConfig();
|
||||
const generateAudio = shouldGenerateAudio(config);
|
||||
const generateImage = shouldGenerateImage(config);
|
||||
const mediaResolverOptions = this.getMediaResolverOptions();
|
||||
const videoPath = generateImage
|
||||
? await resolveMediaGenerationInput(mpvClient, 'video', mediaResolverOptions)
|
||||
: null;
|
||||
const audioSourcePath = generateAudio
|
||||
? await resolveMediaGenerationInput(mpvClient, 'audio', mediaResolverOptions)
|
||||
: null;
|
||||
const missingRequestedMediaInput =
|
||||
(generateImage && !videoPath) || (generateAudio && !audioSourcePath);
|
||||
const shouldQueuePendingYoutubeMedia =
|
||||
missingRequestedMediaInput &&
|
||||
this.deps.shouldRequireRemoteMediaCache?.() === true &&
|
||||
typeof this.deps.queuePendingYoutubeMediaUpdate === 'function';
|
||||
if (missingRequestedMediaInput && !shouldQueuePendingYoutubeMedia) {
|
||||
this.deps.showOsdNotification('No video loaded');
|
||||
return false;
|
||||
}
|
||||
@@ -534,13 +592,13 @@ export class CardCreationService {
|
||||
|
||||
const sentenceField = sentenceCardConfig.sentenceField;
|
||||
const audioFieldName = sentenceCardConfig.audioField || 'SentenceAudio';
|
||||
const translationField = this.deps.getConfig().fields?.translation || 'SelectionText';
|
||||
const translationField = config.fields?.translation || 'SelectionText';
|
||||
let resolvedMiscInfoField: string | null = null;
|
||||
let resolvedSentenceAudioField: string = audioFieldName;
|
||||
|
||||
fields[sentenceField] = sentence;
|
||||
|
||||
const ankiAiConfig = this.deps.getConfig().ai;
|
||||
const ankiAiConfig = config.ai;
|
||||
const ankiAiEnabled =
|
||||
typeof ankiAiConfig === 'object' && ankiAiConfig !== null
|
||||
? ankiAiConfig.enabled === true
|
||||
@@ -563,7 +621,7 @@ export class CardCreationService {
|
||||
|
||||
if (sentenceCardConfig.lapisEnabled || sentenceCardConfig.kikuEnabled) {
|
||||
fields.IsSentenceCard = 'x';
|
||||
fields[getConfiguredWordFieldName(this.deps.getConfig())] = sentence;
|
||||
fields[getConfiguredWordFieldName(config)] = sentence;
|
||||
}
|
||||
|
||||
const pendingNoteInfo = this.createPendingNoteInfo(fields);
|
||||
@@ -572,7 +630,7 @@ export class CardCreationService {
|
||||
);
|
||||
const pendingExpressionText = getPreferredWordValueFromExtractedFields(
|
||||
pendingNoteFields,
|
||||
this.deps.getConfig(),
|
||||
config,
|
||||
).trim();
|
||||
let duplicateNoteIds: number[] = [];
|
||||
if (
|
||||
@@ -590,7 +648,7 @@ export class CardCreationService {
|
||||
}
|
||||
}
|
||||
|
||||
const deck = this.deps.getConfig().deck || 'Default';
|
||||
const deck = config.deck || 'Default';
|
||||
let noteId: number;
|
||||
try {
|
||||
noteId = await this.deps.client.addNote(
|
||||
@@ -636,7 +694,7 @@ export class CardCreationService {
|
||||
this.deps.resolveNoteFieldName(createdNoteInfo, audioFieldName) || audioFieldName;
|
||||
resolvedMiscInfoField = this.deps.resolveConfiguredFieldName(
|
||||
createdNoteInfo,
|
||||
this.deps.getConfig().fields?.miscInfo,
|
||||
config.fields?.miscInfo,
|
||||
);
|
||||
|
||||
const cardTypeFields: Record<string, string> = {};
|
||||
@@ -654,41 +712,69 @@ export class CardCreationService {
|
||||
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> = {};
|
||||
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = audioSourcePath
|
||||
? await this.mediaGenerateAudio(audioSourcePath, startTime, endTime)
|
||||
: null;
|
||||
if (generateAudio) {
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = audioSourcePath
|
||||
? await this.mediaGenerateAudio(audioSourcePath, startTime, endTime)
|
||||
: null;
|
||||
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
const audioValue = `[sound:${audioFilename}]`;
|
||||
mediaFields[resolvedSentenceAudioField] = audioValue;
|
||||
miscInfoFilename = audioFilename;
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
const audioValue = `[sound:${audioFilename}]`;
|
||||
mediaFields[resolvedSentenceAudioField] = audioValue;
|
||||
miscInfoFilename = audioFilename;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to generate sentence audio:', (error as Error).message);
|
||||
errors.push('audio');
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to generate sentence audio:', (error as Error).message);
|
||||
errors.push('audio');
|
||||
}
|
||||
|
||||
try {
|
||||
const imageFilename = this.generateImageFilename();
|
||||
const imageBuffer = await this.generateImageBuffer(videoPath, startTime, endTime);
|
||||
if (generateImage) {
|
||||
try {
|
||||
const imageFilename = this.generateImageFilename();
|
||||
const imageBuffer = await this.generateImageBuffer(videoPath!, startTime, endTime);
|
||||
|
||||
const imageField = this.deps.getConfig().fields?.image;
|
||||
if (imageBuffer && imageField) {
|
||||
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
||||
mediaFields[imageField] = `<img src="${imageFilename}">`;
|
||||
miscInfoFilename = imageFilename;
|
||||
const imageField = config.fields?.image;
|
||||
if (imageBuffer && imageField) {
|
||||
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
||||
mediaFields[imageField] = `<img src="${imageFilename}">`;
|
||||
miscInfoFilename = imageFilename;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to generate sentence image:', (error as Error).message);
|
||||
errors.push('image');
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to generate sentence image:', (error as Error).message);
|
||||
errors.push('image');
|
||||
}
|
||||
|
||||
if (this.deps.getConfig().fields?.miscInfo) {
|
||||
if (config.fields?.miscInfo) {
|
||||
const miscInfo = this.deps.formatMiscInfoPattern(miscInfoFilename || '', startTime);
|
||||
if (miscInfo && resolvedMiscInfoField) {
|
||||
mediaFields[resolvedMiscInfoField] = miscInfo;
|
||||
@@ -704,7 +790,6 @@ export class CardCreationService {
|
||||
}
|
||||
}
|
||||
|
||||
const label = sentence.length > 30 ? sentence.substring(0, 30) + '...' : sentence;
|
||||
const errorSuffix = errors.length > 0 ? `${errors.join(', ')} failed` : undefined;
|
||||
await this.deps.showNotification(noteId, label, errorSuffix);
|
||||
return true;
|
||||
@@ -740,7 +825,7 @@ export class CardCreationService {
|
||||
}
|
||||
|
||||
private async mediaGenerateAudio(
|
||||
videoPath: string,
|
||||
videoPath: MediaInput,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): Promise<Buffer | null> {
|
||||
@@ -754,12 +839,16 @@ export class CardCreationService {
|
||||
startTime,
|
||||
endTime,
|
||||
this.deps.getConfig().media?.audioPadding,
|
||||
mpvClient.currentAudioStreamIndex ?? undefined,
|
||||
resolveAudioStreamIndexForMediaGeneration(
|
||||
videoPath,
|
||||
mpvClient.currentAudioStreamIndex ?? undefined,
|
||||
),
|
||||
this.deps.getConfig().media?.normalizeAudio !== false,
|
||||
);
|
||||
}
|
||||
|
||||
private async generateImageBuffer(
|
||||
videoPath: string,
|
||||
videoPath: MediaInput,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
animatedLeadInSeconds = 0,
|
||||
|
||||
@@ -1,7 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { resolveMediaGenerationInputPath } from './media-source';
|
||||
import * as mediaSource from './media-source';
|
||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||
|
||||
const { resolveMediaGenerationInputPath } = mediaSource;
|
||||
|
||||
type StructuredMediaInput = {
|
||||
path: string;
|
||||
source: string;
|
||||
singleResolvedStream: boolean;
|
||||
inputOptions?: {
|
||||
reconnect?: boolean;
|
||||
userAgent?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
type StructuredMediaResolver = (
|
||||
mpvClient: Parameters<typeof resolveMediaGenerationInputPath>[0],
|
||||
kind?: Parameters<typeof resolveMediaGenerationInputPath>[1],
|
||||
options?: {
|
||||
getCachedMediaPath?: (
|
||||
currentVideoPath: string,
|
||||
kind: Parameters<typeof resolveMediaGenerationInputPath>[1],
|
||||
) => Promise<string | null>;
|
||||
remoteCacheMode?: 'optional' | 'required';
|
||||
logDebug?: (message: string) => void;
|
||||
},
|
||||
) => Promise<StructuredMediaInput | null>;
|
||||
|
||||
test('resolveMediaGenerationInputPath keeps local file paths', async () => {
|
||||
const result = await resolveMediaGenerationInputPath({
|
||||
@@ -27,9 +54,11 @@ test('resolveMediaGenerationInputPath prefers stream-open-filename for remote me
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video', async () => {
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const edlSource = [
|
||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
||||
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
@@ -48,8 +77,52 @@ test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video
|
||||
'video',
|
||||
);
|
||||
|
||||
assert.equal(audioResult, 'https://audio.example/videoplayback?mime=audio%2Fwebm');
|
||||
assert.equal(videoResult, 'https://video.example/videoplayback?mime=video%2Fmp4');
|
||||
assert.equal(audioResult, audioUrl);
|
||||
assert.equal(videoResult, videoUrl);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInputPath strips mpv edl segment options from unwrapped streams', async () => {
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const signedVideoUrl =
|
||||
'https://rr1---sn.example.googlevideo.com/videoplayback?mime=video%2Fmp4&mn=sn-a,sn-b&lsig=abc%3D';
|
||||
const edlSource = [
|
||||
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(signedVideoUrl)},title=clip,length=73,timestamps=chapters`,
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
const result = await resolveMediaGenerationInputPath(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async () => edlSource,
|
||||
},
|
||||
'video',
|
||||
);
|
||||
|
||||
assert.equal(result, signedVideoUrl);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInputPath ignores length-guarded URLs in mpv edl headers', async () => {
|
||||
const initUrl = 'https://init.example/init.mp4';
|
||||
const audioUrl = 'https://audio.example/stream';
|
||||
const videoUrl = 'https://video.example/stream';
|
||||
const edlSource = [
|
||||
`edl://!mp4_dash,init=${toMpvEdlValue(initUrl)}`,
|
||||
'!new_stream',
|
||||
toMpvEdlValue(audioUrl),
|
||||
'!new_stream',
|
||||
toMpvEdlValue(videoUrl),
|
||||
].join(';');
|
||||
|
||||
const audioResult = await resolveMediaGenerationInputPath(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async () => edlSource,
|
||||
},
|
||||
'audio',
|
||||
);
|
||||
|
||||
assert.equal(audioResult, audioUrl);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInputPath falls back to currentVideoPath when stream-open-filename fails', async () => {
|
||||
@@ -62,3 +135,213 @@ test('resolveMediaGenerationInputPath falls back to currentVideoPath when stream
|
||||
|
||||
assert.equal(result, 'https://www.youtube.com/watch?v=abc123');
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput returns single-stream metadata for mpv EDL URLs', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||
}
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const edlSource = [
|
||||
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||
].join(';');
|
||||
|
||||
const result = await resolver!(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'stream-open-filename') return edlSource;
|
||||
if (name === 'user-agent') return 'Mozilla/5.0';
|
||||
if (name === 'http-header-fields') {
|
||||
return ['Cookie: SID=secret', 'Referer: https://www.youtube.com/', 'X-Test: ok'];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
'audio',
|
||||
);
|
||||
|
||||
assert.equal(result?.path, audioUrl);
|
||||
assert.equal(result?.singleResolvedStream, true);
|
||||
assert.equal(result?.inputOptions?.reconnect, true);
|
||||
assert.equal(result?.inputOptions?.userAgent, 'Mozilla/5.0');
|
||||
assert.deepEqual(result?.inputOptions?.headers, {
|
||||
Referer: 'https://www.youtube.com/',
|
||||
'X-Test': 'ok',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput reads file-local mpv request options', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||
}
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
|
||||
const result = await resolver!(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'stream-open-filename') {
|
||||
return 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123';
|
||||
}
|
||||
if (name === 'file-local-options/user-agent') return 'SubMiner Test Agent';
|
||||
if (name === 'options/http-header-fields') return ['X-Shared: ok'];
|
||||
if (name === 'file-local-options/http-header-fields') {
|
||||
return ['Cookie: SID=secret', 'Referer: https://m.youtube.com/', 'X-Local: yes'];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
'video',
|
||||
);
|
||||
|
||||
assert.equal(result?.path, 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123');
|
||||
assert.equal(result?.singleResolvedStream, true);
|
||||
assert.equal(result?.inputOptions?.userAgent, 'SubMiner Test Agent');
|
||||
assert.deepEqual(result?.inputOptions?.headers, {
|
||||
'X-Shared': 'ok',
|
||||
Referer: 'https://m.youtube.com/',
|
||||
'X-Local': 'yes',
|
||||
Origin: 'https://www.youtube.com',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput prefers a ready cached media file for YouTube extraction', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||
}
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
|
||||
const result = await resolver!(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
|
||||
},
|
||||
'video',
|
||||
{
|
||||
getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/abc123/media.mkv',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result?.path, '/tmp/subminer-youtube-media-cache/abc123/media.mkv');
|
||||
assert.equal(result?.source, 'youtube-cache');
|
||||
assert.equal(result?.singleResolvedStream, false);
|
||||
assert.equal(result?.inputOptions, undefined);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput debug-logs sanitized YouTube cache hits', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||
}
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
const logs: string[] = [];
|
||||
|
||||
const result = await resolver!(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123&signature=secret',
|
||||
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
|
||||
},
|
||||
'video',
|
||||
{
|
||||
getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/abc123/media.mkv',
|
||||
logDebug: (message) => logs.push(message),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result?.source, 'youtube-cache');
|
||||
assert.match(logs.join('\n'), /kind=video source=youtube-cache/);
|
||||
assert.match(
|
||||
logs.join('\n'),
|
||||
/input=local:\/tmp\/subminer-youtube-media-cache\/abc123\/media\.mkv/,
|
||||
);
|
||||
assert.match(logs.join('\n'), /current=remote:www\.youtube\.com/);
|
||||
assert.doesNotMatch(logs.join('\n'), /signature=secret|videoplayback/);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput does not fall back to direct remote streams when cache is required', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||
}
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
|
||||
const result = await resolver!(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
|
||||
},
|
||||
'video',
|
||||
{
|
||||
getCachedMediaPath: async () => null,
|
||||
remoteCacheMode: 'required',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput falls back when optional cache lookup fails', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||
}
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
|
||||
const result = await resolver!(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
|
||||
},
|
||||
'video',
|
||||
{
|
||||
getCachedMediaPath: async () => {
|
||||
throw new Error('cache unavailable');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result?.path, 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123');
|
||||
assert.equal(result?.source, 'stream-open-filename');
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInput debug-logs sanitized required-cache misses', async () => {
|
||||
const resolver = (
|
||||
mediaSource as typeof mediaSource & {
|
||||
resolveMediaGenerationInput?: StructuredMediaResolver;
|
||||
}
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
const logs: string[] = [];
|
||||
|
||||
const result = await resolver!(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123&signature=secret',
|
||||
requestProperty: async () => 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123',
|
||||
},
|
||||
'video',
|
||||
{
|
||||
getCachedMediaPath: async () => null,
|
||||
remoteCacheMode: 'required',
|
||||
logDebug: (message) => logs.push(message),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.match(logs.join('\n'), /kind=video source=cache-miss/);
|
||||
assert.match(logs.join('\n'), /mode=required/);
|
||||
assert.match(logs.join('\n'), /current=remote:www\.youtube\.com/);
|
||||
assert.doesNotMatch(logs.join('\n'), /signature=secret|videoplayback|googlevideo/);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,58 @@
|
||||
import { isRemoteMediaPath } from '../jimaku/utils';
|
||||
import type { MediaInput, MediaInputOptions } from '../media-input';
|
||||
import type { MpvClient } from '../types/runtime';
|
||||
import { extractFileUrlsFromMpvEdlSource } from './mpv-edl';
|
||||
|
||||
export type MediaGenerationKind = 'audio' | 'video';
|
||||
export type MediaGenerationInputSource =
|
||||
| 'current-path'
|
||||
| 'stream-open-filename'
|
||||
| 'edl-stream'
|
||||
| 'youtube-cache';
|
||||
|
||||
export interface ResolvedMediaGenerationInput {
|
||||
path: string;
|
||||
kind: MediaGenerationKind;
|
||||
source: MediaGenerationInputSource;
|
||||
singleResolvedStream: boolean;
|
||||
inputOptions?: MediaInputOptions;
|
||||
}
|
||||
|
||||
export interface MediaGenerationInputResolverOptions {
|
||||
getCachedMediaPath?: (
|
||||
currentVideoPath: string,
|
||||
kind: MediaGenerationKind,
|
||||
) => Promise<string | null>;
|
||||
remoteCacheMode?: 'optional' | 'required';
|
||||
logDebug?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function resolveAudioStreamIndexForMediaGeneration(
|
||||
input: MediaInput,
|
||||
audioStreamIndex: number | null | undefined,
|
||||
): number | undefined {
|
||||
if (typeof input === 'object' && 'source' in input && input.source === 'youtube-cache') {
|
||||
return undefined;
|
||||
}
|
||||
return audioStreamIndex ?? undefined;
|
||||
}
|
||||
|
||||
const BLOCKED_HTTP_HEADER_NAMES = new Set(['authorization', 'cookie', 'proxy-authorization']);
|
||||
const HTTP_HEADER_FIELD_PROPERTY_NAMES = [
|
||||
'http-header-fields',
|
||||
'options/http-header-fields',
|
||||
'file-local-options/http-header-fields',
|
||||
] as const;
|
||||
const USER_AGENT_PROPERTY_NAMES = [
|
||||
'file-local-options/user-agent',
|
||||
'options/user-agent',
|
||||
'user-agent',
|
||||
] as const;
|
||||
const REFERRER_PROPERTY_NAMES = [
|
||||
'file-local-options/referrer',
|
||||
'options/referrer',
|
||||
'referrer',
|
||||
] as const;
|
||||
|
||||
function trimToNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
@@ -11,10 +62,20 @@ function trimToNonEmptyString(value: unknown): string | null {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeHeaderName(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
if (BLOCKED_HTTP_HEADER_NAMES.has(trimmed.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function extractUrlsFromMpvEdlSource(source: string): string[] {
|
||||
const matches = source.matchAll(/%\d+%(https?:\/\/.*?)(?=;!new_stream|;!global_tags|$)/gms);
|
||||
return [...matches]
|
||||
.map((match) => trimToNonEmptyString(match[1]))
|
||||
return extractFileUrlsFromMpvEdlSource(source)
|
||||
.map((value) => trimToNonEmptyString(value))
|
||||
.filter((value): value is string => value !== null);
|
||||
}
|
||||
|
||||
@@ -53,6 +114,317 @@ function resolvePreferredUrlFromMpvEdlSource(
|
||||
return kind === 'audio' ? (urls[0] ?? null) : (urls[urls.length - 1] ?? null);
|
||||
}
|
||||
|
||||
function getHostname(value: string): string | null {
|
||||
try {
|
||||
return new URL(value).hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function matchesHost(hostname: string, expectedHost: string): boolean {
|
||||
return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`);
|
||||
}
|
||||
|
||||
function isGoogleVideoMediaPath(value: string): boolean {
|
||||
const host = getHostname(value);
|
||||
return Boolean(host && matchesHost(host, 'googlevideo.com'));
|
||||
}
|
||||
|
||||
function describeMediaPathForDebugLog(value: string): string {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
||||
return `remote:${url.hostname.toLowerCase() || 'unknown'}`;
|
||||
}
|
||||
return `${url.protocol.replace(/:$/, '')}:`;
|
||||
} catch {
|
||||
// Not a URL; treat as a local file path below.
|
||||
}
|
||||
|
||||
if (value.startsWith('edl://')) {
|
||||
return 'edl:';
|
||||
}
|
||||
|
||||
return `local:${value}`;
|
||||
}
|
||||
|
||||
function logMediaResolutionDebug(
|
||||
options: MediaGenerationInputResolverOptions,
|
||||
message: string,
|
||||
): void {
|
||||
if (!options.logDebug) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
options.logDebug(`[media-source] ${message}`);
|
||||
} catch {
|
||||
// Debug logging should not affect media generation.
|
||||
}
|
||||
}
|
||||
|
||||
function logResolvedMediaGenerationInput(
|
||||
options: MediaGenerationInputResolverOptions,
|
||||
currentVideoPath: string,
|
||||
result: ResolvedMediaGenerationInput,
|
||||
): void {
|
||||
logMediaResolutionDebug(
|
||||
options,
|
||||
[
|
||||
`kind=${result.kind}`,
|
||||
`source=${result.source}`,
|
||||
`input=${describeMediaPathForDebugLog(result.path)}`,
|
||||
`current=${describeMediaPathForDebugLog(currentVideoPath)}`,
|
||||
`singleResolvedStream=${result.singleResolvedStream}`,
|
||||
].join(' '),
|
||||
);
|
||||
}
|
||||
|
||||
function logMediaGenerationInputMiss(
|
||||
options: MediaGenerationInputResolverOptions,
|
||||
kind: MediaGenerationKind,
|
||||
currentVideoPath: string,
|
||||
reason: string,
|
||||
): void {
|
||||
logMediaResolutionDebug(
|
||||
options,
|
||||
[
|
||||
`kind=${kind}`,
|
||||
'source=cache-miss',
|
||||
`reason=${reason}`,
|
||||
`mode=${options.remoteCacheMode ?? 'optional'}`,
|
||||
`current=${describeMediaPathForDebugLog(currentVideoPath)}`,
|
||||
].join(' '),
|
||||
);
|
||||
}
|
||||
|
||||
function setHeaderIfMissing(headers: Record<string, string>, name: string, value: string): void {
|
||||
const lowerName = name.toLowerCase();
|
||||
if (!Object.keys(headers).some((existing) => existing.toLowerCase() === lowerName)) {
|
||||
headers[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function parseMpvHeaderField(value: string): [string, string] | null {
|
||||
const separatorIndex = value.indexOf(':');
|
||||
if (separatorIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = normalizeHeaderName(value.slice(0, separatorIndex));
|
||||
const headerValue = trimToNonEmptyString(value.slice(separatorIndex + 1));
|
||||
if (!name || !headerValue) {
|
||||
return null;
|
||||
}
|
||||
return [name, headerValue.replace(/[\r\n]+/g, ' ')];
|
||||
}
|
||||
|
||||
function toHeaderFields(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry): entry is string => typeof entry === 'string');
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function requestOptionalMpvProperty(
|
||||
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
if (!mpvClient.requestProperty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await mpvClient.requestProperty(name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestFirstNonEmptyStringProperty(
|
||||
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
||||
names: readonly string[],
|
||||
): Promise<string | null> {
|
||||
for (const name of names) {
|
||||
const value = trimToNonEmptyString(await requestOptionalMpvProperty(mpvClient, name));
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveRemoteInputOptions(
|
||||
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
||||
resolvedPath: string,
|
||||
): Promise<MediaInputOptions | undefined> {
|
||||
if (!isRemoteMediaPath(resolvedPath) || !mpvClient.requestProperty) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
for (const propertyName of HTTP_HEADER_FIELD_PROPERTY_NAMES) {
|
||||
const mpvHeaderFields = toHeaderFields(
|
||||
await requestOptionalMpvProperty(mpvClient, propertyName),
|
||||
);
|
||||
for (const field of mpvHeaderFields) {
|
||||
const parsed = parseMpvHeaderField(field);
|
||||
if (parsed) {
|
||||
headers[parsed[0]] = parsed[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userAgent = await requestFirstNonEmptyStringProperty(mpvClient, USER_AGENT_PROPERTY_NAMES);
|
||||
const referrer = await requestFirstNonEmptyStringProperty(mpvClient, REFERRER_PROPERTY_NAMES);
|
||||
if (referrer) {
|
||||
setHeaderIfMissing(headers, 'Referer', referrer);
|
||||
}
|
||||
if (isGoogleVideoMediaPath(resolvedPath)) {
|
||||
setHeaderIfMissing(headers, 'Referer', 'https://www.youtube.com/');
|
||||
setHeaderIfMissing(headers, 'Origin', 'https://www.youtube.com');
|
||||
}
|
||||
|
||||
return {
|
||||
reconnect: true,
|
||||
...(userAgent ? { userAgent } : {}),
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function toResolvedMediaGenerationInput(
|
||||
mpvClient: Pick<MpvClient, 'requestProperty'>,
|
||||
path: string,
|
||||
kind: MediaGenerationKind,
|
||||
source: MediaGenerationInputSource,
|
||||
singleResolvedStream: boolean,
|
||||
): Promise<ResolvedMediaGenerationInput> {
|
||||
const inputOptions = await resolveRemoteInputOptions(mpvClient, path);
|
||||
return {
|
||||
path,
|
||||
kind,
|
||||
source,
|
||||
singleResolvedStream,
|
||||
...(inputOptions ? { inputOptions } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveMediaGenerationInput(
|
||||
mpvClient: Pick<MpvClient, 'currentVideoPath' | 'requestProperty'> | null | undefined,
|
||||
kind: MediaGenerationKind = 'video',
|
||||
options: MediaGenerationInputResolverOptions = {},
|
||||
): Promise<ResolvedMediaGenerationInput | null> {
|
||||
const currentVideoPath = trimToNonEmptyString(mpvClient?.currentVideoPath);
|
||||
if (!currentVideoPath) {
|
||||
logMediaResolutionDebug(options, `kind=${kind} source=none reason=no-current-video`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isRemoteMediaPath(currentVideoPath)) {
|
||||
const result: ResolvedMediaGenerationInput = {
|
||||
path: currentVideoPath,
|
||||
kind,
|
||||
source: 'current-path',
|
||||
singleResolvedStream: false,
|
||||
};
|
||||
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
let cachedPath: string | null = null;
|
||||
if (options.getCachedMediaPath) {
|
||||
try {
|
||||
cachedPath = trimToNonEmptyString(await options.getCachedMediaPath(currentVideoPath, kind));
|
||||
} catch {
|
||||
cachedPath = null;
|
||||
}
|
||||
}
|
||||
if (cachedPath) {
|
||||
const result: ResolvedMediaGenerationInput = {
|
||||
path: cachedPath,
|
||||
kind,
|
||||
source: 'youtube-cache',
|
||||
singleResolvedStream: false,
|
||||
};
|
||||
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (options.remoteCacheMode === 'required') {
|
||||
logMediaGenerationInputMiss(options, kind, currentVideoPath, 'required-cache-unavailable');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!mpvClient?.requestProperty) {
|
||||
const result: ResolvedMediaGenerationInput = {
|
||||
path: currentVideoPath,
|
||||
kind,
|
||||
source: 'current-path',
|
||||
singleResolvedStream: false,
|
||||
};
|
||||
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
const streamOpenFilename = trimToNonEmptyString(
|
||||
await mpvClient.requestProperty('stream-open-filename'),
|
||||
);
|
||||
if (streamOpenFilename?.startsWith('edl://')) {
|
||||
const preferredUrl = resolvePreferredUrlFromMpvEdlSource(streamOpenFilename, kind);
|
||||
if (preferredUrl) {
|
||||
const result = await toResolvedMediaGenerationInput(
|
||||
mpvClient,
|
||||
preferredUrl,
|
||||
kind,
|
||||
'edl-stream',
|
||||
true,
|
||||
);
|
||||
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||
return result;
|
||||
}
|
||||
const result = await toResolvedMediaGenerationInput(
|
||||
mpvClient,
|
||||
streamOpenFilename,
|
||||
kind,
|
||||
'stream-open-filename',
|
||||
false,
|
||||
);
|
||||
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||
return result;
|
||||
}
|
||||
if (streamOpenFilename) {
|
||||
const result = await toResolvedMediaGenerationInput(
|
||||
mpvClient,
|
||||
streamOpenFilename,
|
||||
kind,
|
||||
'stream-open-filename',
|
||||
isRemoteMediaPath(streamOpenFilename),
|
||||
);
|
||||
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the current path when mpv does not expose a resolved stream URL.
|
||||
}
|
||||
|
||||
const result = await toResolvedMediaGenerationInput(
|
||||
mpvClient,
|
||||
currentVideoPath,
|
||||
kind,
|
||||
'current-path',
|
||||
false,
|
||||
);
|
||||
logResolvedMediaGenerationInput(options, currentVideoPath, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function resolveMediaGenerationInputPath(
|
||||
mpvClient: Pick<MpvClient, 'currentVideoPath' | 'requestProperty'> | null | undefined,
|
||||
kind: MediaGenerationKind = 'video',
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function toMpvEdlValue(value: string): string {
|
||||
return `%${Buffer.byteLength(value, 'utf8')}%${value}`;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { extractFileUrlsFromMpvEdlSource } from './mpv-edl';
|
||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||
|
||||
test('extractFileUrlsFromMpvEdlSource honors length-guarded file values', () => {
|
||||
const url =
|
||||
'https://rr1---sn.example.googlevideo.com/videoplayback?mime=video%2Fmp4&mn=sn-a,sn-b&lsig=abc%3D';
|
||||
const source = `edl://!new_stream;${toMpvEdlValue(url)},title=clip,length=73`;
|
||||
|
||||
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [url]);
|
||||
});
|
||||
|
||||
test('extractFileUrlsFromMpvEdlSource reads file parameters', () => {
|
||||
const initUrl = 'https://init.example/init.mp4';
|
||||
const fileUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const source = `edl://!mp4_dash,init=${toMpvEdlValue(initUrl)};file=${toMpvEdlValue(
|
||||
fileUrl,
|
||||
)},length=42`;
|
||||
|
||||
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [fileUrl]);
|
||||
});
|
||||
|
||||
test('extractFileUrlsFromMpvEdlSource aggregates file URLs across entries', () => {
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const source = [
|
||||
'edl://!new_stream',
|
||||
toMpvEdlValue(audioUrl),
|
||||
'!new_stream',
|
||||
`file=${toMpvEdlValue(videoUrl)},length=50`,
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [audioUrl, videoUrl]);
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
const EDL_URI_PREFIX = 'edl://';
|
||||
|
||||
const BYTE_COMMA = ','.charCodeAt(0);
|
||||
const BYTE_CR = '\r'.charCodeAt(0);
|
||||
const BYTE_EQUALS = '='.charCodeAt(0);
|
||||
const BYTE_EXCLAMATION = '!'.charCodeAt(0);
|
||||
const BYTE_LF = '\n'.charCodeAt(0);
|
||||
const BYTE_PERCENT = '%'.charCodeAt(0);
|
||||
const BYTE_SEMICOLON = ';'.charCodeAt(0);
|
||||
|
||||
function isDigitByte(value: number | undefined): value is number {
|
||||
return value !== undefined && value >= 48 && value <= 57;
|
||||
}
|
||||
|
||||
function isEntrySeparator(value: number | undefined): boolean {
|
||||
return value === BYTE_SEMICOLON || value === BYTE_LF || value === BYTE_CR;
|
||||
}
|
||||
|
||||
function isParamSeparator(value: number | undefined): boolean {
|
||||
return value === BYTE_COMMA || isEntrySeparator(value);
|
||||
}
|
||||
|
||||
function decodeBytes(buffer: Buffer, start: number, end: number): string {
|
||||
return buffer.subarray(start, end).toString('utf8');
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value);
|
||||
}
|
||||
|
||||
function toEdlDataBuffer(source: string): Buffer {
|
||||
const data = source.startsWith(EDL_URI_PREFIX) ? source.slice(EDL_URI_PREFIX.length) : source;
|
||||
return Buffer.from(data, 'utf8');
|
||||
}
|
||||
|
||||
function parseLengthGuardedValue(
|
||||
buffer: Buffer,
|
||||
position: number,
|
||||
): { value: string; end: number } | null {
|
||||
if (buffer[position] !== BYTE_PERCENT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cursor = position + 1;
|
||||
if (!isDigitByte(buffer[cursor])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let byteLength = 0;
|
||||
while (true) {
|
||||
const digit = buffer[cursor];
|
||||
if (!isDigitByte(digit)) {
|
||||
break;
|
||||
}
|
||||
byteLength = byteLength * 10 + (digit - 48);
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
if (buffer[cursor] !== BYTE_PERCENT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valueStart = cursor + 1;
|
||||
const valueEnd = valueStart + byteLength;
|
||||
if (valueEnd > buffer.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
value: decodeBytes(buffer, valueStart, valueEnd),
|
||||
end: valueEnd,
|
||||
};
|
||||
}
|
||||
|
||||
function skipEntrySeparators(buffer: Buffer, position: number): number {
|
||||
let cursor = position;
|
||||
while (cursor < buffer.length && isEntrySeparator(buffer[cursor])) {
|
||||
cursor += 1;
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function skipEntry(buffer: Buffer, position: number): number {
|
||||
let cursor = position;
|
||||
while (cursor < buffer.length) {
|
||||
const guardedValue = parseLengthGuardedValue(buffer, cursor);
|
||||
if (guardedValue) {
|
||||
cursor = guardedValue.end;
|
||||
continue;
|
||||
}
|
||||
if (isEntrySeparator(buffer[cursor])) {
|
||||
break;
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function parseRawValue(buffer: Buffer, position: number): { value: string; end: number } {
|
||||
let cursor = position;
|
||||
while (
|
||||
cursor < buffer.length &&
|
||||
!isParamSeparator(buffer[cursor]) &&
|
||||
buffer[cursor] !== BYTE_EXCLAMATION
|
||||
) {
|
||||
cursor += 1;
|
||||
}
|
||||
return {
|
||||
value: decodeBytes(buffer, position, cursor),
|
||||
end: cursor,
|
||||
};
|
||||
}
|
||||
|
||||
function parseParamValue(buffer: Buffer, position: number): { value: string; end: number } {
|
||||
return parseLengthGuardedValue(buffer, position) ?? parseRawValue(buffer, position);
|
||||
}
|
||||
|
||||
function parseOptionalParamName(
|
||||
buffer: Buffer,
|
||||
position: number,
|
||||
): { name: string | null; valueStart: number } {
|
||||
let cursor = position;
|
||||
while (
|
||||
cursor < buffer.length &&
|
||||
!isParamSeparator(buffer[cursor]) &&
|
||||
buffer[cursor] !== BYTE_PERCENT &&
|
||||
buffer[cursor] !== BYTE_EXCLAMATION
|
||||
) {
|
||||
if (buffer[cursor] === BYTE_EQUALS) {
|
||||
return {
|
||||
name: decodeBytes(buffer, position, cursor),
|
||||
valueStart: cursor + 1,
|
||||
};
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
return { name: null, valueStart: position };
|
||||
}
|
||||
|
||||
function parseSegmentEntry(buffer: Buffer, position: number): { urls: string[]; end: number } {
|
||||
const urls: string[] = [];
|
||||
let cursor = position;
|
||||
let unnamedParamIndex = 0;
|
||||
|
||||
while (cursor < buffer.length && !isEntrySeparator(buffer[cursor])) {
|
||||
const { name, valueStart } = parseOptionalParamName(buffer, cursor);
|
||||
const value = parseParamValue(buffer, valueStart);
|
||||
const lowerName = name?.toLowerCase() ?? null;
|
||||
const isFileParam = lowerName === 'file' || (lowerName === null && unnamedParamIndex === 0);
|
||||
|
||||
if (isFileParam && isHttpUrl(value.value)) {
|
||||
urls.push(value.value);
|
||||
}
|
||||
|
||||
if (lowerName === null) {
|
||||
unnamedParamIndex += 1;
|
||||
}
|
||||
|
||||
cursor = value.end;
|
||||
if (buffer[cursor] === BYTE_COMMA) {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
if (!isEntrySeparator(buffer[cursor])) {
|
||||
cursor = skipEntry(buffer, cursor);
|
||||
}
|
||||
}
|
||||
|
||||
return { urls, end: cursor };
|
||||
}
|
||||
|
||||
export function extractFileUrlsFromMpvEdlSource(source: string): string[] {
|
||||
const buffer = toEdlDataBuffer(source);
|
||||
const urls: string[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < buffer.length) {
|
||||
cursor = skipEntrySeparators(buffer, cursor);
|
||||
if (cursor >= buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (buffer[cursor] === BYTE_EXCLAMATION) {
|
||||
cursor = skipEntry(buffer, cursor);
|
||||
continue;
|
||||
}
|
||||
|
||||
const segment = parseSegmentEntry(buffer, cursor);
|
||||
urls.push(...segment.urls);
|
||||
cursor = segment.end;
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
@@ -2,7 +2,10 @@ export interface NoteFieldValueInfo {
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
|
||||
export function getNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: string): string | null {
|
||||
export function getNoteFieldValue(
|
||||
noteInfo: NoteFieldValueInfo,
|
||||
preferredName: string,
|
||||
): string | null {
|
||||
const resolvedFieldName = Object.keys(noteInfo.fields).find(
|
||||
(fieldName) => fieldName.toLowerCase() === preferredName.toLowerCase(),
|
||||
);
|
||||
|
||||
@@ -409,3 +409,59 @@ test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing
|
||||
assert.deepEqual(imageContext, sidebarContext);
|
||||
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' } }]);
|
||||
});
|
||||
|
||||
@@ -85,6 +85,12 @@ export interface NoteUpdateWorkflowDeps {
|
||||
) => Promise<Buffer | null>;
|
||||
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
|
||||
consumeSubtitleMiningContext?: () => SubtitleMiningContext | null;
|
||||
queuePendingYoutubeMediaUpdate?: (job: {
|
||||
noteId: number;
|
||||
noteInfo: NoteUpdateWorkflowNoteInfo;
|
||||
context?: SubtitleMiningContext;
|
||||
label: string | number;
|
||||
}) => Promise<boolean>;
|
||||
addConfiguredTagsToNote: (noteId: number) => Promise<void>;
|
||||
showNotification: (noteId: number, label: string | number) => Promise<void>;
|
||||
showOsdNotification: (message: string) => void;
|
||||
@@ -195,6 +201,7 @@ export class NoteUpdateWorkflow {
|
||||
sentenceField,
|
||||
config.fields?.sentence,
|
||||
);
|
||||
const noteLabel = hasExpressionText ? expressionText : noteId;
|
||||
|
||||
const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
|
||||
if (sentenceField && currentSubtitleText) {
|
||||
@@ -227,7 +234,19 @@ export class NoteUpdateWorkflow {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const audioFilename = this.deps.generateAudioFilename();
|
||||
const audioBuffer = await this.deps.generateAudio(subtitleMiningContext ?? undefined);
|
||||
@@ -252,7 +271,7 @@ export class NoteUpdateWorkflow {
|
||||
}
|
||||
}
|
||||
|
||||
if (config.media?.generateImage) {
|
||||
if (!mediaCacheQueued && generateImage) {
|
||||
try {
|
||||
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
|
||||
const imageFilename = this.deps.generateImageFilename();
|
||||
@@ -287,7 +306,7 @@ export class NoteUpdateWorkflow {
|
||||
}
|
||||
}
|
||||
|
||||
if (config.fields?.miscInfo) {
|
||||
if (!mediaCacheQueued && config.fields?.miscInfo) {
|
||||
const miscInfo = this.deps.formatMiscInfoPattern(
|
||||
miscInfoFilename || '',
|
||||
subtitleMiningContext?.startTime ?? this.deps.getCurrentSubtitleStart(),
|
||||
@@ -305,8 +324,8 @@ export class NoteUpdateWorkflow {
|
||||
if (updatePerformed) {
|
||||
await this.deps.client.updateNoteFields(noteId, updatedFields);
|
||||
await this.deps.addConfiguredTagsToNote(noteId);
|
||||
this.deps.logInfo('Updated card fields for:', hasExpressionText ? expressionText : noteId);
|
||||
await this.deps.showNotification(noteId, hasExpressionText ? expressionText : noteId);
|
||||
this.deps.logInfo('Updated card fields for:', noteLabel);
|
||||
await this.deps.showNotification(noteId, noteLabel);
|
||||
}
|
||||
|
||||
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,392 @@
|
||||
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,
|
||||
config.media?.normalizeAudio !== false,
|
||||
);
|
||||
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);
|
||||
}
|
||||
@@ -80,6 +80,8 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal('remoteControlDeviceName' in config.jellyfin, false);
|
||||
assert.equal('deviceId' 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.apiKeyCommand, '');
|
||||
assert.equal(config.texthooker.openBrowser, false);
|
||||
@@ -90,6 +92,7 @@ test('loads defaults when config is missing', () => {
|
||||
model: '',
|
||||
systemPrompt: '',
|
||||
});
|
||||
assert.equal(config.ankiConnect.media.normalizeAudio, true);
|
||||
assert.equal(config.startupWarmups.lowPowerMode, false);
|
||||
assert.equal(config.startupWarmups.mecab, true);
|
||||
assert.equal(config.startupWarmups.yomitanExtension, true);
|
||||
@@ -1750,6 +1753,56 @@ test('parses global shortcuts and startup settings', () => {
|
||||
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', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -111,6 +111,10 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
},
|
||||
youtube: {
|
||||
primarySubLanguages: ['ja', 'jpn'],
|
||||
mediaCache: {
|
||||
mode: 'direct',
|
||||
maxHeight: 720,
|
||||
},
|
||||
},
|
||||
subsync: {
|
||||
alass_path: '',
|
||||
|
||||
@@ -51,6 +51,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
animatedMaxHeight: 0,
|
||||
animatedCrf: 35,
|
||||
syncAnimatedImageToWordAudio: true,
|
||||
normalizeAudio: true,
|
||||
audioPadding: 0,
|
||||
fallbackDuration: 3.0,
|
||||
maxMediaDuration: 30,
|
||||
|
||||
@@ -110,6 +110,7 @@ test('config option registry includes critical paths and has unique entries', ()
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||
'ankiConnect.enabled',
|
||||
'subtitleStyle.nameMatchEnabled',
|
||||
'ankiConnect.media.normalizeAudio',
|
||||
'anilist.characterDictionary.collapsibleSections.description',
|
||||
'mpv.executablePath',
|
||||
'mpv.launchMode',
|
||||
|
||||
@@ -119,6 +119,24 @@ export function buildCoreConfigOptionRegistry(
|
||||
description:
|
||||
'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',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -181,6 +181,12 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.ankiConnect.media.generateAudio,
|
||||
description: 'Generate sentence audio for mined cards.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.normalizeAudio',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.media.normalizeAudio,
|
||||
description: 'Normalize generated sentence audio loudness during media extraction.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.generateImage',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface ConfigOptionRegistryEntry {
|
||||
* `osd` and `osd-system` in NOTIFICATION_TYPE_VALUES.
|
||||
*/
|
||||
enumValues?: readonly string[];
|
||||
enumLabels?: Record<string, string>;
|
||||
/**
|
||||
* Optional settings UI subset when legacy/runtime-valid enum options should remain
|
||||
* editable in config files but hidden from new UI choices, for example
|
||||
|
||||
@@ -297,6 +297,39 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
'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)) {
|
||||
|
||||
@@ -165,6 +165,20 @@ test('settings registry exposes specialized controls for config-assisted inputs'
|
||||
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', () => {
|
||||
const primaryVisible = fields
|
||||
.filter(
|
||||
|
||||
@@ -720,6 +720,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
|
||||
...(option?.settingsEnumValues || option?.enumValues
|
||||
? { enumValues: option.settingsEnumValues ?? option.enumValues }
|
||||
: {}),
|
||||
...(option?.enumLabels ? { enumLabels: option.enumLabels } : {}),
|
||||
restartBehavior: restartBehaviorForPath(leaf.path),
|
||||
advanced:
|
||||
leaf.path.startsWith('controller.') ||
|
||||
|
||||
@@ -40,6 +40,12 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
||||
getAnkiIntegration: () => AnkiIntegration | null;
|
||||
setAnkiIntegration: (integration: AnkiIntegration | null) => void;
|
||||
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;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
@@ -107,6 +113,9 @@ export function registerAnkiJimakuIpcRuntime(
|
||||
mergeAiConfig(config.ai, config.ankiConnect?.ai) as AiConfig,
|
||||
undefined,
|
||||
options.showOverlayNotification,
|
||||
options.getCachedMediaPath,
|
||||
options.shouldRequireRemoteMediaCache,
|
||||
options.getYoutubeMediaSourceUrl,
|
||||
);
|
||||
integration.start();
|
||||
options.setAnkiIntegration(integration);
|
||||
|
||||
@@ -25,6 +25,12 @@ type CreateAnkiIntegrationArgs = {
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
knownWordCacheStatePath: string;
|
||||
getCachedMediaPath?: (
|
||||
currentVideoPath: string,
|
||||
kind: 'audio' | 'video',
|
||||
) => Promise<string | null>;
|
||||
shouldRequireRemoteMediaCache?: () => boolean;
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||
};
|
||||
|
||||
export type OverlayWindowTrackerOptions = {
|
||||
@@ -65,6 +71,9 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
|
||||
args.aiConfig,
|
||||
undefined,
|
||||
args.showOverlayNotification,
|
||||
args.getCachedMediaPath,
|
||||
args.shouldRequireRemoteMediaCache,
|
||||
args.getYoutubeMediaSourceUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,6 +141,12 @@ export function initializeOverlayRuntime(
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
getCachedMediaPath?: (
|
||||
currentVideoPath: string,
|
||||
kind: 'audio' | 'video',
|
||||
) => Promise<string | null>;
|
||||
shouldRequireRemoteMediaCache?: () => boolean;
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||
shouldStartAnkiIntegration?: () => boolean;
|
||||
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||
backendOverride: string | null;
|
||||
@@ -166,6 +181,12 @@ export function initializeOverlayAnkiIntegration(options: {
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
getCachedMediaPath?: (
|
||||
currentVideoPath: string,
|
||||
kind: 'audio' | 'video',
|
||||
) => Promise<string | null>;
|
||||
shouldRequireRemoteMediaCache?: () => boolean;
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||
shouldStartAnkiIntegration?: () => boolean;
|
||||
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||
}): boolean {
|
||||
@@ -200,6 +221,13 @@ export function initializeOverlayAnkiIntegration(options: {
|
||||
showOverlayNotification: options.showOverlayNotification,
|
||||
createFieldGroupingCallback: options.createFieldGroupingCallback,
|
||||
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
|
||||
...(options.getCachedMediaPath ? { getCachedMediaPath: options.getCachedMediaPath } : {}),
|
||||
...(options.shouldRequireRemoteMediaCache
|
||||
? { shouldRequireRemoteMediaCache: options.shouldRequireRemoteMediaCache }
|
||||
: {}),
|
||||
...(options.getYoutubeMediaSourceUrl
|
||||
? { getYoutubeMediaSourceUrl: options.getYoutubeMediaSourceUrl }
|
||||
: {}),
|
||||
});
|
||||
if (options.shouldStartAnkiIntegration?.() !== false) {
|
||||
integration.start();
|
||||
|
||||
@@ -1206,6 +1206,7 @@ export function createStatsApp(
|
||||
const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator();
|
||||
|
||||
const audioPadding = ankiConfig.media?.audioPadding ?? 0;
|
||||
const normalizeAudio = ankiConfig.media?.normalizeAudio !== false;
|
||||
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
|
||||
|
||||
const startSec = startMs / 1000;
|
||||
@@ -1228,7 +1229,14 @@ export function createStatsApp(
|
||||
|
||||
const audioPromise = generateAudio
|
||||
? timeMiningPhase(mode, 'generateAudio', () =>
|
||||
mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding),
|
||||
mediaGen.generateAudio(
|
||||
sourcePath,
|
||||
startSec,
|
||||
clampedEndSec,
|
||||
audioPadding,
|
||||
null,
|
||||
normalizeAudio,
|
||||
),
|
||||
)
|
||||
: Promise.resolve(null);
|
||||
|
||||
|
||||
@@ -123,6 +123,48 @@ test('annotateTokens falls back to reading for known-word matches when headword
|
||||
assert.equal(result[0]?.frequencyRank, 1895);
|
||||
});
|
||||
|
||||
test('annotateTokens ignores partial furigana readings for known-word fallback', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: '待ち合わせてる',
|
||||
headword: '待ち合わせる',
|
||||
reading: 'まあ',
|
||||
partOfSpeech: PartOfSpeech.verb,
|
||||
endPos: 7,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === 'まあ',
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
});
|
||||
|
||||
test('annotateTokens reading fallback still matches kana surfaces with complete readings', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'ください',
|
||||
headword: '下さい',
|
||||
reading: 'ください',
|
||||
partOfSpeech: PartOfSpeech.verb,
|
||||
endPos: 4,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === 'ください',
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
});
|
||||
|
||||
test('annotateTokens excludes frequency for particle/bound_auxiliary and pos1 exclusions', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
|
||||
@@ -635,6 +635,32 @@ export function stripSubtitleAnnotationMetadata(
|
||||
return sharedStripSubtitleAnnotationMetadata(token, options);
|
||||
}
|
||||
|
||||
// Furigana-derived readings can be partial (kanji readings only, e.g. まあ for
|
||||
// 待ち合わせてる); matching those against known words produces false positives,
|
||||
// so the reading fallback requires a reading that plausibly covers the surface:
|
||||
// at least as many characters as the surface, with the surface's kana appearing
|
||||
// in order within the reading.
|
||||
function isCompleteReadingForSurface(surface: string, reading: string): boolean {
|
||||
const surfaceChars = [...normalizeJlptTextForExclusion(surface)];
|
||||
const readingChars = [...normalizeJlptTextForExclusion(reading)];
|
||||
if (readingChars.length < surfaceChars.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let cursor = 0;
|
||||
for (const char of surfaceChars) {
|
||||
if (!isKanaChar(char)) {
|
||||
continue;
|
||||
}
|
||||
const foundAt = readingChars.indexOf(char, cursor);
|
||||
if (foundAt === -1) {
|
||||
return false;
|
||||
}
|
||||
cursor = foundAt + 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function computeTokenKnownStatus(
|
||||
token: MergedToken,
|
||||
isKnownWord: (text: string) => boolean,
|
||||
@@ -650,6 +676,10 @@ function computeTokenKnownStatus(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isCompleteReadingForSurface(token.surface, normalizedReading)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalizedReading !== matchText.trim() && isKnownWord(normalizedReading);
|
||||
}
|
||||
|
||||
|
||||
@@ -964,7 +964,7 @@ test('requestYomitanScanTokens extracts best frequency rank from selected termsF
|
||||
assert.deepEqual(result, [
|
||||
{
|
||||
surface: '潜み',
|
||||
reading: 'ひそ',
|
||||
reading: 'ひそみ',
|
||||
headword: '潜む',
|
||||
startPos: 0,
|
||||
endPos: 2,
|
||||
@@ -974,6 +974,72 @@ test('requestYomitanScanTokens extracts best frequency rank from selected termsF
|
||||
]);
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens emits complete readings for kanji-kana compounds', async () => {
|
||||
let scannerScript = '';
|
||||
const deps = createDeps(async (script) => {
|
||||
if (script.includes('termsFind')) {
|
||||
scannerScript = script;
|
||||
return [];
|
||||
}
|
||||
if (script.includes('optionsGetFull')) {
|
||||
return {
|
||||
profileCurrent: 0,
|
||||
profiles: [
|
||||
{
|
||||
options: {
|
||||
scanning: { length: 40 },
|
||||
dictionaries: [{ name: 'JPDBv2㋕', enabled: true, id: 0 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await requestYomitanScanTokens('待ち合わせてる', deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
|
||||
const result = await runInjectedYomitanScript(scannerScript, (action, params) => {
|
||||
if (action !== 'termsFind') {
|
||||
throw new Error(`unexpected action: ${action}`);
|
||||
}
|
||||
|
||||
const text = (params as { text?: string } | undefined)?.text ?? '';
|
||||
if (!text.startsWith('待ち合わせてる')) {
|
||||
return { originalTextLength: 0, dictionaryEntries: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
originalTextLength: 7,
|
||||
dictionaryEntries: [
|
||||
{
|
||||
headwords: [
|
||||
{
|
||||
term: '待ち合わせる',
|
||||
reading: 'まちあわせる',
|
||||
sources: [{ originalText: '待ち合わせてる', isPrimary: true, matchType: 'exact' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
assert.deepEqual(result, [
|
||||
{
|
||||
surface: '待ち合わせてる',
|
||||
reading: 'まちあわせてる',
|
||||
headword: '待ち合わせる',
|
||||
startPos: 0,
|
||||
endPos: 7,
|
||||
isNameMatch: false,
|
||||
frequencyRank: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens uses frequency from later exact-match entry when first exact entry has none', async () => {
|
||||
let scannerScript = '';
|
||||
const deps = createDeps(async (script) => {
|
||||
|
||||
@@ -817,6 +817,12 @@ const YOMITAN_SCANNING_HELPERS = String.raw`
|
||||
function isCodePointKana(codePoint) { return isCodePointInRanges(codePoint, KANA_RANGES); }
|
||||
function isCodePointJapanese(codePoint) { return isCodePointInRanges(codePoint, JAPANESE_RANGES); }
|
||||
function createFuriganaSegment(text, reading) { return {text, reading}; }
|
||||
function getSegmentReadingContribution(segment) {
|
||||
if (typeof segment.reading === "string" && segment.reading.length > 0) { return segment.reading; }
|
||||
const segmentText = typeof segment.text === "string" ? segment.text : "";
|
||||
const isKanaOnly = segmentText.length > 0 && [...segmentText].every((char) => isCodePointKana(char.codePointAt(0)));
|
||||
return isKanaOnly ? segmentText : "";
|
||||
}
|
||||
function getProlongedHiragana(previousCharacter) {
|
||||
switch (previousCharacter) {
|
||||
case "あ": case "か": case "が": case "さ": case "ざ": case "た": case "だ": case "な": case "は": case "ば": case "ぱ": case "ま": case "や": case "ら": case "わ": case "ぁ": case "ゃ": case "ゎ": return "あ";
|
||||
@@ -1310,7 +1316,7 @@ ${YOMITAN_SCANNING_HELPERS}
|
||||
const segments = distributeFuriganaInflected(preferredHeadword.term, reading, source);
|
||||
const tokenPayload = {
|
||||
surface: segments.map((segment) => segment.text).join("") || source,
|
||||
reading: segments.map((segment) => typeof segment.reading === "string" ? segment.reading : "").join(""),
|
||||
reading: segments.map(getSegmentReadingContribution).join(""),
|
||||
headword: preferredHeadword.term,
|
||||
startPos: i,
|
||||
endPos: i + originalTextLength,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+4
-3
@@ -2,9 +2,10 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { resolveDefaultLogFilePath, setLogRotation } from './logger';
|
||||
import { localDateKey } from './shared/log-files';
|
||||
|
||||
test('resolveDefaultLogFilePath uses APPDATA on windows', () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = localDateKey(new Date());
|
||||
const resolved = resolveDefaultLogFilePath({
|
||||
platform: 'win32',
|
||||
homeDir: 'C:\\Users\\tester',
|
||||
@@ -20,7 +21,7 @@ test('resolveDefaultLogFilePath uses APPDATA on windows', () => {
|
||||
});
|
||||
|
||||
test('resolveDefaultLogFilePath uses .config on linux', () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = localDateKey(new Date());
|
||||
const resolved = resolveDefaultLogFilePath({
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
@@ -34,7 +35,7 @@ test('resolveDefaultLogFilePath uses .config on linux', () => {
|
||||
|
||||
test('setLogRotation accepts numeric retention days', () => {
|
||||
const previous = process.env.SUBMINER_LOG_ROTATION;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = localDateKey(new Date());
|
||||
setLogRotation(14);
|
||||
try {
|
||||
const resolved = resolveDefaultLogFilePath({
|
||||
|
||||
+134
-4
@@ -332,6 +332,7 @@ import {
|
||||
acquireYoutubeSubtitleTrack,
|
||||
acquireYoutubeSubtitleTracks,
|
||||
} from './core/services/youtube/generate';
|
||||
import { createYoutubeMediaCacheService } from './core/services/youtube/media-cache';
|
||||
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
|
||||
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
||||
import {
|
||||
@@ -347,6 +348,7 @@ import {
|
||||
shouldAutoOpenFirstRunSetup,
|
||||
} from './main/runtime/first-run-setup-service';
|
||||
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 {
|
||||
clearYoutubePrimarySubtitleNotificationTimer,
|
||||
@@ -532,7 +534,6 @@ import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './main/runti
|
||||
import {
|
||||
isSameYoutubeMediaPath,
|
||||
isYoutubeMediaPath,
|
||||
isYoutubePlaybackActive,
|
||||
shouldUseCachedYoutubeParsedCues,
|
||||
} from './main/runtime/youtube-playback';
|
||||
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
|
||||
@@ -1172,6 +1173,65 @@ const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({
|
||||
},
|
||||
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({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
now: () => Date.now(),
|
||||
@@ -1316,6 +1376,12 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
|
||||
},
|
||||
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
|
||||
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
|
||||
startYoutubeMediaCache: (url) => {
|
||||
youtubeMediaCache.start(url, {
|
||||
mode: getResolvedConfig().youtube.mediaCache.mode,
|
||||
maxHeight: getResolvedConfig().youtube.mediaCache.maxHeight,
|
||||
});
|
||||
},
|
||||
runYoutubePlaybackFlow: (request) => youtubeFlowRuntime.runYoutubePlaybackFlow(request),
|
||||
logInfo: (message) => logger.info(message),
|
||||
logWarn: (message) => logger.warn(message),
|
||||
@@ -1629,9 +1695,61 @@ const youtubePrimarySubtitleNotificationRuntime = createYoutubePrimarySubtitleNo
|
||||
});
|
||||
|
||||
function isYoutubePlaybackActiveNow(): boolean {
|
||||
return isYoutubePlaybackActive(
|
||||
appState.currentMediaPath,
|
||||
appState.mpvClient?.currentVideoPath ?? null,
|
||||
return Boolean(getCurrentYoutubeMediaCacheSourceUrlSnapshot());
|
||||
}
|
||||
|
||||
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())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2636,6 +2754,7 @@ const overlayNotificationsRuntime = createOverlayNotificationsRuntime({
|
||||
});
|
||||
const {
|
||||
flushQueuedOverlayNotifications,
|
||||
flushQueuedMpvOsdNotifications,
|
||||
openAnkiCardFromNotification,
|
||||
toggleNotificationHistoryPanel,
|
||||
showConfiguredPlaybackFeedback,
|
||||
@@ -3801,6 +3920,7 @@ const {
|
||||
},
|
||||
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
|
||||
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
|
||||
cleanupYoutubeMediaCache: () => youtubeMediaCache.cleanup(),
|
||||
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
|
||||
stopDiscordPresenceService: () => {
|
||||
void appState.discordPresenceService?.stop();
|
||||
@@ -4286,6 +4406,7 @@ const {
|
||||
},
|
||||
onMpvConnected: () => {
|
||||
maybeStartOverlayLoadingOsd();
|
||||
flushQueuedMpvOsdNotifications();
|
||||
if (appState.sessionBindingsInitialized) {
|
||||
sendMpvCommandRuntime(appState.mpvClient, [
|
||||
'script-message',
|
||||
@@ -4361,6 +4482,7 @@ const {
|
||||
subtitlePrefetchRuntime.cancelPendingInit();
|
||||
}
|
||||
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
|
||||
void youtubeMediaCachePlaybackRuntime.handleMediaPathChange(path);
|
||||
if (path) {
|
||||
ensureImmersionTrackerStarted();
|
||||
void subtitlePrefetchRuntime.refreshSubtitlePrefetchFromActiveTrack();
|
||||
@@ -5731,6 +5853,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
);
|
||||
},
|
||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||
getCachedMediaPath: (currentVideoPath, kind) =>
|
||||
getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind),
|
||||
shouldRequireRemoteMediaCache: () => shouldRequireYoutubeMediaCacheForCurrentPlayback(),
|
||||
getYoutubeMediaSourceUrl: () => getCurrentYoutubeMediaCacheSourceUrl(),
|
||||
showDesktopNotification,
|
||||
showOverlayNotification,
|
||||
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
||||
@@ -6207,6 +6333,10 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
showOverlayNotification,
|
||||
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||
getCachedMediaPath: (currentVideoPath, kind) =>
|
||||
getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind),
|
||||
shouldRequireRemoteMediaCache: () => shouldRequireYoutubeMediaCacheForCurrentPlayback(),
|
||||
getYoutubeMediaSourceUrl: () => getCurrentYoutubeMediaCacheSourceUrl(),
|
||||
shouldStartAnkiIntegration: () =>
|
||||
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
||||
},
|
||||
|
||||
@@ -126,6 +126,9 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams {
|
||||
getAnkiIntegration: AnkiJimakuIpcRuntimeOptions['getAnkiIntegration'];
|
||||
setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration'];
|
||||
getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath'];
|
||||
getCachedMediaPath?: AnkiJimakuIpcRuntimeOptions['getCachedMediaPath'];
|
||||
shouldRequireRemoteMediaCache?: AnkiJimakuIpcRuntimeOptions['shouldRequireRemoteMediaCache'];
|
||||
getYoutubeMediaSourceUrl?: AnkiJimakuIpcRuntimeOptions['getYoutubeMediaSourceUrl'];
|
||||
showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification'];
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback'];
|
||||
@@ -317,6 +320,13 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
|
||||
getAnkiIntegration: params.getAnkiIntegration,
|
||||
setAnkiIntegration: params.setAnkiIntegration,
|
||||
getKnownWordCacheStatePath: params.getKnownWordCacheStatePath,
|
||||
...(params.getCachedMediaPath ? { getCachedMediaPath: params.getCachedMediaPath } : {}),
|
||||
...(params.shouldRequireRemoteMediaCache
|
||||
? { shouldRequireRemoteMediaCache: params.shouldRequireRemoteMediaCache }
|
||||
: {}),
|
||||
...(params.getYoutubeMediaSourceUrl
|
||||
? { getYoutubeMediaSourceUrl: params.getYoutubeMediaSourceUrl }
|
||||
: {}),
|
||||
showDesktopNotification: params.showDesktopNotification,
|
||||
showOverlayNotification: params.showOverlayNotification,
|
||||
createFieldGroupingCallback: params.createFieldGroupingCallback,
|
||||
|
||||
@@ -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', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
@@ -514,6 +524,48 @@ test('configured overlay notifications require visible ready overlay window', ()
|
||||
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', () => {
|
||||
const source = readMainSource();
|
||||
const setBlock = source.match(
|
||||
|
||||
@@ -41,18 +41,20 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 32);
|
||||
assert.equal(calls.length, 33);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
|
||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||
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'));
|
||||
});
|
||||
|
||||
@@ -92,6 +94,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
|
||||
throw new Error('stop failed');
|
||||
},
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
clearYomitanSettingsWindow: () => void;
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||
cleanupYoutubeMediaCache: () => void;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
@@ -67,6 +68,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.cleanupJellyfinSubtitleCache();
|
||||
}
|
||||
deps.cleanupYoutubeSubtitleTempDirs();
|
||||
deps.cleanupYoutubeMediaCache();
|
||||
deps.stopDiscordPresenceService();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
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('stop-jellyfin-remote'));
|
||||
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('stop-discord-presence'));
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
|
||||
@@ -147,6 +149,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupYoutubeMediaCache: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
@@ -197,6 +200,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupYoutubeMediaCache: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
@@ -58,6 +58,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||
cleanupYoutubeMediaCache: () => void;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
@@ -142,6 +143,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
|
||||
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
|
||||
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
|
||||
cleanupYoutubeMediaCache: () => deps.cleanupYoutubeMediaCache(),
|
||||
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
|
||||
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
|
||||
});
|
||||
|
||||
@@ -49,6 +49,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: async () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupYoutubeMediaCache: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
getPlaybackFeedbackNotificationOptions,
|
||||
getYoutubeFlowStatusNotificationOptions,
|
||||
notifyConfiguredStatus,
|
||||
} from './configured-status-notification';
|
||||
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[] = [];
|
||||
|
||||
notifyConfiguredStatus('Overlay loading...', {
|
||||
@@ -43,10 +44,10 @@ test('notifyConfiguredStatus falls back to desktop for pre-overlay both status',
|
||||
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[] = [];
|
||||
|
||||
notifyConfiguredStatus('Overlay loading...', {
|
||||
@@ -61,7 +62,7 @@ test('notifyConfiguredStatus falls back to desktop for pre-overlay overlay-only
|
||||
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', () => {
|
||||
@@ -97,6 +98,37 @@ test('notifyConfiguredStatus keeps osd-system on legacy surfaces', () => {
|
||||
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', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -195,6 +227,36 @@ test('playback feedback options reuse subtitle mode notification ids', () => {
|
||||
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', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface ConfiguredStatusNotificationDeps {
|
||||
getNotificationType: () => NotificationType | undefined;
|
||||
isOverlayReady?: () => boolean;
|
||||
showOsd: (message: string) => boolean | void;
|
||||
queueOsd?: (message: string, options: ConfiguredStatusNotificationOptions) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||
}
|
||||
@@ -30,6 +31,25 @@ export function getPlaybackFeedbackNotificationOptions(
|
||||
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(
|
||||
message: string,
|
||||
deps: ConfiguredStatusNotificationDeps,
|
||||
@@ -50,8 +70,7 @@ export function notifyConfiguredStatus(
|
||||
}
|
||||
|
||||
if (showOverlay) {
|
||||
const overlayReady = deps.isOverlayReady?.() ?? true;
|
||||
if (deps.showOverlayNotification && overlayReady) {
|
||||
if (deps.showOverlayNotification) {
|
||||
deps.showOverlayNotification({
|
||||
id: options.id,
|
||||
title: options.title ?? 'SubMiner',
|
||||
@@ -65,7 +84,10 @@ export function notifyConfiguredStatus(
|
||||
}
|
||||
|
||||
if (showOsd) {
|
||||
deps.showOsd(message);
|
||||
const shown = deps.showOsd(message);
|
||||
if (shown === false && delivery !== 'feedback') {
|
||||
deps.queueOsd?.(message, options);
|
||||
}
|
||||
}
|
||||
|
||||
if (desktopEnabled && shouldShowDesktop(type)) {
|
||||
|
||||
@@ -14,6 +14,20 @@ function cleanupDir(dirPath: string): void {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function withTimeZone<T>(timeZone: string, run: () => T): T {
|
||||
const previous = process.env.TZ;
|
||||
process.env.TZ = timeZone;
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.TZ;
|
||||
} else {
|
||||
process.env.TZ = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeLog(logsDir: string, name: string, content: string, mtime: string): string {
|
||||
const logPath = path.join(logsDir, name);
|
||||
fs.writeFileSync(logPath, content, 'utf8');
|
||||
@@ -67,6 +81,80 @@ test('maskUsernamesInLogText redacts linux macOS and Windows home paths', () =>
|
||||
assert.doesNotMatch(masked, /kyle/);
|
||||
});
|
||||
|
||||
test('maskUsernamesInLogText redacts IP addresses and emails', () => {
|
||||
const masked = maskUsernamesInLogText(
|
||||
[
|
||||
'ffmpeg failed after request from public ip 203.0.113.42',
|
||||
'connect tcp 192.168.1.25:443: i/o timeout',
|
||||
'remote addr [2001:db8::1234]:443',
|
||||
'support email kyle@example.test',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.match(masked, /public ip <ip>/);
|
||||
assert.match(masked, /tcp <ip>:443/);
|
||||
assert.match(masked, /remote addr \[<ip>\]:443/);
|
||||
assert.match(masked, /support email <email>/);
|
||||
assert.doesNotMatch(masked, /203\.0\.113\.42/);
|
||||
assert.doesNotMatch(masked, /192\.168\.1\.25/);
|
||||
assert.doesNotMatch(masked, /2001:db8::1234/);
|
||||
assert.doesNotMatch(masked, /kyle@example\.test/);
|
||||
});
|
||||
|
||||
test('maskUsernamesInLogText redacts headers and yt-dlp cookie arguments', () => {
|
||||
const masked = maskUsernamesInLogText(
|
||||
[
|
||||
'Authorization: Bearer ya29.secret-token',
|
||||
'Cookie: SID=session-value; HSID=history-value',
|
||||
'Set-Cookie: VISITOR_INFO1_LIVE=visitor; Path=/',
|
||||
'x-goog-visitor-id: Cgt2aXNpdG9y',
|
||||
'warn yt-dlp header Authorization: Bearer inline-token',
|
||||
'yt-dlp --cookies /Users/kyle/cookies.txt --cookies-from-browser chrome:Profile 1',
|
||||
'yt-dlp --cookies=/tmp/ytdlp-cookies.txt --cookies-from-browser=firefox:default',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.match(masked, /Authorization: <redacted>/);
|
||||
assert.match(masked, /Cookie: <redacted>/);
|
||||
assert.match(masked, /Set-Cookie: <redacted>/);
|
||||
assert.match(masked, /x-goog-visitor-id: <redacted>/i);
|
||||
assert.match(masked, /--cookies <redacted>/);
|
||||
assert.match(masked, /--cookies=<redacted>/);
|
||||
assert.match(masked, /--cookies-from-browser <redacted>/);
|
||||
assert.match(masked, /--cookies-from-browser=<redacted>/);
|
||||
assert.doesNotMatch(masked, /ya29/);
|
||||
assert.doesNotMatch(masked, /inline-token/);
|
||||
assert.doesNotMatch(masked, /session-value/);
|
||||
assert.doesNotMatch(masked, /cookies\.txt/);
|
||||
assert.doesNotMatch(masked, /firefox:default/);
|
||||
});
|
||||
|
||||
test('maskUsernamesInLogText redacts URL credentials and sensitive query values', () => {
|
||||
const masked = maskUsernamesInLogText(
|
||||
[
|
||||
'GET https://alice:secret@example.test/watch?v=abc&access_token=tok123&api_key=key456',
|
||||
'stream https://video.example.test/file.m3u8?signature=sig789&expire=1777777777',
|
||||
'callback subminer://anilist-setup?access_token=ani-token&state=ok',
|
||||
'json {"password":"hunter2","refreshToken":"refresh-token","client_secret":"client-secret"}',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.match(masked, /https:\/\/<credentials>@example\.test/);
|
||||
assert.match(masked, /access_token=<redacted>/);
|
||||
assert.match(masked, /api_key=<redacted>/);
|
||||
assert.match(masked, /signature=<redacted>/);
|
||||
assert.match(masked, /"password":"<redacted>"/);
|
||||
assert.match(masked, /"refreshToken":"<redacted>"/);
|
||||
assert.match(masked, /"client_secret":"<redacted>"/);
|
||||
assert.doesNotMatch(masked, /alice:secret/);
|
||||
assert.doesNotMatch(masked, /tok123/);
|
||||
assert.doesNotMatch(masked, /key456/);
|
||||
assert.doesNotMatch(masked, /sig789/);
|
||||
assert.doesNotMatch(masked, /ani-token/);
|
||||
assert.doesNotMatch(masked, /hunter2/);
|
||||
assert.match(masked, /state=ok/);
|
||||
});
|
||||
|
||||
test('exportLogsArchive exports current-day logs and masks usernames', () => {
|
||||
const root = makeTempDir();
|
||||
const logsDir = path.join(root, 'logs');
|
||||
@@ -152,6 +240,43 @@ test('exportLogsArchive ignores older dated logs when current-day dated logs exi
|
||||
}
|
||||
});
|
||||
|
||||
test('exportLogsArchive ranks dated fallback logs by local day freshness', () => {
|
||||
withTimeZone('America/Los_Angeles', () => {
|
||||
const root = makeTempDir();
|
||||
const logsDir = path.join(root, 'logs');
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const datedLog = writeLog(
|
||||
logsDir,
|
||||
'app-2026-05-25.log',
|
||||
'dated local-day log\n',
|
||||
'2026-05-25T12:00:00Z',
|
||||
);
|
||||
writeLog(
|
||||
logsDir,
|
||||
'app-2026-05-undated.log',
|
||||
'undated touched before local midnight\n',
|
||||
'2026-05-26T01:00:00Z',
|
||||
);
|
||||
|
||||
const result = exportLogsArchive({
|
||||
logsDir,
|
||||
outputDir: root,
|
||||
now: new Date('2026-05-27T16:00:00.000Z'),
|
||||
});
|
||||
|
||||
assert.equal(result.mode, 'most-recent');
|
||||
assert.deepEqual(result.exportedFiles, [datedLog]);
|
||||
|
||||
const entries = readStoredZipEntries(result.zipPath);
|
||||
assert.deepEqual([...entries.keys()], ['logs/app-2026-05-25.log']);
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('exportLogsArchive falls back to newest log per kind', () => {
|
||||
const root = makeTempDir();
|
||||
const logsDir = path.join(root, 'logs');
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { resolveLogBaseDir } from '../../shared/log-files';
|
||||
import { localDateKey, resolveLogBaseDir } from '../../shared/log-files';
|
||||
import { writeStoredZip } from '../../shared/stored-zip';
|
||||
import { redactLogExportText } from './log-redaction';
|
||||
|
||||
type LogCandidate = {
|
||||
path: string;
|
||||
@@ -29,16 +30,10 @@ export type ExportLogsOptions = {
|
||||
now?: Date;
|
||||
};
|
||||
|
||||
const REDACTED_USER = '<user>';
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function localDateKey(date: Date): string {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
function localWeekKey(date: Date): string {
|
||||
const startOfYear = new Date(date.getFullYear(), 0, 1);
|
||||
const dayOfYear =
|
||||
@@ -126,17 +121,41 @@ function selectMostRecentPerKind(candidates: LogCandidate[]): LogCandidate[] {
|
||||
return [...byKind.values()].sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
function localDateEndMs(dateKey: string): number | null {
|
||||
const match = dateKey.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) return null;
|
||||
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
const date = new Date(year, month - 1, day, 23, 59, 59, 999);
|
||||
if (
|
||||
Number.isNaN(date.getTime()) ||
|
||||
date.getFullYear() !== year ||
|
||||
date.getMonth() !== month - 1 ||
|
||||
date.getDate() !== day
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
function localWeekEndMs(weekKey: string): number | null {
|
||||
const match = weekKey.match(/^(\d{4})-W(\d{2})$/);
|
||||
if (!match) return null;
|
||||
|
||||
const year = Number(match[1]);
|
||||
const week = Number(match[2]);
|
||||
const date = new Date(year, 0, week * 7, 23, 59, 59, 999);
|
||||
return Number.isNaN(date.getTime()) ? null : date.getTime();
|
||||
}
|
||||
|
||||
function candidateFreshnessMs(candidate: LogCandidate): number {
|
||||
if (candidate.fileDateKey) {
|
||||
return Date.parse(`${candidate.fileDateKey}T23:59:59.999Z`);
|
||||
return localDateEndMs(candidate.fileDateKey) ?? candidate.mtimeMs;
|
||||
}
|
||||
if (candidate.fileWeekKey) {
|
||||
const match = candidate.fileWeekKey.match(/^(\d{4})-W(\d{2})$/);
|
||||
if (match) {
|
||||
const year = Number(match[1]);
|
||||
const week = Number(match[2]);
|
||||
return Date.UTC(year, 0, week * 7, 23, 59, 59, 999);
|
||||
}
|
||||
return localWeekEndMs(candidate.fileWeekKey) ?? candidate.mtimeMs;
|
||||
}
|
||||
return candidate.mtimeMs;
|
||||
}
|
||||
@@ -167,9 +186,7 @@ function selectLogCandidates(
|
||||
}
|
||||
|
||||
export function maskUsernamesInLogText(text: string): string {
|
||||
return text
|
||||
.replace(/(\/(?:home|Users)\/)([^/\r\n]+)(?=\/|$)/g, `$1${REDACTED_USER}`)
|
||||
.replace(/([A-Za-z]:[\\/]+Users[\\/]+)([^\\/:\r\n]+)(?=[\\/]|$)/g, `$1${REDACTED_USER}`);
|
||||
return redactLogExportText(text);
|
||||
}
|
||||
|
||||
export function exportLogsArchive(options: ExportLogsOptions = {}): ExportLogsResult {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { LOG_EXPORT_REDACTION_RULES, redactLogExportText } from './log-redaction';
|
||||
|
||||
test('log export redaction rules expose auditable fixtures', () => {
|
||||
assert.deepEqual(
|
||||
LOG_EXPORT_REDACTION_RULES.map((rule) => rule.name),
|
||||
[
|
||||
'signed-youtube-media-url-query',
|
||||
'url-sensitive-components',
|
||||
'sensitive-headers',
|
||||
'yt-dlp-cookie-args',
|
||||
'json-secret-values',
|
||||
'generic-sensitive-key-values',
|
||||
'home-path-usernames',
|
||||
'email-addresses',
|
||||
'ip-addresses',
|
||||
],
|
||||
);
|
||||
|
||||
const names = new Set<string>();
|
||||
for (const rule of LOG_EXPORT_REDACTION_RULES) {
|
||||
assert.equal(names.has(rule.name), false, `duplicate rule name: ${rule.name}`);
|
||||
names.add(rule.name);
|
||||
assert.ok(rule.pattern.length > 0, `${rule.name} pattern`);
|
||||
assert.ok(rule.replacement.length > 0, `${rule.name} replacement`);
|
||||
assert.ok(rule.risk.length > 0, `${rule.name} risk`);
|
||||
assert.ok(rule.examples.length > 0, `${rule.name} examples`);
|
||||
|
||||
for (const example of rule.examples) {
|
||||
assert.equal(rule.redact(example.input), example.expected, `${rule.name} direct`);
|
||||
assert.equal(redactLogExportText(example.input), example.expected, rule.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('redactLogExportText redacts parsed URL query values without swallowing punctuation', () => {
|
||||
const masked = redactLogExportText(
|
||||
[
|
||||
'failed URL (https://example.test/watch?access_token=tok123).',
|
||||
'callback subminer://anilist-setup?access_token=ani-token&state=ok',
|
||||
'encoded https://example.test/watch?client%5Fsecret=secret-value&plain=ok!',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.match(masked, /\(https:\/\/example\.test\/watch\?access_token=<redacted>\)\./);
|
||||
assert.match(masked, /subminer:\/\/anilist-setup\?access_token=<redacted>&state=ok/);
|
||||
assert.match(masked, /client%5Fsecret=<redacted>&plain=ok!/);
|
||||
assert.doesNotMatch(masked, /tok123/);
|
||||
assert.doesNotMatch(masked, /ani-token/);
|
||||
assert.doesNotMatch(masked, /secret-value/);
|
||||
});
|
||||
|
||||
test('redactLogExportText redacts URL credentials containing at signs', () => {
|
||||
const masked = redactLogExportText('GET https://alice:p@ss@example.test/watch?state=ok');
|
||||
|
||||
assert.equal(masked, 'GET https://<credentials>@example.test/watch?state=ok');
|
||||
assert.doesNotMatch(masked, /alice/);
|
||||
assert.doesNotMatch(masked, /p@ss/);
|
||||
});
|
||||
|
||||
test('redactLogExportText redacts signed YouTube media URL query strings', () => {
|
||||
const masked = redactLogExportText(
|
||||
[
|
||||
'ffmpeg failed for (https://rr1---sn-a5mekn6r.googlevideo.com/videoplayback?expire=1777777777&ei=EI_VALUE&id=o-SECRETID&itag=251&ip=203.0.113.42&source=youtube&requiressl=yes&n=NSECRET&sparams=expire,ei,ip,id,itag,source,requiressl&sig=SIGSECRET&lsig=LSIGSECRET).',
|
||||
'manifest https://manifest.googlevideo.com/videoplayback?expire=1777777777&signature=SIG2#frag',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.match(
|
||||
masked,
|
||||
/\(https:\/\/rr1---sn-a5mekn6r\.googlevideo\.com\/videoplayback\?<redacted>\)\./,
|
||||
);
|
||||
assert.match(masked, /https:\/\/manifest\.googlevideo\.com\/videoplayback\?<redacted>#frag/);
|
||||
assert.doesNotMatch(masked, /1777777777/);
|
||||
assert.doesNotMatch(masked, /EI_VALUE/);
|
||||
assert.doesNotMatch(masked, /o-SECRETID/);
|
||||
assert.doesNotMatch(masked, /203\.0\.113\.42/);
|
||||
assert.doesNotMatch(masked, /NSECRET/);
|
||||
assert.doesNotMatch(masked, /SIGSECRET/);
|
||||
assert.doesNotMatch(masked, /LSIGSECRET/);
|
||||
});
|
||||
|
||||
test('redactLogExportText redacts nested JSON secret values', () => {
|
||||
const masked = redactLogExportText(
|
||||
'json {"nested":{"token":123,"safe":"ok"},"items":[{"password":true},{"client_secret":null}]}',
|
||||
);
|
||||
|
||||
assert.match(masked, /"token":"<redacted>"/);
|
||||
assert.match(masked, /"safe":"ok"/);
|
||||
assert.match(masked, /"password":"<redacted>"/);
|
||||
assert.match(masked, /"client_secret":"<redacted>"/);
|
||||
assert.doesNotMatch(masked, /123/);
|
||||
assert.doesNotMatch(masked, /true/);
|
||||
assert.doesNotMatch(masked, /null/);
|
||||
});
|
||||
|
||||
test('redactLogExportText redacts compound secret keys', () => {
|
||||
const masked = redactLogExportText(
|
||||
[
|
||||
'json {"openaiApiKey":"sk-secret","google_api_key":"AIza-secret","userPassword":"hunter2","safe":"ok"}',
|
||||
'openaiApiKey=sk-inline google_api_key=AIza-inline userPassword=hunter-inline',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.match(masked, /"openaiApiKey":"<redacted>"/);
|
||||
assert.match(masked, /"google_api_key":"<redacted>"/);
|
||||
assert.match(masked, /"userPassword":"<redacted>"/);
|
||||
assert.match(masked, /"safe":"ok"/);
|
||||
assert.match(masked, /openaiApiKey=<redacted>/);
|
||||
assert.match(masked, /google_api_key=<redacted>/);
|
||||
assert.match(masked, /userPassword=<redacted>/);
|
||||
assert.doesNotMatch(masked, /sk-secret|AIza-secret|hunter2/);
|
||||
assert.doesNotMatch(masked, /sk-inline|AIza-inline|hunter-inline/);
|
||||
});
|
||||
|
||||
test('redactLogExportText redacts quoted secret values containing the opposite quote', () => {
|
||||
const masked = redactLogExportText(`"password":"pa'ss" 'token':'to"ken' "safe":"pa'ss"`);
|
||||
|
||||
assert.equal(masked, `"password":"<redacted>" 'token':'<redacted>' "safe":"pa'ss"`);
|
||||
});
|
||||
|
||||
test('redactLogExportText redacts IPv6 addresses with zone identifiers', () => {
|
||||
const masked = redactLogExportText('connected [fe80::1%en0]:443 and fe80::2%eth0');
|
||||
|
||||
assert.equal(masked, 'connected [<ip>]:443 and <ip>');
|
||||
});
|
||||
|
||||
test('redactLogExportText keeps malformed JSON scans bounded', () => {
|
||||
const malformedLine = `${'{'.repeat(70 * 1024)} token=secret`;
|
||||
const masked = redactLogExportText(`${malformedLine}\njson {"token":"secret","safe":"ok"}`);
|
||||
|
||||
assert.match(masked, /token=<redacted>/);
|
||||
assert.match(masked, /json {"token":"<redacted>","safe":"ok"}/);
|
||||
assert.doesNotMatch(masked, /token=secret/);
|
||||
assert.doesNotMatch(masked, /"token":"secret"/);
|
||||
});
|
||||
@@ -0,0 +1,522 @@
|
||||
import * as net from 'net';
|
||||
|
||||
type LogRedactionExample = {
|
||||
input: string;
|
||||
expected: string;
|
||||
};
|
||||
|
||||
export type LogRedactionRule = {
|
||||
name: string;
|
||||
pattern: string;
|
||||
replacement: string;
|
||||
risk: 'pii' | 'secret' | 'pii-or-secret';
|
||||
examples: readonly LogRedactionExample[];
|
||||
redact: (text: string) => string;
|
||||
};
|
||||
|
||||
const REDACTED_USER = '<user>';
|
||||
const REDACTED_VALUE = '<redacted>';
|
||||
const REDACTED_CREDENTIALS = '<credentials>';
|
||||
const REDACTED_EMAIL = '<email>';
|
||||
const REDACTED_IP = '<ip>';
|
||||
const MAX_JSON_CANDIDATE_SCAN_CHARS = 64 * 1024;
|
||||
|
||||
const SENSITIVE_HEADER_NAMES = [
|
||||
'api-key',
|
||||
'authorization',
|
||||
'cookie',
|
||||
'proxy-authorization',
|
||||
'set-cookie',
|
||||
'x-api-key',
|
||||
'x-emby-token',
|
||||
'x-goog-visitor-id',
|
||||
'x-mediabrowser-token',
|
||||
].join('|');
|
||||
|
||||
const SENSITIVE_VALUE_KEY_PATTERNS = [
|
||||
'access[_-]?token',
|
||||
'api[_-]?key',
|
||||
'apikey',
|
||||
'authorization',
|
||||
'client[_-]?secret',
|
||||
'cookie',
|
||||
'cookies',
|
||||
'id[_-]?token',
|
||||
'password',
|
||||
'passwd',
|
||||
'pwd',
|
||||
'refresh[_-]?token',
|
||||
'secret',
|
||||
'session',
|
||||
'sid',
|
||||
'sig',
|
||||
'signature',
|
||||
'token',
|
||||
].join('|');
|
||||
|
||||
const SENSITIVE_VALUE_KEY_SEQUENCES: readonly (readonly string[])[] = [
|
||||
['access', 'token'],
|
||||
['api', 'key'],
|
||||
['apikey'],
|
||||
['authorization'],
|
||||
['client', 'secret'],
|
||||
['cookie'],
|
||||
['cookies'],
|
||||
['id', 'token'],
|
||||
['password'],
|
||||
['passwd'],
|
||||
['pwd'],
|
||||
['refresh', 'token'],
|
||||
['secret'],
|
||||
['session'],
|
||||
['sid'],
|
||||
['sig'],
|
||||
['signature'],
|
||||
['token'],
|
||||
];
|
||||
const SENSITIVE_NORMALIZED_KEY_SUFFIXES = [
|
||||
'accesstoken',
|
||||
'apikey',
|
||||
'authorization',
|
||||
'clientsecret',
|
||||
'cookie',
|
||||
'cookies',
|
||||
'idtoken',
|
||||
'password',
|
||||
'passwd',
|
||||
'pwd',
|
||||
'refreshtoken',
|
||||
'secret',
|
||||
'session',
|
||||
'signature',
|
||||
'token',
|
||||
] as const;
|
||||
const SENSITIVE_HEADER_RE = new RegExp(
|
||||
`(^|\\r?\\n)(\\s*(?:${SENSITIVE_HEADER_NAMES})\\s*:\\s*)[^\\r\\n]*`,
|
||||
'gi',
|
||||
);
|
||||
const SENSITIVE_INLINE_HEADER_RE = new RegExp(
|
||||
`\\b((?:${SENSITIVE_HEADER_NAMES})\\s*:\\s*)[^\\r\\n]*`,
|
||||
'gi',
|
||||
);
|
||||
const KEY_VALUE_NAME_PATTERN = '[A-Za-z0-9][A-Za-z0-9_.%-]*';
|
||||
const SENSITIVE_QUOTED_VALUE_RE = new RegExp(
|
||||
`(["'])(${KEY_VALUE_NAME_PATTERN})\\1(\\s*[:=]\\s*)(["'])((?:(?!\\4)[^\\r\\n])*)\\4`,
|
||||
'g',
|
||||
);
|
||||
const SENSITIVE_UNQUOTED_VALUE_RE = new RegExp(
|
||||
`\\b(${KEY_VALUE_NAME_PATTERN})(\\s*[:=]\\s*)(?:"[^"\\r\\n]*"|'[^'\\r\\n]*'|[^\\s,}\\]\\)&<>"']+)`,
|
||||
'g',
|
||||
);
|
||||
const YTDLP_COOKIE_EQUALS_RE =
|
||||
/(--(?:cookies|cookies-from-browser)=)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s\r\n]+)/gi;
|
||||
const YTDLP_COOKIE_SPACE_RE =
|
||||
/(--(?:cookies|cookies-from-browser)\s+)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\r\n]*?)(?=\s+--[A-Za-z0-9][A-Za-z0-9-]*|\r?\n|$)/gi;
|
||||
const URL_TOKEN_RE = /\b[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/gi;
|
||||
const URL_CREDENTIALS_RE = /\b([a-z][a-z0-9+.-]*:\/\/)([^/?#\s"'<>]*@)/gi;
|
||||
const URL_QUERY_PAIR_RE = /([?&;]|&)([^=&#;]+)=([^&#;]*)/gi;
|
||||
const TRAILING_URL_PUNCTUATION_RE = /[)\].,;:!?]+$/;
|
||||
const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
|
||||
const BRACKETED_IP_RE = /\[([0-9A-F:.]+(?:%[A-Z0-9_.~-]+)?)\]/gi;
|
||||
const IPV4_RE = /(^|[^A-Za-z0-9_.-])((?:\d{1,3}\.){3}\d{1,3})(?![A-Za-z0-9_.-])/g;
|
||||
const IPV6_RE =
|
||||
/(^|[^A-Za-z0-9_.-])([0-9A-F]{0,4}:[0-9A-F:.]*(?:%[A-Z0-9_.~-]+)?)(?![A-Za-z0-9_.-])/gi;
|
||||
|
||||
function safeDecodeFormComponent(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value.replace(/\+/g, ' '));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function tokenizeValueKey(key: string): string[] {
|
||||
return (
|
||||
key
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.toLowerCase()
|
||||
.match(/[a-z0-9]+/g) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function containsTokenSequence(tokens: readonly string[], sequence: readonly string[]): boolean {
|
||||
for (let index = 0; index <= tokens.length - sequence.length; index += 1) {
|
||||
if (sequence.every((token, offset) => tokens[index + offset] === token)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSensitiveValueKey(key: string): boolean {
|
||||
const decoded = safeDecodeFormComponent(key);
|
||||
const tokens = tokenizeValueKey(decoded);
|
||||
if (SENSITIVE_VALUE_KEY_SEQUENCES.some((sequence) => containsTokenSequence(tokens, sequence))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalized = tokens.join('');
|
||||
return SENSITIVE_NORMALIZED_KEY_SUFFIXES.some((suffix) => normalized.endsWith(suffix));
|
||||
}
|
||||
|
||||
function splitTrailingUrlPunctuation(token: string): { core: string; trailing: string } {
|
||||
const match = token.match(TRAILING_URL_PUNCTUATION_RE);
|
||||
if (!match) return { core: token, trailing: '' };
|
||||
return {
|
||||
core: token.slice(0, -match[0].length),
|
||||
trailing: match[0],
|
||||
};
|
||||
}
|
||||
|
||||
function redactSensitiveUrlQueryPairs(rawUrl: string): string {
|
||||
const queryStart = rawUrl.indexOf('?');
|
||||
if (queryStart === -1) return rawUrl;
|
||||
|
||||
const hashStart = rawUrl.indexOf('#', queryStart);
|
||||
const queryEnd = hashStart === -1 ? rawUrl.length : hashStart;
|
||||
const query = rawUrl.slice(queryStart, queryEnd);
|
||||
const redactedQuery = query.replace(URL_QUERY_PAIR_RE, (match, prefix: string, rawKey: string) =>
|
||||
isSensitiveValueKey(rawKey) ? `${prefix}${rawKey}=${REDACTED_VALUE}` : match,
|
||||
);
|
||||
|
||||
return `${rawUrl.slice(0, queryStart)}${redactedQuery}${rawUrl.slice(queryEnd)}`;
|
||||
}
|
||||
|
||||
function redactUrlToken(token: string): string {
|
||||
const { core, trailing } = splitTrailingUrlPunctuation(token);
|
||||
try {
|
||||
new URL(core);
|
||||
} catch {
|
||||
return token;
|
||||
}
|
||||
|
||||
const withoutCredentials = core.replace(URL_CREDENTIALS_RE, `$1${REDACTED_CREDENTIALS}@`);
|
||||
return `${redactSensitiveUrlQueryPairs(withoutCredentials)}${trailing}`;
|
||||
}
|
||||
|
||||
function redactUrlSensitiveComponents(text: string): string {
|
||||
return text.replace(URL_TOKEN_RE, (token: string) => redactUrlToken(token));
|
||||
}
|
||||
|
||||
function isGoogleVideoHost(hostname: string): boolean {
|
||||
const normalized = hostname.toLowerCase();
|
||||
return normalized === 'googlevideo.com' || normalized.endsWith('.googlevideo.com');
|
||||
}
|
||||
|
||||
function isSignedYouTubeMediaUrl(url: URL): boolean {
|
||||
return isGoogleVideoHost(url.hostname) && url.pathname === '/videoplayback' && url.search !== '';
|
||||
}
|
||||
|
||||
function redactSignedYouTubeMediaUrlToken(token: string): string {
|
||||
const { core, trailing } = splitTrailingUrlPunctuation(token);
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(core);
|
||||
} catch {
|
||||
return token;
|
||||
}
|
||||
|
||||
if (!isSignedYouTubeMediaUrl(parsed)) return token;
|
||||
|
||||
const queryStart = core.indexOf('?');
|
||||
const hashStart = core.indexOf('#', queryStart);
|
||||
if (queryStart === -1) return token;
|
||||
|
||||
const hash = hashStart === -1 ? '' : core.slice(hashStart);
|
||||
return `${core.slice(0, queryStart)}?${REDACTED_VALUE}${hash}${trailing}`;
|
||||
}
|
||||
|
||||
function redactSignedYouTubeMediaUrlQueries(text: string): string {
|
||||
return text.replace(URL_TOKEN_RE, (token: string) => redactSignedYouTubeMediaUrlToken(token));
|
||||
}
|
||||
|
||||
function redactSensitiveHeaders(text: string): string {
|
||||
return text
|
||||
.replace(
|
||||
SENSITIVE_HEADER_RE,
|
||||
(_match, lineStart: string, headerPrefix: string) =>
|
||||
`${lineStart}${headerPrefix}${REDACTED_VALUE}`,
|
||||
)
|
||||
.replace(
|
||||
SENSITIVE_INLINE_HEADER_RE,
|
||||
(_match, headerPrefix: string) => `${headerPrefix}${REDACTED_VALUE}`,
|
||||
);
|
||||
}
|
||||
|
||||
function redactYtDlpCookieArgs(text: string): string {
|
||||
return text
|
||||
.replace(YTDLP_COOKIE_EQUALS_RE, `$1${REDACTED_VALUE}`)
|
||||
.replace(YTDLP_COOKIE_SPACE_RE, `$1${REDACTED_VALUE}`);
|
||||
}
|
||||
|
||||
function findJsonScanEnd(text: string, start: number): number {
|
||||
const boundedEnd = Math.min(text.length, start + MAX_JSON_CANDIDATE_SCAN_CHARS);
|
||||
const newline = text.indexOf('\n', start);
|
||||
if (newline !== -1 && newline < boundedEnd) return newline;
|
||||
return boundedEnd;
|
||||
}
|
||||
|
||||
function findJsonEnd(text: string, start: number, endExclusive: number): number {
|
||||
const stack: string[] = [];
|
||||
let inString = false;
|
||||
let quote = '';
|
||||
let escaped = false;
|
||||
|
||||
for (let index = start; index < endExclusive; index += 1) {
|
||||
const char = text[index];
|
||||
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === '\\') {
|
||||
escaped = true;
|
||||
} else if (char === quote) {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
inString = true;
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '{') {
|
||||
stack.push('}');
|
||||
} else if (char === '[') {
|
||||
stack.push(']');
|
||||
} else if (char === '}' || char === ']') {
|
||||
if (stack.pop() !== char) return -1;
|
||||
if (stack.length === 0) return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function redactJsonValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => redactJsonValue(entry));
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
const redacted: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
redacted[key] = isSensitiveValueKey(key) ? REDACTED_VALUE : redactJsonValue(entry);
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function redactJsonPayloads(text: string): string {
|
||||
let output = '';
|
||||
let cursor = 0;
|
||||
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const char = text[index];
|
||||
if (char !== '{' && char !== '[') continue;
|
||||
|
||||
const scanEnd = findJsonScanEnd(text, index);
|
||||
const end = findJsonEnd(text, index, scanEnd);
|
||||
if (end === -1) {
|
||||
index = Math.max(index, scanEnd - 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = text.slice(index, end + 1);
|
||||
try {
|
||||
const parsed = JSON.parse(candidate);
|
||||
output += text.slice(cursor, index);
|
||||
output += JSON.stringify(redactJsonValue(parsed));
|
||||
cursor = end + 1;
|
||||
index = end;
|
||||
} catch {
|
||||
// Non-JSON brace pairs remain available to the fallback key-value rules.
|
||||
}
|
||||
}
|
||||
|
||||
return `${output}${text.slice(cursor)}`;
|
||||
}
|
||||
|
||||
function redactSensitiveKeyValues(text: string): string {
|
||||
return text
|
||||
.replace(
|
||||
SENSITIVE_QUOTED_VALUE_RE,
|
||||
(match, keyQuote: string, key: string, separator: string, valueQuote: string) =>
|
||||
isSensitiveValueKey(key)
|
||||
? `${keyQuote}${key}${keyQuote}${separator}${valueQuote}${REDACTED_VALUE}${valueQuote}`
|
||||
: match,
|
||||
)
|
||||
.replace(SENSITIVE_UNQUOTED_VALUE_RE, (match, key: string, separator: string) => {
|
||||
if (!isSensitiveValueKey(key)) return match;
|
||||
const value = match.slice(key.length + separator.length);
|
||||
const quote = value[0];
|
||||
if (quote === '"' || quote === "'") {
|
||||
return `${key}${separator}${quote}${REDACTED_VALUE}${quote}`;
|
||||
}
|
||||
return `${key}${separator}${REDACTED_VALUE}`;
|
||||
});
|
||||
}
|
||||
|
||||
function redactHomePathUsernames(text: string): string {
|
||||
return text
|
||||
.replace(/(\/(?:home|Users)\/)([^/\r\n]+)(?=\/|$)/g, `$1${REDACTED_USER}`)
|
||||
.replace(/([A-Za-z]:[\\/]+Users[\\/]+)([^\\/:\r\n]+)(?=[\\/]|$)/g, `$1${REDACTED_USER}`);
|
||||
}
|
||||
|
||||
function normalizeIpCandidate(candidate: string): string {
|
||||
return candidate.split('%', 1)[0] ?? candidate;
|
||||
}
|
||||
|
||||
function isIpAddress(candidate: string): boolean {
|
||||
return net.isIP(normalizeIpCandidate(candidate)) !== 0;
|
||||
}
|
||||
|
||||
function redactIpAddresses(text: string): string {
|
||||
return text
|
||||
.replace(BRACKETED_IP_RE, (match, candidate: string) =>
|
||||
isIpAddress(candidate) ? `[${REDACTED_IP}]` : match,
|
||||
)
|
||||
.replace(IPV4_RE, (match, prefix: string, candidate: string) =>
|
||||
net.isIP(candidate) === 4 ? `${prefix}${REDACTED_IP}` : match,
|
||||
)
|
||||
.replace(IPV6_RE, (match, prefix: string, candidate: string) =>
|
||||
net.isIP(normalizeIpCandidate(candidate)) === 6 ? `${prefix}${REDACTED_IP}` : match,
|
||||
);
|
||||
}
|
||||
|
||||
export const LOG_EXPORT_REDACTION_RULES: readonly LogRedactionRule[] = [
|
||||
{
|
||||
name: 'signed-youtube-media-url-query',
|
||||
pattern: '*.googlevideo.com/videoplayback query strings',
|
||||
replacement: REDACTED_VALUE,
|
||||
risk: 'pii-or-secret',
|
||||
examples: [
|
||||
{
|
||||
input: 'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1&sig=secret',
|
||||
expected: `https://rr1---sn.example.googlevideo.com/videoplayback?${REDACTED_VALUE}`,
|
||||
},
|
||||
],
|
||||
redact: redactSignedYouTubeMediaUrlQueries,
|
||||
},
|
||||
{
|
||||
name: 'url-sensitive-components',
|
||||
pattern: 'URL tokens with credentials or sensitive query params',
|
||||
replacement: `${REDACTED_CREDENTIALS}, ${REDACTED_VALUE}`,
|
||||
risk: 'pii-or-secret',
|
||||
examples: [
|
||||
{
|
||||
input: 'GET https://alice:secret@example.test/watch?access_token=tok123&state=ok',
|
||||
expected: `GET https://${REDACTED_CREDENTIALS}@example.test/watch?access_token=${REDACTED_VALUE}&state=ok`,
|
||||
},
|
||||
],
|
||||
redact: redactUrlSensitiveComponents,
|
||||
},
|
||||
{
|
||||
name: 'sensitive-headers',
|
||||
pattern: `headers: ${SENSITIVE_HEADER_NAMES}`,
|
||||
replacement: REDACTED_VALUE,
|
||||
risk: 'secret',
|
||||
examples: [
|
||||
{
|
||||
input: 'Authorization: Bearer token',
|
||||
expected: `Authorization: ${REDACTED_VALUE}`,
|
||||
},
|
||||
],
|
||||
redact: redactSensitiveHeaders,
|
||||
},
|
||||
{
|
||||
name: 'yt-dlp-cookie-args',
|
||||
pattern: '--cookies and --cookies-from-browser arguments',
|
||||
replacement: REDACTED_VALUE,
|
||||
risk: 'secret',
|
||||
examples: [
|
||||
{
|
||||
input: 'yt-dlp --cookies /Users/kyle/cookies.txt --verbose',
|
||||
expected: `yt-dlp --cookies ${REDACTED_VALUE} --verbose`,
|
||||
},
|
||||
],
|
||||
redact: redactYtDlpCookieArgs,
|
||||
},
|
||||
{
|
||||
name: 'json-secret-values',
|
||||
pattern: `JSON object keys: ${SENSITIVE_VALUE_KEY_PATTERNS}`,
|
||||
replacement: REDACTED_VALUE,
|
||||
risk: 'secret',
|
||||
examples: [
|
||||
{
|
||||
input: 'json {"token":123,"safe":"ok"}',
|
||||
expected: `json {"token":"${REDACTED_VALUE}","safe":"ok"}`,
|
||||
},
|
||||
{
|
||||
input: 'json {"openaiApiKey":"sk-secret","safe":"ok"}',
|
||||
expected: `json {"openaiApiKey":"${REDACTED_VALUE}","safe":"ok"}`,
|
||||
},
|
||||
],
|
||||
redact: redactJsonPayloads,
|
||||
},
|
||||
{
|
||||
name: 'generic-sensitive-key-values',
|
||||
pattern: `key-value pairs: ${SENSITIVE_VALUE_KEY_PATTERNS}`,
|
||||
replacement: REDACTED_VALUE,
|
||||
risk: 'secret',
|
||||
examples: [
|
||||
{
|
||||
input: 'refreshToken=abc123 state=ok',
|
||||
expected: `refreshToken=${REDACTED_VALUE} state=ok`,
|
||||
},
|
||||
{
|
||||
input: 'google_api_key=AIza-secret userPassword=hunter2',
|
||||
expected: `google_api_key=${REDACTED_VALUE} userPassword=${REDACTED_VALUE}`,
|
||||
},
|
||||
],
|
||||
redact: redactSensitiveKeyValues,
|
||||
},
|
||||
{
|
||||
name: 'home-path-usernames',
|
||||
pattern: 'Linux, macOS, and Windows home paths',
|
||||
replacement: REDACTED_USER,
|
||||
risk: 'pii',
|
||||
examples: [
|
||||
{
|
||||
input: '/Users/kyle/Library/Application Support/SubMiner',
|
||||
expected: `/Users/${REDACTED_USER}/Library/Application Support/SubMiner`,
|
||||
},
|
||||
],
|
||||
redact: redactHomePathUsernames,
|
||||
},
|
||||
{
|
||||
name: 'email-addresses',
|
||||
pattern: 'email-like addresses',
|
||||
replacement: REDACTED_EMAIL,
|
||||
risk: 'pii',
|
||||
examples: [
|
||||
{
|
||||
input: 'support kyle@example.test',
|
||||
expected: `support ${REDACTED_EMAIL}`,
|
||||
},
|
||||
],
|
||||
redact: (text) => text.replace(EMAIL_RE, REDACTED_EMAIL),
|
||||
},
|
||||
{
|
||||
name: 'ip-addresses',
|
||||
pattern: 'IPv4, bracketed IPv6, and bare IPv6 candidates validated with net.isIP',
|
||||
replacement: REDACTED_IP,
|
||||
risk: 'pii',
|
||||
examples: [
|
||||
{
|
||||
input: 'remote addr [2001:db8::1234]:443 and 203.0.113.42',
|
||||
expected: `remote addr [${REDACTED_IP}]:443 and ${REDACTED_IP}`,
|
||||
},
|
||||
],
|
||||
redact: redactIpAddresses,
|
||||
},
|
||||
];
|
||||
|
||||
export function redactLogExportText(text: string): string {
|
||||
return LOG_EXPORT_REDACTION_RULES.reduce((redacted, rule) => rule.redact(redacted), text);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { withConfiguredOverlayNotificationPosition } from './overlay-notificatio
|
||||
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
|
||||
import {
|
||||
getPlaybackFeedbackNotificationOptions,
|
||||
getYoutubeFlowStatusNotificationOptions,
|
||||
notifyConfiguredStatus,
|
||||
type ConfiguredStatusNotificationOptions,
|
||||
} from './configured-status-notification';
|
||||
@@ -32,7 +33,7 @@ export interface OverlayNotificationsRuntimeDeps {
|
||||
getMainOverlayWindow: () => BrowserWindow | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
|
||||
showMpvOsd: (message: string) => void;
|
||||
showMpvOsd: (message: string) => boolean | void;
|
||||
getMpvClient: () => MpvIpcClient | null;
|
||||
getAnkiIntegration: () => AnkiIntegration | null;
|
||||
getRuntimeOptionsManager: () => RuntimeOptionsManager | null;
|
||||
@@ -42,6 +43,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
|
||||
isVisibleOverlayContentReady: () => boolean;
|
||||
getConfiguredStatusNotificationType: () => NotificationType;
|
||||
flushQueuedOverlayNotifications: () => void;
|
||||
flushQueuedMpvOsdNotifications: () => void;
|
||||
showOverlayNotification: (payload: OverlayNotificationPayload) => void;
|
||||
dismissOverlayNotification: (id: string) => void;
|
||||
openAnkiCardFromNotification: (noteId: number) => Promise<void>;
|
||||
@@ -95,11 +97,38 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
|
||||
});
|
||||
let overlayLoadingOsdController: ReturnType<typeof createOverlayLoadingOsdController> | null =
|
||||
null;
|
||||
const queuedConfiguredOsdNotifications = new Map<
|
||||
string,
|
||||
{ message: string; options: ConfiguredStatusNotificationOptions }
|
||||
>();
|
||||
|
||||
function flushQueuedOverlayNotifications(): void {
|
||||
overlayNotificationDelivery.flush();
|
||||
}
|
||||
|
||||
function queueConfiguredOsdNotification(
|
||||
message: string,
|
||||
options: ConfiguredStatusNotificationOptions,
|
||||
): void {
|
||||
const key = options.id ?? message;
|
||||
queuedConfiguredOsdNotifications.set(key, { message, options });
|
||||
while (queuedConfiguredOsdNotifications.size > 16) {
|
||||
const oldestKey = queuedConfiguredOsdNotifications.keys().next().value;
|
||||
if (typeof oldestKey !== 'string') {
|
||||
break;
|
||||
}
|
||||
queuedConfiguredOsdNotifications.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function flushQueuedMpvOsdNotifications(): void {
|
||||
for (const [key, entry] of [...queuedConfiguredOsdNotifications.entries()]) {
|
||||
if (deps.showMpvOsd(entry.message) !== false) {
|
||||
queuedConfiguredOsdNotifications.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sendOverlayNotificationEvent(payload: OverlayNotificationEventPayload): void {
|
||||
overlayNotificationDelivery.send(payload);
|
||||
}
|
||||
@@ -145,6 +174,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
|
||||
getNotificationType: () => deps.getResolvedConfig().ankiConnect.behavior.notificationType,
|
||||
isOverlayReady: () => isVisibleOverlayContentReady(),
|
||||
showOsd: (text) => deps.showMpvOsd(text),
|
||||
queueOsd: (text, queueOptions) => queueConfiguredOsdNotification(text, queueOptions),
|
||||
showOverlayNotification,
|
||||
showDesktopNotification: (title, notificationOptions) =>
|
||||
showDesktopNotification(title, notificationOptions),
|
||||
@@ -177,18 +207,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
|
||||
}
|
||||
|
||||
function showYoutubeFlowStatusNotification(message: string): void {
|
||||
const progress =
|
||||
message.startsWith('Downloading subtitles') ||
|
||||
message.startsWith('Loading subtitles') ||
|
||||
message.startsWith('Getting subtitles') ||
|
||||
message === 'Opening YouTube video';
|
||||
showConfiguredStatusNotification(message, {
|
||||
id: 'youtube-subtitles-status',
|
||||
title: 'YouTube subtitles',
|
||||
variant: progress ? 'progress' : 'info',
|
||||
persistent: progress,
|
||||
desktop: !progress,
|
||||
});
|
||||
showConfiguredStatusNotification(message, getYoutubeFlowStatusNotificationOptions(message));
|
||||
}
|
||||
|
||||
function getOverlayLoadingOsdController(): ReturnType<typeof createOverlayLoadingOsdController> {
|
||||
@@ -238,6 +257,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
|
||||
isVisibleOverlayContentReady,
|
||||
getConfiguredStatusNotificationType,
|
||||
flushQueuedOverlayNotifications,
|
||||
flushQueuedMpvOsdNotifications,
|
||||
showOverlayNotification,
|
||||
dismissOverlayNotification,
|
||||
openAnkiCardFromNotification,
|
||||
|
||||
@@ -41,6 +41,9 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
getCachedMediaPath?: OverlayRuntimeOptionsMainDeps['getCachedMediaPath'];
|
||||
shouldRequireRemoteMediaCache?: OverlayRuntimeOptionsMainDeps['shouldRequireRemoteMediaCache'];
|
||||
getYoutubeMediaSourceUrl?: OverlayRuntimeOptionsMainDeps['getYoutubeMediaSourceUrl'];
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
bindOverlayOwner?: () => void;
|
||||
releaseOverlayOwner?: () => void;
|
||||
@@ -77,6 +80,13 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
showOverlayNotification: deps.showOverlayNotification,
|
||||
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
|
||||
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
|
||||
...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}),
|
||||
...(deps.shouldRequireRemoteMediaCache
|
||||
? { shouldRequireRemoteMediaCache: deps.shouldRequireRemoteMediaCache }
|
||||
: {}),
|
||||
...(deps.getYoutubeMediaSourceUrl
|
||||
? { getYoutubeMediaSourceUrl: deps.getYoutubeMediaSourceUrl }
|
||||
: {}),
|
||||
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
|
||||
bindOverlayOwner: deps.bindOverlayOwner,
|
||||
releaseOverlayOwner: deps.releaseOverlayOwner,
|
||||
|
||||
@@ -37,6 +37,12 @@ type OverlayRuntimeOptions = {
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
getCachedMediaPath?: (
|
||||
currentVideoPath: string,
|
||||
kind: 'audio' | 'video',
|
||||
) => Promise<string | null>;
|
||||
shouldRequireRemoteMediaCache?: () => boolean;
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
bindOverlayOwner?: () => void;
|
||||
releaseOverlayOwner?: () => void;
|
||||
@@ -71,6 +77,12 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
getCachedMediaPath?: (
|
||||
currentVideoPath: string,
|
||||
kind: 'audio' | 'video',
|
||||
) => Promise<string | null>;
|
||||
shouldRequireRemoteMediaCache?: () => boolean;
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
bindOverlayOwner?: () => void;
|
||||
releaseOverlayOwner?: () => void;
|
||||
@@ -97,6 +109,13 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
showOverlayNotification: deps.showOverlayNotification,
|
||||
createFieldGroupingCallback: deps.createFieldGroupingCallback,
|
||||
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
|
||||
...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}),
|
||||
...(deps.shouldRequireRemoteMediaCache
|
||||
? { shouldRequireRemoteMediaCache: deps.shouldRequireRemoteMediaCache }
|
||||
: {}),
|
||||
...(deps.getYoutubeMediaSourceUrl
|
||||
? { getYoutubeMediaSourceUrl: deps.getYoutubeMediaSourceUrl }
|
||||
: {}),
|
||||
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
|
||||
bindOverlayOwner: deps.bindOverlayOwner,
|
||||
releaseOverlayOwner: deps.releaseOverlayOwner,
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { createYoutubeFlowRuntime } from './youtube-flow';
|
||||
import type { YoutubeTrackProbeResult } from '../../core/services/youtube/track-probe';
|
||||
import type { YoutubePickerOpenPayload, YoutubeTrackOption } from '../../types';
|
||||
|
||||
const primaryTrack: YoutubeTrackOption = {
|
||||
@@ -20,6 +21,66 @@ const secondaryTrack: YoutubeTrackOption = {
|
||||
label: 'English (manual)',
|
||||
};
|
||||
|
||||
test('youtube flow announces manual picker opening before probing tracks', async () => {
|
||||
const osdMessages: string[] = [];
|
||||
let resolveProbe: (probe: YoutubeTrackProbeResult) => void = () => {};
|
||||
const probePromise = new Promise<YoutubeTrackProbeResult>((resolve) => {
|
||||
resolveProbe = resolve;
|
||||
});
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => await probePromise,
|
||||
acquireYoutubeSubtitleTracks: async () => new Map(),
|
||||
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/unused.vtt' }),
|
||||
openPicker: async (payload) => {
|
||||
queueMicrotask(() => {
|
||||
void runtime.resolveActivePicker({
|
||||
sessionId: payload.sessionId,
|
||||
action: 'continue-without-subtitles',
|
||||
primaryTrackId: null,
|
||||
secondaryTrackId: null,
|
||||
});
|
||||
});
|
||||
return true;
|
||||
},
|
||||
pauseMpv: () => {},
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: () => {},
|
||||
requestMpvProperty: async () => null,
|
||||
refreshCurrentSubtitle: () => {},
|
||||
startTokenizationWarmups: async () => {},
|
||||
waitForTokenizationReady: async () => {},
|
||||
waitForAnkiReady: async () => {},
|
||||
wait: async () => {},
|
||||
waitForPlaybackWindowReady: async () => {},
|
||||
waitForOverlayGeometryReady: async () => {},
|
||||
focusOverlayWindow: () => {},
|
||||
showMpvOsd: (text) => {
|
||||
osdMessages.push(text);
|
||||
},
|
||||
reportSubtitleFailure: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
warn: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
log: () => {},
|
||||
getYoutubeOutputDir: () => '/tmp',
|
||||
});
|
||||
|
||||
const pending = runtime.openManualPicker({ url: 'https://example.com' });
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(osdMessages, ['Opening YouTube subtitle picker...']);
|
||||
|
||||
resolveProbe({
|
||||
videoId: 'video123',
|
||||
title: 'Video 123',
|
||||
tracks: [],
|
||||
});
|
||||
await pending;
|
||||
});
|
||||
|
||||
test('youtube flow can open a manual picker session and load the selected subtitles', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const focusOverlayCalls: string[] = [];
|
||||
@@ -126,6 +187,7 @@ test('youtube flow can open a manual picker session and load the selected subtit
|
||||
assert.equal(openedPayloads[0]?.defaultSecondaryTrackId, secondaryTrack.id);
|
||||
assert.ok(waits.includes(150));
|
||||
assert.deepEqual(osdMessages, [
|
||||
'Opening YouTube subtitle picker...',
|
||||
'Getting subtitles...',
|
||||
'Downloading subtitles...',
|
||||
'Loading subtitles...',
|
||||
|
||||
@@ -755,6 +755,8 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||
url: string;
|
||||
mode?: YoutubeFlowMode;
|
||||
}): Promise<void> => {
|
||||
deps.showMpvOsd('Opening YouTube subtitle picker...');
|
||||
|
||||
let probe: YoutubeTrackProbeResult;
|
||||
try {
|
||||
probe = await deps.probeYoutubeTracks(input.url);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createYoutubeMediaCachePlaybackRuntime } from './youtube-media-cache-playback';
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
test('youtube media cache starts when mpv reports a youtube path in background mode', async () => {
|
||||
const starts: Array<{ url: string; maxHeight: number }> = [];
|
||||
const runtime = createYoutubeMediaCachePlaybackRuntime({
|
||||
getMediaCacheConfig: () => ({ mode: 'background', maxHeight: 480 }),
|
||||
startYoutubeMediaCache: (url, options) => {
|
||||
starts.push({ url, maxHeight: options.maxHeight ?? 0 });
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
await runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc123');
|
||||
|
||||
assert.deepEqual(starts, [
|
||||
{
|
||||
url: 'https://www.youtube.com/watch?v=abc123',
|
||||
maxHeight: 480,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('youtube media cache starts from original playlist url when mpv path is a resolved stream', async () => {
|
||||
const starts: string[] = [];
|
||||
const propertyRequests: string[] = [];
|
||||
const runtime = createYoutubeMediaCachePlaybackRuntime({
|
||||
getMediaCacheConfig: () => ({ mode: 'background', maxHeight: 720 }),
|
||||
requestMpvProperty: async (name) => {
|
||||
propertyRequests.push(name);
|
||||
if (name === 'playlist-playing-pos') {
|
||||
return 0;
|
||||
}
|
||||
if (name === 'playlist') {
|
||||
return [{ filename: 'https://www.youtube.com/watch?v=abc123' }];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
startYoutubeMediaCache: (url) => {
|
||||
starts.push(url);
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
await runtime.handleMediaPathChange(
|
||||
'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777',
|
||||
);
|
||||
|
||||
assert.deepEqual(propertyRequests, ['playlist-playing-pos', 'playlist']);
|
||||
assert.deepEqual(starts, ['https://www.youtube.com/watch?v=abc123']);
|
||||
assert.equal(await runtime.getActiveYoutubeSourceUrl(), 'https://www.youtube.com/watch?v=abc123');
|
||||
});
|
||||
|
||||
test('youtube media cache can recover playlist source from current playlist marker', async () => {
|
||||
const starts: string[] = [];
|
||||
const runtime = createYoutubeMediaCachePlaybackRuntime({
|
||||
getMediaCacheConfig: () => ({ mode: 'background', maxHeight: 720 }),
|
||||
requestMpvProperty: async (name) => {
|
||||
if (name === 'playlist-playing-pos') {
|
||||
return -1;
|
||||
}
|
||||
if (name === 'playlist') {
|
||||
return [
|
||||
{
|
||||
filename: 'https://example.com/other.mp4',
|
||||
},
|
||||
{
|
||||
filename: 'https://youtu.be/abc123',
|
||||
current: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
startYoutubeMediaCache: (url) => {
|
||||
starts.push(url);
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
await runtime.handleMediaPathChange(
|
||||
'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777',
|
||||
);
|
||||
|
||||
assert.deepEqual(starts, ['https://youtu.be/abc123']);
|
||||
assert.equal(await runtime.getActiveYoutubeSourceUrl(), 'https://youtu.be/abc123');
|
||||
});
|
||||
|
||||
test('youtube media source getter awaits in-flight playlist recovery', async () => {
|
||||
const starts: string[] = [];
|
||||
const playlist = createDeferred<unknown>();
|
||||
const runtime = createYoutubeMediaCachePlaybackRuntime({
|
||||
getMediaCacheConfig: () => ({ mode: 'background', maxHeight: 720 }),
|
||||
requestMpvProperty: async (name) => {
|
||||
if (name === 'playlist-playing-pos') {
|
||||
return 0;
|
||||
}
|
||||
if (name === 'playlist') {
|
||||
return await playlist.promise;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
startYoutubeMediaCache: (url) => {
|
||||
starts.push(url);
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
const pathChange = runtime.handleMediaPathChange(
|
||||
'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777',
|
||||
);
|
||||
const sourceUrl = runtime.getActiveYoutubeSourceUrl();
|
||||
|
||||
playlist.resolve([{ filename: 'https://www.youtube.com/watch?v=abc123' }]);
|
||||
|
||||
assert.equal(await sourceUrl, 'https://www.youtube.com/watch?v=abc123');
|
||||
await pathChange;
|
||||
assert.deepEqual(starts, ['https://www.youtube.com/watch?v=abc123']);
|
||||
});
|
||||
|
||||
test('youtube media cache ignores non-youtube paths and direct mode', async () => {
|
||||
const starts: string[] = [];
|
||||
const propertyRequests: string[] = [];
|
||||
let mode: 'direct' | 'background' = 'background';
|
||||
const runtime = createYoutubeMediaCachePlaybackRuntime({
|
||||
getMediaCacheConfig: () => ({ mode, maxHeight: 720 }),
|
||||
requestMpvProperty: async (name) => {
|
||||
propertyRequests.push(name);
|
||||
return [
|
||||
{
|
||||
filename: 'https://www.youtube.com/watch?v=abc123',
|
||||
current: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
startYoutubeMediaCache: (url) => {
|
||||
starts.push(url);
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
await runtime.handleMediaPathChange('/tmp/video.mkv');
|
||||
mode = 'direct';
|
||||
await runtime.handleMediaPathChange('https://youtu.be/abc123');
|
||||
|
||||
assert.deepEqual(propertyRequests, []);
|
||||
assert.deepEqual(starts, []);
|
||||
});
|
||||
|
||||
test('youtube media cache logs synchronous start failures from path changes', async () => {
|
||||
const warnings: string[] = [];
|
||||
const runtime = createYoutubeMediaCachePlaybackRuntime({
|
||||
getMediaCacheConfig: () => ({ mode: 'background', maxHeight: 720 }),
|
||||
startYoutubeMediaCache: () => {
|
||||
throw new Error('yt-dlp missing');
|
||||
},
|
||||
logWarn: (message) => {
|
||||
warnings.push(message);
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.handleMediaPathChange('https://youtu.be/abc123');
|
||||
|
||||
assert.deepEqual(warnings, ['Failed to start YouTube media cache: yt-dlp missing']);
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { YoutubeMediaCacheMode } from '../../types/integrations';
|
||||
import { isYoutubeMediaPath } from './youtube-playback';
|
||||
|
||||
type PlaylistEntry = {
|
||||
filename: string;
|
||||
current: boolean;
|
||||
playing: boolean;
|
||||
};
|
||||
|
||||
export interface YoutubeMediaCachePlaybackRuntimeDeps {
|
||||
getMediaCacheConfig: () => {
|
||||
mode: YoutubeMediaCacheMode;
|
||||
maxHeight?: number;
|
||||
};
|
||||
requestMpvProperty?: (name: string) => Promise<unknown>;
|
||||
startYoutubeMediaCache: (
|
||||
url: string,
|
||||
options: {
|
||||
mode: YoutubeMediaCacheMode;
|
||||
maxHeight?: number;
|
||||
},
|
||||
) => void;
|
||||
logWarn: (message: string) => void;
|
||||
}
|
||||
|
||||
function trimToNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizePlaylistIndex(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
function isGoogleVideoStreamPath(mediaPath: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(mediaPath);
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
return host === 'googlevideo.com' || host.endsWith('.googlevideo.com');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePlaylistEntries(raw: unknown): PlaylistEntry[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
return raw.map((entry) => {
|
||||
const item = (entry ?? {}) as {
|
||||
filename?: unknown;
|
||||
current?: unknown;
|
||||
playing?: unknown;
|
||||
};
|
||||
return {
|
||||
filename: trimToNonEmptyString(item.filename) ?? '',
|
||||
current: item.current === true,
|
||||
playing: item.playing === true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePlaylistEntry(
|
||||
playlist: PlaylistEntry[],
|
||||
playingPosValue: unknown,
|
||||
): PlaylistEntry | null {
|
||||
if (playlist.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const playingPos = normalizePlaylistIndex(playingPosValue);
|
||||
if (playingPos !== null && playingPos < playlist.length) {
|
||||
return playlist[playingPos] ?? null;
|
||||
}
|
||||
|
||||
return playlist.find((entry) => entry.current || entry.playing) ?? null;
|
||||
}
|
||||
|
||||
async function requestMpvPropertySafely(
|
||||
deps: YoutubeMediaCachePlaybackRuntimeDeps,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
if (!deps.requestMpvProperty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await deps.requestMpvProperty(name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveYoutubeSourceFromPlaylist(
|
||||
deps: YoutubeMediaCachePlaybackRuntimeDeps,
|
||||
): Promise<string | null> {
|
||||
const [playingPosValue, playlistValue] = await Promise.all([
|
||||
requestMpvPropertySafely(deps, 'playlist-playing-pos'),
|
||||
requestMpvPropertySafely(deps, 'playlist'),
|
||||
]);
|
||||
const playlistEntry = resolvePlaylistEntry(
|
||||
normalizePlaylistEntries(playlistValue),
|
||||
playingPosValue,
|
||||
);
|
||||
return playlistEntry?.filename && isYoutubeMediaPath(playlistEntry.filename)
|
||||
? playlistEntry.filename
|
||||
: null;
|
||||
}
|
||||
|
||||
async function resolveYoutubeSourceUrl(
|
||||
deps: YoutubeMediaCachePlaybackRuntimeDeps,
|
||||
mediaPath: string,
|
||||
): Promise<string | null> {
|
||||
const directPath = trimToNonEmptyString(mediaPath);
|
||||
if (directPath && isYoutubeMediaPath(directPath)) {
|
||||
return directPath;
|
||||
}
|
||||
if (!directPath || !isGoogleVideoStreamPath(directPath)) {
|
||||
return null;
|
||||
}
|
||||
return await resolveYoutubeSourceFromPlaylist(deps);
|
||||
}
|
||||
|
||||
export function createYoutubeMediaCachePlaybackRuntime(deps: YoutubeMediaCachePlaybackRuntimeDeps) {
|
||||
let activeYoutubeSourceUrl: string | null = null;
|
||||
let activeYoutubeSourceUrlPromise: Promise<string | null> | null = null;
|
||||
let generation = 0;
|
||||
|
||||
const handleMediaPathChange = async (mediaPath: string): Promise<void> => {
|
||||
const currentGeneration = ++generation;
|
||||
const config = deps.getMediaCacheConfig();
|
||||
if (config.mode !== 'background') {
|
||||
activeYoutubeSourceUrl = null;
|
||||
activeYoutubeSourceUrlPromise = Promise.resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isYoutubeMediaPath(mediaPath)) {
|
||||
activeYoutubeSourceUrl = null;
|
||||
}
|
||||
|
||||
const sourceUrlPromise = resolveYoutubeSourceUrl(deps, mediaPath);
|
||||
activeYoutubeSourceUrlPromise = sourceUrlPromise.then((sourceUrl) =>
|
||||
currentGeneration === generation ? sourceUrl : null,
|
||||
);
|
||||
|
||||
const sourceUrl = await sourceUrlPromise;
|
||||
if (currentGeneration !== generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeYoutubeSourceUrl = sourceUrl;
|
||||
if (!sourceUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
deps.startYoutubeMediaCache(sourceUrl, {
|
||||
mode: config.mode,
|
||||
maxHeight: config.maxHeight,
|
||||
});
|
||||
} catch (error) {
|
||||
deps.logWarn(
|
||||
`Failed to start YouTube media cache: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getActiveYoutubeSourceUrl: async (): Promise<string | null> =>
|
||||
activeYoutubeSourceUrlPromise ?? activeYoutubeSourceUrl,
|
||||
getActiveYoutubeSourceUrlSnapshot: (): string | null => activeYoutubeSourceUrl,
|
||||
handleMediaPathChange,
|
||||
};
|
||||
}
|
||||
@@ -146,3 +146,135 @@ test('youtube playback runtime resolves the socket path lazily for windows start
|
||||
|
||||
assert.ok(calls.some((entry) => entry.includes('--input-ipc-server=/tmp/updated.sock')));
|
||||
});
|
||||
|
||||
test('youtube playback runtime starts media cache without blocking the subtitle flow', async () => {
|
||||
const calls: string[] = [];
|
||||
let resolveCache: (() => void) | undefined;
|
||||
const cachePromise = new Promise<void>((resolve) => {
|
||||
resolveCache = resolve;
|
||||
});
|
||||
|
||||
const runtime = createYoutubePlaybackRuntime({
|
||||
platform: 'linux',
|
||||
directPlaybackFormat: 'best',
|
||||
mpvYtdlFormat: 'bestvideo+bestaudio',
|
||||
autoLaunchTimeoutMs: 2_000,
|
||||
connectTimeoutMs: 1_000,
|
||||
getSocketPath: () => '/tmp/mpv.sock',
|
||||
getMpvConnected: () => true,
|
||||
invalidatePendingAutoplayReadyFallbacks: () => {
|
||||
calls.push('invalidate-autoplay');
|
||||
},
|
||||
setAppOwnedFlowInFlight: (next) => {
|
||||
calls.push(`app-owned:${next}`);
|
||||
},
|
||||
ensureYoutubePlaybackRuntimeReady: async () => {
|
||||
calls.push('ensure-runtime-ready');
|
||||
},
|
||||
resolveYoutubePlaybackUrl: async () => {
|
||||
throw new Error('linux path should not resolve direct playback url');
|
||||
},
|
||||
launchWindowsMpv: async () => ({ ok: false }),
|
||||
waitForYoutubeMpvConnected: async () => true,
|
||||
prepareYoutubePlaybackInMpv: async ({ url }) => {
|
||||
calls.push(`prepare:${url}`);
|
||||
return true;
|
||||
},
|
||||
startYoutubeMediaCache: async (url) => {
|
||||
calls.push(`cache:${url}`);
|
||||
await cachePromise;
|
||||
calls.push('cache-done');
|
||||
},
|
||||
runYoutubePlaybackFlow: async ({ url, mode }) => {
|
||||
calls.push(`run-flow:${url}:${mode}`);
|
||||
},
|
||||
logInfo: (message) => {
|
||||
calls.push(`info:${message}`);
|
||||
},
|
||||
logWarn: (message) => {
|
||||
calls.push(`warn:${message}`);
|
||||
},
|
||||
schedule: () => 1 as never,
|
||||
clearScheduled: () => {},
|
||||
});
|
||||
|
||||
await runtime.runYoutubePlaybackFlow({
|
||||
url: 'https://youtu.be/demo',
|
||||
mode: 'download',
|
||||
source: 'second-instance',
|
||||
});
|
||||
|
||||
const prepareIndex = calls.indexOf('prepare:https://youtu.be/demo');
|
||||
const cacheIndex = calls.indexOf('cache:https://youtu.be/demo');
|
||||
const runFlowIndex = calls.indexOf('run-flow:https://youtu.be/demo:download');
|
||||
assert.notEqual(prepareIndex, -1);
|
||||
assert.notEqual(cacheIndex, -1);
|
||||
assert.notEqual(runFlowIndex, -1);
|
||||
assert.ok(prepareIndex < cacheIndex);
|
||||
assert.ok(cacheIndex < runFlowIndex);
|
||||
assert.equal(calls.includes('cache-done'), false);
|
||||
const resolveCacheNow = resolveCache;
|
||||
assert.ok(resolveCacheNow);
|
||||
resolveCacheNow();
|
||||
});
|
||||
|
||||
test('youtube playback runtime logs synchronous media cache startup failures', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = createYoutubePlaybackRuntime({
|
||||
platform: 'linux',
|
||||
directPlaybackFormat: 'best',
|
||||
mpvYtdlFormat: 'bestvideo+bestaudio',
|
||||
autoLaunchTimeoutMs: 2_000,
|
||||
connectTimeoutMs: 1_000,
|
||||
getSocketPath: () => '/tmp/mpv.sock',
|
||||
getMpvConnected: () => true,
|
||||
invalidatePendingAutoplayReadyFallbacks: () => {
|
||||
calls.push('invalidate-autoplay');
|
||||
},
|
||||
setAppOwnedFlowInFlight: (next) => {
|
||||
calls.push(`app-owned:${next}`);
|
||||
},
|
||||
ensureYoutubePlaybackRuntimeReady: async () => {
|
||||
calls.push('ensure-runtime-ready');
|
||||
},
|
||||
resolveYoutubePlaybackUrl: async () => {
|
||||
throw new Error('linux path should not resolve direct playback url');
|
||||
},
|
||||
launchWindowsMpv: async () => ({ ok: false }),
|
||||
waitForYoutubeMpvConnected: async () => true,
|
||||
prepareYoutubePlaybackInMpv: async ({ url }) => {
|
||||
calls.push(`prepare:${url}`);
|
||||
return true;
|
||||
},
|
||||
startYoutubeMediaCache: () => {
|
||||
calls.push('cache');
|
||||
throw new Error('cache exploded');
|
||||
},
|
||||
runYoutubePlaybackFlow: async ({ url, mode }) => {
|
||||
calls.push(`run-flow:${url}:${mode}`);
|
||||
},
|
||||
logInfo: (message) => {
|
||||
calls.push(`info:${message}`);
|
||||
},
|
||||
logWarn: (message) => {
|
||||
calls.push(`warn:${message}`);
|
||||
},
|
||||
schedule: () => 1 as never,
|
||||
clearScheduled: () => {},
|
||||
});
|
||||
|
||||
await runtime.runYoutubePlaybackFlow({
|
||||
url: 'https://youtu.be/demo',
|
||||
mode: 'download',
|
||||
source: 'second-instance',
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
assert.ok(calls.includes('run-flow:https://youtu.be/demo:download'));
|
||||
assert.ok(
|
||||
calls.some((entry) =>
|
||||
entry.startsWith('warn:Failed to start YouTube media cache: cache exploded'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ export type YoutubePlaybackRuntimeDeps = {
|
||||
launchWindowsMpv: (playbackUrl: string, args: string[]) => Promise<LaunchResult>;
|
||||
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>;
|
||||
prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise<boolean>;
|
||||
startYoutubeMediaCache?: (url: string) => void | Promise<void>;
|
||||
runYoutubePlaybackFlow: (request: {
|
||||
url: string;
|
||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||
@@ -126,6 +127,17 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
||||
if (!mediaReady) {
|
||||
throw new Error('Timed out waiting for mpv to load the requested YouTube URL.');
|
||||
}
|
||||
if (deps.startYoutubeMediaCache) {
|
||||
void new Promise<void>((resolve) => {
|
||||
resolve(deps.startYoutubeMediaCache?.(request.url));
|
||||
}).catch((error) => {
|
||||
deps.logWarn(
|
||||
`Failed to start YouTube media cache: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
await deps.runYoutubePlaybackFlow({
|
||||
url: request.url,
|
||||
|
||||
+150
-3
@@ -8,6 +8,10 @@ import { buildAnimatedImageVideoFilter, MediaGenerator } from './media-generator
|
||||
|
||||
async function withStubbedFfmpeg(
|
||||
run: (generator: MediaGenerator, argsPath: string) => Promise<void>,
|
||||
options: {
|
||||
logDebug?: (message: string) => void;
|
||||
now?: () => number;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-media-generator-test-'));
|
||||
const binDir = path.join(root, 'bin');
|
||||
@@ -25,7 +29,7 @@ async function withStubbedFfmpeg(
|
||||
" console.log(' V..... libaom-av1');",
|
||||
' process.exit(0);',
|
||||
'}',
|
||||
"fs.writeFileSync(process.env.SUBMINER_TEST_FFMPEG_ARGS, `${args.join('\\n')}\\n`, 'utf8');",
|
||||
"fs.writeFileSync(process.env.SUBMINER_TEST_FFMPEG_ARGS, JSON.stringify(args), 'utf8');",
|
||||
'const outputPath = args.at(-1);',
|
||||
"fs.writeFileSync(outputPath, 'avif', 'utf8');",
|
||||
].join('\n'),
|
||||
@@ -44,7 +48,7 @@ async function withStubbedFfmpeg(
|
||||
const originalArgsPath = process.env.SUBMINER_TEST_FFMPEG_ARGS;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
|
||||
process.env.SUBMINER_TEST_FFMPEG_ARGS = argsPath;
|
||||
const generator = new MediaGenerator(tempDir);
|
||||
const generator = new MediaGenerator(tempDir, options);
|
||||
|
||||
try {
|
||||
await run(generator, argsPath);
|
||||
@@ -61,7 +65,7 @@ async function withStubbedFfmpeg(
|
||||
}
|
||||
|
||||
function readFfmpegArgs(argsPath: string): string[] {
|
||||
return fs.readFileSync(argsPath, 'utf8').trim().split('\n');
|
||||
return JSON.parse(fs.readFileSync(argsPath, 'utf8')) as string[];
|
||||
}
|
||||
|
||||
test('buildAnimatedImageVideoFilter holds lead-in until the next frame after the audio boundary', () => {
|
||||
@@ -159,6 +163,24 @@ test('generateAudio defaults to unpadded sentence timing', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAudio normalizes sentence audio by default', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAudio('/video.mp4', 10, 12);
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args[args.indexOf('-af') + 1], 'loudnorm=I=-23:TP=-2:LRA=11');
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAudio can preserve raw sentence audio loudness', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAudio('/video.mp4', 10, 12, 0, null, false);
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args.includes('-af'), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAudio clips leading padding without adding it to trailing duration', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAudio('/video.mp4', 0.2, 1.2, 0.5);
|
||||
@@ -182,3 +204,128 @@ test('generateAudio recreates missing temp directory before invoking ffmpeg', as
|
||||
assert.equal(fs.existsSync(path.dirname(outputPath!)), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAudio adds remote input options before the ffmpeg input', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAudio(
|
||||
{
|
||||
path: 'https://rr1---sn.example.googlevideo.com/videoplayback?mime=audio%2Fwebm',
|
||||
inputOptions: {
|
||||
reconnect: true,
|
||||
userAgent: 'Mozilla/5.0',
|
||||
headers: {
|
||||
Referer: 'https://www.youtube.com/',
|
||||
Origin: 'https://www.youtube.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
10,
|
||||
12,
|
||||
);
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
const inputIndex = args.indexOf('-i');
|
||||
assert.ok(inputIndex > 0);
|
||||
assert.ok(args.indexOf('-reconnect') > -1);
|
||||
assert.ok(args.indexOf('-reconnect') < inputIndex);
|
||||
assert.equal(args[args.indexOf('-reconnect') + 1], '1');
|
||||
assert.equal(args[args.indexOf('-reconnect_streamed') + 1], '1');
|
||||
assert.equal(args[args.indexOf('-reconnect_on_network_error') + 1], '1');
|
||||
assert.equal(args[args.indexOf('-reconnect_on_http_error') + 1], '403,5xx');
|
||||
assert.equal(args[args.indexOf('-reconnect_delay_max') + 1], '5');
|
||||
assert.equal(args[args.indexOf('-user_agent') + 1], 'Mozilla/5.0');
|
||||
assert.equal(
|
||||
args[args.indexOf('-headers') + 1],
|
||||
'Referer: https://www.youtube.com/\r\nOrigin: https://www.youtube.com\r\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAudio skips stale audio stream maps for single resolved streams', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAudio(
|
||||
{
|
||||
path: 'https://rr1---sn.example.googlevideo.com/videoplayback?mime=audio%2Fwebm',
|
||||
singleResolvedStream: true,
|
||||
},
|
||||
10,
|
||||
12,
|
||||
0,
|
||||
22,
|
||||
);
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args.includes('-map'), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAudio keeps explicit audio stream maps for normal media paths', async () => {
|
||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||
await generator.generateAudio('/video.mp4', 10, 12, 0, 2);
|
||||
|
||||
const args = readFfmpegArgs(argsPath);
|
||||
assert.equal(args[args.indexOf('-map') + 1], '0:2');
|
||||
});
|
||||
});
|
||||
|
||||
test('generateAudio debug-logs cached input and completion timing', async () => {
|
||||
const logs: string[] = [];
|
||||
const times = [1000, 1052];
|
||||
|
||||
await withStubbedFfmpeg(
|
||||
async (generator) => {
|
||||
await generator.generateAudio(
|
||||
{
|
||||
path: '/tmp/subminer-youtube-media-cache/abc123/media.mkv',
|
||||
source: 'youtube-cache',
|
||||
},
|
||||
10,
|
||||
12,
|
||||
);
|
||||
},
|
||||
{
|
||||
logDebug: (message) => logs.push(message),
|
||||
now: () => times.shift() ?? 1052,
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(logs.join('\n'), /\[media-generator\] audio start/);
|
||||
assert.match(logs.join('\n'), /source=youtube-cache/);
|
||||
assert.match(
|
||||
logs.join('\n'),
|
||||
/input=local:\/tmp\/subminer-youtube-media-cache\/abc123\/media\.mkv/,
|
||||
);
|
||||
assert.match(logs.join('\n'), /\[media-generator\] audio complete/);
|
||||
assert.match(logs.join('\n'), /elapsedMs=52/);
|
||||
assert.match(logs.join('\n'), /bytes=4/);
|
||||
});
|
||||
|
||||
test('generateAudio debug logs sanitize remote inputs', async () => {
|
||||
const logs: string[] = [];
|
||||
const times = [1000, 1003];
|
||||
|
||||
await withStubbedFfmpeg(
|
||||
async (generator) => {
|
||||
await generator.generateAudio(
|
||||
{
|
||||
path: 'https://rr1---sn.example.googlevideo.com/videoplayback?signature=secret&expire=123',
|
||||
inputOptions: {
|
||||
reconnect: true,
|
||||
headers: {
|
||||
Referer: 'https://www.youtube.com/watch?v=abc123',
|
||||
},
|
||||
},
|
||||
},
|
||||
10,
|
||||
12,
|
||||
);
|
||||
},
|
||||
{
|
||||
logDebug: (message) => logs.push(message),
|
||||
now: () => times.shift() ?? 1003,
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(logs.join('\n'), /input=remote:rr1---sn\.example\.googlevideo\.com/);
|
||||
assert.doesNotMatch(logs.join('\n'), /signature=secret|expire=123|Referer|abc123/);
|
||||
});
|
||||
|
||||
+150
-8
@@ -21,8 +21,12 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { createLogger } from './logger';
|
||||
import { normalizeMediaInput, type MediaInput } from './media-input';
|
||||
|
||||
const log = createLogger('media');
|
||||
const AUDIO_NORMALIZATION_FILTER = 'loudnorm=I=-23:TP=-2:LRA=11';
|
||||
|
||||
export type { MediaInput, MediaInputOptions } from './media-input';
|
||||
|
||||
function normalizeAnimatedImageFps(fps: number | undefined): number {
|
||||
const fallbackFps = 10;
|
||||
@@ -69,12 +73,65 @@ export function buildAnimatedImageVideoFilter(options: {
|
||||
return vfParts.join(',');
|
||||
}
|
||||
|
||||
export interface MediaGeneratorOptions {
|
||||
logDebug?: (message: string) => void;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
function sanitizeDebugToken(value: string, fallback: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return fallback;
|
||||
}
|
||||
const sanitized = trimmed.replace(/[^A-Za-z0-9_.:-]+/g, '-').slice(0, 80);
|
||||
return sanitized || fallback;
|
||||
}
|
||||
|
||||
function describeMediaInputPathForDebugLog(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 describeMediaInputForDebugLog(input: MediaInput): string {
|
||||
const pathValue = typeof input === 'string' ? input : input.path;
|
||||
const sourceValue = typeof input === 'string' ? 'raw' : input.source;
|
||||
const source = sanitizeDebugToken(sourceValue ?? 'raw', 'raw');
|
||||
return `source=${source} input=${describeMediaInputPathForDebugLog(pathValue)}`;
|
||||
}
|
||||
|
||||
function describeFfmpegFailureForDebugLog(error: ExecFileException): string {
|
||||
const code = typeof error.code === 'string' || typeof error.code === 'number' ? error.code : null;
|
||||
const signal = typeof error.signal === 'string' ? error.signal : null;
|
||||
if (code !== null) {
|
||||
return `code=${code}`;
|
||||
}
|
||||
if (signal) {
|
||||
return `signal=${signal}`;
|
||||
}
|
||||
return `name=${sanitizeDebugToken(error.name || 'Error', 'Error')}`;
|
||||
}
|
||||
|
||||
export class MediaGenerator {
|
||||
private tempDir: string;
|
||||
private notifyIconDir: string;
|
||||
private av1EncoderPromise: Promise<string | null> | null = null;
|
||||
private readonly options: MediaGeneratorOptions;
|
||||
|
||||
constructor(tempDir?: string) {
|
||||
constructor(tempDir?: string, options: MediaGeneratorOptions = {}) {
|
||||
this.options = options;
|
||||
this.tempDir = tempDir || path.join(os.tmpdir(), 'subminer-media');
|
||||
this.notifyIconDir = path.join(os.tmpdir(), 'subminer-notify');
|
||||
this.ensureDirectory(this.tempDir);
|
||||
@@ -83,6 +140,28 @@ export class MediaGenerator {
|
||||
this.cleanupOldNotificationIcons();
|
||||
}
|
||||
|
||||
private nowMs(): number {
|
||||
try {
|
||||
const value = this.options.now?.() ?? Date.now();
|
||||
return Number.isFinite(value) ? value : Date.now();
|
||||
} catch {
|
||||
return Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
private elapsedMs(startedAt: number): number {
|
||||
return Math.max(0, Math.round(this.nowMs() - startedAt));
|
||||
}
|
||||
|
||||
private logMediaDebug(message: string): void {
|
||||
const logDebug = this.options.logDebug ?? ((line: string) => log.debug(line));
|
||||
try {
|
||||
logDebug(`[media-generator] ${message}`);
|
||||
} catch {
|
||||
// Debug logging should not affect media generation.
|
||||
}
|
||||
}
|
||||
|
||||
private ensureDirectory(dir: string): void {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
@@ -181,21 +260,34 @@ export class MediaGenerator {
|
||||
}
|
||||
|
||||
async generateAudio(
|
||||
videoPath: string,
|
||||
videoPath: MediaInput,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
padding: number = 0,
|
||||
audioStreamIndex: number | null = null,
|
||||
normalizeAudio = true,
|
||||
): Promise<Buffer> {
|
||||
const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
|
||||
const start = Math.max(0, startTime - safePadding);
|
||||
const duration = endTime - start + safePadding;
|
||||
const mediaInput = normalizeMediaInput(videoPath);
|
||||
const inputDescription = describeMediaInputForDebugLog(videoPath);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const outputPath = this.createTempOutputPath('audio', 'mp3');
|
||||
const args: string[] = ['-ss', start.toString(), '-t', duration.toString(), '-i', videoPath];
|
||||
const startedAt = this.nowMs();
|
||||
const args: string[] = [
|
||||
'-ss',
|
||||
start.toString(),
|
||||
'-t',
|
||||
duration.toString(),
|
||||
...mediaInput.inputArgs,
|
||||
'-i',
|
||||
mediaInput.path,
|
||||
];
|
||||
|
||||
if (
|
||||
!mediaInput.singleResolvedStream &&
|
||||
typeof audioStreamIndex === 'number' &&
|
||||
Number.isInteger(audioStreamIndex) &&
|
||||
audioStreamIndex >= 0
|
||||
@@ -203,10 +295,20 @@ export class MediaGenerator {
|
||||
args.push('-map', `0:${audioStreamIndex}`);
|
||||
}
|
||||
|
||||
args.push('-vn', '-acodec', 'libmp3lame', '-q:a', '2', '-ar', '44100', '-y', outputPath);
|
||||
args.push('-vn');
|
||||
if (normalizeAudio) {
|
||||
args.push('-af', AUDIO_NORMALIZATION_FILTER);
|
||||
}
|
||||
args.push('-acodec', 'libmp3lame', '-q:a', '2', '-ar', '44100', '-y', outputPath);
|
||||
|
||||
this.logMediaDebug(
|
||||
`audio start ${inputDescription} start=${start} duration=${duration} padding=${safePadding}`,
|
||||
);
|
||||
execFile('ffmpeg', args, { timeout: 30000 }, (error) => {
|
||||
if (error) {
|
||||
this.logMediaDebug(
|
||||
`audio failed ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} ${describeFfmpegFailureForDebugLog(error)}`,
|
||||
);
|
||||
reject(this.ffmpegError('audio generation', error));
|
||||
return;
|
||||
}
|
||||
@@ -214,6 +316,9 @@ export class MediaGenerator {
|
||||
try {
|
||||
const data = fs.readFileSync(outputPath);
|
||||
fs.unlinkSync(outputPath);
|
||||
this.logMediaDebug(
|
||||
`audio complete ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} bytes=${data.byteLength}`,
|
||||
);
|
||||
resolve(data);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
@@ -223,7 +328,7 @@ export class MediaGenerator {
|
||||
}
|
||||
|
||||
async generateScreenshot(
|
||||
videoPath: string,
|
||||
videoPath: MediaInput,
|
||||
timestamp: number,
|
||||
options: {
|
||||
format: 'jpg' | 'png' | 'webp';
|
||||
@@ -239,8 +344,18 @@ export class MediaGenerator {
|
||||
png: 'png',
|
||||
webp: 'webp',
|
||||
};
|
||||
const mediaInput = normalizeMediaInput(videoPath);
|
||||
const inputDescription = describeMediaInputForDebugLog(videoPath);
|
||||
|
||||
const args: string[] = ['-ss', timestamp.toString(), '-i', videoPath, '-vframes', '1'];
|
||||
const args: string[] = [
|
||||
'-ss',
|
||||
timestamp.toString(),
|
||||
...mediaInput.inputArgs,
|
||||
'-i',
|
||||
mediaInput.path,
|
||||
'-vframes',
|
||||
'1',
|
||||
];
|
||||
|
||||
const vfParts: string[] = [];
|
||||
if (maxWidth && maxWidth > 0 && maxHeight && maxHeight > 0) {
|
||||
@@ -270,10 +385,17 @@ export class MediaGenerator {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const outputPath = this.createTempOutputPath('screenshot', ext);
|
||||
const startedAt = this.nowMs();
|
||||
args.push(outputPath);
|
||||
|
||||
this.logMediaDebug(
|
||||
`screenshot start ${inputDescription} timestamp=${timestamp} format=${format} maxWidth=${maxWidth ?? 'none'} maxHeight=${maxHeight ?? 'none'}`,
|
||||
);
|
||||
execFile('ffmpeg', args, { timeout: 30000 }, (error) => {
|
||||
if (error) {
|
||||
this.logMediaDebug(
|
||||
`screenshot failed ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} ${describeFfmpegFailureForDebugLog(error)}`,
|
||||
);
|
||||
reject(this.ffmpegError('screenshot generation', error));
|
||||
return;
|
||||
}
|
||||
@@ -281,6 +403,9 @@ export class MediaGenerator {
|
||||
try {
|
||||
const data = fs.readFileSync(outputPath);
|
||||
fs.unlinkSync(outputPath);
|
||||
this.logMediaDebug(
|
||||
`screenshot complete ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} bytes=${data.byteLength}`,
|
||||
);
|
||||
resolve(data);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
@@ -334,7 +459,7 @@ export class MediaGenerator {
|
||||
}
|
||||
|
||||
async generateAnimatedImage(
|
||||
videoPath: string,
|
||||
videoPath: MediaInput,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
padding: number = 0,
|
||||
@@ -352,10 +477,15 @@ export class MediaGenerator {
|
||||
const start = Math.max(0, startTime - safePadding);
|
||||
const duration = roundDurationUpToNextFrameBoundary(endTime - start + safePadding, clampedFps);
|
||||
const totalLeadingStillDuration = Math.max(0, leadingStillDuration);
|
||||
const inputDescription = describeMediaInputForDebugLog(videoPath);
|
||||
|
||||
const clampedCrf = Math.max(0, Math.min(63, crf));
|
||||
|
||||
const encoderDetectionStartedAt = this.nowMs();
|
||||
const av1Encoder = await this.detectAv1Encoder();
|
||||
this.logMediaDebug(
|
||||
`animated-image encoder ${inputDescription} elapsedMs=${this.elapsedMs(encoderDetectionStartedAt)} encoder=${av1Encoder ?? 'none'}`,
|
||||
);
|
||||
if (!av1Encoder) {
|
||||
throw new Error(
|
||||
'No supported AV1 encoder found for animated AVIF (tried libaom-av1, libsvtav1, librav1e).',
|
||||
@@ -364,6 +494,8 @@ export class MediaGenerator {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const outputPath = this.createTempOutputPath('animation', 'avif');
|
||||
const mediaInput = normalizeMediaInput(videoPath);
|
||||
const startedAt = this.nowMs();
|
||||
|
||||
const encoderArgs: string[] = ['-c:v', av1Encoder];
|
||||
if (av1Encoder === 'libaom-av1') {
|
||||
@@ -375,6 +507,9 @@ export class MediaGenerator {
|
||||
encoderArgs.push('-qp', clampedCrf.toString(), '-speed', '8');
|
||||
}
|
||||
|
||||
this.logMediaDebug(
|
||||
`animated-image start ${inputDescription} start=${start} duration=${duration} padding=${safePadding} fps=${clampedFps} maxWidth=${maxWidth ?? 'none'} maxHeight=${maxHeight ?? 'none'} crf=${clampedCrf} encoder=${av1Encoder}`,
|
||||
);
|
||||
execFile(
|
||||
'ffmpeg',
|
||||
[
|
||||
@@ -382,8 +517,9 @@ export class MediaGenerator {
|
||||
start.toString(),
|
||||
'-t',
|
||||
duration.toString(),
|
||||
...mediaInput.inputArgs,
|
||||
'-i',
|
||||
videoPath,
|
||||
mediaInput.path,
|
||||
'-vf',
|
||||
buildAnimatedImageVideoFilter({
|
||||
fps: clampedFps,
|
||||
@@ -398,6 +534,9 @@ export class MediaGenerator {
|
||||
{ timeout: 60000 },
|
||||
(error) => {
|
||||
if (error) {
|
||||
this.logMediaDebug(
|
||||
`animated-image failed ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} ${describeFfmpegFailureForDebugLog(error)}`,
|
||||
);
|
||||
reject(this.ffmpegError('animation generation', error));
|
||||
return;
|
||||
}
|
||||
@@ -405,6 +544,9 @@ export class MediaGenerator {
|
||||
try {
|
||||
const data = fs.readFileSync(outputPath);
|
||||
fs.unlinkSync(outputPath);
|
||||
this.logMediaDebug(
|
||||
`animated-image complete ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} bytes=${data.byteLength}`,
|
||||
);
|
||||
resolve(data);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user