mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-10 04:19:25 -07:00
Compare commits
18 Commits
refactor-m
...
ba9bae63e4
| Author | SHA1 | Date | |
|---|---|---|---|
|
ba9bae63e4
|
|||
|
415c758840
|
|||
|
ff72976bae
|
|||
|
0def04b09c
|
|||
|
6bf148514e
|
|||
|
07b91f8704
|
|||
|
d8a7ae77b0
|
|||
|
809b57af44
|
|||
|
ef716b82c7
|
|||
|
d65575c80d
|
|||
|
8da3a26855
|
|||
|
8928bfdf7e
|
|||
|
16f7b2507b
|
|||
|
7d8d2ae7a7
|
|||
|
3fb33af116
|
|||
|
8ddace5536
|
|||
|
e7242d006f
|
|||
|
7666a094f4
|
15
CHANGELOG.md
15
CHANGELOG.md
@@ -1,16 +1,25 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.9.0 (2026-03-22)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Subtitle Sidebar: Added subtitle sidebar state and behavior updates, including startup-auto-open controls and resume positioning improvements.
|
||||||
|
- Subtitle Sidebar: Fixed subtitle prefetch and embedded overlay passthrough sync between sidebar and overlay subtitle rendering.
|
||||||
|
- Launcher: Added an app-owned YouTube subtitle picker flow that boots mpv paused, opens an overlay track picker, and downloads selected subtitles into external files.
|
||||||
|
- Launcher: Added explicit `download` and `generate` YouTube subtitle modes with `download` as the default path.
|
||||||
|
- Launcher: Disabled mpv native YouTube subtitle auto-loading for the app-owned flow so external subtitle files stay authoritative.
|
||||||
|
- Launcher: Added OSD status messages for YouTube playback startup, subtitle acquisition, and subtitle loading so the flow stays visible before and during the picker.
|
||||||
|
|
||||||
## v0.8.0 (2026-03-22)
|
## v0.8.0 (2026-03-22)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Overlay: Added the subtitle sidebar feature with a new `subtitleSidebar` configuration surface.
|
- Overlay: Added the subtitle sidebar feature with a new `subtitleSidebar` configuration surface and rendered sidebar modal with cue list rendering, click-to-seek, active-cue highlighting, and embedded layout support.
|
||||||
- Overlay: Added a sidebar modal with cue list rendering, click-to-seek, active-cue highlighting, and embedded layout support.
|
|
||||||
- IPC: Added sidebar snapshot plumbing between renderer and main process for overlay/sidebar synchronization.
|
- IPC: Added sidebar snapshot plumbing between renderer and main process for overlay/sidebar synchronization.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Config: Added hot-reloadable sidebar options for enablement, layout, visibility, typography, opacity, sizing, and interaction behavior (`autoOpen`, `pauseOnHover`, `autoScroll`, toggle key).
|
- Config: Added hot-reloadable sidebar options for enablement, layout, visibility, typography, opacity, sizing, and interaction behavior (`autoOpen`, `pauseOnHover`, `autoScroll`, toggle key).
|
||||||
- Docs: Added full `subtitleSidebar` documentation coverage, including sample config, option table, and toggle shortcut notes.
|
- Docs: Added full `subtitleSidebar` documentation coverage, including sample config, option table, and toggle shortcut notes.
|
||||||
- Runtime: Improved subtitle prefetch and rendering flow so sidebar and overlay subtitle states are kept in sync across media transitions.
|
- Runtime: Improved subtitle prefetch/rendering flow so sidebar and overlay subtitle states stay in sync across media transitions.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Overlay: Kept sidebar cue tracking stable across playback transitions and timing edge cases.
|
- Overlay: Kept sidebar cue tracking stable across playback transitions and timing edge cases.
|
||||||
|
|||||||
216
README.md
216
README.md
@@ -1,60 +1,159 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/SubMiner.png" width="140" alt="SubMiner logo">
|
|
||||||
|
<img src="assets/SubMiner.png" width="160" alt="SubMiner logo">
|
||||||
|
|
||||||
# SubMiner
|
# SubMiner
|
||||||
|
|
||||||
**Sentence-mine from mpv — look up words, one-key Anki export, immersion tracking.**
|
## Turn mpv into a sentence-mining workstation.
|
||||||
|
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
Look up words with Yomitan, export to Anki in one key, track your immersion — all without leaving mpv.
|
||||||
[](https://github.com/ksyasuda/SubMiner)
|
|
||||||
[](https://docs.subminer.moe)
|
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||||
[](https://aur.archlinux.org/packages/subminer-bin)
|
[](https://github.com/ksyasuda/SubMiner)
|
||||||
|
[](https://docs.subminer.moe)
|
||||||
|
[](https://aur.archlinux.org/packages/subminer-bin)
|
||||||
|
|
||||||
|
[](./assets/minecard.mp4)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
## How It Works
|
||||||
|
|
||||||
SubMiner is an Electron overlay for [mpv](https://mpv.io) that turns video into a sentence-mining workstation. Look up any word with [Yomitan](https://github.com/yomidevs/yomitan), mine it to Anki with one key, and track your immersion over time.
|
SubMiner runs as an invisible Electron overlay on top of mpv. Subtitles render as an interactive layer. Move your cursor over any word and trigger a [Yomitan](https://github.com/yomidevs/yomitan) lookup. Press one key to snapshot the sentence, audio, and screenshot into Anki via AnkiConnect.
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
[](./assets/minecard.mp4)
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
**Dictionary lookups** — Yomitan runs inside the overlay. Hover or navigate to any word for full dictionary popups without leaving mpv.
|
### Dictionary Lookups
|
||||||
|
|
||||||
**One-key Anki mining** — Press one key to create a card with the sentence, audio clip, screenshot, and machine translation from the exact playback moment.
|
Yomitan runs inside the overlay. Trigger a lookup on any word for full dictionary popups — definitions, pitch accent, frequency data — without ever leaving mpv.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan popup with dictionary entry and mine button over annotated subtitles in mpv">
|
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan dictionary popup over annotated subtitles in mpv">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**Reading annotations** — Real-time subtitle annotations with N+1 targeting, frequency highlighting, JLPT tags, and a character name dictionary. Grammar-only tokens render as plain text.
|
<br>
|
||||||
|
|
||||||
|
### Instant Anki Mining
|
||||||
|
|
||||||
|
Create an Anki card with the sentence, audio clip, screenshot, and machine translation from the exact playback moment with one key press, click, or controller input.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs-site/public/screenshots/annotations.png" width="800" alt="Annotated subtitles with frequency highlighting, JLPT underlines, known words, and N+1 targets">
|
<img src="docs-site/public/screenshots/one-key-mining.png" width="800" alt="Anki card created from SubMiner with sentence, audio, and screenshot">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**Immersion dashboard** — Local stats dashboard with watch time, anime progress, vocabulary growth, mining throughput, and session history.
|
<br>
|
||||||
|
|
||||||
|
### Reading Annotations
|
||||||
|
|
||||||
|
Real-time subtitle annotations with frequency highlighting, JLPT tags, N+1 targeting, and a character name dictionary. Known words fade back; new words stand out. Grammar-only tokens render as plain text so you focus on what matters.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs-site/public/screenshots/stats-overview.png" width="800" alt="Stats dashboard with watch time, cards mined, streaks, and tracking snapshot">
|
<img src="docs-site/public/screenshots/annotations.png" width="800" alt="Annotated subtitles with frequency coloring, JLPT underlines, and N+1 targets">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**Integrations** — AniList episode tracking, Jellyfin remote playback, Jimaku subtitle downloads, alass/ffsubsync, and an annotated websocket feed for external clients.
|
<br>
|
||||||
|
|
||||||
|
### Immersion Dashboard
|
||||||
|
|
||||||
|
Local stats dashboard — watch time, anime library, vocabulary growth, mining throughput, session history, and trends. All stored locally, no third-party tracking.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs-site/public/screenshots/texthooker.png" width="800" alt="Texthooker page with annotated subtitle lines and frequency highlighting">
|
<img src="docs-site/public/screenshots/stats-overview.png" width="800" alt="Stats dashboard showing watch time, cards mined, streaks, and tracking data">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
### Integrations
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><b>AniList</b></td>
|
||||||
|
<td>Automatic episode tracking and progress sync</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>Jellyfin</b></td>
|
||||||
|
<td>Browse and launch media from your Jellyfin server</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>Jimaku</b></td>
|
||||||
|
<td>Search and download Japanese subtitles</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>alass / ffsubsync</b></td>
|
||||||
|
<td>Automatic subtitle retiming</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>WebSocket</b></td>
|
||||||
|
<td>Annotated subtitle feed for external clients (texthooker pages, custom tools)</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs-site/public/screenshots/texthooker.png" width="800" alt="Texthooker page receiving annotated subtitle lines via WebSocket">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
| | Required | Optional |
|
||||||
|
| -------------- | --------------------------------------- | -------------------------------------- |
|
||||||
|
| **Player** | [`mpv`](https://mpv.io) with IPC socket | — |
|
||||||
|
| **Processing** | `ffmpeg`, `mecab` + `mecab-ipadic` | `guessit` (AniSkip) |
|
||||||
|
| **Media** | — | `yt-dlp`, `chafa`, `ffmpegthumbnailer` |
|
||||||
|
| **Selection** | — | `fzf` / `rofi` |
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> [`bun`](https://bun.sh) is required if building from source or using the CLI wrapper: `subminer`. Pre-built releases (AppImage, DMG, installer) do not require it.
|
||||||
|
|
||||||
|
**Platform-specific:**
|
||||||
|
|
||||||
|
| Linux | macOS | Windows |
|
||||||
|
| ----------------------------------- | ------------------------ | ------------- |
|
||||||
|
| `hyprctl` or `xdotool` + `xwininfo` | Accessibility permission | No extra deps |
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Arch Linux</b></summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
paru -S --needed mpv ffmpeg mecab-git mecab-ipadic
|
||||||
|
# Optional
|
||||||
|
paru -S --needed yt-dlp fzf rofi chafa ffmpegthumbnailer xdotool xorg-xwininfo
|
||||||
|
# X11 / XWAYLAND
|
||||||
|
paru -S --needed xdotool xorg-xwininfo
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>macOS</b></summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install mpv ffmpeg mecab mecab-ipadic
|
||||||
|
# Optional
|
||||||
|
brew install yt-dlp fzf rofi chafa ffmpegthumbnailer
|
||||||
|
```
|
||||||
|
|
||||||
|
Grant Accessibility permission to SubMiner in **System Settings > Privacy & Security > Accessibility**.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Windows</b></summary>
|
||||||
|
|
||||||
|
Install [`mpv`](https://mpv.io/installation/) and [`ffmpeg`](https://ffmpeg.org/download.html) and ensure both are on your `PATH`.
|
||||||
|
|
||||||
|
For MeCab, install [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Install
|
### 1. Install
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Arch Linux (AUR)</b></summary>
|
<summary><b>Arch Linux (AUR)</b></summary>
|
||||||
@@ -88,53 +187,62 @@ wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>macOS / Windows / From source</b></summary>
|
<summary><b>macOS</b></summary>
|
||||||
|
|
||||||
**macOS** — Download the latest DMG/ZIP from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) and drag `SubMiner.app` into `/Applications`.
|
Download the latest DMG or ZIP from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) and drag `SubMiner.app` into `/Applications`.
|
||||||
|
|
||||||
**Windows** — Download the latest installer or portable `.zip` from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). Keep `mpv` on `PATH`.
|
|
||||||
|
|
||||||
**From source** — See [docs.subminer.moe/installation#from-source](https://docs.subminer.moe/installation#from-source).
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
### First Launch
|
<details>
|
||||||
|
<summary><b>Windows</b></summary>
|
||||||
|
|
||||||
Run `SubMiner.AppImage` (Linux), `SubMiner.app` (macOS), or `SubMiner.exe` (Windows). On first launch, SubMiner starts in the tray, creates a default config, and opens a setup popup where you can install the mpv plugin and configure Yomitan dictionaries.
|
Download the latest installer or portable `.zip` from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). Make sure `mpv` is on your `PATH`.
|
||||||
|
|
||||||
### Mine
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>From source</b></summary>
|
||||||
|
|
||||||
|
See the [build-from-source guide](https://docs.subminer.moe/installation#from-source).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### 2. First Launch
|
||||||
|
|
||||||
|
Run the app. On first launch SubMiner starts in the system tray, creates a default config, and opens a setup popup to install the mpv plugin and configure Yomitan dictionaries.
|
||||||
|
|
||||||
|
### 3. Mine
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
subminer video.mkv # auto-starts overlay + resumes playback
|
subminer video.mkv # play video with overlay
|
||||||
subminer --start video.mkv # explicit overlay start (if plugin auto_start=no)
|
subminer --start video.mkv # explicit overlay start
|
||||||
subminer stats # open the immersion dashboard
|
subminer stats # open immersion dashboard
|
||||||
subminer stats -b # keep the stats daemon running in background
|
subminer stats -b # stats daemon in background
|
||||||
subminer stats -s # stop the dedicated stats daemon
|
|
||||||
subminer stats cleanup # repair/prune stored stats vocabulary rows
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
| Required | Optional |
|
|
||||||
| ------------------------------------------------------ | ----------------------------- |
|
|
||||||
| [`mpv`](https://mpv.io) with IPC socket | `yt-dlp` |
|
|
||||||
| `ffmpeg` | `guessit` (AniSkip detection) |
|
|
||||||
| `mecab` + `mecab-ipadic` | `fzf` / `rofi` |
|
|
||||||
| [`bun`](https://bun.sh) (source builds, Linux wrapper) | `chafa`, `ffmpegthumbnailer` |
|
|
||||||
| Linux: `hyprctl` or `xdotool` + `xwininfo` | |
|
|
||||||
| macOS: Accessibility permission | |
|
|
||||||
|
|
||||||
Windows uses native window tracking and does not need the Linux compositor tools.
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
Full guides on configuration, Anki, Jellyfin, immersion tracking, and more at **[docs.subminer.moe](https://docs.subminer.moe)**.
|
Full guides on configuration, Anki setup, Jellyfin, immersion tracking, and more: **[docs.subminer.moe](https://docs.subminer.moe)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
Built on [GameSentenceMiner](https://github.com/bpwhelan/GameSentenceMiner), [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui), [Anacreon-Script](https://github.com/friedrich-de/Anacreon-Script), and [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary). Subtitles from [Jimaku.cc](https://jimaku.cc). Lookups via [Yomitan](https://github.com/yomidevs/yomitan). JLPT tags from [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab).
|
SubMiner builds on the work of these open-source projects:
|
||||||
|
|
||||||
|
| Project | Role |
|
||||||
|
| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||||
|
| [Anacreon-Script](https://github.com/friedrich-de/Anacreon-Script) | Inspiration for the mining workflow |
|
||||||
|
| [asbplayer](https://github.com/killergerbah/asbplayer) | Inspiration for subtitle sidebar and logic for YouTube subtitle parsing |
|
||||||
|
| [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) | Character name recognition in subtitles |
|
||||||
|
| [GameSentenceMiner](https://github.com/bpwhelan/GameSentenceMiner) | Inspiration for Electron overlay with Yomitan integration |
|
||||||
|
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
|
||||||
|
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
|
||||||
|
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
|
||||||
|
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
|
||||||
|
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: TASK-194
|
id: TASK-194
|
||||||
title: Redesign YouTube subtitle acquisition around download-first track selection
|
title: App-owned YouTube subtitle picker flow
|
||||||
status: To Do
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-03-18 07:52'
|
created_date: '2026-03-18 07:52'
|
||||||
labels: []
|
labels: []
|
||||||
@@ -18,17 +18,16 @@ priority: medium
|
|||||||
## Description
|
## Description
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
Replace the current YouTube subtitle-generation-first flow with a download-first flow that enumerates available YouTube subtitle tracks, prompts for primary and secondary track selection before playback, downloads selected tracks into external subtitle files for mpv, and preserves generation as an explicit mode and as fallback behavior in auto mode. Keep the existing SubMiner tokenization and annotation pipeline as the downstream consumer of downloaded subtitle files.
|
Replace the YouTube subtitle-generation-first flow with an app-owned picker flow that boots mpv paused, opens an overlay track picker, downloads selected subtitles into external subtitle files, and preserves generation as an explicit mode. Keep the existing SubMiner tokenization and annotation pipeline as the downstream consumer of downloaded subtitle files.
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 Launcher and config expose YouTube subtitle acquisition modes `download`, `generate`, and `auto`, with `download` as the default for launcher YouTube playback.
|
- [x] #1 Launcher and app expose YouTube subtitle acquisition modes `download` and `generate`, with `download` as the default.
|
||||||
- [ ] #2 YouTube playback enumerates available subtitle tracks before mpv launch and presents a selection UI that supports primary and secondary subtitle choices.
|
- [x] #2 YouTube playback boots mpv paused and presents an overlay selection UI for primary and secondary subtitle choices.
|
||||||
- [ ] #3 Selected YouTube subtitle tracks are downloaded to external subtitle files and loaded into mpv before playback starts when download mode succeeds.
|
- [x] #3 Selected YouTube subtitle tracks are downloaded to external subtitle files and loaded into mpv before playback resumes.
|
||||||
- [ ] #4 `auto` mode attempts download-first for the selected tracks and falls back to generation only when required tracks cannot be downloaded or download fails.
|
- [x] #4 `generate` mode preserves the existing subtitle generation path as an explicit opt-in behavior.
|
||||||
- [ ] #5 `generate` mode preserves the existing whisper/AI generation path as an explicit opt-in behavior.
|
- [x] #5 Downloaded YouTube subtitle files integrate with the existing SubMiner subtitle/tokenization/annotation pipeline without regressing current overlay behavior.
|
||||||
- [ ] #6 Downloaded YouTube subtitle files integrate with the existing SubMiner subtitle/tokenization/annotation pipeline without regressing current overlay behavior.
|
- [x] #6 Tests cover mode selection, subtitle-track enumeration/selection flow, and the paused bootstrap plus app handoff path.
|
||||||
- [ ] #7 Tests cover mode selection, subtitle-track enumeration/selection flow, download-first success path, and fallback behavior for auto mode.
|
- [x] #7 User-facing config and launcher docs are updated to describe the new modes and default behavior.
|
||||||
- [ ] #8 User-facing config and launcher docs are updated to describe the new modes and default behavior.
|
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: subtitle sidebar
|
|
||||||
|
|
||||||
- Added subtitle sidebar state and behavior updates, including startup-auto-open controls and resume positioning improvements.
|
|
||||||
- Fixed subtitle prefetch and embedded overlay passthrough sync between sidebar and overlay subtitle rendering.
|
|
||||||
5
changes/2026-03-23-immersion-youtube.md
Normal file
5
changes/2026-03-23-immersion-youtube.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: immersion
|
||||||
|
|
||||||
|
- Hardened immersion tracker storage/session/query paths with the updated YouTube metadata flow.
|
||||||
|
- Added metadata probe support for YouTube subtitle retrieval edge cases.
|
||||||
@@ -29,7 +29,8 @@ In both modes, the enrichment workflow is the same:
|
|||||||
4. Fills the translation field from the secondary subtitle or AI.
|
4. Fills the translation field from the secondary subtitle or AI.
|
||||||
5. Writes metadata to the miscInfo field.
|
5. Writes metadata to the miscInfo field.
|
||||||
|
|
||||||
Polling mode uses the query `"deck:<your-deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks.
|
Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks.
|
||||||
|
Known-word sync scope is controlled by `ankiConnect.knownWords.decks` (object map), with `ankiConnect.deck` used as legacy fallback.
|
||||||
|
|
||||||
### Proxy Mode Setup (Yomitan / Texthooker)
|
### Proxy Mode Setup (Yomitan / Texthooker)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.9.0 (2026-03-22)
|
||||||
|
- Added an app-owned YouTube subtitle picker flow that boots mpv paused, opens an overlay picker, and downloads selected subtitles into external files before playback resumes.
|
||||||
|
- Added explicit launcher/app YouTube subtitle modes `download` and `generate`, with `download` as the default path.
|
||||||
|
- Disabled mpv native YouTube subtitle auto-loading for the app-owned flow so injected external subtitle files stay authoritative.
|
||||||
|
- Added OSD status updates covering YouTube playback startup, subtitle acquisition, and subtitle loading.
|
||||||
|
- Improved sidebar startup/resume behavior and overlay/sidebar subtitle synchronization.
|
||||||
|
|
||||||
## v0.8.0 (2026-03-22)
|
## v0.8.0 (2026-03-22)
|
||||||
- Added a configurable subtitle sidebar feature (`subtitleSidebar`) with overlay/embedded rendering, click-to-seek cue list, and hot-reloadable visibility and behavior controls.
|
- Added a configurable subtitle sidebar feature (`subtitleSidebar`) with overlay/embedded rendering, click-to-seek cue list, and hot-reloadable visibility and behavior controls.
|
||||||
- Added release docs updates for sidebar configuration, including options, sample config, and toggle shortcut behavior.
|
- Added a rendered sidebar modal with cue list display, click-to-seek, active-cue highlighting, and embedded layout support.
|
||||||
- Synced sidebar and overlay subtitle states during playback transitions via IPC-backed snapshot plumbing.
|
- Added sidebar snapshot plumbing between main and renderer for overlay/sidebar synchronization.
|
||||||
- Fixed sidebar cue tracking to remain stable across timing edge cases and stale subtitle refreshes.
|
- Added sidebar configuration options for visibility and behavior (enabled, layout, toggle key, autoOpen, pauseOnHover, autoScroll) plus typography and sizing controls.
|
||||||
- Improved sidebar resume/start behavior by jumping directly to the first resolved active cue.
|
- Documented `subtitleSidebar` configuration and behavior in user-facing docs (configuration.md, shortcuts.md, config.example.jsonc).
|
||||||
|
- Updated subtitle prefetch/rendering flow to keep overlay and sidebar state in sync through media transitions.
|
||||||
|
- Kept sidebar cue tracking stable across playback transitions and timing edge cases.
|
||||||
|
- Fixed sidebar startup/resume positioning to jump directly to the first resolved active cue.
|
||||||
|
- Prevented stale subtitle refreshes from regressing active-cue state.
|
||||||
|
|
||||||
## v0.7.0 (2026-03-19)
|
## v0.7.0 (2026-03-19)
|
||||||
- Added a full local immersion dashboard release line with Overview, Library, Trends, Vocabulary, and Sessions drill-down views backed by SQLite tracking data.
|
- Added a full local immersion dashboard release line with Overview, Library, Trends, Vocabulary, and Sessions drill-down views backed by SQLite tracking data.
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ For most users, start with this minimal configuration:
|
|||||||
"ankiConnect": {
|
"ankiConnect": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"deck": "YourDeckName",
|
"deck": "YourDeckName",
|
||||||
|
"knownWords": {
|
||||||
|
"decks": {
|
||||||
|
"YourDeckName": ["Word", "Word Reading", "Expression"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"sentence": "Sentence",
|
"sentence": "Sentence",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
@@ -26,6 +31,8 @@ For most users, start with this minimal configuration:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`ankiConnect.deck` is still accepted for backward-compatible polling scope and legacy known-word fallback behavior. For known-word cache scope, prefer `ankiConnect.knownWords.decks` with deck-to-fields mapping.
|
||||||
|
|
||||||
Then customize as needed using the sections below.
|
Then customize as needed using the sections below.
|
||||||
|
|
||||||
## Configuration File
|
## Configuration File
|
||||||
@@ -348,7 +355,8 @@ Configure the parsed-subtitle sidebar modal.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"subtitleSidebar": {
|
"subtitleSidebar": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
|
"autoOpen": false,
|
||||||
"layout": "overlay",
|
"layout": "overlay",
|
||||||
"toggleKey": "Backslash",
|
"toggleKey": "Backslash",
|
||||||
"pauseVideoOnHover": false,
|
"pauseVideoOnHover": false,
|
||||||
@@ -362,12 +370,13 @@ Configure the parsed-subtitle sidebar modal.
|
|||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| --------------------------- | ---------------- | -------------------------------------------------------------------------------- |
|
| --------------------------- | ---------------- | -------------------------------------------------------------------------------- |
|
||||||
| `enabled` | boolean | Enable subtitle sidebar support (`false` by default) |
|
| `enabled` | boolean | Enable subtitle sidebar support (`false` by default) |
|
||||||
|
| `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) |
|
||||||
| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout |
|
| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout |
|
||||||
| `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) |
|
| `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) |
|
||||||
| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list |
|
| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list |
|
||||||
| `autoScroll` | boolean | Keep the active cue in view while playback advances |
|
| `autoScroll` | boolean | Keep the active cue in view while playback advances |
|
||||||
| `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) |
|
| `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) |
|
||||||
| `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.78`) |
|
| `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.95`) |
|
||||||
| `backgroundColor` | string | Sidebar shell background color |
|
| `backgroundColor` | string | Sidebar shell background color |
|
||||||
| `textColor` | hex color | Default cue text color |
|
| `textColor` | hex color | Default cue text color |
|
||||||
| `fontFamily` | string | CSS `font-family` value applied to sidebar cue text |
|
| `fontFamily` | string | CSS `font-family` value applied to sidebar cue text |
|
||||||
@@ -751,6 +760,8 @@ Anki and YouTube subtitle cleanup both read this provider, then apply feature-lo
|
|||||||
| `apiKey` | string | Static API key for the shared provider |
|
| `apiKey` | string | Static API key for the shared provider |
|
||||||
| `apiKeyCommand` | string | Shell command used to resolve the API key |
|
| `apiKeyCommand` | string | Shell command used to resolve the API key |
|
||||||
| `baseUrl` | string (URL) | OpenAI-compatible base URL |
|
| `baseUrl` | string (URL) | OpenAI-compatible base URL |
|
||||||
|
| `model` | string | Optional model override for shared provider workflows |
|
||||||
|
| `systemPrompt` | string | Optional system prompt override for shared provider workflows |
|
||||||
| `requestTimeoutMs` | integer milliseconds | Shared request timeout (default: `15000`) |
|
| `requestTimeoutMs` | integer milliseconds | Shared request timeout (default: `15000`) |
|
||||||
|
|
||||||
SubMiner uses the shared provider in two places:
|
SubMiner uses the shared provider in two places:
|
||||||
@@ -840,8 +851,8 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
|
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
|
||||||
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
|
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
|
||||||
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
||||||
| `deck` | string | Anki deck to monitor for new cards |
|
| `ankiConnect.deck` | string | Legacy Anki polling/compatibility scope. Newer known-word cache scoping should use `ankiConnect.knownWords.decks`. |
|
||||||
| `ankiConnect.knownWords.decks` | array of strings | Decks used for known-word cache lookups. When omitted/empty, falls back to `ankiConnect.deck`. |
|
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping for known-word cache queries (for example `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
|
||||||
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
||||||
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
|
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
|
||||||
| `fields.image` | string | Card field for images (default: `Picture`) |
|
| `fields.image` | string | Card field for images (default: `Picture`) |
|
||||||
@@ -862,6 +873,7 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
|
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
|
||||||
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
|
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
|
||||||
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
|
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
|
||||||
|
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
|
||||||
| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) |
|
| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) |
|
||||||
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
|
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
|
||||||
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
|
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
|
||||||
@@ -870,10 +882,11 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
|
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
|
||||||
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
||||||
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
|
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
|
||||||
|
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
|
||||||
| `ankiConnect.knownWords.color` | hex color string | Text color for tokens already found in the local known-word cache (default: `"#a6da95"`). |
|
| `ankiConnect.knownWords.color` | hex color string | Text color for tokens already found in the local known-word cache (default: `"#a6da95"`). |
|
||||||
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
||||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||||
| `ankiConnect.knownWords.decks` | array of strings | Decks used by known-word cache refresh. Leave empty for compatibility with legacy `deck` scope. |
|
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
|
||||||
| `ankiConnect.nPlusOne.nPlusOne` | hex color string | Text color for the single target token to study when exactly one unknown candidate exists in a sentence (default: `"#c6a0f6"`). |
|
| `ankiConnect.nPlusOne.nPlusOne` | hex color string | Text color for the single target token to study when exactly one unknown candidate exists in a sentence (default: `"#c6a0f6"`). |
|
||||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||||
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
|
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
|
||||||
@@ -919,7 +932,7 @@ Known-word cache policy:
|
|||||||
- `ankiConnect.nPlusOne.nPlusOne` sets the color for the single target token when exactly one eligible unknown word exists.
|
- `ankiConnect.nPlusOne.nPlusOne` sets the color for the single target token when exactly one eligible unknown word exists.
|
||||||
- `ankiConnect.nPlusOne.minSentenceWords` sets the minimum token count required in a sentence for N+1 highlighting (default: `3`).
|
- `ankiConnect.nPlusOne.minSentenceWords` sets the minimum token count required in a sentence for N+1 highlighting (default: `3`).
|
||||||
- `ankiConnect.knownWords.color` sets the known-word highlight color for tokens already in Anki.
|
- `ankiConnect.knownWords.color` sets the known-word highlight color for tokens already in Anki.
|
||||||
- `ankiConnect.knownWords.decks` accepts one or more decks. If empty, it uses the legacy single `ankiConnect.deck` value as scope.
|
- `ankiConnect.knownWords.decks` accepts an object keyed by deck name. If omitted or empty, it falls back to the legacy `ankiConnect.deck` single-deck scope.
|
||||||
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
|
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
|
||||||
- The cache is automatically invalidated when the configured scope changes (for example, when deck changes).
|
- The cache is automatically invalidated when the configured scope changes (for example, when deck changes).
|
||||||
- Cache lookups are in-memory. By default, token headwords are matched against cached `Expression` / `Word` values; set `ankiConnect.knownWords.matchMode` to `"surface"` for raw subtitle text matching.
|
- Cache lookups are in-memory. By default, token headwords are matched against cached `Expression` / `Word` values; set `ankiConnect.knownWords.matchMode` to `"surface"` for raw subtitle text matching.
|
||||||
@@ -1283,7 +1296,7 @@ Configure the local stats UI served from SubMiner and the in-app stats overlay t
|
|||||||
{
|
{
|
||||||
"stats": {
|
"stats": {
|
||||||
"toggleKey": "Backquote",
|
"toggleKey": "Backquote",
|
||||||
"serverPort": 5175,
|
"serverPort": 6969,
|
||||||
"autoStartServer": true,
|
"autoStartServer": true,
|
||||||
"autoOpenBrowser": true
|
"autoOpenBrowser": true
|
||||||
}
|
}
|
||||||
@@ -1293,7 +1306,7 @@ Configure the local stats UI served from SubMiner and the in-app stats overlay t
|
|||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ----------------- | ----------------- | --------------------------------------------------------------------------- |
|
| ----------------- | ----------------- | --------------------------------------------------------------------------- |
|
||||||
| `toggleKey` | Electron key code | Overlay-local key code used to toggle the stats overlay. Default `Backquote`. |
|
| `toggleKey` | Electron key code | Overlay-local key code used to toggle the stats overlay. Default `Backquote`. |
|
||||||
| `serverPort` | integer | Localhost port for the browser stats UI. Default `5175`. |
|
| `serverPort` | integer | Localhost port for the browser stats UI. Default `6969`. |
|
||||||
| `autoStartServer` | `true`, `false` | Start the local stats HTTP server automatically once immersion tracking is active. Default `true`. |
|
| `autoStartServer` | `true`, `false` | Start the local stats HTTP server automatically once immersion tracking is active. Default `true`. |
|
||||||
| `autoOpenBrowser` | `true`, `false` | When `subminer stats` starts the server on demand, also open the dashboard in your default browser. Default `true`. |
|
| `autoOpenBrowser` | `true`, `false` | When `subminer stats` starts the server on demand, also open the dashboard in your default browser. Default `true`. |
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ The same immersion data powers the stats dashboard.
|
|||||||
- Launcher command: run `subminer stats` to start the local stats server on demand and open the dashboard in your browser.
|
- Launcher command: run `subminer stats` to start the local stats server on demand and open the dashboard in your browser.
|
||||||
- Background server: run `subminer stats -b` to start or reuse a dedicated background stats daemon without keeping the launcher attached, and `subminer stats -s` to stop that daemon.
|
- Background server: run `subminer stats -b` to start or reuse a dedicated background stats daemon without keeping the launcher attached, and `subminer stats -s` to stop that daemon.
|
||||||
- Maintenance command: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand.
|
- Maintenance command: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand.
|
||||||
- Browser page: open `http://127.0.0.1:5175` directly if the local stats server is already running.
|
- Browser page: open `http://127.0.0.1:6969` directly if the local stats server is already running.
|
||||||
|
|
||||||
### Dashboard Tabs
|
### Dashboard Tabs
|
||||||
|
|
||||||
@@ -42,6 +42,8 @@ Recent sessions, streak calendar, watch-time history, and a tracking snapshot wi
|
|||||||
|
|
||||||
Cover-art library with search and sorting, per-series progress, episode drill-down, and direct links into mined cards.
|
Cover-art library with search and sorting, per-series progress, episode drill-down, and direct links into mined cards.
|
||||||
|
|
||||||
|
When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
#### Trends
|
#### Trends
|
||||||
@@ -68,7 +70,7 @@ Stats server config lives under `stats`:
|
|||||||
{
|
{
|
||||||
"stats": {
|
"stats": {
|
||||||
"toggleKey": "Backquote",
|
"toggleKey": "Backquote",
|
||||||
"serverPort": 5175,
|
"serverPort": 6969,
|
||||||
"autoStartServer": true,
|
"autoStartServer": true,
|
||||||
"autoOpenBrowser": true
|
"autoOpenBrowser": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,28 @@ This guide walks through the sentence mining loop — from watching a video to c
|
|||||||
|
|
||||||
SubMiner runs as a transparent overlay on top of mpv. As subtitles play, the overlay displays them as interactive text. You hover a word, trigger Yomitan lookup with your configured lookup key/modifier, then create an Anki card with a single action. SubMiner automatically attaches the sentence, audio clip, and screenshot.
|
SubMiner runs as a transparent overlay on top of mpv. As subtitles play, the overlay displays them as interactive text. You hover a word, trigger Yomitan lookup with your configured lookup key/modifier, then create an Anki card with a single action. SubMiner automatically attaches the sentence, audio clip, and screenshot.
|
||||||
|
|
||||||
```text
|
```mermaid
|
||||||
Watch video → See subtitle → Hover word + trigger lookup → Yomitan popup → Add to Anki
|
flowchart LR
|
||||||
↓
|
classDef step fill:#c6a0f6,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
||||||
SubMiner auto-fills:
|
classDef action fill:#8aadf4,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
||||||
sentence, audio, image, translation
|
classDef result fill:#a6da95,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
||||||
|
classDef enrich fill:#8bd5ca,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
||||||
|
|
||||||
|
Watch["Watch Video"]:::step
|
||||||
|
Sub["Subtitle Appears"]:::step
|
||||||
|
Hover["Hover Word"]:::action
|
||||||
|
Lookup["Trigger Lookup"]:::action
|
||||||
|
Yomi["Yomitan Popup"]:::result
|
||||||
|
Add["Add to Anki"]:::result
|
||||||
|
|
||||||
|
Watch --> Sub --> Hover --> Lookup --> Yomi --> Add
|
||||||
|
|
||||||
|
Add --> Enrich["SubMiner Enriches"]:::enrich
|
||||||
|
|
||||||
|
Enrich --> S["Sentence"]:::enrich
|
||||||
|
Enrich --> A["Audio Clip"]:::enrich
|
||||||
|
Enrich --> I["Screenshot"]:::enrich
|
||||||
|
Enrich --> T["Translation"]:::enrich
|
||||||
```
|
```
|
||||||
|
|
||||||
## Subtitle Delivery Path (Startup + Runtime)
|
## Subtitle Delivery Path (Startup + Runtime)
|
||||||
@@ -208,7 +225,7 @@ Enable it in your config:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Open the dashboard in the overlay with `stats.toggleKey` (default: `` ` ``), launch it in a browser with `subminer stats`, keep a dedicated background server alive with `subminer stats -b`, stop that background server with `subminer stats -s`, or visit `http://127.0.0.1:5175` directly if the local stats server is already running. The dashboard covers overview totals, anime progress, session detail, and vocabulary drill-down from the same local immersion database.
|
Open the dashboard in the overlay with `stats.toggleKey` (default: `` ` ``), launch it in a browser with `subminer stats`, keep a dedicated background server alive with `subminer stats -b`, stop that background server with `subminer stats -s`, or visit `http://127.0.0.1:6969` directly if the local stats server is already running. The dashboard covers overview totals, anime progress, session detail, and vocabulary drill-down from the same local immersion database.
|
||||||
|
|
||||||
See [Immersion Tracking](/immersion-tracking) for dashboard details, schema, and retention settings.
|
See [Immersion Tracking](/immersion-tracking) for dashboard details, schema, and retention settings.
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB |
BIN
docs-site/public/screenshots/one-key-mining.png
Normal file
BIN
docs-site/public/screenshots/one-key-mining.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 614 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
@@ -24,7 +24,7 @@ N+1 highlighting identifies sentences where you know every word except one, maki
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting |
|
| `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting |
|
||||||
| `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between Anki cache refreshes |
|
| `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between Anki cache refreshes |
|
||||||
| `ankiConnect.knownWords.decks` | `[]` | Decks to query (falls back to `ankiConnect.deck`) |
|
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries (legacy fallback: `ankiConnect.deck`) |
|
||||||
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
|
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
|
||||||
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
|
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
|
||||||
| `ankiConnect.nPlusOne.nPlusOne` | `#c6a0f6` | Color for the single unknown target word |
|
| `ankiConnect.nPlusOne.nPlusOne` | `#c6a0f6` | Color for the single unknown target word |
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ Read when: you need to find the owner module for a behavior or test surface
|
|||||||
- Subtitle/token pipeline: `src/core/services/tokenizer*`, `src/subtitle/`, `src/tokenizers/`
|
- Subtitle/token pipeline: `src/core/services/tokenizer*`, `src/subtitle/`, `src/tokenizers/`
|
||||||
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
|
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
|
||||||
- Immersion tracking: `src/core/services/immersion-tracker/`
|
- Immersion tracking: `src/core/services/immersion-tracker/`
|
||||||
|
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
|
||||||
- AniList tracking: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`
|
- AniList tracking: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`
|
||||||
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
||||||
- Window trackers: `src/window-trackers/`
|
- Window trackers: `src/window-trackers/`
|
||||||
|
|||||||
@@ -553,10 +553,12 @@ export function buildSubminerScriptOpts(
|
|||||||
socketPath: string,
|
socketPath: string,
|
||||||
aniSkipMetadata: AniSkipMetadata | null,
|
aniSkipMetadata: AniSkipMetadata | null,
|
||||||
logLevel: LogLevel = 'info',
|
logLevel: LogLevel = 'info',
|
||||||
|
extraParts: string[] = [],
|
||||||
): string {
|
): string {
|
||||||
const parts = [
|
const parts = [
|
||||||
`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`,
|
`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`,
|
||||||
`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`,
|
`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`,
|
||||||
|
...extraParts.map(sanitizeScriptOptValue),
|
||||||
];
|
];
|
||||||
if (logLevel !== 'info') {
|
if (logLevel !== 'info') {
|
||||||
parts.push(`subminer-log_level=${sanitizeScriptOptValue(logLevel)}`);
|
parts.push(`subminer-log_level=${sanitizeScriptOptValue(logLevel)}`);
|
||||||
|
|||||||
@@ -149,20 +149,16 @@ test('doctor command forwards refresh-known-words to app binary', () => {
|
|||||||
context.args.doctorRefreshKnownWords = true;
|
context.args.doctorRefreshKnownWords = true;
|
||||||
const forwarded: string[][] = [];
|
const forwarded: string[][] = [];
|
||||||
|
|
||||||
assert.throws(
|
const handled = runDoctorCommand(context, {
|
||||||
() =>
|
|
||||||
runDoctorCommand(context, {
|
|
||||||
commandExists: () => false,
|
commandExists: () => false,
|
||||||
configExists: () => true,
|
configExists: () => true,
|
||||||
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
||||||
runAppCommandWithInherit: (_appPath, appArgs) => {
|
runAppCommandWithInherit: (_appPath, appArgs) => {
|
||||||
forwarded.push(appArgs);
|
forwarded.push(appArgs);
|
||||||
throw new ExitSignal(0);
|
|
||||||
},
|
},
|
||||||
}),
|
});
|
||||||
(error: unknown) => error instanceof ExitSignal && error.code === 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
assert.equal(handled, true);
|
||||||
assert.deepEqual(forwarded, [['--refresh-known-words']]);
|
assert.deepEqual(forwarded, [['--refresh-known-words']]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,31 +183,25 @@ test('dictionary command forwards --dictionary and target path to app binary', (
|
|||||||
context.args.dictionaryTarget = '/tmp/anime';
|
context.args.dictionaryTarget = '/tmp/anime';
|
||||||
const forwarded: string[][] = [];
|
const forwarded: string[][] = [];
|
||||||
|
|
||||||
assert.throws(
|
const handled = runDictionaryCommand(context, {
|
||||||
() =>
|
|
||||||
runDictionaryCommand(context, {
|
|
||||||
runAppCommandWithInherit: (_appPath, appArgs) => {
|
runAppCommandWithInherit: (_appPath, appArgs) => {
|
||||||
forwarded.push(appArgs);
|
forwarded.push(appArgs);
|
||||||
throw new ExitSignal(0);
|
|
||||||
},
|
},
|
||||||
}),
|
});
|
||||||
(error: unknown) => error instanceof ExitSignal && error.code === 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
assert.equal(handled, true);
|
||||||
assert.deepEqual(forwarded, [['--dictionary', '--dictionary-target', '/tmp/anime']]);
|
assert.deepEqual(forwarded, [['--dictionary', '--dictionary-target', '/tmp/anime']]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dictionary command throws if app handoff unexpectedly returns', () => {
|
test('dictionary command returns after app handoff starts', () => {
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
context.args.dictionary = true;
|
context.args.dictionary = true;
|
||||||
|
|
||||||
assert.throws(
|
const handled = runDictionaryCommand(context, {
|
||||||
() =>
|
runAppCommandWithInherit: () => undefined,
|
||||||
runDictionaryCommand(context, {
|
});
|
||||||
runAppCommandWithInherit: () => undefined as never,
|
|
||||||
}),
|
assert.equal(handled, true);
|
||||||
/unexpectedly returned/,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats command launches attached app command with response path', async () => {
|
test('stats command launches attached app command with response path', async () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { runAppCommandWithInherit } from '../mpv.js';
|
|||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
interface DictionaryCommandDeps {
|
interface DictionaryCommandDeps {
|
||||||
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => never;
|
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultDeps: DictionaryCommandDeps = {
|
const defaultDeps: DictionaryCommandDeps = {
|
||||||
@@ -27,5 +27,5 @@ export function runDictionaryCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
deps.runAppCommandWithInherit(appPath, forwarded);
|
deps.runAppCommandWithInherit(appPath, forwarded);
|
||||||
throw new Error('Dictionary command app handoff unexpectedly returned.');
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface DoctorCommandDeps {
|
|||||||
commandExists(command: string): boolean;
|
commandExists(command: string): boolean;
|
||||||
configExists(path: string): boolean;
|
configExists(path: string): boolean;
|
||||||
resolveMainConfigPath(): string;
|
resolveMainConfigPath(): string;
|
||||||
runAppCommandWithInherit(appPath: string, appArgs: string[]): never;
|
runAppCommandWithInherit(appPath: string, appArgs: string[]): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultDeps: DoctorCommandDeps = {
|
const defaultDeps: DoctorCommandDeps = {
|
||||||
@@ -85,6 +85,7 @@ export function runDoctorCommand(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
deps.runAppCommandWithInherit(appPath, ['--refresh-known-words']);
|
deps.runAppCommandWithInherit(appPath, ['--refresh-known-words']);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasHardFailure = checks.some((entry) =>
|
const hasHardFailure = checks.some((entry) =>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
|||||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||||
appendPasswordStore(forwarded);
|
appendPasswordStore(forwarded);
|
||||||
runAppCommandWithInherit(appPath, forwarded);
|
runAppCommandWithInherit(appPath, forwarded);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.jellyfinLogin) {
|
if (args.jellyfinLogin) {
|
||||||
@@ -44,6 +45,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
|||||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||||
appendPasswordStore(forwarded);
|
appendPasswordStore(forwarded);
|
||||||
runAppCommandWithInherit(appPath, forwarded);
|
runAppCommandWithInherit(appPath, forwarded);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.jellyfinLogout) {
|
if (args.jellyfinLogout) {
|
||||||
@@ -51,6 +53,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
|||||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||||
appendPasswordStore(forwarded);
|
appendPasswordStore(forwarded);
|
||||||
runAppCommandWithInherit(appPath, forwarded);
|
runAppCommandWithInherit(appPath, forwarded);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.jellyfinPlay) {
|
if (args.jellyfinPlay) {
|
||||||
@@ -69,13 +72,8 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
|||||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||||
appendPasswordStore(forwarded);
|
appendPasswordStore(forwarded);
|
||||||
runAppCommandWithInherit(appPath, forwarded);
|
runAppCommandWithInherit(appPath, forwarded);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Boolean(
|
return false;
|
||||||
args.jellyfin ||
|
|
||||||
args.jellyfinLogin ||
|
|
||||||
args.jellyfinLogout ||
|
|
||||||
args.jellyfinPlay ||
|
|
||||||
args.jellyfinDiscovery,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
113
launcher/commands/playback-command.test.ts
Normal file
113
launcher/commands/playback-command.test.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
import { runPlaybackCommandWithDeps } from './playback-command.js';
|
||||||
|
|
||||||
|
function createContext(): LauncherCommandContext {
|
||||||
|
return {
|
||||||
|
args: {
|
||||||
|
backend: 'auto',
|
||||||
|
directory: '.',
|
||||||
|
recursive: false,
|
||||||
|
profile: '',
|
||||||
|
startOverlay: false,
|
||||||
|
youtubeMode: 'download',
|
||||||
|
whisperBin: '',
|
||||||
|
whisperModel: '',
|
||||||
|
whisperVadModel: '',
|
||||||
|
whisperThreads: 0,
|
||||||
|
youtubeSubgenOutDir: '',
|
||||||
|
youtubeSubgenAudioFormat: '',
|
||||||
|
youtubeSubgenKeepTemp: false,
|
||||||
|
youtubeFixWithAi: false,
|
||||||
|
youtubePrimarySubLangs: [],
|
||||||
|
youtubeSecondarySubLangs: [],
|
||||||
|
youtubeAudioLangs: [],
|
||||||
|
youtubeWhisperSourceLanguage: '',
|
||||||
|
aiConfig: {},
|
||||||
|
useTexthooker: false,
|
||||||
|
autoStartOverlay: false,
|
||||||
|
texthookerOnly: false,
|
||||||
|
useRofi: false,
|
||||||
|
logLevel: 'info',
|
||||||
|
passwordStore: '',
|
||||||
|
target: 'https://www.youtube.com/watch?v=65Ovd7t8sNw',
|
||||||
|
targetKind: 'url',
|
||||||
|
jimakuApiKey: '',
|
||||||
|
jimakuApiKeyCommand: '',
|
||||||
|
jimakuApiBaseUrl: '',
|
||||||
|
jimakuLanguagePreference: 'ja',
|
||||||
|
jimakuMaxEntryResults: 20,
|
||||||
|
jellyfin: false,
|
||||||
|
jellyfinLogin: false,
|
||||||
|
jellyfinLogout: false,
|
||||||
|
jellyfinPlay: false,
|
||||||
|
jellyfinDiscovery: false,
|
||||||
|
dictionary: false,
|
||||||
|
stats: false,
|
||||||
|
doctor: false,
|
||||||
|
doctorRefreshKnownWords: false,
|
||||||
|
configPath: false,
|
||||||
|
configShow: false,
|
||||||
|
mpvIdle: false,
|
||||||
|
mpvSocket: false,
|
||||||
|
mpvStatus: false,
|
||||||
|
mpvArgs: '',
|
||||||
|
appPassthrough: false,
|
||||||
|
appArgs: [],
|
||||||
|
jellyfinServer: '',
|
||||||
|
jellyfinUsername: '',
|
||||||
|
jellyfinPassword: '',
|
||||||
|
},
|
||||||
|
scriptPath: '/tmp/subminer',
|
||||||
|
scriptName: 'subminer',
|
||||||
|
mpvSocketPath: '/tmp/subminer.sock',
|
||||||
|
pluginRuntimeConfig: {
|
||||||
|
socketPath: '/tmp/subminer.sock',
|
||||||
|
autoStart: true,
|
||||||
|
autoStartVisibleOverlay: true,
|
||||||
|
autoStartPauseUntilReady: true,
|
||||||
|
},
|
||||||
|
appPath: '/tmp/SubMiner.AppImage',
|
||||||
|
launcherJellyfinConfig: {},
|
||||||
|
processAdapter: {
|
||||||
|
platform: () => 'linux',
|
||||||
|
onSignal: () => {},
|
||||||
|
writeStdout: () => {},
|
||||||
|
exit: (_code: number): never => {
|
||||||
|
throw new Error('unexpected exit');
|
||||||
|
},
|
||||||
|
setExitCode: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('youtube playback launches overlay with youtube-play args in the primary app start', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const context = createContext();
|
||||||
|
|
||||||
|
await runPlaybackCommandWithDeps(context, {
|
||||||
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }),
|
||||||
|
checkDependencies: () => {},
|
||||||
|
registerCleanup: () => {},
|
||||||
|
startMpv: async () => {
|
||||||
|
calls.push('startMpv');
|
||||||
|
},
|
||||||
|
waitForUnixSocketReady: async () => true,
|
||||||
|
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
|
||||||
|
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
|
||||||
|
},
|
||||||
|
launchAppCommandDetached: (_appPath: string, appArgs: string[]) => {
|
||||||
|
calls.push(`launch:${appArgs.join(' ')}`);
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
cleanupPlaybackSession: async () => {},
|
||||||
|
getMpvProc: () => null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'startMpv',
|
||||||
|
'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw --youtube-mode download',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -6,13 +6,13 @@ import { commandExists, isYoutubeTarget, realpathMaybe, resolvePathMaybe } from
|
|||||||
import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js';
|
import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js';
|
||||||
import {
|
import {
|
||||||
cleanupPlaybackSession,
|
cleanupPlaybackSession,
|
||||||
|
launchAppCommandDetached,
|
||||||
startMpv,
|
startMpv,
|
||||||
startOverlay,
|
startOverlay,
|
||||||
state,
|
state,
|
||||||
stopOverlay,
|
stopOverlay,
|
||||||
waitForUnixSocketReady,
|
waitForUnixSocketReady,
|
||||||
} from '../mpv.js';
|
} from '../mpv.js';
|
||||||
import { generateYoutubeSubtitles } from '../youtube.js';
|
|
||||||
import type { Args } from '../types.js';
|
import type { Args } from '../types.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
import { ensureLauncherSetupReady } from '../setup-gate.js';
|
import { ensureLauncherSetupReady } from '../setup-gate.js';
|
||||||
@@ -126,30 +126,66 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
|
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
|
||||||
|
return runPlaybackCommandWithDeps(context, {
|
||||||
|
ensurePlaybackSetupReady,
|
||||||
|
chooseTarget,
|
||||||
|
checkDependencies,
|
||||||
|
registerCleanup,
|
||||||
|
startMpv,
|
||||||
|
waitForUnixSocketReady,
|
||||||
|
startOverlay,
|
||||||
|
launchAppCommandDetached,
|
||||||
|
log,
|
||||||
|
cleanupPlaybackSession,
|
||||||
|
getMpvProc: () => state.mpvProc,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlaybackCommandDeps = {
|
||||||
|
ensurePlaybackSetupReady: (context: LauncherCommandContext) => Promise<void>;
|
||||||
|
chooseTarget: (
|
||||||
|
args: Args,
|
||||||
|
scriptPath: string,
|
||||||
|
) => Promise<{ target: string; kind: 'file' | 'url' } | null>;
|
||||||
|
checkDependencies: (args: Args) => void;
|
||||||
|
registerCleanup: (context: LauncherCommandContext) => void;
|
||||||
|
startMpv: typeof startMpv;
|
||||||
|
waitForUnixSocketReady: typeof waitForUnixSocketReady;
|
||||||
|
startOverlay: typeof startOverlay;
|
||||||
|
launchAppCommandDetached: typeof launchAppCommandDetached;
|
||||||
|
log: typeof log;
|
||||||
|
cleanupPlaybackSession: typeof cleanupPlaybackSession;
|
||||||
|
getMpvProc: () => typeof state.mpvProc;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function runPlaybackCommandWithDeps(
|
||||||
|
context: LauncherCommandContext,
|
||||||
|
deps: PlaybackCommandDeps,
|
||||||
|
): Promise<void> {
|
||||||
const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig, processAdapter } = context;
|
const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig, processAdapter } = context;
|
||||||
if (!appPath) {
|
if (!appPath) {
|
||||||
fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
|
fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensurePlaybackSetupReady(context);
|
await deps.ensurePlaybackSetupReady(context);
|
||||||
|
|
||||||
if (!args.target) {
|
if (!args.target) {
|
||||||
checkPickerDependencies(args);
|
checkPickerDependencies(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetChoice = await chooseTarget(args, scriptPath);
|
const targetChoice = await deps.chooseTarget(args, scriptPath);
|
||||||
if (!targetChoice) {
|
if (!targetChoice) {
|
||||||
log('info', args.logLevel, 'No video selected, exiting');
|
deps.log('info', args.logLevel, 'No video selected, exiting');
|
||||||
processAdapter.exit(0);
|
processAdapter.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
checkDependencies({
|
deps.checkDependencies({
|
||||||
...args,
|
...args,
|
||||||
target: targetChoice ? targetChoice.target : args.target,
|
target: targetChoice ? targetChoice.target : args.target,
|
||||||
targetKind: targetChoice ? targetChoice.kind : 'url',
|
targetKind: targetChoice ? targetChoice.kind : 'url',
|
||||||
});
|
});
|
||||||
|
|
||||||
registerCleanup(context);
|
deps.registerCleanup(context);
|
||||||
|
|
||||||
const selectedTarget = targetChoice
|
const selectedTarget = targetChoice
|
||||||
? {
|
? {
|
||||||
@@ -159,30 +195,11 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
: { target: args.target, kind: 'url' as const };
|
: { target: args.target, kind: 'url' as const };
|
||||||
|
|
||||||
const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target);
|
const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target);
|
||||||
let preloadedSubtitles: { primaryPath?: string; secondaryPath?: string } | undefined;
|
const isAppOwnedYoutubeFlow = isYoutubeUrl;
|
||||||
|
const youtubeMode = args.youtubeMode ?? 'download';
|
||||||
|
|
||||||
if (isYoutubeUrl) {
|
if (isYoutubeUrl) {
|
||||||
log('info', args.logLevel, 'YouTube subtitle generation: preload before mpv');
|
deps.log('info', args.logLevel, 'YouTube subtitle flow: app-owned picker after mpv bootstrap');
|
||||||
const generated = await generateYoutubeSubtitles(selectedTarget.target, args);
|
|
||||||
preloadedSubtitles = {
|
|
||||||
primaryPath: generated.primaryPath,
|
|
||||||
secondaryPath: generated.secondaryPath,
|
|
||||||
};
|
|
||||||
const primaryStatus = generated.primaryPath
|
|
||||||
? 'ready'
|
|
||||||
: generated.primaryNative
|
|
||||||
? 'native'
|
|
||||||
: 'missing';
|
|
||||||
const secondaryStatus = generated.secondaryPath
|
|
||||||
? 'ready'
|
|
||||||
: generated.secondaryNative
|
|
||||||
? 'native'
|
|
||||||
: 'missing';
|
|
||||||
log(
|
|
||||||
'info',
|
|
||||||
args.logLevel,
|
|
||||||
`YouTube subtitle result: primary=${primaryStatus}, secondary=${secondaryStatus}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldPauseUntilOverlayReady =
|
const shouldPauseUntilOverlayReady =
|
||||||
@@ -191,47 +208,57 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
pluginRuntimeConfig.autoStartPauseUntilReady;
|
pluginRuntimeConfig.autoStartPauseUntilReady;
|
||||||
|
|
||||||
if (shouldPauseUntilOverlayReady) {
|
if (shouldPauseUntilOverlayReady) {
|
||||||
log('info', args.logLevel, 'Configured to pause mpv until overlay and tokenization are ready');
|
deps.log('info', args.logLevel, 'Configured to pause mpv until overlay and tokenization are ready');
|
||||||
}
|
}
|
||||||
|
|
||||||
await startMpv(
|
await deps.startMpv(
|
||||||
selectedTarget.target,
|
selectedTarget.target,
|
||||||
selectedTarget.kind,
|
selectedTarget.kind,
|
||||||
args,
|
args,
|
||||||
mpvSocketPath,
|
mpvSocketPath,
|
||||||
appPath,
|
appPath,
|
||||||
preloadedSubtitles,
|
undefined,
|
||||||
{ startPaused: shouldPauseUntilOverlayReady },
|
{
|
||||||
|
startPaused: shouldPauseUntilOverlayReady || isAppOwnedYoutubeFlow,
|
||||||
|
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const ready = await waitForUnixSocketReady(mpvSocketPath, 10000);
|
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 10000);
|
||||||
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
|
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
|
||||||
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay;
|
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay || isAppOwnedYoutubeFlow;
|
||||||
if (shouldStartOverlay) {
|
if (shouldStartOverlay) {
|
||||||
if (ready) {
|
if (ready) {
|
||||||
log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay');
|
deps.log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay');
|
||||||
} else {
|
} else {
|
||||||
log(
|
deps.log(
|
||||||
'info',
|
'info',
|
||||||
args.logLevel,
|
args.logLevel,
|
||||||
'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway',
|
'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await startOverlay(appPath, args, mpvSocketPath);
|
await deps.startOverlay(
|
||||||
|
appPath,
|
||||||
|
args,
|
||||||
|
mpvSocketPath,
|
||||||
|
isAppOwnedYoutubeFlow
|
||||||
|
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
} else if (pluginAutoStartEnabled) {
|
} else if (pluginAutoStartEnabled) {
|
||||||
if (ready) {
|
if (ready) {
|
||||||
log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
||||||
} else {
|
} else {
|
||||||
log('info', args.logLevel, 'MPV IPC socket not ready yet, relying on mpv plugin auto-start');
|
deps.log('info', args.logLevel, 'MPV IPC socket not ready yet, relying on mpv plugin auto-start');
|
||||||
}
|
}
|
||||||
} else if (ready) {
|
} else if (ready) {
|
||||||
log(
|
deps.log(
|
||||||
'info',
|
'info',
|
||||||
args.logLevel,
|
args.logLevel,
|
||||||
'MPV IPC socket ready, overlay auto-start disabled (use y-s to start)',
|
'MPV IPC socket ready, overlay auto-start disabled (use y-s to start)',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
log(
|
deps.log(
|
||||||
'info',
|
'info',
|
||||||
args.logLevel,
|
args.logLevel,
|
||||||
'MPV IPC socket not ready yet, overlay auto-start disabled (use y-s to start)',
|
'MPV IPC socket not ready yet, overlay auto-start disabled (use y-s to start)',
|
||||||
@@ -239,7 +266,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
const mpvProc = state.mpvProc;
|
const mpvProc = deps.getMpvProc();
|
||||||
if (!mpvProc) {
|
if (!mpvProc) {
|
||||||
stopOverlay(args);
|
stopOverlay(args);
|
||||||
resolve();
|
resolve();
|
||||||
@@ -247,7 +274,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const finalize = (code: number | null | undefined) => {
|
const finalize = (code: number | null | undefined) => {
|
||||||
void cleanupPlaybackSession(args).finally(() => {
|
void deps.cleanupPlaybackSession(args).finally(() => {
|
||||||
processAdapter.setExitCode(code ?? 0);
|
processAdapter.setExitCode(code ?? 0);
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
|
|||||||
youtubeSubgenAudioFormat: process.env.SUBMINER_YT_SUBGEN_AUDIO_FORMAT || 'm4a',
|
youtubeSubgenAudioFormat: process.env.SUBMINER_YT_SUBGEN_AUDIO_FORMAT || 'm4a',
|
||||||
youtubeSubgenKeepTemp: process.env.SUBMINER_YT_SUBGEN_KEEP_TEMP === '1',
|
youtubeSubgenKeepTemp: process.env.SUBMINER_YT_SUBGEN_KEEP_TEMP === '1',
|
||||||
youtubeFixWithAi: launcherConfig.fixWithAi === true,
|
youtubeFixWithAi: launcherConfig.fixWithAi === true,
|
||||||
|
youtubeMode: undefined,
|
||||||
jimakuApiKey: process.env.SUBMINER_JIMAKU_API_KEY || '',
|
jimakuApiKey: process.env.SUBMINER_JIMAKU_API_KEY || '',
|
||||||
jimakuApiKeyCommand: process.env.SUBMINER_JIMAKU_API_KEY_COMMAND || '',
|
jimakuApiKeyCommand: process.env.SUBMINER_JIMAKU_API_KEY_COMMAND || '',
|
||||||
jimakuApiBaseUrl: process.env.SUBMINER_JIMAKU_API_BASE_URL || DEFAULT_JIMAKU_API_BASE_URL,
|
jimakuApiBaseUrl: process.env.SUBMINER_JIMAKU_API_BASE_URL || DEFAULT_JIMAKU_API_BASE_URL,
|
||||||
@@ -250,6 +251,9 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (invocations.ytInvocation) {
|
if (invocations.ytInvocation) {
|
||||||
|
if (invocations.ytInvocation.mode) {
|
||||||
|
parsed.youtubeMode = invocations.ytInvocation.mode;
|
||||||
|
}
|
||||||
if (invocations.ytInvocation.logLevel)
|
if (invocations.ytInvocation.logLevel)
|
||||||
parsed.logLevel = parseLogLevel(invocations.ytInvocation.logLevel);
|
parsed.logLevel = parseLogLevel(invocations.ytInvocation.logLevel);
|
||||||
if (invocations.ytInvocation.outDir)
|
if (invocations.ytInvocation.outDir)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface JellyfinInvocation {
|
|||||||
|
|
||||||
export interface YtInvocation {
|
export interface YtInvocation {
|
||||||
target?: string;
|
target?: string;
|
||||||
|
mode?: 'download' | 'generate';
|
||||||
outDir?: string;
|
outDir?: string;
|
||||||
keepTemp?: boolean;
|
keepTemp?: boolean;
|
||||||
whisperBin?: string;
|
whisperBin?: string;
|
||||||
@@ -222,6 +223,7 @@ export function parseCliPrograms(
|
|||||||
.alias('youtube')
|
.alias('youtube')
|
||||||
.description('YouTube workflows')
|
.description('YouTube workflows')
|
||||||
.argument('[target]', 'YouTube URL or ytsearch: query')
|
.argument('[target]', 'YouTube URL or ytsearch: query')
|
||||||
|
.option('--mode <mode>', 'YouTube subtitle acquisition mode')
|
||||||
.option('-o, --out-dir <dir>', 'Subtitle output dir')
|
.option('-o, --out-dir <dir>', 'Subtitle output dir')
|
||||||
.option('--keep-temp', 'Keep temp files')
|
.option('--keep-temp', 'Keep temp files')
|
||||||
.option('--whisper-bin <path>', 'whisper.cpp CLI path')
|
.option('--whisper-bin <path>', 'whisper.cpp CLI path')
|
||||||
@@ -233,6 +235,10 @@ export function parseCliPrograms(
|
|||||||
.action((target: string | undefined, options: Record<string, unknown>) => {
|
.action((target: string | undefined, options: Record<string, unknown>) => {
|
||||||
ytInvocation = {
|
ytInvocation = {
|
||||||
target,
|
target,
|
||||||
|
mode:
|
||||||
|
typeof options.mode === 'string' && (options.mode === 'download' || options.mode === 'generate')
|
||||||
|
? options.mode
|
||||||
|
: undefined,
|
||||||
outDir: typeof options.outDir === 'string' ? options.outDir : undefined,
|
outDir: typeof options.outDir === 'string' ? options.outDir : undefined,
|
||||||
keepTemp: options.keepTemp === true,
|
keepTemp: options.keepTemp === true,
|
||||||
whisperBin: typeof options.whisperBin === 'string' ? options.whisperBin : undefined,
|
whisperBin: typeof options.whisperBin === 'string' ? options.whisperBin : undefined,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { getDefaultMpvLogFile } from './types.js';
|
import { getDefaultLauncherLogFile, getDefaultMpvLogFile } from './types.js';
|
||||||
|
|
||||||
test('getDefaultMpvLogFile uses APPDATA on windows', () => {
|
test('getDefaultMpvLogFile uses APPDATA on windows', () => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const resolved = getDefaultMpvLogFile({
|
const resolved = getDefaultMpvLogFile({
|
||||||
platform: 'win32',
|
platform: 'win32',
|
||||||
homeDir: 'C:\\Users\\tester',
|
homeDir: 'C:\\Users\\tester',
|
||||||
@@ -17,8 +18,27 @@ test('getDefaultMpvLogFile uses APPDATA on windows', () => {
|
|||||||
'C:\\Users\\tester\\AppData\\Roaming',
|
'C:\\Users\\tester\\AppData\\Roaming',
|
||||||
'SubMiner',
|
'SubMiner',
|
||||||
'logs',
|
'logs',
|
||||||
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`,
|
`mpv-${today}.log`,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getDefaultLauncherLogFile uses launcher prefix', () => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const resolved = getDefaultLauncherLogFile({
|
||||||
|
platform: 'linux',
|
||||||
|
homeDir: '/home/tester',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
resolved,
|
||||||
|
path.join(
|
||||||
|
'/home/tester',
|
||||||
|
'.config',
|
||||||
|
'SubMiner',
|
||||||
|
'logs',
|
||||||
|
`launcher-${today}.log`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import type { LogLevel } from './types.js';
|
import type { LogLevel } from './types.js';
|
||||||
import { DEFAULT_MPV_LOG_FILE } from './types.js';
|
import { DEFAULT_MPV_LOG_FILE, getDefaultLauncherLogFile } from './types.js';
|
||||||
|
import { appendLogLine, resolveDefaultLogFilePath } from '../src/shared/log-files.js';
|
||||||
|
|
||||||
export const COLORS = {
|
export const COLORS = {
|
||||||
red: '\x1b[0;31m',
|
red: '\x1b[0;31m',
|
||||||
@@ -28,14 +27,32 @@ export function getMpvLogPath(): string {
|
|||||||
return DEFAULT_MPV_LOG_FILE;
|
return DEFAULT_MPV_LOG_FILE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLauncherLogPath(): string {
|
||||||
|
const envPath = process.env.SUBMINER_LAUNCHER_LOG?.trim();
|
||||||
|
if (envPath) return envPath;
|
||||||
|
return getDefaultLauncherLogFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppLogPath(): string {
|
||||||
|
const envPath = process.env.SUBMINER_APP_LOG?.trim();
|
||||||
|
if (envPath) return envPath;
|
||||||
|
return resolveDefaultLogFilePath('app');
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendTimestampedLog(logPath: string, message: string): void {
|
||||||
|
appendLogLine(logPath, `[${new Date().toISOString()}] ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function appendToMpvLog(message: string): void {
|
export function appendToMpvLog(message: string): void {
|
||||||
const logPath = getMpvLogPath();
|
appendTimestampedLog(getMpvLogPath(), message);
|
||||||
try {
|
}
|
||||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
||||||
fs.appendFileSync(logPath, `[${new Date().toISOString()}] ${message}\n`, { encoding: 'utf8' });
|
export function appendToLauncherLog(message: string): void {
|
||||||
} catch {
|
appendTimestampedLog(getLauncherLogPath(), message);
|
||||||
// ignore logging failures
|
}
|
||||||
}
|
|
||||||
|
export function appendToAppLog(message: string): void {
|
||||||
|
appendTimestampedLog(getAppLogPath(), message);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function log(level: LogLevel, configured: LogLevel, message: string): void {
|
export function log(level: LogLevel, configured: LogLevel, message: string): void {
|
||||||
@@ -49,11 +66,11 @@ export function log(level: LogLevel, configured: LogLevel, message: string): voi
|
|||||||
? COLORS.red
|
? COLORS.red
|
||||||
: COLORS.cyan;
|
: COLORS.cyan;
|
||||||
process.stdout.write(`${color}[${level.toUpperCase()}]${COLORS.reset} ${message}\n`);
|
process.stdout.write(`${color}[${level.toUpperCase()}]${COLORS.reset} ${message}\n`);
|
||||||
appendToMpvLog(`[${level.toUpperCase()}] ${message}`);
|
appendToLauncherLog(`[${level.toUpperCase()}] ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fail(message: string): never {
|
export function fail(message: string): never {
|
||||||
process.stderr.write(`${COLORS.red}[ERROR]${COLORS.reset} ${message}\n`);
|
process.stderr.write(`${COLORS.red}[ERROR]${COLORS.reset} ${message}\n`);
|
||||||
appendToMpvLog(`[ERROR] ${message}`);
|
appendToLauncherLog(`[ERROR] ${message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,136 +205,6 @@ test('doctor refresh-known-words forwards app refresh command without requiring
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('youtube command rejects removed --mode option', () => {
|
|
||||||
withTempDir((root) => {
|
|
||||||
const homeDir = path.join(root, 'home');
|
|
||||||
const xdgConfigHome = path.join(root, 'xdg');
|
|
||||||
const appPath = path.join(root, 'fake-subminer.sh');
|
|
||||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
|
||||||
fs.chmodSync(appPath, 0o755);
|
|
||||||
|
|
||||||
const env = {
|
|
||||||
...makeTestEnv(homeDir, xdgConfigHome),
|
|
||||||
SUBMINER_APPIMAGE_PATH: appPath,
|
|
||||||
};
|
|
||||||
const result = runLauncher(
|
|
||||||
['youtube', 'https://www.youtube.com/watch?v=test123', '--mode', 'automatic'],
|
|
||||||
env,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(result.status, 1);
|
|
||||||
assert.match(result.stderr, /unknown option '--mode'/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('youtube playback generates subtitles before mpv launch', { timeout: 15000 }, () => {
|
|
||||||
withTempDir((root) => {
|
|
||||||
const homeDir = path.join(root, 'home');
|
|
||||||
const xdgConfigHome = path.join(root, 'xdg');
|
|
||||||
const binDir = path.join(root, 'bin');
|
|
||||||
const appPath = path.join(root, 'fake-subminer.sh');
|
|
||||||
const ytdlpLogPath = path.join(root, 'yt-dlp.log');
|
|
||||||
const mpvCapturePath = path.join(root, 'mpv-order.txt');
|
|
||||||
const mpvArgsPath = path.join(root, 'mpv-args.txt');
|
|
||||||
const socketPath = path.join(root, 'mpv.sock');
|
|
||||||
const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/'));
|
|
||||||
|
|
||||||
fs.mkdirSync(binDir, { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
|
||||||
JSON.stringify({
|
|
||||||
version: 1,
|
|
||||||
status: 'completed',
|
|
||||||
completedAt: '2026-03-08T00:00:00.000Z',
|
|
||||||
completionSource: 'user',
|
|
||||||
lastSeenYomitanDictionaryCount: 0,
|
|
||||||
pluginInstallStatus: 'installed',
|
|
||||||
pluginInstallPathSummary: null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
|
||||||
`socket_path=${socketPath}\nauto_start=no\nauto_start_visible_overlay=no\nauto_start_pause_until_ready=no\n`,
|
|
||||||
);
|
|
||||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
|
||||||
fs.chmodSync(appPath, 0o755);
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(binDir, 'yt-dlp'),
|
|
||||||
`#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
printf '%s\\n' "$*" >> "$SUBMINER_TEST_YTDLP_LOG"
|
|
||||||
if printf '%s\\n' "$*" | grep -q -- '--dump-single-json'; then
|
|
||||||
printf '{"id":"video123"}\\n'
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
out_dir=""
|
|
||||||
prev=""
|
|
||||||
for arg in "$@"; do
|
|
||||||
if [ "$prev" = "-o" ]; then
|
|
||||||
out_dir=$(dirname "$arg")
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
prev="$arg"
|
|
||||||
done
|
|
||||||
mkdir -p "$out_dir"
|
|
||||||
printf '1\\n00:00:00,000 --> 00:00:01,000\\nこんにちは\\n' > "$out_dir/video123.ja.srt"
|
|
||||||
printf '1\\n00:00:00,000 --> 00:00:01,000\\nhello\\n' > "$out_dir/video123.en.srt"
|
|
||||||
`,
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755);
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(binDir, 'ffmpeg'), '#!/bin/sh\nexit 0\n', 'utf8');
|
|
||||||
fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755);
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(binDir, 'mpv'),
|
|
||||||
`#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
if [ -s "$SUBMINER_TEST_YTDLP_LOG" ]; then
|
|
||||||
printf 'generated-before-mpv\\n' > "$SUBMINER_TEST_MPV_ORDER"
|
|
||||||
else
|
|
||||||
printf 'mpv-before-generation\\n' > "$SUBMINER_TEST_MPV_ORDER"
|
|
||||||
fi
|
|
||||||
printf '%s\\n' "$@" > "$SUBMINER_TEST_MPV_ARGS"
|
|
||||||
socket_path=""
|
|
||||||
for arg in "$@"; do
|
|
||||||
case "$arg" in
|
|
||||||
--input-ipc-server=*)
|
|
||||||
socket_path="\${arg#--input-ipc-server=}"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const path=require('node:path'); const socket=process.argv[1]||''; try{ if(socket) fs.mkdirSync(path.dirname(socket),{recursive:true}); }catch{} try{ if(socket) fs.rmSync(socket,{force:true}); }catch{} const server=net.createServer((c)=>c.end()); server.on('error',()=>process.exit(0)); if(!socket) process.exit(0); try{ server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250)); } catch { process.exit(0); }" "$socket_path"
|
|
||||||
`,
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
fs.chmodSync(path.join(binDir, 'mpv'), 0o755);
|
|
||||||
|
|
||||||
const env = {
|
|
||||||
...makeTestEnv(homeDir, xdgConfigHome),
|
|
||||||
PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
|
|
||||||
Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
|
|
||||||
SUBMINER_APPIMAGE_PATH: appPath,
|
|
||||||
SUBMINER_TEST_YTDLP_LOG: ytdlpLogPath,
|
|
||||||
SUBMINER_TEST_MPV_ORDER: mpvCapturePath,
|
|
||||||
SUBMINER_TEST_MPV_ARGS: mpvArgsPath,
|
|
||||||
};
|
|
||||||
const result = runLauncher(['youtube', 'https://www.youtube.com/watch?v=test123'], env);
|
|
||||||
|
|
||||||
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
|
||||||
assert.equal(fs.readFileSync(mpvCapturePath, 'utf8').trim(), 'generated-before-mpv');
|
|
||||||
assert.match(
|
|
||||||
fs.readFileSync(mpvArgsPath, 'utf8'),
|
|
||||||
/https:\/\/www\.youtube\.com\/watch\?v=test123/,
|
|
||||||
);
|
|
||||||
assert.match(fs.readFileSync(ytdlpLogPath, 'utf8'), /--dump-single-json/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, () => {
|
test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, () => {
|
||||||
withTempDir((root) => {
|
withTempDir((root) => {
|
||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
@@ -387,6 +257,10 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
|
|||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
fs.chmodSync(path.join(binDir, 'mpv'), 0o755);
|
fs.chmodSync(path.join(binDir, 'mpv'), 0o755);
|
||||||
|
fs.writeFileSync(path.join(binDir, 'yt-dlp'), '#!/bin/sh\nexit 0\n', 'utf8');
|
||||||
|
fs.writeFileSync(path.join(binDir, 'ffmpeg'), '#!/bin/sh\nexit 0\n', 'utf8');
|
||||||
|
fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755);
|
||||||
|
fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755);
|
||||||
|
|
||||||
const env = {
|
const env = {
|
||||||
...makeTestEnv(homeDir, xdgConfigHome),
|
...makeTestEnv(homeDir, xdgConfigHome),
|
||||||
@@ -466,6 +340,10 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
|
|||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
fs.chmodSync(path.join(binDir, 'mpv'), 0o755);
|
fs.chmodSync(path.join(binDir, 'mpv'), 0o755);
|
||||||
|
fs.writeFileSync(path.join(binDir, 'yt-dlp'), '#!/bin/sh\nexit 0\n', 'utf8');
|
||||||
|
fs.writeFileSync(path.join(binDir, 'ffmpeg'), '#!/bin/sh\nexit 0\n', 'utf8');
|
||||||
|
fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755);
|
||||||
|
fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755);
|
||||||
|
|
||||||
const env = {
|
const env = {
|
||||||
...makeTestEnv(homeDir, xdgConfigHome),
|
...makeTestEnv(homeDir, xdgConfigHome),
|
||||||
@@ -484,6 +362,84 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('launcher disables plugin startup pause gate for app-owned youtube flow', { timeout: 15000 }, () => {
|
||||||
|
withTempDir((root) => {
|
||||||
|
const homeDir = path.join(root, 'home');
|
||||||
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
|
const binDir = path.join(root, 'bin');
|
||||||
|
const appPath = path.join(root, 'fake-subminer.sh');
|
||||||
|
const mpvArgsPath = path.join(root, 'mpv-args.txt');
|
||||||
|
const socketPath = path.join(root, 'mpv.sock');
|
||||||
|
const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/'));
|
||||||
|
|
||||||
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
status: 'completed',
|
||||||
|
completedAt: '2026-03-08T00:00:00.000Z',
|
||||||
|
completionSource: 'user',
|
||||||
|
lastSeenYomitanDictionaryCount: 0,
|
||||||
|
pluginInstallStatus: 'installed',
|
||||||
|
pluginInstallPathSummary: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||||
|
`socket_path=${socketPath}\nauto_start=yes\nauto_start_visible_overlay=yes\nauto_start_pause_until_ready=yes\n`,
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
appPath,
|
||||||
|
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||||
|
);
|
||||||
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(binDir, 'mpv'),
|
||||||
|
`#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
printf '%s\\n' "$@" > "$SUBMINER_TEST_MPV_ARGS"
|
||||||
|
socket_path=""
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--input-ipc-server=*)
|
||||||
|
socket_path="\${arg#--input-ipc-server=}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const path=require('node:path'); const socket=process.argv[1]||''; try{ if (socket) fs.mkdirSync(path.dirname(socket),{recursive:true}); }catch{} try{ if (socket) fs.rmSync(socket,{force:true}); }catch{} if(!socket) process.exit(0); const server=net.createServer((c)=>c.end()); server.on('error',()=>process.exit(0)); try{ server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250)); } catch { process.exit(0); }" "$socket_path"
|
||||||
|
`,
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
fs.chmodSync(path.join(binDir, 'mpv'), 0o755);
|
||||||
|
fs.writeFileSync(path.join(binDir, 'yt-dlp'), '#!/bin/sh\nexit 0\n', 'utf8');
|
||||||
|
fs.writeFileSync(path.join(binDir, 'ffmpeg'), '#!/bin/sh\nexit 0\n', 'utf8');
|
||||||
|
fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755);
|
||||||
|
fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755);
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...makeTestEnv(homeDir, xdgConfigHome),
|
||||||
|
PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
|
||||||
|
Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
|
||||||
|
DISPLAY: ':99',
|
||||||
|
XDG_SESSION_TYPE: 'x11',
|
||||||
|
SUBMINER_APPIMAGE_PATH: appPath,
|
||||||
|
SUBMINER_TEST_MPV_ARGS: mpvArgsPath,
|
||||||
|
SUBMINER_TEST_CAPTURE: path.join(root, 'captured-args.txt'),
|
||||||
|
};
|
||||||
|
const result = runLauncher(['yt', 'https://www.youtube.com/watch?v=abc123'], env);
|
||||||
|
|
||||||
|
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||||
|
assert.match(
|
||||||
|
fs.readFileSync(mpvArgsPath, 'utf8'),
|
||||||
|
/--script-opts=.*subminer-auto_start_pause_until_ready=no/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('dictionary command forwards --dictionary and --dictionary-target to app command path', () => {
|
test('dictionary command forwards --dictionary and --dictionary-target to app command path', () => {
|
||||||
withTempDir((root) => {
|
withTempDir((root) => {
|
||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
|
|||||||
@@ -302,7 +302,47 @@ test('startOverlay resolves without fixed 2s sleep when readiness signals arrive
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cleanupPlaybackSession preserves background app while stopping mpv-owned children', async () => {
|
test('startOverlay captures app stdout and stderr into app log', async () => {
|
||||||
|
const { dir, socketPath } = createTempSocketPath();
|
||||||
|
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||||
|
const appLogPath = path.join(dir, 'app.log');
|
||||||
|
const originalAppLog = process.env.SUBMINER_APP_LOG;
|
||||||
|
fs.writeFileSync(
|
||||||
|
appPath,
|
||||||
|
'#!/bin/sh\nprintf "hello from stdout\\n"\nprintf "hello from stderr\\n" >&2\nexit 0\n',
|
||||||
|
);
|
||||||
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
fs.writeFileSync(socketPath, '');
|
||||||
|
const originalCreateConnection = net.createConnection;
|
||||||
|
try {
|
||||||
|
process.env.SUBMINER_APP_LOG = appLogPath;
|
||||||
|
net.createConnection = (() => {
|
||||||
|
const socket = new EventEmitter() as net.Socket;
|
||||||
|
socket.destroy = (() => socket) as net.Socket['destroy'];
|
||||||
|
socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
|
||||||
|
setTimeout(() => socket.emit('connect'), 10);
|
||||||
|
return socket;
|
||||||
|
}) as typeof net.createConnection;
|
||||||
|
|
||||||
|
await startOverlay(appPath, makeArgs(), socketPath);
|
||||||
|
|
||||||
|
const logText = fs.readFileSync(appLogPath, 'utf8');
|
||||||
|
assert.match(logText, /\[STDOUT\] hello from stdout/);
|
||||||
|
assert.match(logText, /\[STDERR\] hello from stderr/);
|
||||||
|
} finally {
|
||||||
|
net.createConnection = originalCreateConnection;
|
||||||
|
state.overlayProc = null;
|
||||||
|
state.overlayManagedByLauncher = false;
|
||||||
|
if (originalAppLog === undefined) {
|
||||||
|
delete process.env.SUBMINER_APP_LOG;
|
||||||
|
} else {
|
||||||
|
process.env.SUBMINER_APP_LOG = originalAppLog;
|
||||||
|
}
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cleanupPlaybackSession stops launcher-managed overlay app and mpv-owned children', async () => {
|
||||||
const { dir } = createTempSocketPath();
|
const { dir } = createTempSocketPath();
|
||||||
const appPath = path.join(dir, 'fake-subminer.sh');
|
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||||
const appInvocationsPath = path.join(dir, 'app-invocations.log');
|
const appInvocationsPath = path.join(dir, 'app-invocations.log');
|
||||||
@@ -345,8 +385,8 @@ test('cleanupPlaybackSession preserves background app while stopping mpv-owned c
|
|||||||
try {
|
try {
|
||||||
await cleanupPlaybackSession(makeArgs());
|
await cleanupPlaybackSession(makeArgs());
|
||||||
|
|
||||||
assert.deepEqual(calls, ['mpv-kill', 'helper-kill']);
|
assert.deepEqual(calls, ['overlay-kill', 'mpv-kill', 'helper-kill']);
|
||||||
assert.equal(fs.existsSync(appInvocationsPath), false);
|
assert.match(fs.readFileSync(appInvocationsPath, 'utf8'), /--stop/);
|
||||||
} finally {
|
} finally {
|
||||||
state.overlayProc = null;
|
state.overlayProc = null;
|
||||||
state.mpvProc = null;
|
state.mpvProc = null;
|
||||||
|
|||||||
204
launcher/mpv.ts
204
launcher/mpv.ts
@@ -5,7 +5,7 @@ import net from 'node:net';
|
|||||||
import { spawn, spawnSync } from 'node:child_process';
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
|
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
|
||||||
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
|
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
|
||||||
import { log, fail, getMpvLogPath } from './log.js';
|
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
|
||||||
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
|
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
|
||||||
import {
|
import {
|
||||||
commandExists,
|
commandExists,
|
||||||
@@ -542,7 +542,7 @@ export async function startMpv(
|
|||||||
socketPath: string,
|
socketPath: string,
|
||||||
appPath: string,
|
appPath: string,
|
||||||
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
|
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
|
||||||
options?: { startPaused?: boolean },
|
options?: { startPaused?: boolean; disableYoutubeSubtitleAutoLoad?: boolean },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
|
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
|
||||||
fail(`Video file not found: ${target}`);
|
fail(`Video file not found: ${target}`);
|
||||||
@@ -575,6 +575,7 @@ export async function startMpv(
|
|||||||
log('debug', args.logLevel, `YouTube subtitle langs: ${subtitleLangs}`);
|
log('debug', args.logLevel, `YouTube subtitle langs: ${subtitleLangs}`);
|
||||||
log('debug', args.logLevel, `YouTube audio langs: ${audioLangs}`);
|
log('debug', args.logLevel, `YouTube audio langs: ${audioLangs}`);
|
||||||
mpvArgs.push(`--ytdl-format=${DEFAULT_YOUTUBE_YTDL_FORMAT}`, `--alang=${audioLangs}`);
|
mpvArgs.push(`--ytdl-format=${DEFAULT_YOUTUBE_YTDL_FORMAT}`, `--alang=${audioLangs}`);
|
||||||
|
if (options?.disableYoutubeSubtitleAutoLoad !== true) {
|
||||||
mpvArgs.push(
|
mpvArgs.push(
|
||||||
'--sub-auto=fuzzy',
|
'--sub-auto=fuzzy',
|
||||||
`--slang=${subtitleLangs}`,
|
`--slang=${subtitleLangs}`,
|
||||||
@@ -582,6 +583,9 @@ export async function startMpv(
|
|||||||
'--ytdl-raw-options-append=sub-format=vtt/best',
|
'--ytdl-raw-options-append=sub-format=vtt/best',
|
||||||
`--ytdl-raw-options-append=sub-langs=${subtitleLangs}`,
|
`--ytdl-raw-options-append=sub-langs=${subtitleLangs}`,
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
mpvArgs.push('--sub-auto=no');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,7 +601,17 @@ export async function startMpv(
|
|||||||
const aniSkipMetadata = shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles)
|
const aniSkipMetadata = shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles)
|
||||||
? await resolveAniSkipMetadataForFile(target)
|
? await resolveAniSkipMetadataForFile(target)
|
||||||
: null;
|
: null;
|
||||||
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata, args.logLevel);
|
const extraScriptOpts =
|
||||||
|
targetKind === 'url' && isYoutubeTarget(target) && options?.disableYoutubeSubtitleAutoLoad === true
|
||||||
|
? ['subminer-auto_start_pause_until_ready=no']
|
||||||
|
: [];
|
||||||
|
const scriptOpts = buildSubminerScriptOpts(
|
||||||
|
appPath,
|
||||||
|
socketPath,
|
||||||
|
aniSkipMetadata,
|
||||||
|
args.logLevel,
|
||||||
|
extraScriptOpts,
|
||||||
|
);
|
||||||
if (aniSkipMetadata) {
|
if (aniSkipMetadata) {
|
||||||
log(
|
log(
|
||||||
'debug',
|
'debug',
|
||||||
@@ -661,19 +675,25 @@ async function waitForOverlayStartCommandSettled(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startOverlay(appPath: string, args: Args, socketPath: string): Promise<void> {
|
export async function startOverlay(
|
||||||
|
appPath: string,
|
||||||
|
args: Args,
|
||||||
|
socketPath: string,
|
||||||
|
extraAppArgs: string[] = [],
|
||||||
|
): Promise<void> {
|
||||||
const backend = detectBackend(args.backend);
|
const backend = detectBackend(args.backend);
|
||||||
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
|
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
|
||||||
|
|
||||||
const overlayArgs = ['--start', '--backend', backend, '--socket', socketPath];
|
const overlayArgs = ['--start', '--backend', backend, '--socket', socketPath, ...extraAppArgs];
|
||||||
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
|
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
|
||||||
if (args.useTexthooker) overlayArgs.push('--texthooker');
|
if (args.useTexthooker) overlayArgs.push('--texthooker');
|
||||||
|
|
||||||
const target = resolveAppSpawnTarget(appPath, overlayArgs);
|
const target = resolveAppSpawnTarget(appPath, overlayArgs);
|
||||||
state.overlayProc = spawn(target.command, target.args, {
|
state.overlayProc = spawn(target.command, target.args, {
|
||||||
stdio: 'inherit',
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: buildAppEnv(),
|
env: buildAppEnv(),
|
||||||
});
|
});
|
||||||
|
attachAppProcessLogging(state.overlayProc);
|
||||||
state.overlayManagedByLauncher = true;
|
state.overlayManagedByLauncher = true;
|
||||||
|
|
||||||
const [socketReady] = await Promise.all([
|
const [socketReady] = await Promise.all([
|
||||||
@@ -699,10 +719,7 @@ export function launchTexthookerOnly(appPath: string, args: Args): never {
|
|||||||
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
|
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
|
||||||
|
|
||||||
log('info', args.logLevel, 'Launching texthooker mode...');
|
log('info', args.logLevel, 'Launching texthooker mode...');
|
||||||
const result = spawnSync(appPath, overlayArgs, {
|
const result = runSyncAppCommand(appPath, overlayArgs, true);
|
||||||
stdio: 'inherit',
|
|
||||||
env: buildAppEnv(),
|
|
||||||
});
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
fail(`Failed to launch texthooker mode: ${result.error.message}`);
|
fail(`Failed to launch texthooker mode: ${result.error.message}`);
|
||||||
}
|
}
|
||||||
@@ -713,30 +730,7 @@ export function stopOverlay(args: Args): void {
|
|||||||
if (state.stopRequested) return;
|
if (state.stopRequested) return;
|
||||||
state.stopRequested = true;
|
state.stopRequested = true;
|
||||||
|
|
||||||
if (state.overlayManagedByLauncher && state.appPath) {
|
stopManagedOverlayApp(args);
|
||||||
log('info', args.logLevel, 'Stopping SubMiner overlay...');
|
|
||||||
|
|
||||||
const stopArgs = ['--stop'];
|
|
||||||
if (args.logLevel !== 'info') stopArgs.push('--log-level', args.logLevel);
|
|
||||||
|
|
||||||
const result = spawnSync(state.appPath, stopArgs, {
|
|
||||||
stdio: 'ignore',
|
|
||||||
env: buildAppEnv(),
|
|
||||||
});
|
|
||||||
if (result.error) {
|
|
||||||
log('warn', args.logLevel, `Failed to stop SubMiner overlay: ${result.error.message}`);
|
|
||||||
} else if (typeof result.status === 'number' && result.status !== 0) {
|
|
||||||
log('warn', args.logLevel, `SubMiner overlay stop command exited with status ${result.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.overlayProc && !state.overlayProc.killed) {
|
|
||||||
try {
|
|
||||||
state.overlayProc.kill('SIGTERM');
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.mpvProc && !state.mpvProc.killed) {
|
if (state.mpvProc && !state.mpvProc.killed) {
|
||||||
try {
|
try {
|
||||||
@@ -761,6 +755,8 @@ export function stopOverlay(args: Args): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupPlaybackSession(args: Args): Promise<void> {
|
export async function cleanupPlaybackSession(args: Args): Promise<void> {
|
||||||
|
stopManagedOverlayApp(args);
|
||||||
|
|
||||||
if (state.mpvProc && !state.mpvProc.killed) {
|
if (state.mpvProc && !state.mpvProc.killed) {
|
||||||
try {
|
try {
|
||||||
state.mpvProc.kill('SIGTERM');
|
state.mpvProc.kill('SIGTERM');
|
||||||
@@ -783,9 +779,40 @@ export async function cleanupPlaybackSession(args: Args): Promise<void> {
|
|||||||
await terminateTrackedDetachedMpv(args.logLevel);
|
await terminateTrackedDetachedMpv(args.logLevel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stopManagedOverlayApp(args: Args): void {
|
||||||
|
if (!(state.overlayManagedByLauncher && state.appPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log('info', args.logLevel, 'Stopping SubMiner overlay...');
|
||||||
|
|
||||||
|
const stopArgs = ['--stop'];
|
||||||
|
if (args.logLevel !== 'info') stopArgs.push('--log-level', args.logLevel);
|
||||||
|
|
||||||
|
const target = resolveAppSpawnTarget(state.appPath, stopArgs);
|
||||||
|
const result = spawnSync(target.command, target.args, {
|
||||||
|
stdio: 'ignore',
|
||||||
|
env: buildAppEnv(),
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
log('warn', args.logLevel, `Failed to stop SubMiner overlay: ${result.error.message}`);
|
||||||
|
} else if (typeof result.status === 'number' && result.status !== 0) {
|
||||||
|
log('warn', args.logLevel, `SubMiner overlay stop command exited with status ${result.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.overlayProc && !state.overlayProc.killed) {
|
||||||
|
try {
|
||||||
|
state.overlayProc.kill('SIGTERM');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildAppEnv(): NodeJS.ProcessEnv {
|
function buildAppEnv(): NodeJS.ProcessEnv {
|
||||||
const env: Record<string, string | undefined> = {
|
const env: Record<string, string | undefined> = {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
SUBMINER_APP_LOG: getAppLogPath(),
|
||||||
SUBMINER_MPV_LOG: getMpvLogPath(),
|
SUBMINER_MPV_LOG: getMpvLogPath(),
|
||||||
};
|
};
|
||||||
delete env.ELECTRON_RUN_AS_NODE;
|
delete env.ELECTRON_RUN_AS_NODE;
|
||||||
@@ -804,6 +831,64 @@ function buildAppEnv(): NodeJS.ProcessEnv {
|
|||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendCapturedAppOutput(kind: 'STDOUT' | 'STDERR', chunk: string): void {
|
||||||
|
const normalized = chunk.replace(/\r\n/g, '\n');
|
||||||
|
for (const line of normalized.split('\n')) {
|
||||||
|
if (!line) continue;
|
||||||
|
appendToAppLog(`[${kind}] ${line}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachAppProcessLogging(
|
||||||
|
proc: ReturnType<typeof spawn>,
|
||||||
|
options?: {
|
||||||
|
mirrorStdout?: boolean;
|
||||||
|
mirrorStderr?: boolean;
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
proc.stdout?.setEncoding('utf8');
|
||||||
|
proc.stderr?.setEncoding('utf8');
|
||||||
|
proc.stdout?.on('data', (chunk: string) => {
|
||||||
|
appendCapturedAppOutput('STDOUT', chunk);
|
||||||
|
if (options?.mirrorStdout) process.stdout.write(chunk);
|
||||||
|
});
|
||||||
|
proc.stderr?.on('data', (chunk: string) => {
|
||||||
|
appendCapturedAppOutput('STDERR', chunk);
|
||||||
|
if (options?.mirrorStderr) process.stderr.write(chunk);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSyncAppCommand(
|
||||||
|
appPath: string,
|
||||||
|
appArgs: string[],
|
||||||
|
mirrorOutput: boolean,
|
||||||
|
): {
|
||||||
|
status: number;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
error?: Error;
|
||||||
|
} {
|
||||||
|
const target = resolveAppSpawnTarget(appPath, appArgs);
|
||||||
|
const result = spawnSync(target.command, target.args, {
|
||||||
|
env: buildAppEnv(),
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
if (result.stdout) {
|
||||||
|
appendCapturedAppOutput('STDOUT', result.stdout);
|
||||||
|
if (mirrorOutput) process.stdout.write(result.stdout);
|
||||||
|
}
|
||||||
|
if (result.stderr) {
|
||||||
|
appendCapturedAppOutput('STDERR', result.stderr);
|
||||||
|
if (mirrorOutput) process.stderr.write(result.stderr);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: result.status ?? 1,
|
||||||
|
stdout: result.stdout ?? '',
|
||||||
|
stderr: result.stderr ?? '',
|
||||||
|
error: result.error ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function maybeCaptureAppArgs(appArgs: string[]): boolean {
|
function maybeCaptureAppArgs(appArgs: string[]): boolean {
|
||||||
const capturePath = process.env.SUBMINER_TEST_CAPTURE?.trim();
|
const capturePath = process.env.SUBMINER_TEST_CAPTURE?.trim();
|
||||||
if (!capturePath) {
|
if (!capturePath) {
|
||||||
@@ -821,20 +906,23 @@ function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget
|
|||||||
return resolveCommandInvocation(appPath, appArgs);
|
return resolveCommandInvocation(appPath, appArgs);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runAppCommandWithInherit(appPath: string, appArgs: string[]): never {
|
export function runAppCommandWithInherit(appPath: string, appArgs: string[]): void {
|
||||||
if (maybeCaptureAppArgs(appArgs)) {
|
if (maybeCaptureAppArgs(appArgs)) {
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = resolveAppSpawnTarget(appPath, appArgs);
|
const target = resolveAppSpawnTarget(appPath, appArgs);
|
||||||
const result = spawnSync(target.command, target.args, {
|
const proc = spawn(target.command, target.args, {
|
||||||
stdio: 'inherit',
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: buildAppEnv(),
|
env: buildAppEnv(),
|
||||||
});
|
});
|
||||||
if (result.error) {
|
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
|
||||||
fail(`Failed to run app command: ${result.error.message}`);
|
proc.once('error', (error) => {
|
||||||
}
|
fail(`Failed to run app command: ${error.message}`);
|
||||||
process.exit(result.status ?? 0);
|
});
|
||||||
|
proc.once('close', (code) => {
|
||||||
|
process.exit(code ?? 0);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runAppCommandCaptureOutput(
|
export function runAppCommandCaptureOutput(
|
||||||
@@ -854,18 +942,7 @@ export function runAppCommandCaptureOutput(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = resolveAppSpawnTarget(appPath, appArgs);
|
return runSyncAppCommand(appPath, appArgs, false);
|
||||||
const result = spawnSync(target.command, target.args, {
|
|
||||||
env: buildAppEnv(),
|
|
||||||
encoding: 'utf8',
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: result.status ?? 1,
|
|
||||||
stdout: result.stdout ?? '',
|
|
||||||
stderr: result.stderr ?? '',
|
|
||||||
error: result.error ?? undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runAppCommandAttached(
|
export function runAppCommandAttached(
|
||||||
@@ -887,13 +964,14 @@ export function runAppCommandAttached(
|
|||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const proc = spawn(target.command, target.args, {
|
const proc = spawn(target.command, target.args, {
|
||||||
stdio: 'inherit',
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: buildAppEnv(),
|
env: buildAppEnv(),
|
||||||
});
|
});
|
||||||
|
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
|
||||||
proc.once('error', (error) => {
|
proc.once('error', (error) => {
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
proc.once('exit', (code, signal) => {
|
proc.once('close', (code, signal) => {
|
||||||
if (code !== null) {
|
if (code !== null) {
|
||||||
resolve(code);
|
resolve(code);
|
||||||
} else if (signal) {
|
} else if (signal) {
|
||||||
@@ -921,10 +999,7 @@ export function runAppCommandWithInheritLogged(
|
|||||||
logLevel,
|
logLevel,
|
||||||
`${label}: launching app with args: ${[target.command, ...target.args].join(' ')}`,
|
`${label}: launching app with args: ${[target.command, ...target.args].join(' ')}`,
|
||||||
);
|
);
|
||||||
const result = spawnSync(target.command, target.args, {
|
const result = runSyncAppCommand(appPath, appArgs, true);
|
||||||
stdio: 'inherit',
|
|
||||||
env: buildAppEnv(),
|
|
||||||
});
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
fail(`Failed to run app command: ${result.error.message}`);
|
fail(`Failed to run app command: ${result.error.message}`);
|
||||||
}
|
}
|
||||||
@@ -953,8 +1028,13 @@ export function launchAppCommandDetached(
|
|||||||
logLevel,
|
logLevel,
|
||||||
`${label}: launching detached app with args: ${[target.command, ...target.args].join(' ')}`,
|
`${label}: launching detached app with args: ${[target.command, ...target.args].join(' ')}`,
|
||||||
);
|
);
|
||||||
|
const appLogPath = getAppLogPath();
|
||||||
|
fs.mkdirSync(path.dirname(appLogPath), { recursive: true });
|
||||||
|
const stdoutFd = fs.openSync(appLogPath, 'a');
|
||||||
|
const stderrFd = fs.openSync(appLogPath, 'a');
|
||||||
|
try {
|
||||||
const proc = spawn(target.command, target.args, {
|
const proc = spawn(target.command, target.args, {
|
||||||
stdio: 'ignore',
|
stdio: ['ignore', stdoutFd, stderrFd],
|
||||||
detached: true,
|
detached: true,
|
||||||
env: buildAppEnv(),
|
env: buildAppEnv(),
|
||||||
});
|
});
|
||||||
@@ -962,6 +1042,10 @@ export function launchAppCommandDetached(
|
|||||||
log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`);
|
log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`);
|
||||||
});
|
});
|
||||||
proc.unref();
|
proc.unref();
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(stdoutFd);
|
||||||
|
fs.closeSync(stderrFd);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function launchMpvIdleDetached(
|
export function launchMpvIdleDetached(
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ test('parseArgs maps mpv idle action', () => {
|
|||||||
assert.equal(parsed.mpvStatus, false);
|
assert.equal(parsed.mpvStatus, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseArgs captures youtube mode forwarding', () => {
|
||||||
|
const parsed = parseArgs(['youtube', 'https://example.com', '--mode', 'generate'], 'subminer', {});
|
||||||
|
|
||||||
|
assert.equal(parsed.target, 'https://example.com');
|
||||||
|
assert.equal(parsed.youtubeMode, 'generate');
|
||||||
|
});
|
||||||
|
|
||||||
test('parseArgs maps dictionary command and log-level override', () => {
|
test('parseArgs maps dictionary command and log-level override', () => {
|
||||||
const parsed = parseArgs(['dictionary', '.', '--log-level', 'debug'], 'subminer', {});
|
const parsed = parseArgs(['dictionary', '.', '--log-level', 'debug'], 'subminer', {});
|
||||||
|
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ test(
|
|||||||
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
|
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
|
||||||
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
|
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
|
||||||
await waitForJsonLines(appStartPath, 1);
|
await waitForJsonLines(appStartPath, 1);
|
||||||
|
await waitForJsonLines(appStopPath, 1);
|
||||||
|
|
||||||
const appStartEntries = readJsonLines(appStartPath);
|
const appStartEntries = readJsonLines(appStartPath);
|
||||||
const appStopEntries = readJsonLines(appStopPath);
|
const appStopEntries = readJsonLines(appStopPath);
|
||||||
@@ -324,7 +325,7 @@ test(
|
|||||||
assert.match(result.stdout, /Starting SubMiner overlay/i);
|
assert.match(result.stdout, /Starting SubMiner overlay/i);
|
||||||
|
|
||||||
assert.equal(appStartEntries.length, 1);
|
assert.equal(appStartEntries.length, 1);
|
||||||
assert.equal(appStopEntries.length, 0);
|
assert.equal(appStopEntries.length, 1);
|
||||||
assert.equal(mpvEntries.length >= 1, true);
|
assert.equal(mpvEntries.length >= 1, true);
|
||||||
|
|
||||||
const appStartArgs = appStartEntries[0]?.argv;
|
const appStartArgs = appStartEntries[0]?.argv;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
import { resolveDefaultLogFilePath } from '../src/shared/log-files.js';
|
||||||
export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js';
|
export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js';
|
||||||
|
|
||||||
export const ROFI_THEME_FILE = 'subminer.rasi';
|
export const ROFI_THEME_FILE = 'subminer.rasi';
|
||||||
@@ -29,21 +30,28 @@ export const DEFAULT_YOUTUBE_SUBGEN_OUT_DIR = path.join(
|
|||||||
'subminer',
|
'subminer',
|
||||||
'youtube-subs',
|
'youtube-subs',
|
||||||
);
|
);
|
||||||
|
export function getDefaultLauncherLogFile(options?: {
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
|
homeDir?: string;
|
||||||
|
appDataDir?: string;
|
||||||
|
}): string {
|
||||||
|
return resolveDefaultLogFilePath('launcher', {
|
||||||
|
platform: options?.platform ?? process.platform,
|
||||||
|
homeDir: options?.homeDir ?? os.homedir(),
|
||||||
|
appDataDir: options?.appDataDir,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getDefaultMpvLogFile(options?: {
|
export function getDefaultMpvLogFile(options?: {
|
||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
homeDir?: string;
|
homeDir?: string;
|
||||||
appDataDir?: string;
|
appDataDir?: string;
|
||||||
}): string {
|
}): string {
|
||||||
const platform = options?.platform ?? process.platform;
|
return resolveDefaultLogFilePath('mpv', {
|
||||||
const homeDir = options?.homeDir ?? os.homedir();
|
platform: options?.platform ?? process.platform,
|
||||||
const baseDir =
|
homeDir: options?.homeDir ?? os.homedir(),
|
||||||
platform === 'win32'
|
appDataDir: options?.appDataDir,
|
||||||
? path.join(
|
});
|
||||||
options?.appDataDir?.trim() || path.join(homeDir, 'AppData', 'Roaming'),
|
|
||||||
'SubMiner',
|
|
||||||
)
|
|
||||||
: path.join(homeDir, '.config', 'SubMiner');
|
|
||||||
return path.join(baseDir, 'logs', `SubMiner-${new Date().toISOString().slice(0, 10)}.log`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_MPV_LOG_FILE = getDefaultMpvLogFile();
|
export const DEFAULT_MPV_LOG_FILE = getDefaultMpvLogFile();
|
||||||
@@ -79,6 +87,7 @@ export interface Args {
|
|||||||
recursive: boolean;
|
recursive: boolean;
|
||||||
profile: string;
|
profile: string;
|
||||||
startOverlay: boolean;
|
startOverlay: boolean;
|
||||||
|
youtubeMode?: 'download' | 'generate';
|
||||||
whisperBin: string;
|
whisperBin: string;
|
||||||
whisperModel: string;
|
whisperModel: string;
|
||||||
whisperVadModel: string;
|
whisperVadModel: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function M.load(options_lib, default_socket_path)
|
|||||||
auto_start = true,
|
auto_start = true,
|
||||||
auto_start_visible_overlay = true,
|
auto_start_visible_overlay = true,
|
||||||
auto_start_pause_until_ready = true,
|
auto_start_pause_until_ready = true,
|
||||||
|
auto_start_pause_until_ready_timeout_seconds = 15,
|
||||||
osd_messages = true,
|
osd_messages = true,
|
||||||
log_level = "info",
|
log_level = "info",
|
||||||
aniskip_enabled = true,
|
aniskip_enabled = true,
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ local M = {}
|
|||||||
|
|
||||||
local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
|
local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
|
||||||
local OVERLAY_START_MAX_ATTEMPTS = 6
|
local OVERLAY_START_MAX_ATTEMPTS = 6
|
||||||
local AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
|
|
||||||
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
|
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
|
||||||
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
|
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
|
||||||
|
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
|
||||||
|
|
||||||
function M.create(ctx)
|
function M.create(ctx)
|
||||||
local mp = ctx.mp
|
local mp = ctx.mp
|
||||||
@@ -34,6 +34,23 @@ function M.create(ctx)
|
|||||||
return options_helper.coerce_bool(raw_pause_until_ready, false)
|
return options_helper.coerce_bool(raw_pause_until_ready, false)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function resolve_pause_until_ready_timeout_seconds()
|
||||||
|
local raw_timeout_seconds = opts.auto_start_pause_until_ready_timeout_seconds
|
||||||
|
if raw_timeout_seconds == nil then
|
||||||
|
raw_timeout_seconds = opts["auto-start-pause-until-ready-timeout-seconds"]
|
||||||
|
end
|
||||||
|
if type(raw_timeout_seconds) == "number" then
|
||||||
|
return raw_timeout_seconds
|
||||||
|
end
|
||||||
|
if type(raw_timeout_seconds) == "string" then
|
||||||
|
local parsed = tonumber(raw_timeout_seconds)
|
||||||
|
if parsed ~= nil then
|
||||||
|
return parsed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS
|
||||||
|
end
|
||||||
|
|
||||||
local function normalize_socket_path(path)
|
local function normalize_socket_path(path)
|
||||||
if type(path) ~= "string" then
|
if type(path) ~= "string" then
|
||||||
return nil
|
return nil
|
||||||
@@ -118,7 +135,9 @@ function M.create(ctx)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
subminer_log("info", "process", "Pausing playback until SubMiner overlay/tokenization readiness signal")
|
subminer_log("info", "process", "Pausing playback until SubMiner overlay/tokenization readiness signal")
|
||||||
state.auto_play_ready_timeout = mp.add_timeout(AUTO_PLAY_READY_TIMEOUT_SECONDS, function()
|
local timeout_seconds = resolve_pause_until_ready_timeout_seconds()
|
||||||
|
if timeout_seconds and timeout_seconds > 0 then
|
||||||
|
state.auto_play_ready_timeout = mp.add_timeout(timeout_seconds, function()
|
||||||
if not state.auto_play_ready_gate_armed then
|
if not state.auto_play_ready_gate_armed then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -130,6 +149,7 @@ function M.create(ctx)
|
|||||||
release_auto_play_ready_gate("timeout")
|
release_auto_play_ready_gate("timeout")
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local function notify_auto_play_ready()
|
local function notify_auto_play_ready()
|
||||||
release_auto_play_ready_gate("tokenization-ready")
|
release_auto_play_ready_gate("tokenization-ready")
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import { NoteUpdateWorkflow } from './anki-integration/note-update-workflow';
|
|||||||
import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow';
|
import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow';
|
||||||
import { resolveAnimatedImageLeadInSeconds } from './anki-integration/animated-image-sync';
|
import { resolveAnimatedImageLeadInSeconds } from './anki-integration/animated-image-sync';
|
||||||
import { AnkiIntegrationRuntime, normalizeAnkiIntegrationConfig } from './anki-integration/runtime';
|
import { AnkiIntegrationRuntime, normalizeAnkiIntegrationConfig } from './anki-integration/runtime';
|
||||||
|
import { resolveMediaGenerationInputPath } from './anki-integration/media-source';
|
||||||
|
|
||||||
const log = createLogger('anki').child('integration');
|
const log = createLogger('anki').child('integration');
|
||||||
|
|
||||||
@@ -597,6 +598,10 @@ export class AnkiIntegration {
|
|||||||
this.runtime.start();
|
this.runtime.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
waitUntilReady(): Promise<void> {
|
||||||
|
return this.runtime.waitUntilReady();
|
||||||
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
this.runtime.stop();
|
this.runtime.stop();
|
||||||
}
|
}
|
||||||
@@ -647,7 +652,10 @@ export class AnkiIntegration {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoPath = mpvClient.currentVideoPath;
|
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'audio');
|
||||||
|
if (!videoPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
let startTime = mpvClient.currentSubStart;
|
let startTime = mpvClient.currentSubStart;
|
||||||
let endTime = mpvClient.currentSubEnd;
|
let endTime = mpvClient.currentSubEnd;
|
||||||
|
|
||||||
@@ -672,7 +680,10 @@ export class AnkiIntegration {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoPath = this.mpvClient.currentVideoPath;
|
const videoPath = await resolveMediaGenerationInputPath(this.mpvClient, 'video');
|
||||||
|
if (!videoPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const timestamp = this.mpvClient.currentTimePos || 0;
|
const timestamp = this.mpvClient.currentTimePos || 0;
|
||||||
|
|
||||||
if (this.config.media?.imageType === 'avif') {
|
if (this.config.media?.imageType === 'avif') {
|
||||||
@@ -946,8 +957,15 @@ export class AnkiIntegration {
|
|||||||
if (this.mpvClient && this.mpvClient.currentVideoPath) {
|
if (this.mpvClient && this.mpvClient.currentVideoPath) {
|
||||||
try {
|
try {
|
||||||
const timestamp = this.mpvClient.currentTimePos || 0;
|
const timestamp = this.mpvClient.currentTimePos || 0;
|
||||||
|
const notificationIconSource = await resolveMediaGenerationInputPath(
|
||||||
|
this.mpvClient,
|
||||||
|
'video',
|
||||||
|
);
|
||||||
|
if (!notificationIconSource) {
|
||||||
|
throw new Error('No media source available for notification icon');
|
||||||
|
}
|
||||||
const iconBuffer = await this.mediaGenerator.generateNotificationIcon(
|
const iconBuffer = await this.mediaGenerator.generateNotificationIcon(
|
||||||
this.mpvClient.currentVideoPath,
|
notificationIconSource,
|
||||||
timestamp,
|
timestamp,
|
||||||
);
|
);
|
||||||
if (iconBuffer && iconBuffer.length > 0) {
|
if (iconBuffer && iconBuffer.length > 0) {
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ export class AnkiConnectProxyServer {
|
|||||||
private pendingNoteIdSet = new Set<number>();
|
private pendingNoteIdSet = new Set<number>();
|
||||||
private inFlightNoteIds = new Set<number>();
|
private inFlightNoteIds = new Set<number>();
|
||||||
private processingQueue = false;
|
private processingQueue = false;
|
||||||
|
private readyPromise: Promise<void> | null = null;
|
||||||
|
private resolveReady: (() => void) | null = null;
|
||||||
|
private rejectReady: ((error: Error) => void) | null = null;
|
||||||
|
|
||||||
constructor(private readonly deps: AnkiConnectProxyServerDeps) {
|
constructor(private readonly deps: AnkiConnectProxyServerDeps) {
|
||||||
this.client = axios.create({
|
this.client = axios.create({
|
||||||
@@ -48,6 +51,13 @@ export class AnkiConnectProxyServer {
|
|||||||
return this.server !== null;
|
return this.server !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
waitUntilReady(): Promise<void> {
|
||||||
|
if (!this.server || this.server.listening) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return this.readyPromise ?? Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
start(options: StartProxyOptions): void {
|
start(options: StartProxyOptions): void {
|
||||||
this.stop();
|
this.stop();
|
||||||
|
|
||||||
@@ -58,15 +68,26 @@ export class AnkiConnectProxyServer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.readyPromise = new Promise<void>((resolve, reject) => {
|
||||||
|
this.resolveReady = resolve;
|
||||||
|
this.rejectReady = reject;
|
||||||
|
});
|
||||||
|
|
||||||
this.server = http.createServer((req, res) => {
|
this.server = http.createServer((req, res) => {
|
||||||
void this.handleRequest(req, res, options.upstreamUrl);
|
void this.handleRequest(req, res, options.upstreamUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.server.on('error', (error) => {
|
this.server.on('error', (error) => {
|
||||||
|
this.rejectReady?.(error as Error);
|
||||||
|
this.resolveReady = null;
|
||||||
|
this.rejectReady = null;
|
||||||
this.deps.logError('[anki-proxy] Server error:', (error as Error).message);
|
this.deps.logError('[anki-proxy] Server error:', (error as Error).message);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.server.listen(options.port, options.host, () => {
|
this.server.listen(options.port, options.host, () => {
|
||||||
|
this.resolveReady?.();
|
||||||
|
this.resolveReady = null;
|
||||||
|
this.rejectReady = null;
|
||||||
this.deps.logInfo(
|
this.deps.logInfo(
|
||||||
`[anki-proxy] Listening on http://${options.host}:${options.port} -> ${options.upstreamUrl}`,
|
`[anki-proxy] Listening on http://${options.host}:${options.port} -> ${options.upstreamUrl}`,
|
||||||
);
|
);
|
||||||
@@ -79,6 +100,10 @@ export class AnkiConnectProxyServer {
|
|||||||
this.server = null;
|
this.server = null;
|
||||||
this.deps.logInfo('[anki-proxy] Stopped');
|
this.deps.logInfo('[anki-proxy] Stopped');
|
||||||
}
|
}
|
||||||
|
this.rejectReady?.(new Error('AnkiConnect proxy stopped before becoming ready'));
|
||||||
|
this.readyPromise = null;
|
||||||
|
this.resolveReady = null;
|
||||||
|
this.rejectReady = null;
|
||||||
this.pendingNoteIds = [];
|
this.pendingNoteIds = [];
|
||||||
this.pendingNoteIdSet.clear();
|
this.pendingNoteIdSet.clear();
|
||||||
this.inFlightNoteIds.clear();
|
this.inFlightNoteIds.clear();
|
||||||
|
|||||||
@@ -283,3 +283,117 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
|
|||||||
assert.equal(calls.notesInfo, 1);
|
assert.equal(calls.notesInfo, 1);
|
||||||
assert.equal(calls.updateNoteFields, 1);
|
assert.equal(calls.updateNoteFields, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('CardCreationService uses stream-open-filename for remote media generation', async () => {
|
||||||
|
const audioPaths: string[] = [];
|
||||||
|
const imagePaths: string[] = [];
|
||||||
|
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',
|
||||||
|
'!global_tags,title=test',
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
const service = new CardCreationService({
|
||||||
|
getConfig: () =>
|
||||||
|
({
|
||||||
|
deck: 'Mining',
|
||||||
|
fields: {
|
||||||
|
sentence: 'Sentence',
|
||||||
|
audio: 'SentenceAudio',
|
||||||
|
image: 'Picture',
|
||||||
|
},
|
||||||
|
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: 1,
|
||||||
|
currentSubEnd: 2,
|
||||||
|
currentTimePos: 1.5,
|
||||||
|
currentAudioStreamIndex: 0,
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
assert.equal(name, 'stream-open-filename');
|
||||||
|
return edlSource;
|
||||||
|
},
|
||||||
|
}) as never,
|
||||||
|
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) => {
|
||||||
|
audioPaths.push(path);
|
||||||
|
return Buffer.from('audio');
|
||||||
|
},
|
||||||
|
generateScreenshot: async (path) => {
|
||||||
|
imagePaths.push(path);
|
||||||
|
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('テスト', 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']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { createLogger } from '../logger';
|
|||||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||||
import { MpvClient } from '../types';
|
import { MpvClient } from '../types';
|
||||||
import { resolveSentenceBackText } from './ai';
|
import { resolveSentenceBackText } from './ai';
|
||||||
|
import { resolveMediaGenerationInputPath } from './media-source';
|
||||||
|
|
||||||
const log = createLogger('anki').child('integration.card-creation');
|
const log = createLogger('anki').child('integration.card-creation');
|
||||||
|
|
||||||
@@ -501,7 +502,12 @@ export class CardCreationService {
|
|||||||
this.deps.showOsdNotification('Creating sentence card...');
|
this.deps.showOsdNotification('Creating sentence card...');
|
||||||
try {
|
try {
|
||||||
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
||||||
const videoPath = mpvClient.currentVideoPath;
|
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'video');
|
||||||
|
const audioSourcePath = await resolveMediaGenerationInputPath(mpvClient, 'audio');
|
||||||
|
if (!videoPath) {
|
||||||
|
this.deps.showOsdNotification('No video loaded');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const fields: Record<string, string> = {};
|
const fields: Record<string, string> = {};
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
let miscInfoFilename: string | null = null;
|
let miscInfoFilename: string | null = null;
|
||||||
@@ -605,7 +611,9 @@ export class CardCreationService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const audioFilename = this.generateAudioFilename();
|
const audioFilename = this.generateAudioFilename();
|
||||||
const audioBuffer = await this.mediaGenerateAudio(videoPath, startTime, endTime);
|
const audioBuffer = audioSourcePath
|
||||||
|
? await this.mediaGenerateAudio(audioSourcePath, startTime, endTime)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||||
|
|||||||
64
src/anki-integration/media-source.test.ts
Normal file
64
src/anki-integration/media-source.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { resolveMediaGenerationInputPath } from './media-source';
|
||||||
|
|
||||||
|
test('resolveMediaGenerationInputPath keeps local file paths', async () => {
|
||||||
|
const result = await resolveMediaGenerationInputPath({
|
||||||
|
currentVideoPath: '/tmp/video.mkv',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, '/tmp/video.mkv');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveMediaGenerationInputPath prefers stream-open-filename for remote media', async () => {
|
||||||
|
const requests: string[] = [];
|
||||||
|
|
||||||
|
const result = await resolveMediaGenerationInputPath({
|
||||||
|
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
requests.push(name);
|
||||||
|
return 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123');
|
||||||
|
assert.deepEqual(requests, ['stream-open-filename']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video', async () => {
|
||||||
|
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',
|
||||||
|
'!global_tags,title=test',
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
const audioResult = await resolveMediaGenerationInputPath(
|
||||||
|
{
|
||||||
|
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
requestProperty: async () => edlSource,
|
||||||
|
},
|
||||||
|
'audio',
|
||||||
|
);
|
||||||
|
const videoResult = await resolveMediaGenerationInputPath(
|
||||||
|
{
|
||||||
|
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
requestProperty: async () => edlSource,
|
||||||
|
},
|
||||||
|
'video',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(audioResult, 'https://audio.example/videoplayback?mime=audio%2Fwebm');
|
||||||
|
assert.equal(videoResult, 'https://video.example/videoplayback?mime=video%2Fmp4');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveMediaGenerationInputPath falls back to currentVideoPath when stream-open-filename fails', async () => {
|
||||||
|
const result = await resolveMediaGenerationInputPath({
|
||||||
|
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
requestProperty: async () => {
|
||||||
|
throw new Error('property unavailable');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, 'https://www.youtube.com/watch?v=abc123');
|
||||||
|
});
|
||||||
84
src/anki-integration/media-source.ts
Normal file
84
src/anki-integration/media-source.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { isRemoteMediaPath } from '../jimaku/utils';
|
||||||
|
import type { MpvClient } from '../types';
|
||||||
|
|
||||||
|
export type MediaGenerationKind = 'audio' | 'video';
|
||||||
|
|
||||||
|
function trimToNonEmptyString(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUrlsFromMpvEdlSource(source: string): string[] {
|
||||||
|
const matches = source.matchAll(/%\d+%(https?:\/\/.*?)(?=;!new_stream|;!global_tags|$)/gms);
|
||||||
|
return [...matches]
|
||||||
|
.map((match) => trimToNonEmptyString(match[1]))
|
||||||
|
.filter((value): value is string => value !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyMediaUrl(url: string): MediaGenerationKind | null {
|
||||||
|
try {
|
||||||
|
const mime = new URL(url).searchParams.get('mime')?.toLowerCase() ?? '';
|
||||||
|
if (mime.startsWith('audio/')) {
|
||||||
|
return 'audio';
|
||||||
|
}
|
||||||
|
if (mime.startsWith('video/')) {
|
||||||
|
return 'video';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed URLs and fall back to stream order.
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePreferredUrlFromMpvEdlSource(
|
||||||
|
source: string,
|
||||||
|
kind: MediaGenerationKind,
|
||||||
|
): string | null {
|
||||||
|
const urls = extractUrlsFromMpvEdlSource(source);
|
||||||
|
if (urls.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typedMatch = urls.find((url) => classifyMediaUrl(url) === kind);
|
||||||
|
if (typedMatch) {
|
||||||
|
return typedMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// mpv EDL sources usually list audio streams first and video streams last, so
|
||||||
|
// when classifyMediaUrl cannot identify a typed URL we fall back to stream order.
|
||||||
|
return kind === 'audio' ? urls[0] ?? null : urls[urls.length - 1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveMediaGenerationInputPath(
|
||||||
|
mpvClient: Pick<MpvClient, 'currentVideoPath' | 'requestProperty'> | null | undefined,
|
||||||
|
kind: MediaGenerationKind = 'video',
|
||||||
|
): Promise<string | null> {
|
||||||
|
const currentVideoPath = trimToNonEmptyString(mpvClient?.currentVideoPath);
|
||||||
|
if (!currentVideoPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRemoteMediaPath(currentVideoPath) || !mpvClient?.requestProperty) {
|
||||||
|
return currentVideoPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const streamOpenFilename = trimToNonEmptyString(
|
||||||
|
await mpvClient.requestProperty('stream-open-filename'),
|
||||||
|
);
|
||||||
|
if (streamOpenFilename?.startsWith('edl://')) {
|
||||||
|
return resolvePreferredUrlFromMpvEdlSource(streamOpenFilename, kind) ?? streamOpenFilename;
|
||||||
|
}
|
||||||
|
if (streamOpenFilename) {
|
||||||
|
return streamOpenFilename;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to the current path when mpv does not expose a resolved stream URL.
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentVideoPath;
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ function createRuntime(
|
|||||||
start: ({ host, port, upstreamUrl }) =>
|
start: ({ host, port, upstreamUrl }) =>
|
||||||
calls.push(`proxy:start:${host}:${port}:${upstreamUrl}`),
|
calls.push(`proxy:start:${host}:${port}:${upstreamUrl}`),
|
||||||
stop: () => calls.push('proxy:stop'),
|
stop: () => calls.push('proxy:stop'),
|
||||||
|
waitUntilReady: async () => undefined,
|
||||||
}),
|
}),
|
||||||
logInfo: () => undefined,
|
logInfo: () => undefined,
|
||||||
logWarn: () => undefined,
|
logWarn: () => undefined,
|
||||||
@@ -80,6 +81,44 @@ test('AnkiIntegrationRuntime starts proxy transport when proxy mode is enabled',
|
|||||||
assert.deepEqual(calls, ['known:start', 'proxy:start:127.0.0.1:9999:http://upstream:8765']);
|
assert.deepEqual(calls, ['known:start', 'proxy:start:127.0.0.1:9999:http://upstream:8765']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('AnkiIntegrationRuntime waits for proxy readiness when proxy mode is enabled', async () => {
|
||||||
|
let releaseReady!: () => void;
|
||||||
|
const waitUntilReadyCalls: string[] = [];
|
||||||
|
const readyPromise = new Promise<void>((resolve) => {
|
||||||
|
releaseReady = resolve;
|
||||||
|
});
|
||||||
|
const { runtime } = createRuntime(
|
||||||
|
{
|
||||||
|
proxy: {
|
||||||
|
enabled: true,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 9999,
|
||||||
|
upstreamUrl: 'http://upstream:8765',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyServerFactory: () => ({
|
||||||
|
start: () => undefined,
|
||||||
|
stop: () => undefined,
|
||||||
|
waitUntilReady: async () => {
|
||||||
|
waitUntilReadyCalls.push('proxy:wait-until-ready');
|
||||||
|
await readyPromise;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
runtime.start();
|
||||||
|
const waitPromise = runtime.waitUntilReady().then(() => {
|
||||||
|
waitUntilReadyCalls.push('proxy:ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(waitUntilReadyCalls, ['proxy:wait-until-ready']);
|
||||||
|
releaseReady();
|
||||||
|
await waitPromise;
|
||||||
|
assert.deepEqual(waitUntilReadyCalls, ['proxy:wait-until-ready', 'proxy:ready']);
|
||||||
|
});
|
||||||
|
|
||||||
test('AnkiIntegrationRuntime switches transports and clears known words when runtime patch disables highlighting', () => {
|
test('AnkiIntegrationRuntime switches transports and clears known words when runtime patch disables highlighting', () => {
|
||||||
const { runtime, calls } = createRuntime({
|
const { runtime, calls } = createRuntime({
|
||||||
knownWords: {
|
knownWords: {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
export interface AnkiIntegrationRuntimeProxyServer {
|
export interface AnkiIntegrationRuntimeProxyServer {
|
||||||
start(options: { host: string; port: number; upstreamUrl: string }): void;
|
start(options: { host: string; port: number; upstreamUrl: string }): void;
|
||||||
stop(): void;
|
stop(): void;
|
||||||
|
waitUntilReady(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AnkiIntegrationRuntimeDeps {
|
interface AnkiIntegrationRuntimeDeps {
|
||||||
@@ -131,6 +132,13 @@ export class AnkiIntegrationRuntime {
|
|||||||
return this.config;
|
return this.config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
waitUntilReady(): Promise<void> {
|
||||||
|
if (!this.started || !this.isProxyTransportEnabled()) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return this.getOrCreateProxyServer().waitUntilReady();
|
||||||
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.started) {
|
if (this.started) {
|
||||||
this.stop();
|
this.stop();
|
||||||
|
|||||||
@@ -56,6 +56,15 @@ test('parseArgs captures launch-mpv targets and keeps it out of app startup', ()
|
|||||||
assert.equal(shouldStartApp(args), false);
|
assert.equal(shouldStartApp(args), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseArgs captures youtube playback commands and mode', () => {
|
||||||
|
const args = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc', '--youtube-mode', 'generate']);
|
||||||
|
|
||||||
|
assert.equal(args.youtubePlay, 'https://youtube.com/watch?v=abc');
|
||||||
|
assert.equal(args.youtubeMode, 'generate');
|
||||||
|
assert.equal(hasExplicitCommand(args), true);
|
||||||
|
assert.equal(shouldStartApp(args), true);
|
||||||
|
});
|
||||||
|
|
||||||
test('parseArgs handles jellyfin item listing controls', () => {
|
test('parseArgs handles jellyfin item listing controls', () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
'--jellyfin-items',
|
'--jellyfin-items',
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export interface CliArgs {
|
|||||||
start: boolean;
|
start: boolean;
|
||||||
launchMpv: boolean;
|
launchMpv: boolean;
|
||||||
launchMpvTargets: string[];
|
launchMpvTargets: string[];
|
||||||
|
youtubePlay?: string;
|
||||||
|
youtubeMode?: 'download' | 'generate';
|
||||||
stop: boolean;
|
stop: boolean;
|
||||||
toggle: boolean;
|
toggle: boolean;
|
||||||
toggleVisibleOverlay: boolean;
|
toggleVisibleOverlay: boolean;
|
||||||
@@ -79,6 +81,8 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
start: false,
|
start: false,
|
||||||
launchMpv: false,
|
launchMpv: false,
|
||||||
launchMpvTargets: [],
|
launchMpvTargets: [],
|
||||||
|
youtubePlay: undefined,
|
||||||
|
youtubeMode: undefined,
|
||||||
stop: false,
|
stop: false,
|
||||||
toggle: false,
|
toggle: false,
|
||||||
toggleVisibleOverlay: false,
|
toggleVisibleOverlay: false,
|
||||||
@@ -140,7 +144,19 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
|
|
||||||
if (arg === '--background') args.background = true;
|
if (arg === '--background') args.background = true;
|
||||||
else if (arg === '--start') args.start = true;
|
else if (arg === '--start') args.start = true;
|
||||||
else if (arg === '--launch-mpv') {
|
else if (arg.startsWith('--youtube-play=')) {
|
||||||
|
const value = arg.split('=', 2)[1];
|
||||||
|
if (value) args.youtubePlay = value;
|
||||||
|
} else if (arg === '--youtube-play') {
|
||||||
|
const value = readValue(argv[i + 1]);
|
||||||
|
if (value) args.youtubePlay = value;
|
||||||
|
} else if (arg.startsWith('--youtube-mode=')) {
|
||||||
|
const value = arg.split('=', 2)[1];
|
||||||
|
if (value === 'download' || value === 'generate') args.youtubeMode = value;
|
||||||
|
} else if (arg === '--youtube-mode') {
|
||||||
|
const value = readValue(argv[i + 1]);
|
||||||
|
if (value === 'download' || value === 'generate') args.youtubeMode = value;
|
||||||
|
} else if (arg === '--launch-mpv') {
|
||||||
args.launchMpv = true;
|
args.launchMpv = true;
|
||||||
args.launchMpvTargets = argv.slice(i + 1).filter((value) => value && !value.startsWith('--'));
|
args.launchMpvTargets = argv.slice(i + 1).filter((value) => value && !value.startsWith('--'));
|
||||||
break;
|
break;
|
||||||
@@ -334,6 +350,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
|||||||
return (
|
return (
|
||||||
args.background ||
|
args.background ||
|
||||||
args.start ||
|
args.start ||
|
||||||
|
Boolean(args.youtubePlay) ||
|
||||||
args.launchMpv ||
|
args.launchMpv ||
|
||||||
args.stop ||
|
args.stop ||
|
||||||
args.toggle ||
|
args.toggle ||
|
||||||
@@ -385,6 +402,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
if (
|
if (
|
||||||
args.background ||
|
args.background ||
|
||||||
args.start ||
|
args.start ||
|
||||||
|
Boolean(args.youtubePlay) ||
|
||||||
args.launchMpv ||
|
args.launchMpv ||
|
||||||
args.toggle ||
|
args.toggle ||
|
||||||
args.toggleVisibleOverlay ||
|
args.toggleVisibleOverlay ||
|
||||||
@@ -452,6 +470,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
|||||||
!args.jellyfinItems &&
|
!args.jellyfinItems &&
|
||||||
!args.jellyfinSubtitles &&
|
!args.jellyfinSubtitles &&
|
||||||
!args.jellyfinPlay &&
|
!args.jellyfinPlay &&
|
||||||
|
!args.youtubePlay &&
|
||||||
!args.jellyfinRemoteAnnounce &&
|
!args.jellyfinRemoteAnnounce &&
|
||||||
!args.jellyfinPreviewAuth &&
|
!args.jellyfinPreviewAuth &&
|
||||||
!args.texthooker &&
|
!args.texthooker &&
|
||||||
@@ -481,5 +500,6 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
|||||||
args.triggerSubsync ||
|
args.triggerSubsync ||
|
||||||
args.markAudioCard ||
|
args.markAudioCard ||
|
||||||
args.openRuntimeOptions
|
args.openRuntimeOptions
|
||||||
|
|| Boolean(args.youtubePlay)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ ${B}Session${R}
|
|||||||
--background Start in tray/background mode
|
--background Start in tray/background mode
|
||||||
--start Connect to mpv and launch overlay
|
--start Connect to mpv and launch overlay
|
||||||
--launch-mpv ${D}[targets...]${R} Launch mpv with the SubMiner mpv profile and exit
|
--launch-mpv ${D}[targets...]${R} Launch mpv with the SubMiner mpv profile and exit
|
||||||
|
--youtube-play ${D}URL${R} Open YouTube subtitle picker flow for a URL
|
||||||
|
--youtube-mode ${D}download|generate${R} Subtitle acquisition mode for YouTube flow
|
||||||
--stop Stop the running instance
|
--stop Stop the running instance
|
||||||
--stats Open the stats dashboard in your browser
|
--stats Open the stats dashboard in your browser
|
||||||
--texthooker Start texthooker server only ${D}(no overlay)${R}
|
--texthooker Start texthooker server only ${D}(no overlay)${R}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
start: false,
|
start: false,
|
||||||
launchMpv: false,
|
launchMpv: false,
|
||||||
launchMpvTargets: [],
|
launchMpvTargets: [],
|
||||||
|
youtubePlay: undefined,
|
||||||
|
youtubeMode: undefined,
|
||||||
stop: false,
|
stop: false,
|
||||||
toggle: false,
|
toggle: false,
|
||||||
toggleVisibleOverlay: false,
|
toggleVisibleOverlay: false,
|
||||||
@@ -184,6 +186,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
|||||||
runJellyfinCommand: async () => {
|
runJellyfinCommand: async () => {
|
||||||
calls.push('runJellyfinCommand');
|
calls.push('runJellyfinCommand');
|
||||||
},
|
},
|
||||||
|
runYoutubePlaybackFlow: async ({ url, mode }) => {
|
||||||
|
calls.push(`runYoutubePlaybackFlow:${url}:${mode}`);
|
||||||
|
},
|
||||||
printHelp: () => {
|
printHelp: () => {
|
||||||
calls.push('printHelp');
|
calls.push('printHelp');
|
||||||
},
|
},
|
||||||
@@ -226,6 +231,40 @@ test('handleCliCommand reconnects MPV for second-instance --start when overlay r
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('handleCliCommand starts youtube playback flow on initial launch', () => {
|
||||||
|
const { deps, calls } = createDeps({
|
||||||
|
runYoutubePlaybackFlow: async (request) => {
|
||||||
|
calls.push(`youtube:${request.url}:${request.mode}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handleCliCommand(
|
||||||
|
makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'generate' }),
|
||||||
|
'initial',
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'initializeOverlayRuntime',
|
||||||
|
'youtube:https://youtube.com/watch?v=abc:generate',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleCliCommand defaults youtube mode to download when omitted', () => {
|
||||||
|
const { deps, calls } = createDeps({
|
||||||
|
runYoutubePlaybackFlow: async (request) => {
|
||||||
|
calls.push(`youtube:${request.url}:${request.mode}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handleCliCommand(makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc' }), 'initial', deps);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'initializeOverlayRuntime',
|
||||||
|
'youtube:https://youtube.com/watch?v=abc:download',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('handleCliCommand processes --start for second-instance when overlay runtime is not initialized', () => {
|
test('handleCliCommand processes --start for second-instance when overlay runtime is not initialized', () => {
|
||||||
const { deps, calls } = createDeps();
|
const { deps, calls } = createDeps();
|
||||||
const args = makeArgs({ start: true });
|
const args = makeArgs({ start: true });
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ export interface CliCommandServiceDeps {
|
|||||||
}>;
|
}>;
|
||||||
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
|
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
|
||||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||||
|
runYoutubePlaybackFlow: (request: {
|
||||||
|
url: string;
|
||||||
|
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||||
|
source: CliCommandSource;
|
||||||
|
}) => Promise<void>;
|
||||||
printHelp: () => void;
|
printHelp: () => void;
|
||||||
hasMainWindow: () => boolean;
|
hasMainWindow: () => boolean;
|
||||||
getMultiCopyTimeoutMs: () => number;
|
getMultiCopyTimeoutMs: () => number;
|
||||||
@@ -135,6 +140,7 @@ interface AnilistCliRuntime {
|
|||||||
interface AppCliRuntime {
|
interface AppCliRuntime {
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
hasMainWindow: () => boolean;
|
hasMainWindow: () => boolean;
|
||||||
|
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CliCommandDepsRuntimeOptions {
|
export interface CliCommandDepsRuntimeOptions {
|
||||||
@@ -226,6 +232,7 @@ export function createCliCommandDepsRuntime(
|
|||||||
generateCharacterDictionary: options.dictionary.generate,
|
generateCharacterDictionary: options.dictionary.generate,
|
||||||
runStatsCommand: options.jellyfin.runStatsCommand,
|
runStatsCommand: options.jellyfin.runStatsCommand,
|
||||||
runJellyfinCommand: options.jellyfin.runCommand,
|
runJellyfinCommand: options.jellyfin.runCommand,
|
||||||
|
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
|
||||||
printHelp: options.ui.printHelp,
|
printHelp: options.ui.printHelp,
|
||||||
hasMainWindow: options.app.hasMainWindow,
|
hasMainWindow: options.app.hasMainWindow,
|
||||||
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
|
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
|
||||||
@@ -396,6 +403,19 @@ export function handleCliCommand(
|
|||||||
} else if (args.jellyfin) {
|
} else if (args.jellyfin) {
|
||||||
deps.openJellyfinSetup();
|
deps.openJellyfinSetup();
|
||||||
deps.log('Opened Jellyfin setup flow.');
|
deps.log('Opened Jellyfin setup flow.');
|
||||||
|
} else if (args.youtubePlay) {
|
||||||
|
const youtubeUrl = args.youtubePlay;
|
||||||
|
runAsyncWithOsd(
|
||||||
|
() =>
|
||||||
|
deps.runYoutubePlaybackFlow({
|
||||||
|
url: youtubeUrl,
|
||||||
|
mode: args.youtubeMode ?? 'download',
|
||||||
|
source,
|
||||||
|
}),
|
||||||
|
deps,
|
||||||
|
'runYoutubePlaybackFlow',
|
||||||
|
'YouTube playback failed',
|
||||||
|
);
|
||||||
} else if (args.dictionary) {
|
} else if (args.dictionary) {
|
||||||
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
|
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
|
||||||
deps.log('Generating character dictionary for current anime...');
|
deps.log('Generating character dictionary for current anime...');
|
||||||
|
|||||||
@@ -37,6 +37,21 @@ async function waitForPendingAnimeMetadata(tracker: ImmersionTrackerService): Pr
|
|||||||
await privateApi.pendingAnimeMetadataUpdates?.get(videoId);
|
await privateApi.pendingAnimeMetadataUpdates?.get(videoId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForCondition(
|
||||||
|
predicate: () => boolean,
|
||||||
|
timeoutMs = 1_000,
|
||||||
|
intervalMs = 10,
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (predicate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||||
|
}
|
||||||
|
assert.equal(predicate(), true);
|
||||||
|
}
|
||||||
|
|
||||||
function makeMergedToken(overrides: Partial<MergedToken>): MergedToken {
|
function makeMergedToken(overrides: Partial<MergedToken>): MergedToken {
|
||||||
return {
|
return {
|
||||||
surface: '',
|
surface: '',
|
||||||
@@ -2297,6 +2312,132 @@ test('reassignAnimeAnilist preserves existing description when description is om
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('handleMediaChange stores youtube metadata for new youtube sessions', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalPath = process.env.PATH;
|
||||||
|
let fakeBinDir: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yt-dlp-bin-'));
|
||||||
|
const ytDlpOutput =
|
||||||
|
'{"id":"abc123","title":"Video Name","webpage_url":"https://www.youtube.com/watch?v=abc123","thumbnail":"https://i.ytimg.com/vi/abc123/hqdefault.jpg","channel_id":"UCcreator123","channel":"Creator Name","channel_url":"https://www.youtube.com/channel/UCcreator123","uploader_id":"@creator","uploader_url":"https://www.youtube.com/@creator","description":"Video description","channel_follower_count":12345,"thumbnails":[{"url":"https://i.ytimg.com/vi/abc123/hqdefault.jpg"},{"url":"https://yt3.googleusercontent.com/channel-avatar=s88"}]}';
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const outputPath = path.join(fakeBinDir, 'output.json');
|
||||||
|
fs.writeFileSync(outputPath, ytDlpOutput, 'utf8');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(fakeBinDir, 'yt-dlp.cmd'),
|
||||||
|
'@echo off\r\ntype "%~dp0output.json"\r\n',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const scriptPath = path.join(fakeBinDir, 'yt-dlp');
|
||||||
|
fs.writeFileSync(
|
||||||
|
scriptPath,
|
||||||
|
`#!/bin/sh
|
||||||
|
printf '%s\n' '${ytDlpOutput}'
|
||||||
|
`,
|
||||||
|
{ mode: 0o755 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
process.env.PATH = `${fakeBinDir}${path.delimiter}${originalPath ?? ''}`;
|
||||||
|
|
||||||
|
globalThis.fetch = async (input) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.includes('/oembed')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
thumbnail_url: 'https://i.ytimg.com/vi/abc123/hqdefault.jpg',
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Response(new Uint8Array([1, 2, 3]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'image/jpeg' },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor({ dbPath });
|
||||||
|
tracker.handleMediaChange('https://www.youtube.com/watch?v=abc123', 'Player Title');
|
||||||
|
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||||
|
await waitForCondition(
|
||||||
|
() => {
|
||||||
|
const stored = privateApi.db
|
||||||
|
.prepare("SELECT 1 AS ready FROM imm_youtube_videos WHERE youtube_video_id = 'abc123'")
|
||||||
|
.get() as { ready: number } | null;
|
||||||
|
return stored?.ready === 1;
|
||||||
|
},
|
||||||
|
5_000,
|
||||||
|
);
|
||||||
|
const row = privateApi.db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
youtube_video_id AS youtubeVideoId,
|
||||||
|
video_url AS videoUrl,
|
||||||
|
video_title AS videoTitle,
|
||||||
|
video_thumbnail_url AS videoThumbnailUrl,
|
||||||
|
channel_id AS channelId,
|
||||||
|
channel_name AS channelName,
|
||||||
|
channel_url AS channelUrl,
|
||||||
|
channel_thumbnail_url AS channelThumbnailUrl,
|
||||||
|
uploader_id AS uploaderId,
|
||||||
|
uploader_url AS uploaderUrl,
|
||||||
|
description AS description
|
||||||
|
FROM imm_youtube_videos
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.get() as {
|
||||||
|
youtubeVideoId: string;
|
||||||
|
videoUrl: string;
|
||||||
|
videoTitle: string;
|
||||||
|
videoThumbnailUrl: string;
|
||||||
|
channelId: string;
|
||||||
|
channelName: string;
|
||||||
|
channelUrl: string;
|
||||||
|
channelThumbnailUrl: string;
|
||||||
|
uploaderId: string;
|
||||||
|
uploaderUrl: string;
|
||||||
|
description: string;
|
||||||
|
} | null;
|
||||||
|
const videoRow = privateApi.db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT canonical_title AS canonicalTitle
|
||||||
|
FROM imm_videos
|
||||||
|
WHERE video_id = 1
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.get() as { canonicalTitle: string } | null;
|
||||||
|
|
||||||
|
assert.ok(row);
|
||||||
|
assert.ok(videoRow);
|
||||||
|
assert.equal(row.youtubeVideoId, 'abc123');
|
||||||
|
assert.equal(row.videoUrl, 'https://www.youtube.com/watch?v=abc123');
|
||||||
|
assert.equal(row.videoTitle, 'Video Name');
|
||||||
|
assert.equal(row.videoThumbnailUrl, 'https://i.ytimg.com/vi/abc123/hqdefault.jpg');
|
||||||
|
assert.equal(row.channelId, 'UCcreator123');
|
||||||
|
assert.equal(row.channelName, 'Creator Name');
|
||||||
|
assert.equal(row.channelUrl, 'https://www.youtube.com/channel/UCcreator123');
|
||||||
|
assert.equal(row.channelThumbnailUrl, 'https://yt3.googleusercontent.com/channel-avatar=s88');
|
||||||
|
assert.equal(row.uploaderId, '@creator');
|
||||||
|
assert.equal(row.uploaderUrl, 'https://www.youtube.com/@creator');
|
||||||
|
assert.equal(row.description, 'Video description');
|
||||||
|
assert.equal(videoRow.canonicalTitle, 'Video Name');
|
||||||
|
} finally {
|
||||||
|
process.env.PATH = originalPath;
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
if (fakeBinDir) {
|
||||||
|
fs.rmSync(fakeBinDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('reassignAnimeAnilist clears description when description is explicitly null', async () => {
|
test('reassignAnimeAnilist clears description when description is explicitly null', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
|
import { MediaGenerator } from '../../media-generator';
|
||||||
import type { CoverArtFetcher } from './anilist/cover-art-fetcher';
|
import type { CoverArtFetcher } from './anilist/cover-art-fetcher';
|
||||||
import { getLocalVideoMetadata, guessAnimeVideoMetadata } from './immersion-tracker/metadata';
|
import { getLocalVideoMetadata, guessAnimeVideoMetadata } from './immersion-tracker/metadata';
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
type TrackerPreparedStatements,
|
type TrackerPreparedStatements,
|
||||||
updateVideoMetadataRecord,
|
updateVideoMetadataRecord,
|
||||||
updateVideoTitleRecord,
|
updateVideoTitleRecord,
|
||||||
|
upsertYoutubeVideoMetadata,
|
||||||
} from './immersion-tracker/storage';
|
} from './immersion-tracker/storage';
|
||||||
import {
|
import {
|
||||||
applySessionLifetimeSummary,
|
applySessionLifetimeSummary,
|
||||||
@@ -153,6 +155,104 @@ import {
|
|||||||
import type { MergedToken } from '../../types';
|
import type { MergedToken } from '../../types';
|
||||||
import { shouldExcludeTokenFromVocabularyPersistence } from './tokenizer/annotation-stage';
|
import { shouldExcludeTokenFromVocabularyPersistence } from './tokenizer/annotation-stage';
|
||||||
import { deriveStoredPartOfSpeech } from './tokenizer/part-of-speech';
|
import { deriveStoredPartOfSpeech } from './tokenizer/part-of-speech';
|
||||||
|
import { probeYoutubeVideoMetadata } from './youtube/metadata-probe';
|
||||||
|
|
||||||
|
const YOUTUBE_COVER_RETRY_MS = 5 * 60 * 1000;
|
||||||
|
const YOUTUBE_SCREENSHOT_MAX_SECONDS = 120;
|
||||||
|
const YOUTUBE_OEMBED_ENDPOINT = 'https://www.youtube.com/oembed';
|
||||||
|
const YOUTUBE_ID_PATTERN = /^[A-Za-z0-9_-]{6,}$/;
|
||||||
|
|
||||||
|
function isValidYouTubeVideoId(value: string | null): boolean {
|
||||||
|
return Boolean(value && YOUTUBE_ID_PATTERN.test(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractYouTubeVideoId(mediaUrl: string): string | null {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(mediaUrl);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = parsed.hostname.toLowerCase();
|
||||||
|
if (
|
||||||
|
host !== 'youtu.be' &&
|
||||||
|
!host.endsWith('.youtu.be') &&
|
||||||
|
!host.endsWith('youtube.com') &&
|
||||||
|
!host.endsWith('youtube-nocookie.com')
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host === 'youtu.be' || host.endsWith('.youtu.be')) {
|
||||||
|
const pathId = parsed.pathname.split('/').filter(Boolean)[0];
|
||||||
|
return isValidYouTubeVideoId(pathId ?? null) ? (pathId as string) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryId = parsed.searchParams.get('v') ?? parsed.searchParams.get('vi') ?? null;
|
||||||
|
if (isValidYouTubeVideoId(queryId)) {
|
||||||
|
return queryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathParts = parsed.pathname.split('/').filter(Boolean);
|
||||||
|
for (let i = 0; i < pathParts.length; i += 1) {
|
||||||
|
const current = pathParts[i];
|
||||||
|
const next = pathParts[i + 1];
|
||||||
|
if (!current || !next) continue;
|
||||||
|
if (
|
||||||
|
current.toLowerCase() === 'shorts' ||
|
||||||
|
current.toLowerCase() === 'embed' ||
|
||||||
|
current.toLowerCase() === 'live' ||
|
||||||
|
current.toLowerCase() === 'v'
|
||||||
|
) {
|
||||||
|
const candidate = decodeURIComponent(next);
|
||||||
|
if (isValidYouTubeVideoId(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildYouTubeThumbnailUrls(videoId: string): string[] {
|
||||||
|
return [
|
||||||
|
`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`,
|
||||||
|
`https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
|
||||||
|
`https://i.ytimg.com/vi/${videoId}/sddefault.jpg`,
|
||||||
|
`https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`,
|
||||||
|
`https://i.ytimg.com/vi/${videoId}/0.jpg`,
|
||||||
|
`https://i.ytimg.com/vi/${videoId}/default.jpg`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchYouTubeOEmbedThumbnail(mediaUrl: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${YOUTUBE_OEMBED_ENDPOINT}?url=${encodeURIComponent(mediaUrl)}&format=json`);
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as { thumbnail_url?: unknown };
|
||||||
|
const candidate = typeof payload.thumbnail_url === 'string' ? payload.thumbnail_url.trim() : '';
|
||||||
|
return candidate || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadImage(url: string): Promise<Buffer | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (contentType && !contentType.toLowerCase().startsWith('image/')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Buffer.from(await response.arrayBuffer());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
AnimeAnilistEntryRow,
|
AnimeAnilistEntryRow,
|
||||||
@@ -212,9 +312,11 @@ export class ImmersionTrackerService {
|
|||||||
private sessionState: SessionState | null = null;
|
private sessionState: SessionState | null = null;
|
||||||
private currentVideoKey = '';
|
private currentVideoKey = '';
|
||||||
private currentMediaPathOrUrl = '';
|
private currentMediaPathOrUrl = '';
|
||||||
|
private readonly mediaGenerator = new MediaGenerator();
|
||||||
private readonly preparedStatements: TrackerPreparedStatements;
|
private readonly preparedStatements: TrackerPreparedStatements;
|
||||||
private coverArtFetcher: CoverArtFetcher | null = null;
|
private coverArtFetcher: CoverArtFetcher | null = null;
|
||||||
private readonly pendingCoverFetches = new Map<number, Promise<boolean>>();
|
private readonly pendingCoverFetches = new Map<number, Promise<boolean>>();
|
||||||
|
private readonly pendingYoutubeMetadataFetches = new Map<number, Promise<void>>();
|
||||||
private readonly recordedSubtitleKeys = new Set<string>();
|
private readonly recordedSubtitleKeys = new Set<string>();
|
||||||
private readonly pendingAnimeMetadataUpdates = new Map<number, Promise<void>>();
|
private readonly pendingAnimeMetadataUpdates = new Map<number, Promise<void>>();
|
||||||
private readonly resolveLegacyVocabularyPos:
|
private readonly resolveLegacyVocabularyPos:
|
||||||
@@ -647,6 +749,17 @@ export class ImmersionTrackerService {
|
|||||||
if (existing?.coverBlob) {
|
if (existing?.coverBlob) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const row = this.db
|
||||||
|
.prepare('SELECT source_url AS sourceUrl FROM imm_videos WHERE video_id = ?')
|
||||||
|
.get(videoId) as { sourceUrl: string | null } | null;
|
||||||
|
const sourceUrl = row?.sourceUrl?.trim() ?? '';
|
||||||
|
const youtubeVideoId = sourceUrl ? extractYouTubeVideoId(sourceUrl) : null;
|
||||||
|
if (youtubeVideoId) {
|
||||||
|
const youtubePromise = this.ensureYouTubeCoverArt(videoId, sourceUrl, youtubeVideoId);
|
||||||
|
return await youtubePromise;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.coverArtFetcher) {
|
if (!this.coverArtFetcher) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -677,6 +790,143 @@ export class ImmersionTrackerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ensureYouTubeCoverArt(videoId: number, sourceUrl: string, youtubeVideoId: string): Promise<boolean> {
|
||||||
|
const existing = this.pendingCoverFetches.get(videoId);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const promise = this.captureYouTubeCoverArt(videoId, sourceUrl, youtubeVideoId);
|
||||||
|
this.pendingCoverFetches.set(videoId, promise);
|
||||||
|
promise.finally(() => {
|
||||||
|
this.pendingCoverFetches.delete(videoId);
|
||||||
|
});
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async captureYouTubeCoverArt(
|
||||||
|
videoId: number,
|
||||||
|
sourceUrl: string,
|
||||||
|
youtubeVideoId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (this.isDestroyed) return false;
|
||||||
|
const existing = await this.getCoverArt(videoId);
|
||||||
|
if (existing?.coverBlob) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
existing?.coverUrl === null &&
|
||||||
|
existing?.anilistId === null &&
|
||||||
|
existing?.coverBlob === null &&
|
||||||
|
Date.now() - existing.fetchedAtMs < YOUTUBE_COVER_RETRY_MS
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let coverBlob: Buffer | null = null;
|
||||||
|
let coverUrl: string | null = null;
|
||||||
|
|
||||||
|
const embedThumbnailUrl = await fetchYouTubeOEmbedThumbnail(sourceUrl);
|
||||||
|
if (embedThumbnailUrl) {
|
||||||
|
const embedBlob = await downloadImage(embedThumbnailUrl);
|
||||||
|
if (embedBlob) {
|
||||||
|
coverBlob = embedBlob;
|
||||||
|
coverUrl = embedThumbnailUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!coverBlob) {
|
||||||
|
for (const candidate of buildYouTubeThumbnailUrls(youtubeVideoId)) {
|
||||||
|
const candidateBlob = await downloadImage(candidate);
|
||||||
|
if (!candidateBlob) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
coverBlob = candidateBlob;
|
||||||
|
coverUrl = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!coverBlob) {
|
||||||
|
const durationMs = getVideoDurationMs(this.db, videoId);
|
||||||
|
const maxSeconds = durationMs > 0 ? Math.min(durationMs / 1000, YOUTUBE_SCREENSHOT_MAX_SECONDS) : null;
|
||||||
|
const seekSecond = Math.random() * (maxSeconds ?? YOUTUBE_SCREENSHOT_MAX_SECONDS);
|
||||||
|
try {
|
||||||
|
coverBlob = await this.mediaGenerator.generateScreenshot(
|
||||||
|
sourceUrl,
|
||||||
|
seekSecond,
|
||||||
|
{
|
||||||
|
format: 'jpg',
|
||||||
|
quality: 90,
|
||||||
|
maxWidth: 640,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
'cover-art: failed to generate YouTube screenshot for videoId=%d: %s',
|
||||||
|
videoId,
|
||||||
|
(error as Error).message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coverBlob) {
|
||||||
|
upsertCoverArt(this.db, videoId, {
|
||||||
|
anilistId: existing?.anilistId ?? null,
|
||||||
|
coverUrl,
|
||||||
|
coverBlob,
|
||||||
|
titleRomaji: existing?.titleRomaji ?? null,
|
||||||
|
titleEnglish: existing?.titleEnglish ?? null,
|
||||||
|
episodesTotal: existing?.episodesTotal ?? null,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldCacheNoMatch =
|
||||||
|
!existing || (existing.coverUrl === null && existing.anilistId === null);
|
||||||
|
if (shouldCacheNoMatch) {
|
||||||
|
upsertCoverArt(this.db, videoId, {
|
||||||
|
anilistId: null,
|
||||||
|
coverUrl: null,
|
||||||
|
coverBlob: null,
|
||||||
|
titleRomaji: existing?.titleRomaji ?? null,
|
||||||
|
titleEnglish: existing?.titleEnglish ?? null,
|
||||||
|
episodesTotal: existing?.episodesTotal ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private captureYoutubeMetadataAsync(videoId: number, sourceUrl: string): void {
|
||||||
|
if (this.pendingYoutubeMetadataFetches.has(videoId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = (async () => {
|
||||||
|
try {
|
||||||
|
const metadata = await probeYoutubeVideoMetadata(sourceUrl);
|
||||||
|
if (!metadata) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
upsertYoutubeVideoMetadata(this.db, videoId, metadata);
|
||||||
|
if (metadata.videoTitle?.trim()) {
|
||||||
|
updateVideoTitleRecord(this.db, videoId, metadata.videoTitle.trim());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug(
|
||||||
|
'youtube metadata capture skipped for videoId=%d: %s',
|
||||||
|
videoId,
|
||||||
|
(error as Error).message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
this.pendingYoutubeMetadataFetches.set(videoId, pending);
|
||||||
|
pending.finally(() => {
|
||||||
|
this.pendingYoutubeMetadataFetches.delete(videoId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
|
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
|
||||||
const normalizedPath = normalizeMediaPath(mediaPath);
|
const normalizedPath = normalizeMediaPath(mediaPath);
|
||||||
const normalizedTitle = normalizeText(mediaTitle);
|
const normalizedTitle = normalizeText(mediaTitle);
|
||||||
@@ -721,6 +971,13 @@ export class ImmersionTrackerService {
|
|||||||
`Starting immersion session for path=${normalizedPath} videoId=${sessionInfo.videoId}`,
|
`Starting immersion session for path=${normalizedPath} videoId=${sessionInfo.videoId}`,
|
||||||
);
|
);
|
||||||
this.startSession(sessionInfo.videoId, sessionInfo.startedAtMs);
|
this.startSession(sessionInfo.videoId, sessionInfo.startedAtMs);
|
||||||
|
if (sourceType === SOURCE_TYPE_REMOTE) {
|
||||||
|
const youtubeVideoId = extractYouTubeVideoId(normalizedPath);
|
||||||
|
if (youtubeVideoId) {
|
||||||
|
void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId);
|
||||||
|
this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
|
this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
|
||||||
this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath);
|
this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
} from '../query.js';
|
} from '../query.js';
|
||||||
import {
|
import {
|
||||||
SOURCE_TYPE_LOCAL,
|
SOURCE_TYPE_LOCAL,
|
||||||
|
SOURCE_TYPE_REMOTE,
|
||||||
EVENT_CARD_MINED,
|
EVENT_CARD_MINED,
|
||||||
EVENT_SUBTITLE_LINE,
|
EVENT_SUBTITLE_LINE,
|
||||||
EVENT_YOMITAN_LOOKUP,
|
EVENT_YOMITAN_LOOKUP,
|
||||||
@@ -1956,6 +1957,100 @@ test('media library and detail queries read lifetime totals', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('media library and detail queries include joined youtube metadata when present', () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSchema(db);
|
||||||
|
|
||||||
|
const mediaOne = getOrCreateVideoRecord(db, 'yt:https://www.youtube.com/watch?v=abc123', {
|
||||||
|
canonicalTitle: 'Local Fallback Title',
|
||||||
|
sourcePath: null,
|
||||||
|
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
sourceType: SOURCE_TYPE_REMOTE,
|
||||||
|
});
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO imm_lifetime_media (
|
||||||
|
video_id,
|
||||||
|
total_sessions,
|
||||||
|
total_active_ms,
|
||||||
|
total_cards,
|
||||||
|
total_lines_seen,
|
||||||
|
total_tokens_seen,
|
||||||
|
completed,
|
||||||
|
first_watched_ms,
|
||||||
|
last_watched_ms,
|
||||||
|
CREATED_DATE,
|
||||||
|
LAST_UPDATE_DATE
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
).run(mediaOne, 2, 6_000, 1, 5, 80, 0, 1_000, 9_000, 9_000, 9_000);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO imm_youtube_videos (
|
||||||
|
video_id,
|
||||||
|
youtube_video_id,
|
||||||
|
video_url,
|
||||||
|
video_title,
|
||||||
|
video_thumbnail_url,
|
||||||
|
channel_id,
|
||||||
|
channel_name,
|
||||||
|
channel_url,
|
||||||
|
channel_thumbnail_url,
|
||||||
|
uploader_id,
|
||||||
|
uploader_url,
|
||||||
|
description,
|
||||||
|
metadata_json,
|
||||||
|
fetched_at_ms,
|
||||||
|
CREATED_DATE,
|
||||||
|
LAST_UPDATE_DATE
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
).run(
|
||||||
|
mediaOne,
|
||||||
|
'abc123',
|
||||||
|
'https://www.youtube.com/watch?v=abc123',
|
||||||
|
'Tracked Video Title',
|
||||||
|
'https://i.ytimg.com/vi/abc123/hqdefault.jpg',
|
||||||
|
'UCcreator123',
|
||||||
|
'Creator Name',
|
||||||
|
'https://www.youtube.com/channel/UCcreator123',
|
||||||
|
'https://yt3.googleusercontent.com/channel-avatar=s88',
|
||||||
|
'@creator',
|
||||||
|
'https://www.youtube.com/@creator',
|
||||||
|
'Video description',
|
||||||
|
'{"source":"test"}',
|
||||||
|
10_000,
|
||||||
|
10_000,
|
||||||
|
10_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const library = getMediaLibrary(db);
|
||||||
|
const detail = getMediaDetail(db, mediaOne);
|
||||||
|
|
||||||
|
assert.equal(library.length, 1);
|
||||||
|
assert.equal(library[0]?.youtubeVideoId, 'abc123');
|
||||||
|
assert.equal(library[0]?.videoTitle, 'Tracked Video Title');
|
||||||
|
assert.equal(library[0]?.channelId, 'UCcreator123');
|
||||||
|
assert.equal(library[0]?.channelName, 'Creator Name');
|
||||||
|
assert.equal(library[0]?.channelUrl, 'https://www.youtube.com/channel/UCcreator123');
|
||||||
|
assert.equal(detail?.youtubeVideoId, 'abc123');
|
||||||
|
assert.equal(detail?.videoUrl, 'https://www.youtube.com/watch?v=abc123');
|
||||||
|
assert.equal(detail?.videoThumbnailUrl, 'https://i.ytimg.com/vi/abc123/hqdefault.jpg');
|
||||||
|
assert.equal(detail?.channelThumbnailUrl, 'https://yt3.googleusercontent.com/channel-avatar=s88');
|
||||||
|
assert.equal(detail?.uploaderId, '@creator');
|
||||||
|
assert.equal(detail?.uploaderUrl, 'https://www.youtube.com/@creator');
|
||||||
|
assert.equal(detail?.description, 'Video description');
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('cover art queries reuse a shared blob across duplicate anime art rows', () => {
|
test('cover art queries reuse a shared blob across duplicate anime art rows', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|||||||
@@ -1817,6 +1817,17 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
|
|||||||
COALESCE(lm.total_cards, 0) AS totalCards,
|
COALESCE(lm.total_cards, 0) AS totalCards,
|
||||||
COALESCE(lm.total_tokens_seen, 0) AS totalTokensSeen,
|
COALESCE(lm.total_tokens_seen, 0) AS totalTokensSeen,
|
||||||
COALESCE(lm.last_watched_ms, 0) AS lastWatchedMs,
|
COALESCE(lm.last_watched_ms, 0) AS lastWatchedMs,
|
||||||
|
yv.youtube_video_id AS youtubeVideoId,
|
||||||
|
yv.video_url AS videoUrl,
|
||||||
|
yv.video_title AS videoTitle,
|
||||||
|
yv.video_thumbnail_url AS videoThumbnailUrl,
|
||||||
|
yv.channel_id AS channelId,
|
||||||
|
yv.channel_name AS channelName,
|
||||||
|
yv.channel_url AS channelUrl,
|
||||||
|
yv.channel_thumbnail_url AS channelThumbnailUrl,
|
||||||
|
yv.uploader_id AS uploaderId,
|
||||||
|
yv.uploader_url AS uploaderUrl,
|
||||||
|
yv.description AS description,
|
||||||
CASE
|
CASE
|
||||||
WHEN ma.cover_blob_hash IS NOT NULL OR ma.cover_blob IS NOT NULL THEN 1
|
WHEN ma.cover_blob_hash IS NOT NULL OR ma.cover_blob IS NOT NULL THEN 1
|
||||||
ELSE 0
|
ELSE 0
|
||||||
@@ -1824,6 +1835,7 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
|
|||||||
FROM imm_videos v
|
FROM imm_videos v
|
||||||
JOIN imm_lifetime_media lm ON lm.video_id = v.video_id
|
JOIN imm_lifetime_media lm ON lm.video_id = v.video_id
|
||||||
LEFT JOIN imm_media_art ma ON ma.video_id = v.video_id
|
LEFT JOIN imm_media_art ma ON ma.video_id = v.video_id
|
||||||
|
LEFT JOIN imm_youtube_videos yv ON yv.video_id = v.video_id
|
||||||
ORDER BY lm.last_watched_ms DESC
|
ORDER BY lm.last_watched_ms DESC
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
@@ -1846,9 +1858,21 @@ export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRo
|
|||||||
COALESCE(lm.total_lines_seen, 0) AS totalLinesSeen,
|
COALESCE(lm.total_lines_seen, 0) AS totalLinesSeen,
|
||||||
COALESCE(SUM(COALESCE(asm.lookupCount, s.lookup_count, 0)), 0) AS totalLookupCount,
|
COALESCE(SUM(COALESCE(asm.lookupCount, s.lookup_count, 0)), 0) AS totalLookupCount,
|
||||||
COALESCE(SUM(COALESCE(asm.lookupHits, s.lookup_hits, 0)), 0) AS totalLookupHits,
|
COALESCE(SUM(COALESCE(asm.lookupHits, s.lookup_hits, 0)), 0) AS totalLookupHits,
|
||||||
COALESCE(SUM(COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0)), 0) AS totalYomitanLookupCount
|
COALESCE(SUM(COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0)), 0) AS totalYomitanLookupCount,
|
||||||
|
yv.youtube_video_id AS youtubeVideoId,
|
||||||
|
yv.video_url AS videoUrl,
|
||||||
|
yv.video_title AS videoTitle,
|
||||||
|
yv.video_thumbnail_url AS videoThumbnailUrl,
|
||||||
|
yv.channel_id AS channelId,
|
||||||
|
yv.channel_name AS channelName,
|
||||||
|
yv.channel_url AS channelUrl,
|
||||||
|
yv.channel_thumbnail_url AS channelThumbnailUrl,
|
||||||
|
yv.uploader_id AS uploaderId,
|
||||||
|
yv.uploader_url AS uploaderUrl,
|
||||||
|
yv.description AS description
|
||||||
FROM imm_videos v
|
FROM imm_videos v
|
||||||
JOIN imm_lifetime_media lm ON lm.video_id = v.video_id
|
JOIN imm_lifetime_media lm ON lm.video_id = v.video_id
|
||||||
|
LEFT JOIN imm_youtube_videos yv ON yv.video_id = v.video_id
|
||||||
LEFT JOIN imm_sessions s ON s.video_id = v.video_id
|
LEFT JOIN imm_sessions s ON s.video_id = v.video_id
|
||||||
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
|
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
|
||||||
WHERE v.video_id = ?
|
WHERE v.video_id = ?
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ test('ensureSchema creates immersion core tables', () => {
|
|||||||
assert.ok(tableNames.has('imm_kanji_line_occurrences'));
|
assert.ok(tableNames.has('imm_kanji_line_occurrences'));
|
||||||
assert.ok(tableNames.has('imm_rollup_state'));
|
assert.ok(tableNames.has('imm_rollup_state'));
|
||||||
assert.ok(tableNames.has('imm_cover_art_blobs'));
|
assert.ok(tableNames.has('imm_cover_art_blobs'));
|
||||||
|
assert.ok(tableNames.has('imm_youtube_videos'));
|
||||||
|
|
||||||
const videoColumns = new Set(
|
const videoColumns = new Set(
|
||||||
(
|
(
|
||||||
@@ -146,6 +147,114 @@ test('ensureSchema creates immersion core tables', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ensureSchema adds youtube metadata table to existing schema version 15 databases', () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE imm_schema_version (
|
||||||
|
schema_version INTEGER PRIMARY KEY,
|
||||||
|
applied_at_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO imm_schema_version(schema_version, applied_at_ms) VALUES (15, 1000);
|
||||||
|
|
||||||
|
CREATE TABLE imm_rollup_state(
|
||||||
|
state_key TEXT PRIMARY KEY,
|
||||||
|
state_value INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO imm_rollup_state(state_key, state_value) VALUES ('last_rollup_sample_ms', 123);
|
||||||
|
|
||||||
|
CREATE TABLE imm_anime(
|
||||||
|
anime_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
normalized_title_key TEXT NOT NULL UNIQUE,
|
||||||
|
canonical_title TEXT NOT NULL,
|
||||||
|
anilist_id INTEGER UNIQUE,
|
||||||
|
title_romaji TEXT,
|
||||||
|
title_english TEXT,
|
||||||
|
title_native TEXT,
|
||||||
|
episodes_total INTEGER,
|
||||||
|
description TEXT,
|
||||||
|
metadata_json TEXT,
|
||||||
|
CREATED_DATE INTEGER,
|
||||||
|
LAST_UPDATE_DATE INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE imm_videos(
|
||||||
|
video_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
video_key TEXT NOT NULL UNIQUE,
|
||||||
|
anime_id INTEGER,
|
||||||
|
canonical_title TEXT NOT NULL,
|
||||||
|
source_type INTEGER NOT NULL,
|
||||||
|
source_path TEXT,
|
||||||
|
source_url TEXT,
|
||||||
|
parsed_basename TEXT,
|
||||||
|
parsed_title TEXT,
|
||||||
|
parsed_season INTEGER,
|
||||||
|
parsed_episode INTEGER,
|
||||||
|
parser_source TEXT,
|
||||||
|
parser_confidence REAL,
|
||||||
|
parse_metadata_json TEXT,
|
||||||
|
watched INTEGER NOT NULL DEFAULT 0,
|
||||||
|
duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
|
||||||
|
file_size_bytes INTEGER CHECK(file_size_bytes>=0),
|
||||||
|
codec_id INTEGER, container_id INTEGER,
|
||||||
|
width_px INTEGER, height_px INTEGER, fps_x100 INTEGER,
|
||||||
|
bitrate_kbps INTEGER, audio_codec_id INTEGER,
|
||||||
|
hash_sha256 TEXT, screenshot_path TEXT,
|
||||||
|
metadata_json TEXT,
|
||||||
|
CREATED_DATE INTEGER,
|
||||||
|
LAST_UPDATE_DATE INTEGER,
|
||||||
|
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
ensureSchema(db);
|
||||||
|
|
||||||
|
const tables = new Set(
|
||||||
|
(
|
||||||
|
db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'imm_%'`).all() as Array<{
|
||||||
|
name: string;
|
||||||
|
}>
|
||||||
|
).map((row) => row.name),
|
||||||
|
);
|
||||||
|
assert.ok(tables.has('imm_youtube_videos'));
|
||||||
|
|
||||||
|
const columns = new Set(
|
||||||
|
(
|
||||||
|
db.prepare('PRAGMA table_info(imm_youtube_videos)').all() as Array<{
|
||||||
|
name: string;
|
||||||
|
}>
|
||||||
|
).map((row) => row.name),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
columns,
|
||||||
|
new Set([
|
||||||
|
'video_id',
|
||||||
|
'youtube_video_id',
|
||||||
|
'video_url',
|
||||||
|
'video_title',
|
||||||
|
'video_thumbnail_url',
|
||||||
|
'channel_id',
|
||||||
|
'channel_name',
|
||||||
|
'channel_url',
|
||||||
|
'channel_thumbnail_url',
|
||||||
|
'uploader_id',
|
||||||
|
'uploader_url',
|
||||||
|
'description',
|
||||||
|
'metadata_json',
|
||||||
|
'fetched_at_ms',
|
||||||
|
'CREATED_DATE',
|
||||||
|
'LAST_UPDATE_DATE',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('ensureSchema creates large-history performance indexes', () => {
|
test('ensureSchema creates large-history performance indexes', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
@@ -169,6 +278,8 @@ test('ensureSchema creates large-history performance indexes', () => {
|
|||||||
assert.ok(indexNames.has('idx_kanji_frequency'));
|
assert.ok(indexNames.has('idx_kanji_frequency'));
|
||||||
assert.ok(indexNames.has('idx_media_art_anilist_id'));
|
assert.ok(indexNames.has('idx_media_art_anilist_id'));
|
||||||
assert.ok(indexNames.has('idx_media_art_cover_url'));
|
assert.ok(indexNames.has('idx_media_art_cover_url'));
|
||||||
|
assert.ok(indexNames.has('idx_youtube_videos_channel_id'));
|
||||||
|
assert.ok(indexNames.has('idx_youtube_videos_youtube_video_id'));
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
cleanupDbPath(dbPath);
|
cleanupDbPath(dbPath);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
|
|||||||
import { parseMediaInfo } from '../../../jimaku/utils';
|
import { parseMediaInfo } from '../../../jimaku/utils';
|
||||||
import type { DatabaseSync } from './sqlite';
|
import type { DatabaseSync } from './sqlite';
|
||||||
import { SCHEMA_VERSION } from './types';
|
import { SCHEMA_VERSION } from './types';
|
||||||
import type { QueuedWrite, VideoMetadata } from './types';
|
import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types';
|
||||||
|
|
||||||
export interface TrackerPreparedStatements {
|
export interface TrackerPreparedStatements {
|
||||||
telemetryInsertStmt: ReturnType<DatabaseSync['prepare']>;
|
telemetryInsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||||
@@ -743,6 +743,27 @@ export function ensureSchema(db: DatabaseSync): void {
|
|||||||
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
|
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS imm_youtube_videos(
|
||||||
|
video_id INTEGER PRIMARY KEY,
|
||||||
|
youtube_video_id TEXT NOT NULL,
|
||||||
|
video_url TEXT NOT NULL,
|
||||||
|
video_title TEXT,
|
||||||
|
video_thumbnail_url TEXT,
|
||||||
|
channel_id TEXT,
|
||||||
|
channel_name TEXT,
|
||||||
|
channel_url TEXT,
|
||||||
|
channel_thumbnail_url TEXT,
|
||||||
|
uploader_id TEXT,
|
||||||
|
uploader_url TEXT,
|
||||||
|
description TEXT,
|
||||||
|
metadata_json TEXT,
|
||||||
|
fetched_at_ms INTEGER NOT NULL,
|
||||||
|
CREATED_DATE INTEGER,
|
||||||
|
LAST_UPDATE_DATE INTEGER,
|
||||||
|
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS imm_cover_art_blobs(
|
CREATE TABLE IF NOT EXISTS imm_cover_art_blobs(
|
||||||
blob_hash TEXT PRIMARY KEY,
|
blob_hash TEXT PRIMARY KEY,
|
||||||
@@ -1134,6 +1155,14 @@ export function ensureSchema(db: DatabaseSync): void {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_media_art_cover_url
|
CREATE INDEX IF NOT EXISTS idx_media_art_cover_url
|
||||||
ON imm_media_art(cover_url)
|
ON imm_media_art(cover_url)
|
||||||
`);
|
`);
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_youtube_videos_channel_id
|
||||||
|
ON imm_youtube_videos(channel_id)
|
||||||
|
`);
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_youtube_videos_youtube_video_id
|
||||||
|
ON imm_youtube_videos(youtube_video_id)
|
||||||
|
`);
|
||||||
|
|
||||||
if (currentVersion?.schema_version && currentVersion.schema_version < SCHEMA_VERSION) {
|
if (currentVersion?.schema_version && currentVersion.schema_version < SCHEMA_VERSION) {
|
||||||
db.exec('DELETE FROM imm_daily_rollups');
|
db.exec('DELETE FROM imm_daily_rollups');
|
||||||
@@ -1506,3 +1535,65 @@ export function updateVideoTitleRecord(
|
|||||||
`,
|
`,
|
||||||
).run(canonicalTitle, Date.now(), videoId);
|
).run(canonicalTitle, Date.now(), videoId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function upsertYoutubeVideoMetadata(
|
||||||
|
db: DatabaseSync,
|
||||||
|
videoId: number,
|
||||||
|
metadata: YoutubeVideoMetadata,
|
||||||
|
): void {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO imm_youtube_videos (
|
||||||
|
video_id,
|
||||||
|
youtube_video_id,
|
||||||
|
video_url,
|
||||||
|
video_title,
|
||||||
|
video_thumbnail_url,
|
||||||
|
channel_id,
|
||||||
|
channel_name,
|
||||||
|
channel_url,
|
||||||
|
channel_thumbnail_url,
|
||||||
|
uploader_id,
|
||||||
|
uploader_url,
|
||||||
|
description,
|
||||||
|
metadata_json,
|
||||||
|
fetched_at_ms,
|
||||||
|
CREATED_DATE,
|
||||||
|
LAST_UPDATE_DATE
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(video_id) DO UPDATE SET
|
||||||
|
youtube_video_id = excluded.youtube_video_id,
|
||||||
|
video_url = excluded.video_url,
|
||||||
|
video_title = excluded.video_title,
|
||||||
|
video_thumbnail_url = excluded.video_thumbnail_url,
|
||||||
|
channel_id = excluded.channel_id,
|
||||||
|
channel_name = excluded.channel_name,
|
||||||
|
channel_url = excluded.channel_url,
|
||||||
|
channel_thumbnail_url = excluded.channel_thumbnail_url,
|
||||||
|
uploader_id = excluded.uploader_id,
|
||||||
|
uploader_url = excluded.uploader_url,
|
||||||
|
description = excluded.description,
|
||||||
|
metadata_json = excluded.metadata_json,
|
||||||
|
fetched_at_ms = excluded.fetched_at_ms,
|
||||||
|
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE
|
||||||
|
`,
|
||||||
|
).run(
|
||||||
|
videoId,
|
||||||
|
metadata.youtubeVideoId,
|
||||||
|
metadata.videoUrl,
|
||||||
|
metadata.videoTitle ?? null,
|
||||||
|
metadata.videoThumbnailUrl ?? null,
|
||||||
|
metadata.channelId ?? null,
|
||||||
|
metadata.channelName ?? null,
|
||||||
|
metadata.channelUrl ?? null,
|
||||||
|
metadata.channelThumbnailUrl ?? null,
|
||||||
|
metadata.uploaderId ?? null,
|
||||||
|
metadata.uploaderUrl ?? null,
|
||||||
|
metadata.description ?? null,
|
||||||
|
metadata.metadataJson ?? null,
|
||||||
|
nowMs,
|
||||||
|
nowMs,
|
||||||
|
nowMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const SCHEMA_VERSION = 15;
|
export const SCHEMA_VERSION = 16;
|
||||||
export const DEFAULT_QUEUE_CAP = 1_000;
|
export const DEFAULT_QUEUE_CAP = 1_000;
|
||||||
export const DEFAULT_BATCH_SIZE = 25;
|
export const DEFAULT_BATCH_SIZE = 25;
|
||||||
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
|
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
|
||||||
@@ -420,6 +420,17 @@ export interface MediaLibraryRow {
|
|||||||
totalTokensSeen: number;
|
totalTokensSeen: number;
|
||||||
lastWatchedMs: number;
|
lastWatchedMs: number;
|
||||||
hasCoverArt: number;
|
hasCoverArt: number;
|
||||||
|
youtubeVideoId: string | null;
|
||||||
|
videoUrl: string | null;
|
||||||
|
videoTitle: string | null;
|
||||||
|
videoThumbnailUrl: string | null;
|
||||||
|
channelId: string | null;
|
||||||
|
channelName: string | null;
|
||||||
|
channelUrl: string | null;
|
||||||
|
channelThumbnailUrl: string | null;
|
||||||
|
uploaderId: string | null;
|
||||||
|
uploaderUrl: string | null;
|
||||||
|
description: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaDetailRow {
|
export interface MediaDetailRow {
|
||||||
@@ -434,6 +445,32 @@ export interface MediaDetailRow {
|
|||||||
totalLookupCount: number;
|
totalLookupCount: number;
|
||||||
totalLookupHits: number;
|
totalLookupHits: number;
|
||||||
totalYomitanLookupCount: number;
|
totalYomitanLookupCount: number;
|
||||||
|
youtubeVideoId: string | null;
|
||||||
|
videoUrl: string | null;
|
||||||
|
videoTitle: string | null;
|
||||||
|
videoThumbnailUrl: string | null;
|
||||||
|
channelId: string | null;
|
||||||
|
channelName: string | null;
|
||||||
|
channelUrl: string | null;
|
||||||
|
channelThumbnailUrl: string | null;
|
||||||
|
uploaderId: string | null;
|
||||||
|
uploaderUrl: string | null;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YoutubeVideoMetadata {
|
||||||
|
youtubeVideoId: string;
|
||||||
|
videoUrl: string;
|
||||||
|
videoTitle: string | null;
|
||||||
|
videoThumbnailUrl: string | null;
|
||||||
|
channelId: string | null;
|
||||||
|
channelName: string | null;
|
||||||
|
channelUrl: string | null;
|
||||||
|
channelThumbnailUrl: string | null;
|
||||||
|
uploaderId: string | null;
|
||||||
|
uploaderUrl: string | null;
|
||||||
|
description: string | null;
|
||||||
|
metadataJson: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnimeLibraryRow {
|
export interface AnimeLibraryRow {
|
||||||
|
|||||||
@@ -79,7 +79,10 @@ export {
|
|||||||
handleOverlayWindowBeforeInputEvent,
|
handleOverlayWindowBeforeInputEvent,
|
||||||
isTabInputForMpvForwarding,
|
isTabInputForMpvForwarding,
|
||||||
} from './overlay-window-input';
|
} from './overlay-window-input';
|
||||||
export { initializeOverlayRuntime } from './overlay-runtime-init';
|
export {
|
||||||
|
initializeOverlayAnkiIntegration,
|
||||||
|
initializeOverlayRuntime,
|
||||||
|
} from './overlay-runtime-init';
|
||||||
export { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility';
|
export { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility';
|
||||||
export {
|
export {
|
||||||
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
|
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
|
|||||||
getAnilistQueueStatus: () => ({}),
|
getAnilistQueueStatus: () => ({}),
|
||||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||||
|
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||||
immersionTracker: null,
|
immersionTracker: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -236,6 +237,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
|||||||
return { ok: true, message: 'done' };
|
return { ok: true, message: 'done' };
|
||||||
},
|
},
|
||||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }),
|
appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }),
|
||||||
|
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' });
|
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' });
|
||||||
@@ -305,6 +307,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
|||||||
getAnilistQueueStatus: () => ({}),
|
getAnilistQueueStatus: () => ({}),
|
||||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||||
|
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||||
},
|
},
|
||||||
registrar,
|
registrar,
|
||||||
);
|
);
|
||||||
@@ -611,6 +614,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
|||||||
getAnilistQueueStatus: () => ({}),
|
getAnilistQueueStatus: () => ({}),
|
||||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||||
|
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||||
},
|
},
|
||||||
registrar,
|
registrar,
|
||||||
);
|
);
|
||||||
@@ -677,6 +681,7 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
|||||||
getAnilistQueueStatus: () => ({}),
|
getAnilistQueueStatus: () => ({}),
|
||||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||||
|
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||||
},
|
},
|
||||||
registrar,
|
registrar,
|
||||||
);
|
);
|
||||||
@@ -746,6 +751,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
|
|||||||
getAnilistQueueStatus: () => ({}),
|
getAnilistQueueStatus: () => ({}),
|
||||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||||
|
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||||
},
|
},
|
||||||
registrar,
|
registrar,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import type {
|
|||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
SubsyncManualRunRequest,
|
SubsyncManualRunRequest,
|
||||||
SubsyncResult,
|
SubsyncResult,
|
||||||
|
YoutubePickerResolveRequest,
|
||||||
|
YoutubePickerResolveResult,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +25,7 @@ import {
|
|||||||
parseRuntimeOptionValue,
|
parseRuntimeOptionValue,
|
||||||
parseSubtitlePosition,
|
parseSubtitlePosition,
|
||||||
parseSubsyncManualRunRequest,
|
parseSubsyncManualRunRequest,
|
||||||
|
parseYoutubePickerResolveRequest,
|
||||||
} from '../../shared/ipc/validators';
|
} from '../../shared/ipc/validators';
|
||||||
|
|
||||||
const { BrowserWindow, ipcMain } = electron;
|
const { BrowserWindow, ipcMain } = electron;
|
||||||
@@ -61,6 +64,7 @@ export interface IpcServiceDeps {
|
|||||||
getCurrentSecondarySub: () => string;
|
getCurrentSecondarySub: () => string;
|
||||||
focusMainWindow: () => void;
|
focusMainWindow: () => void;
|
||||||
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||||
|
onYoutubePickerResolve: (request: YoutubePickerResolveRequest) => Promise<YoutubePickerResolveResult>;
|
||||||
getAnkiConnectStatus: () => boolean;
|
getAnkiConnectStatus: () => boolean;
|
||||||
getRuntimeOptions: () => unknown;
|
getRuntimeOptions: () => unknown;
|
||||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||||
@@ -163,6 +167,7 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
getMpvClient: () => MpvClientLike | null;
|
getMpvClient: () => MpvClientLike | null;
|
||||||
focusMainWindow: () => void;
|
focusMainWindow: () => void;
|
||||||
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||||
|
onYoutubePickerResolve: (request: YoutubePickerResolveRequest) => Promise<YoutubePickerResolveResult>;
|
||||||
getAnkiConnectStatus: () => boolean;
|
getAnkiConnectStatus: () => boolean;
|
||||||
getRuntimeOptions: () => unknown;
|
getRuntimeOptions: () => unknown;
|
||||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||||
@@ -225,6 +230,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
mainWindow.focus();
|
mainWindow.focus();
|
||||||
},
|
},
|
||||||
runSubsyncManual: options.runSubsyncManual,
|
runSubsyncManual: options.runSubsyncManual,
|
||||||
|
onYoutubePickerResolve: options.onYoutubePickerResolve,
|
||||||
getAnkiConnectStatus: options.getAnkiConnectStatus,
|
getAnkiConnectStatus: options.getAnkiConnectStatus,
|
||||||
getRuntimeOptions: options.getRuntimeOptions,
|
getRuntimeOptions: options.getRuntimeOptions,
|
||||||
setRuntimeOption: options.setRuntimeOption,
|
setRuntimeOption: options.setRuntimeOption,
|
||||||
@@ -285,6 +291,14 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
deps.onOverlayModalOpened(parsedModal);
|
deps.onOverlayModalOpened(parsedModal);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipc.handle(IPC_CHANNELS.request.youtubePickerResolve, async (_event: unknown, request: unknown) => {
|
||||||
|
const parsedRequest = parseYoutubePickerResolveRequest(request);
|
||||||
|
if (!parsedRequest) {
|
||||||
|
return { ok: false, message: 'Invalid YouTube picker resolve payload' };
|
||||||
|
}
|
||||||
|
return await deps.onYoutubePickerResolve(parsedRequest);
|
||||||
|
});
|
||||||
|
|
||||||
ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => {
|
ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => {
|
||||||
deps.openYomitanSettings();
|
deps.openYomitanSettings();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { initializeOverlayRuntime } from './overlay-runtime-init';
|
import { initializeOverlayAnkiIntegration, initializeOverlayRuntime } from './overlay-runtime-init';
|
||||||
|
|
||||||
test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled is false', () => {
|
test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled is false', () => {
|
||||||
let createdIntegrations = 0;
|
let createdIntegrations = 0;
|
||||||
@@ -109,6 +109,136 @@ test('initializeOverlayRuntime starts Anki integration when ankiConnect.enabled
|
|||||||
assert.equal(setIntegrationCalls, 1);
|
assert.equal(setIntegrationCalls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('initializeOverlayAnkiIntegration can initialize Anki transport after overlay runtime already exists', () => {
|
||||||
|
let createdIntegrations = 0;
|
||||||
|
let startedIntegrations = 0;
|
||||||
|
let setIntegrationCalls = 0;
|
||||||
|
|
||||||
|
initializeOverlayAnkiIntegration({
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
ankiConnect: { enabled: true } as never,
|
||||||
|
}),
|
||||||
|
getSubtitleTimingTracker: () => ({}),
|
||||||
|
getMpvClient: () => ({
|
||||||
|
send: () => {},
|
||||||
|
}),
|
||||||
|
getRuntimeOptionsManager: () => ({
|
||||||
|
getEffectiveAnkiConnectConfig: (config) => config as never,
|
||||||
|
}),
|
||||||
|
createAnkiIntegration: (args) => {
|
||||||
|
createdIntegrations += 1;
|
||||||
|
assert.equal(args.config.enabled, true);
|
||||||
|
return {
|
||||||
|
start: () => {
|
||||||
|
startedIntegrations += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
setAnkiIntegration: () => {
|
||||||
|
setIntegrationCalls += 1;
|
||||||
|
},
|
||||||
|
showDesktopNotification: () => {},
|
||||||
|
createFieldGroupingCallback: () => async () => ({
|
||||||
|
keepNoteId: 11,
|
||||||
|
deleteNoteId: 12,
|
||||||
|
deleteDuplicate: false,
|
||||||
|
cancelled: false,
|
||||||
|
}),
|
||||||
|
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(createdIntegrations, 1);
|
||||||
|
assert.equal(startedIntegrations, 1);
|
||||||
|
assert.equal(setIntegrationCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initializeOverlayAnkiIntegration returns false when integration already exists', () => {
|
||||||
|
let createdIntegrations = 0;
|
||||||
|
let startedIntegrations = 0;
|
||||||
|
let setIntegrationCalls = 0;
|
||||||
|
|
||||||
|
const result = initializeOverlayAnkiIntegration({
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
ankiConnect: { enabled: true } as never,
|
||||||
|
}),
|
||||||
|
getSubtitleTimingTracker: () => ({}),
|
||||||
|
getMpvClient: () => ({
|
||||||
|
send: () => {},
|
||||||
|
}),
|
||||||
|
getRuntimeOptionsManager: () => ({
|
||||||
|
getEffectiveAnkiConnectConfig: (config) => config as never,
|
||||||
|
}),
|
||||||
|
getAnkiIntegration: () => ({}),
|
||||||
|
createAnkiIntegration: () => {
|
||||||
|
createdIntegrations += 1;
|
||||||
|
return {
|
||||||
|
start: () => {
|
||||||
|
startedIntegrations += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
setAnkiIntegration: () => {
|
||||||
|
setIntegrationCalls += 1;
|
||||||
|
},
|
||||||
|
showDesktopNotification: () => {},
|
||||||
|
createFieldGroupingCallback: () => async () => ({
|
||||||
|
keepNoteId: 11,
|
||||||
|
deleteNoteId: 12,
|
||||||
|
deleteDuplicate: false,
|
||||||
|
cancelled: false,
|
||||||
|
}),
|
||||||
|
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, false);
|
||||||
|
assert.equal(createdIntegrations, 0);
|
||||||
|
assert.equal(startedIntegrations, 0);
|
||||||
|
assert.equal(setIntegrationCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initializeOverlayAnkiIntegration returns false when ankiConnect is disabled', () => {
|
||||||
|
let createdIntegrations = 0;
|
||||||
|
let startedIntegrations = 0;
|
||||||
|
let setIntegrationCalls = 0;
|
||||||
|
|
||||||
|
const result = initializeOverlayAnkiIntegration({
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
ankiConnect: { enabled: false } as never,
|
||||||
|
}),
|
||||||
|
getSubtitleTimingTracker: () => ({}),
|
||||||
|
getMpvClient: () => ({
|
||||||
|
send: () => {},
|
||||||
|
}),
|
||||||
|
getRuntimeOptionsManager: () => ({
|
||||||
|
getEffectiveAnkiConnectConfig: (config) => config as never,
|
||||||
|
}),
|
||||||
|
createAnkiIntegration: () => {
|
||||||
|
createdIntegrations += 1;
|
||||||
|
return {
|
||||||
|
start: () => {
|
||||||
|
startedIntegrations += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
setAnkiIntegration: () => {
|
||||||
|
setIntegrationCalls += 1;
|
||||||
|
},
|
||||||
|
showDesktopNotification: () => {},
|
||||||
|
createFieldGroupingCallback: () => async () => ({
|
||||||
|
keepNoteId: 11,
|
||||||
|
deleteNoteId: 12,
|
||||||
|
deleteDuplicate: false,
|
||||||
|
cancelled: false,
|
||||||
|
}),
|
||||||
|
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, false);
|
||||||
|
assert.equal(createdIntegrations, 0);
|
||||||
|
assert.equal(startedIntegrations, 0);
|
||||||
|
assert.equal(setIntegrationCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('initializeOverlayRuntime can skip starting Anki integration transport', () => {
|
test('initializeOverlayRuntime can skip starting Anki integration transport', () => {
|
||||||
let createdIntegrations = 0;
|
let createdIntegrations = 0;
|
||||||
let startedIntegrations = 0;
|
let startedIntegrations = 0;
|
||||||
|
|||||||
@@ -47,6 +47,24 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initializeOverlayRuntime(options: {
|
export function initializeOverlayRuntime(options: {
|
||||||
|
getMpvSocketPath: () => string;
|
||||||
|
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig };
|
||||||
|
getSubtitleTimingTracker: () => unknown | null;
|
||||||
|
getMpvClient: () => {
|
||||||
|
send?: (payload: { command: string[] }) => void;
|
||||||
|
} | null;
|
||||||
|
getRuntimeOptionsManager: () => {
|
||||||
|
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
|
||||||
|
} | null;
|
||||||
|
getAnkiIntegration?: () => unknown | null;
|
||||||
|
setAnkiIntegration: (integration: unknown | null) => void;
|
||||||
|
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||||
|
createFieldGroupingCallback: () => (
|
||||||
|
data: KikuFieldGroupingRequestData,
|
||||||
|
) => Promise<KikuFieldGroupingChoice>;
|
||||||
|
getKnownWordCacheStatePath: () => string;
|
||||||
|
shouldStartAnkiIntegration?: () => boolean;
|
||||||
|
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||||
backendOverride: string | null;
|
backendOverride: string | null;
|
||||||
createMainWindow: () => void;
|
createMainWindow: () => void;
|
||||||
registerGlobalShortcuts: () => void;
|
registerGlobalShortcuts: () => void;
|
||||||
@@ -60,23 +78,6 @@ export function initializeOverlayRuntime(options: {
|
|||||||
override?: string | null,
|
override?: string | null,
|
||||||
targetMpvSocketPath?: string | null,
|
targetMpvSocketPath?: string | null,
|
||||||
) => BaseWindowTracker | null;
|
) => BaseWindowTracker | null;
|
||||||
getMpvSocketPath: () => string;
|
|
||||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig };
|
|
||||||
getSubtitleTimingTracker: () => unknown | null;
|
|
||||||
getMpvClient: () => {
|
|
||||||
send?: (payload: { command: string[] }) => void;
|
|
||||||
} | null;
|
|
||||||
getRuntimeOptionsManager: () => {
|
|
||||||
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
|
|
||||||
} | null;
|
|
||||||
setAnkiIntegration: (integration: unknown | null) => void;
|
|
||||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
|
||||||
createFieldGroupingCallback: () => (
|
|
||||||
data: KikuFieldGroupingRequestData,
|
|
||||||
) => Promise<KikuFieldGroupingChoice>;
|
|
||||||
getKnownWordCacheStatePath: () => string;
|
|
||||||
shouldStartAnkiIntegration?: () => boolean;
|
|
||||||
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
|
||||||
}): void {
|
}): void {
|
||||||
options.createMainWindow();
|
options.createMainWindow();
|
||||||
options.registerGlobalShortcuts();
|
options.registerGlobalShortcuts();
|
||||||
@@ -112,17 +113,48 @@ export function initializeOverlayRuntime(options: {
|
|||||||
windowTracker.start();
|
windowTracker.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initializeOverlayAnkiIntegration(options);
|
||||||
|
|
||||||
|
options.updateVisibleOverlayVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initializeOverlayAnkiIntegration(options: {
|
||||||
|
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig };
|
||||||
|
getSubtitleTimingTracker: () => unknown | null;
|
||||||
|
getMpvClient: () => {
|
||||||
|
send?: (payload: { command: string[] }) => void;
|
||||||
|
} | null;
|
||||||
|
getRuntimeOptionsManager: () => {
|
||||||
|
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
|
||||||
|
} | null;
|
||||||
|
getAnkiIntegration?: () => unknown | null;
|
||||||
|
setAnkiIntegration: (integration: unknown | null) => void;
|
||||||
|
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||||
|
createFieldGroupingCallback: () => (
|
||||||
|
data: KikuFieldGroupingRequestData,
|
||||||
|
) => Promise<KikuFieldGroupingChoice>;
|
||||||
|
getKnownWordCacheStatePath: () => string;
|
||||||
|
shouldStartAnkiIntegration?: () => boolean;
|
||||||
|
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||||
|
}): boolean {
|
||||||
|
if (options.getAnkiIntegration?.()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const config = options.getResolvedConfig();
|
const config = options.getResolvedConfig();
|
||||||
const subtitleTimingTracker = options.getSubtitleTimingTracker();
|
const subtitleTimingTracker = options.getSubtitleTimingTracker();
|
||||||
const mpvClient = options.getMpvClient();
|
const mpvClient = options.getMpvClient();
|
||||||
const runtimeOptionsManager = options.getRuntimeOptionsManager();
|
const runtimeOptionsManager = options.getRuntimeOptionsManager();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
config.ankiConnect?.enabled === true &&
|
config.ankiConnect?.enabled !== true ||
|
||||||
subtitleTimingTracker &&
|
!subtitleTimingTracker ||
|
||||||
mpvClient &&
|
!mpvClient ||
|
||||||
runtimeOptionsManager
|
!runtimeOptionsManager
|
||||||
) {
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const effectiveAnkiConfig = runtimeOptionsManager.getEffectiveAnkiConnectConfig(
|
const effectiveAnkiConfig = runtimeOptionsManager.getEffectiveAnkiConnectConfig(
|
||||||
config.ankiConnect,
|
config.ankiConnect,
|
||||||
);
|
);
|
||||||
@@ -140,7 +172,5 @@ export function initializeOverlayRuntime(options: {
|
|||||||
integration.start();
|
integration.start();
|
||||||
}
|
}
|
||||||
options.setAnkiIntegration(integration);
|
options.setAnkiIntegration(integration);
|
||||||
}
|
return true;
|
||||||
|
|
||||||
options.updateVisibleOverlayVisibility();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,3 +194,167 @@ test('runAppReadyRuntime headless refresh bootstraps Anki runtime without UI sta
|
|||||||
'run-headless-command',
|
'run-headless-command',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('runAppReadyRuntime loads Yomitan before headless overlay fallback initialization', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
await runAppReadyRuntime({
|
||||||
|
ensureDefaultConfigBootstrap: () => {
|
||||||
|
calls.push('bootstrap');
|
||||||
|
},
|
||||||
|
loadSubtitlePosition: () => {
|
||||||
|
calls.push('load-subtitle-position');
|
||||||
|
},
|
||||||
|
resolveKeybindings: () => {
|
||||||
|
calls.push('resolve-keybindings');
|
||||||
|
},
|
||||||
|
createMpvClient: () => {
|
||||||
|
calls.push('create-mpv');
|
||||||
|
},
|
||||||
|
reloadConfig: () => {
|
||||||
|
calls.push('reload-config');
|
||||||
|
},
|
||||||
|
getResolvedConfig: () => ({}),
|
||||||
|
getConfigWarnings: () => [],
|
||||||
|
logConfigWarning: () => {},
|
||||||
|
setLogLevel: () => {},
|
||||||
|
initRuntimeOptionsManager: () => {
|
||||||
|
calls.push('init-runtime-options');
|
||||||
|
},
|
||||||
|
setSecondarySubMode: () => {},
|
||||||
|
defaultSecondarySubMode: 'hover',
|
||||||
|
defaultWebsocketPort: 0,
|
||||||
|
defaultAnnotationWebsocketPort: 0,
|
||||||
|
defaultTexthookerPort: 0,
|
||||||
|
hasMpvWebsocketPlugin: () => false,
|
||||||
|
startSubtitleWebsocket: () => {},
|
||||||
|
startAnnotationWebsocket: () => {},
|
||||||
|
startTexthooker: () => {},
|
||||||
|
log: () => {},
|
||||||
|
createMecabTokenizerAndCheck: async () => {},
|
||||||
|
createSubtitleTimingTracker: () => {
|
||||||
|
calls.push('subtitle-timing');
|
||||||
|
},
|
||||||
|
createImmersionTracker: () => {},
|
||||||
|
startJellyfinRemoteSession: async () => {},
|
||||||
|
loadYomitanExtension: async () => {
|
||||||
|
calls.push('load-yomitan');
|
||||||
|
},
|
||||||
|
handleFirstRunSetup: async () => {},
|
||||||
|
prewarmSubtitleDictionaries: async () => {},
|
||||||
|
startBackgroundWarmups: () => {},
|
||||||
|
texthookerOnlyMode: false,
|
||||||
|
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||||
|
setVisibleOverlayVisible: () => {},
|
||||||
|
initializeOverlayRuntime: () => {
|
||||||
|
calls.push('init-overlay');
|
||||||
|
},
|
||||||
|
handleInitialArgs: () => {
|
||||||
|
calls.push('handle-initial-args');
|
||||||
|
},
|
||||||
|
shouldRunHeadlessInitialCommand: () => true,
|
||||||
|
shouldUseMinimalStartup: () => false,
|
||||||
|
shouldSkipHeavyStartup: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'bootstrap',
|
||||||
|
'reload-config',
|
||||||
|
'init-runtime-options',
|
||||||
|
'create-mpv',
|
||||||
|
'subtitle-timing',
|
||||||
|
'load-yomitan',
|
||||||
|
'init-overlay',
|
||||||
|
'handle-initial-args',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
await runAppReadyRuntime({
|
||||||
|
ensureDefaultConfigBootstrap: () => {
|
||||||
|
calls.push('bootstrap');
|
||||||
|
},
|
||||||
|
loadSubtitlePosition: () => {
|
||||||
|
calls.push('load-subtitle-position');
|
||||||
|
},
|
||||||
|
resolveKeybindings: () => {
|
||||||
|
calls.push('resolve-keybindings');
|
||||||
|
},
|
||||||
|
createMpvClient: () => {
|
||||||
|
calls.push('create-mpv');
|
||||||
|
},
|
||||||
|
reloadConfig: () => {
|
||||||
|
calls.push('reload-config');
|
||||||
|
},
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
websocket: { enabled: false },
|
||||||
|
annotationWebsocket: { enabled: false },
|
||||||
|
texthooker: { launchAtStartup: false },
|
||||||
|
}),
|
||||||
|
getConfigWarnings: () => [],
|
||||||
|
logConfigWarning: () => {},
|
||||||
|
setLogLevel: () => {
|
||||||
|
calls.push('set-log-level');
|
||||||
|
},
|
||||||
|
initRuntimeOptionsManager: () => {
|
||||||
|
calls.push('init-runtime-options');
|
||||||
|
},
|
||||||
|
setSecondarySubMode: () => {
|
||||||
|
calls.push('set-secondary-sub-mode');
|
||||||
|
},
|
||||||
|
defaultSecondarySubMode: 'hover',
|
||||||
|
defaultWebsocketPort: 0,
|
||||||
|
defaultAnnotationWebsocketPort: 0,
|
||||||
|
defaultTexthookerPort: 0,
|
||||||
|
hasMpvWebsocketPlugin: () => false,
|
||||||
|
startSubtitleWebsocket: () => {
|
||||||
|
calls.push('subtitle-ws');
|
||||||
|
},
|
||||||
|
startAnnotationWebsocket: () => {
|
||||||
|
calls.push('annotation-ws');
|
||||||
|
},
|
||||||
|
startTexthooker: () => {
|
||||||
|
calls.push('texthooker');
|
||||||
|
},
|
||||||
|
log: () => {
|
||||||
|
calls.push('log');
|
||||||
|
},
|
||||||
|
createMecabTokenizerAndCheck: async () => {},
|
||||||
|
createSubtitleTimingTracker: () => {
|
||||||
|
calls.push('subtitle-timing');
|
||||||
|
},
|
||||||
|
createImmersionTracker: () => {
|
||||||
|
calls.push('immersion');
|
||||||
|
},
|
||||||
|
startJellyfinRemoteSession: async () => {},
|
||||||
|
loadYomitanExtension: async () => {
|
||||||
|
calls.push('load-yomitan');
|
||||||
|
},
|
||||||
|
handleFirstRunSetup: async () => {
|
||||||
|
calls.push('first-run');
|
||||||
|
},
|
||||||
|
prewarmSubtitleDictionaries: async () => {},
|
||||||
|
startBackgroundWarmups: () => {
|
||||||
|
calls.push('warmups');
|
||||||
|
},
|
||||||
|
texthookerOnlyMode: false,
|
||||||
|
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
|
||||||
|
setVisibleOverlayVisible: () => {
|
||||||
|
calls.push('visible-overlay');
|
||||||
|
},
|
||||||
|
initializeOverlayRuntime: () => {
|
||||||
|
calls.push('init-overlay');
|
||||||
|
},
|
||||||
|
handleInitialArgs: () => {
|
||||||
|
calls.push('handle-initial-args');
|
||||||
|
},
|
||||||
|
shouldUseMinimalStartup: () => false,
|
||||||
|
shouldSkipHeavyStartup: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(calls.indexOf('load-yomitan') !== -1);
|
||||||
|
assert.ok(calls.indexOf('init-overlay') !== -1);
|
||||||
|
assert.ok(calls.indexOf('load-yomitan') < calls.indexOf('init-overlay'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
|||||||
} else {
|
} else {
|
||||||
deps.createMpvClient();
|
deps.createMpvClient();
|
||||||
deps.createSubtitleTimingTracker();
|
deps.createSubtitleTimingTracker();
|
||||||
|
await deps.loadYomitanExtension();
|
||||||
deps.initializeOverlayRuntime();
|
deps.initializeOverlayRuntime();
|
||||||
deps.handleInitialArgs();
|
deps.handleInitialArgs();
|
||||||
}
|
}
|
||||||
@@ -290,13 +291,14 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
|||||||
if (deps.texthookerOnlyMode) {
|
if (deps.texthookerOnlyMode) {
|
||||||
deps.log('Texthooker-only mode enabled; skipping overlay window.');
|
deps.log('Texthooker-only mode enabled; skipping overlay window.');
|
||||||
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
|
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
|
||||||
|
await deps.loadYomitanExtension();
|
||||||
deps.setVisibleOverlayVisible(true);
|
deps.setVisibleOverlayVisible(true);
|
||||||
deps.initializeOverlayRuntime();
|
deps.initializeOverlayRuntime();
|
||||||
} else {
|
} else {
|
||||||
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
|
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
|
||||||
|
await deps.loadYomitanExtension();
|
||||||
}
|
}
|
||||||
|
|
||||||
await deps.loadYomitanExtension();
|
|
||||||
await deps.handleFirstRunSetup();
|
await deps.handleFirstRunSetup();
|
||||||
deps.handleInitialArgs();
|
deps.handleInitialArgs();
|
||||||
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||||
|
|||||||
25
src/core/services/youtube/generate.ts
Normal file
25
src/core/services/youtube/generate.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { YoutubeFlowMode } from '../../../types';
|
||||||
|
import type { YoutubeTrackOption } from './track-probe';
|
||||||
|
import { downloadYoutubeSubtitleTrack, downloadYoutubeSubtitleTracks } from './track-download';
|
||||||
|
|
||||||
|
export function isYoutubeGenerationMode(mode: YoutubeFlowMode): boolean {
|
||||||
|
return mode === 'generate';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acquireYoutubeSubtitleTrack(input: {
|
||||||
|
targetUrl: string;
|
||||||
|
outputDir: string;
|
||||||
|
track: YoutubeTrackOption;
|
||||||
|
mode: YoutubeFlowMode;
|
||||||
|
}): Promise<{ path: string }> {
|
||||||
|
return await downloadYoutubeSubtitleTrack(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acquireYoutubeSubtitleTracks(input: {
|
||||||
|
targetUrl: string;
|
||||||
|
outputDir: string;
|
||||||
|
tracks: YoutubeTrackOption[];
|
||||||
|
mode: YoutubeFlowMode;
|
||||||
|
}): Promise<Map<string, string>> {
|
||||||
|
return await downloadYoutubeSubtitleTracks(input);
|
||||||
|
}
|
||||||
1
src/core/services/youtube/kinds.ts
Normal file
1
src/core/services/youtube/kinds.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type YoutubeTrackKind = 'manual' | 'auto';
|
||||||
41
src/core/services/youtube/labels.ts
Normal file
41
src/core/services/youtube/labels.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { YoutubeTrackKind } from './kinds';
|
||||||
|
|
||||||
|
export type { YoutubeTrackKind };
|
||||||
|
|
||||||
|
export function normalizeYoutubeLangCode(value: string): string {
|
||||||
|
return value.trim().toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9-]+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isJapaneseYoutubeLang(value: string): boolean {
|
||||||
|
const normalized = normalizeYoutubeLangCode(value);
|
||||||
|
return (
|
||||||
|
normalized === 'ja' ||
|
||||||
|
normalized === 'jp' ||
|
||||||
|
normalized === 'jpn' ||
|
||||||
|
normalized === 'japanese' ||
|
||||||
|
normalized.startsWith('ja-') ||
|
||||||
|
normalized.startsWith('jp-')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEnglishYoutubeLang(value: string): boolean {
|
||||||
|
const normalized = normalizeYoutubeLangCode(value);
|
||||||
|
return (
|
||||||
|
normalized === 'en' ||
|
||||||
|
normalized === 'eng' ||
|
||||||
|
normalized === 'english' ||
|
||||||
|
normalized === 'enus' ||
|
||||||
|
normalized === 'en-us' ||
|
||||||
|
normalized.startsWith('en-')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatYoutubeTrackLabel(input: {
|
||||||
|
language: string;
|
||||||
|
kind: YoutubeTrackKind;
|
||||||
|
title?: string;
|
||||||
|
}): string {
|
||||||
|
const language = input.language.trim() || 'unknown';
|
||||||
|
const base = input.title?.trim() || language;
|
||||||
|
return `${base} (${input.kind})`;
|
||||||
|
}
|
||||||
49
src/core/services/youtube/metadata-probe.test.ts
Normal file
49
src/core/services/youtube/metadata-probe.test.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { probeYoutubeVideoMetadata } from './metadata-probe';
|
||||||
|
|
||||||
|
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-metadata-probe-'));
|
||||||
|
try {
|
||||||
|
return await fn(dir);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakeYtDlpScript(dir: string, payload: string): void {
|
||||||
|
const scriptPath = path.join(dir, 'yt-dlp');
|
||||||
|
const script = `#!/usr/bin/env node
|
||||||
|
process.stdout.write(${JSON.stringify(payload)});
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
fs.chmodSync(scriptPath, 0o755);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(scriptPath + '.cmd', `@echo off\r\nnode "${scriptPath}"\r\n`, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withFakeYtDlp<T>(payload: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
return await withTempDir(async (root) => {
|
||||||
|
const binDir = path.join(root, 'bin');
|
||||||
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
|
makeFakeYtDlpScript(binDir, payload);
|
||||||
|
const originalPath = process.env.PATH ?? '';
|
||||||
|
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
process.env.PATH = originalPath;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('probeYoutubeVideoMetadata returns null on malformed yt-dlp JSON', async () => {
|
||||||
|
await withFakeYtDlp('not-json', async () => {
|
||||||
|
const result = await probeYoutubeVideoMetadata('https://www.youtube.com/watch?v=abc123');
|
||||||
|
assert.equal(result, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
108
src/core/services/youtube/metadata-probe.ts
Normal file
108
src/core/services/youtube/metadata-probe.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import type { YoutubeVideoMetadata } from '../immersion-tracker/types';
|
||||||
|
|
||||||
|
type YtDlpThumbnail = {
|
||||||
|
url?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type YtDlpYoutubeMetadata = {
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
webpage_url?: string;
|
||||||
|
thumbnail?: string;
|
||||||
|
thumbnails?: YtDlpThumbnail[];
|
||||||
|
channel_id?: string;
|
||||||
|
channel?: string;
|
||||||
|
channel_url?: string;
|
||||||
|
uploader_id?: string;
|
||||||
|
uploader_url?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function runCapture(command: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
proc.stdout.setEncoding('utf8');
|
||||||
|
proc.stderr.setEncoding('utf8');
|
||||||
|
proc.stdout.on('data', (chunk) => {
|
||||||
|
stdout += String(chunk);
|
||||||
|
});
|
||||||
|
proc.stderr.on('data', (chunk) => {
|
||||||
|
stderr += String(chunk);
|
||||||
|
});
|
||||||
|
proc.once('error', reject);
|
||||||
|
proc.once('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve({ stdout, stderr });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(stderr.trim() || `yt-dlp exited with status ${code ?? 'unknown'}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickChannelThumbnail(thumbnails: YtDlpThumbnail[] | undefined): string | null {
|
||||||
|
if (!Array.isArray(thumbnails)) return null;
|
||||||
|
for (const thumbnail of thumbnails) {
|
||||||
|
const candidate = thumbnail.url?.trim();
|
||||||
|
if (!candidate) continue;
|
||||||
|
if (candidate.includes('/vi/')) continue;
|
||||||
|
if (
|
||||||
|
typeof thumbnail.width === 'number' &&
|
||||||
|
typeof thumbnail.height === 'number' &&
|
||||||
|
thumbnail.width > 0 &&
|
||||||
|
thumbnail.height > 0
|
||||||
|
) {
|
||||||
|
const ratio = thumbnail.width / thumbnail.height;
|
||||||
|
if (ratio >= 0.8 && ratio <= 1.25) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (candidate.includes('yt3.googleusercontent.com')) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeYoutubeVideoMetadata(
|
||||||
|
targetUrl: string,
|
||||||
|
): Promise<YoutubeVideoMetadata | null> {
|
||||||
|
const { stdout } = await runCapture('yt-dlp', [
|
||||||
|
'--dump-single-json',
|
||||||
|
'--no-warnings',
|
||||||
|
'--skip-download',
|
||||||
|
targetUrl,
|
||||||
|
]);
|
||||||
|
let info: YtDlpYoutubeMetadata;
|
||||||
|
try {
|
||||||
|
info = JSON.parse(stdout) as YtDlpYoutubeMetadata;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const youtubeVideoId = info.id?.trim();
|
||||||
|
const videoUrl = info.webpage_url?.trim() || targetUrl.trim();
|
||||||
|
if (!youtubeVideoId || !videoUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
youtubeVideoId,
|
||||||
|
videoUrl,
|
||||||
|
videoTitle: info.title?.trim() || null,
|
||||||
|
videoThumbnailUrl: info.thumbnail?.trim() || null,
|
||||||
|
channelId: info.channel_id?.trim() || null,
|
||||||
|
channelName: info.channel?.trim() || null,
|
||||||
|
channelUrl: info.channel_url?.trim() || null,
|
||||||
|
channelThumbnailUrl: pickChannelThumbnail(info.thumbnails),
|
||||||
|
uploaderId: info.uploader_id?.trim() || null,
|
||||||
|
uploaderUrl: info.uploader_url?.trim() || null,
|
||||||
|
description: info.description?.trim() || null,
|
||||||
|
metadataJson: JSON.stringify(info),
|
||||||
|
};
|
||||||
|
}
|
||||||
29
src/core/services/youtube/retime.test.ts
Normal file
29
src/core/services/youtube/retime.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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 { retimeYoutubeSubtitle } from './retime';
|
||||||
|
|
||||||
|
test('retimeYoutubeSubtitle uses the downloaded subtitle path as-is', async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-retime-'));
|
||||||
|
try {
|
||||||
|
const primaryPath = path.join(root, 'primary.vtt');
|
||||||
|
const referencePath = path.join(root, 'reference.vtt');
|
||||||
|
fs.writeFileSync(primaryPath, 'WEBVTT\n', 'utf8');
|
||||||
|
fs.writeFileSync(referencePath, 'WEBVTT\n', 'utf8');
|
||||||
|
|
||||||
|
const result = await retimeYoutubeSubtitle({
|
||||||
|
primaryPath,
|
||||||
|
secondaryPath: referencePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.strategy, 'none');
|
||||||
|
assert.equal(result.path, primaryPath);
|
||||||
|
assert.equal(result.message, 'Using downloaded subtitle as-is (no automatic retime enabled)');
|
||||||
|
assert.equal(fs.readFileSync(result.path, 'utf8'), 'WEBVTT\n');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
11
src/core/services/youtube/retime.ts
Normal file
11
src/core/services/youtube/retime.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export async function retimeYoutubeSubtitle(input: {
|
||||||
|
primaryPath: string;
|
||||||
|
secondaryPath: string | null;
|
||||||
|
}): Promise<{ ok: boolean; path: string; strategy: 'none' | 'alass' | 'ffsubsync'; message: string }> {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
path: input.primaryPath,
|
||||||
|
strategy: 'none',
|
||||||
|
message: `Using downloaded subtitle as-is${input.secondaryPath ? ' (no automatic retime enabled)' : ''}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
40
src/core/services/youtube/timedtext.test.ts
Normal file
40
src/core/services/youtube/timedtext.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { convertYoutubeTimedTextToVtt } from './timedtext';
|
||||||
|
|
||||||
|
test('convertYoutubeTimedTextToVtt leaves malformed numeric entities literal', () => {
|
||||||
|
const result = convertYoutubeTimedTextToVtt(
|
||||||
|
'<timedtext><body><p t="0" d="1000">� � A</p></body></timedtext>',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
result,
|
||||||
|
['WEBVTT', '', '00:00:00.000 --> 00:00:01.000', '� � A', ''].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convertYoutubeTimedTextToVtt does not swallow text after zero-length overlap rows', () => {
|
||||||
|
const result = convertYoutubeTimedTextToVtt(
|
||||||
|
[
|
||||||
|
'<timedtext><body>',
|
||||||
|
'<p t="0" d="2000">今日は</p>',
|
||||||
|
'<p t="1000" d="0">今日はいい天気ですね</p>',
|
||||||
|
'<p t="1000" d="2000">今日はいい天気ですね</p>',
|
||||||
|
'</body></timedtext>',
|
||||||
|
].join(''),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
result,
|
||||||
|
[
|
||||||
|
'WEBVTT',
|
||||||
|
'',
|
||||||
|
'00:00:00.000 --> 00:00:00.999',
|
||||||
|
'今日は',
|
||||||
|
'',
|
||||||
|
'00:00:01.000 --> 00:00:03.000',
|
||||||
|
'いい天気ですね',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
117
src/core/services/youtube/timedtext.ts
Normal file
117
src/core/services/youtube/timedtext.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
interface YoutubeTimedTextRow {
|
||||||
|
startMs: number;
|
||||||
|
durationMs: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const YOUTUBE_TIMEDTEXT_EXTENSIONS = new Set(['srv1', 'srv2', 'srv3', 'ytsrv3']);
|
||||||
|
|
||||||
|
function decodeNumericEntity(match: string, codePoint: number): string {
|
||||||
|
if (
|
||||||
|
!Number.isInteger(codePoint) ||
|
||||||
|
codePoint < 0 ||
|
||||||
|
codePoint > 0x10ffff ||
|
||||||
|
(codePoint >= 0xd800 && codePoint <= 0xdfff)
|
||||||
|
) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
return String.fromCodePoint(codePoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtmlEntities(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&#(\d+);/g, (match, codePoint) =>
|
||||||
|
decodeNumericEntity(match, Number(codePoint)),
|
||||||
|
)
|
||||||
|
.replace(/&#x([0-9a-f]+);/gi, (match, codePoint) =>
|
||||||
|
decodeNumericEntity(match, Number.parseInt(codePoint, 16)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAttributeMap(raw: string): Map<string, string> {
|
||||||
|
const attrs = new Map<string, string>();
|
||||||
|
for (const match of raw.matchAll(/([a-zA-Z0-9:_-]+)="([^"]*)"/g)) {
|
||||||
|
attrs.set(match[1]!, match[2]!);
|
||||||
|
}
|
||||||
|
return attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractYoutubeTimedTextRows(xml: string): YoutubeTimedTextRow[] {
|
||||||
|
const rows: YoutubeTimedTextRow[] = [];
|
||||||
|
|
||||||
|
for (const match of xml.matchAll(/<p\b([^>]*)>([\s\S]*?)<\/p>/g)) {
|
||||||
|
const attrs = parseAttributeMap(match[1] ?? '');
|
||||||
|
const startMs = Number(attrs.get('t'));
|
||||||
|
const durationMs = Number(attrs.get('d'));
|
||||||
|
if (!Number.isFinite(startMs) || !Number.isFinite(durationMs)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inner = (match[2] ?? '')
|
||||||
|
.replace(/<br\s*\/?>/gi, '\n')
|
||||||
|
.replace(/<[^>]+>/g, '');
|
||||||
|
const text = decodeHtmlEntities(inner).trim();
|
||||||
|
if (!text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({ startMs, durationMs, text });
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVttTimestamp(ms: number): string {
|
||||||
|
const totalMs = Math.max(0, Math.floor(ms));
|
||||||
|
const hours = Math.floor(totalMs / 3_600_000);
|
||||||
|
const minutes = Math.floor((totalMs % 3_600_000) / 60_000);
|
||||||
|
const seconds = Math.floor((totalMs % 60_000) / 1_000);
|
||||||
|
const millis = totalMs % 1_000;
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isYoutubeTimedTextExtension(value: string | undefined): boolean {
|
||||||
|
if (!value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return YOUTUBE_TIMEDTEXT_EXTENSIONS.has(value.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertYoutubeTimedTextToVtt(xml: string): string {
|
||||||
|
const rows = extractYoutubeTimedTextRows(xml);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return 'WEBVTT\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks: string[] = [];
|
||||||
|
let previousText = '';
|
||||||
|
for (let index = 0; index < rows.length; index += 1) {
|
||||||
|
const row = rows[index]!;
|
||||||
|
const nextRow = rows[index + 1];
|
||||||
|
const unclampedEnd = row.startMs + row.durationMs;
|
||||||
|
const clampedEnd =
|
||||||
|
nextRow && unclampedEnd > nextRow.startMs
|
||||||
|
? Math.max(row.startMs, nextRow.startMs - 1)
|
||||||
|
: unclampedEnd;
|
||||||
|
if (clampedEnd <= row.startMs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text =
|
||||||
|
previousText && row.text.startsWith(previousText)
|
||||||
|
? row.text.slice(previousText.length).trimStart()
|
||||||
|
: row.text;
|
||||||
|
previousText = row.text;
|
||||||
|
if (!text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
blocks.push(`${formatVttTimestamp(row.startMs)} --> ${formatVttTimestamp(clampedEnd)}\n${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `WEBVTT\n\n${blocks.join('\n\n')}\n`;
|
||||||
|
}
|
||||||
544
src/core/services/youtube/track-download.test.ts
Normal file
544
src/core/services/youtube/track-download.test.ts
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
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 { downloadYoutubeSubtitleTrack, downloadYoutubeSubtitleTracks } from './track-download';
|
||||||
|
|
||||||
|
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-track-download-'));
|
||||||
|
try {
|
||||||
|
return await fn(dir);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakeYtDlpScript(dir: string): string {
|
||||||
|
const scriptPath = path.join(dir, 'yt-dlp');
|
||||||
|
const script = `#!/usr/bin/env node
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
let outputTemplate = '';
|
||||||
|
const wantsAutoSubs = args.includes('--write-auto-subs');
|
||||||
|
const wantsManualSubs = args.includes('--write-subs');
|
||||||
|
const subLangIndex = args.indexOf('--sub-langs');
|
||||||
|
const subLang = subLangIndex >= 0 ? args[subLangIndex + 1] || '' : '';
|
||||||
|
const subLangs = subLang ? subLang.split(',').filter(Boolean) : [];
|
||||||
|
for (let i = 0; i < args.length; i += 1) {
|
||||||
|
if (args[i] === '-o' && typeof args[i + 1] === 'string') {
|
||||||
|
outputTemplate = args[i + 1];
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.YTDLP_EXPECT_AUTO_SUBS === '1' && !wantsAutoSubs) {
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
if (process.env.YTDLP_EXPECT_MANUAL_SUBS === '1' && !wantsManualSubs) {
|
||||||
|
process.exit(3);
|
||||||
|
}
|
||||||
|
if (process.env.YTDLP_EXPECT_SUB_LANG && subLang !== process.env.YTDLP_EXPECT_SUB_LANG) {
|
||||||
|
process.exit(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = outputTemplate.replace(/\.%\([^)]+\)s$/, '');
|
||||||
|
if (!prefix) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.dirname(prefix), { recursive: true });
|
||||||
|
|
||||||
|
if (process.env.YTDLP_FAKE_MODE === 'multi') {
|
||||||
|
for (const lang of subLangs) {
|
||||||
|
fs.writeFileSync(\`\${prefix}.\${lang}.vtt\`, 'WEBVTT\\n');
|
||||||
|
}
|
||||||
|
} else if (process.env.YTDLP_FAKE_MODE === 'rolling-auto') {
|
||||||
|
fs.writeFileSync(
|
||||||
|
\`\${prefix}.vtt\`,
|
||||||
|
[
|
||||||
|
'WEBVTT',
|
||||||
|
'',
|
||||||
|
'00:00:01.000 --> 00:00:02.000',
|
||||||
|
'今日は',
|
||||||
|
'',
|
||||||
|
'00:00:02.000 --> 00:00:03.000',
|
||||||
|
'今日はいい天気ですね',
|
||||||
|
'',
|
||||||
|
'00:00:03.000 --> 00:00:04.000',
|
||||||
|
'今日はいい天気ですね本当に',
|
||||||
|
'',
|
||||||
|
].join('\\n'),
|
||||||
|
);
|
||||||
|
} else if (process.env.YTDLP_FAKE_MODE === 'multi-primary-only-fail') {
|
||||||
|
const primaryLang = subLangs[0];
|
||||||
|
if (primaryLang) {
|
||||||
|
fs.writeFileSync(\`\${prefix}.\${primaryLang}.vtt\`, 'WEBVTT\\n');
|
||||||
|
}
|
||||||
|
process.stderr.write("ERROR: Unable to download video subtitles for 'en': HTTP Error 429: Too Many Requests\\n");
|
||||||
|
process.exit(1);
|
||||||
|
} else if (process.env.YTDLP_FAKE_MODE === 'both') {
|
||||||
|
fs.writeFileSync(\`\${prefix}.vtt\`, 'WEBVTT\\n');
|
||||||
|
fs.writeFileSync(\`\${prefix}.orig.webp\`, 'webp');
|
||||||
|
} else if (process.env.YTDLP_FAKE_MODE === 'webp-only') {
|
||||||
|
fs.writeFileSync(\`\${prefix}.orig.webp\`, 'webp');
|
||||||
|
} else {
|
||||||
|
fs.writeFileSync(\`\${prefix}.vtt\`, 'WEBVTT\\n');
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||||
|
fs.chmodSync(scriptPath, 0o755);
|
||||||
|
return scriptPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withFakeYtDlp<T>(
|
||||||
|
mode: 'both' | 'webp-only' | 'multi' | 'multi-primary-only-fail' | 'rolling-auto',
|
||||||
|
fn: (dir: string, binDir: string) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
return await withTempDir(async (root) => {
|
||||||
|
const binDir = path.join(root, 'bin');
|
||||||
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
|
makeFakeYtDlpScript(binDir);
|
||||||
|
|
||||||
|
const originalPath = process.env.PATH ?? '';
|
||||||
|
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
||||||
|
process.env.YTDLP_FAKE_MODE = mode;
|
||||||
|
try {
|
||||||
|
return await fn(root, binDir);
|
||||||
|
} finally {
|
||||||
|
process.env.PATH = originalPath;
|
||||||
|
delete process.env.YTDLP_FAKE_MODE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withFakeYtDlpExpectations<T>(
|
||||||
|
expectations: Partial<Record<'YTDLP_EXPECT_AUTO_SUBS' | 'YTDLP_EXPECT_MANUAL_SUBS' | 'YTDLP_EXPECT_SUB_LANG', string>>,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const previous = {
|
||||||
|
YTDLP_EXPECT_AUTO_SUBS: process.env.YTDLP_EXPECT_AUTO_SUBS,
|
||||||
|
YTDLP_EXPECT_MANUAL_SUBS: process.env.YTDLP_EXPECT_MANUAL_SUBS,
|
||||||
|
YTDLP_EXPECT_SUB_LANG: process.env.YTDLP_EXPECT_SUB_LANG,
|
||||||
|
};
|
||||||
|
Object.assign(process.env, expectations);
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
for (const [key, value] of Object.entries(previous)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
} else {
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withStubFetch<T>(
|
||||||
|
handler: (url: string) => Promise<Response> | Response,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||||
|
const url =
|
||||||
|
typeof input === 'string'
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: input.url;
|
||||||
|
return await handler(url);
|
||||||
|
}) as typeof fetch;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTrack prefers subtitle files over later webp artifacts', async () => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withFakeYtDlp('both', async (root) => {
|
||||||
|
const result = await downloadYoutubeSubtitleTrack({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir: path.join(root, 'out'),
|
||||||
|
track: {
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
},
|
||||||
|
mode: 'download',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(path.extname(result.path), '.vtt');
|
||||||
|
assert.match(path.basename(result.path), /^auto-ja-orig\./);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTrack ignores stale subtitle files from prior runs', async () => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withFakeYtDlp('webp-only', async (root) => {
|
||||||
|
const outputDir = path.join(root, 'out');
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(outputDir, 'auto-ja.vtt'), 'stale subtitle');
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
async () =>
|
||||||
|
await downloadYoutubeSubtitleTrack({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir,
|
||||||
|
track: {
|
||||||
|
id: 'auto:ja',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
},
|
||||||
|
mode: 'download',
|
||||||
|
}),
|
||||||
|
/No subtitle file was downloaded/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTrack uses auto subtitle flags and raw source language for auto tracks', async () => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withFakeYtDlp('both', async (root) => {
|
||||||
|
await withFakeYtDlpExpectations(
|
||||||
|
{
|
||||||
|
YTDLP_EXPECT_AUTO_SUBS: '1',
|
||||||
|
YTDLP_EXPECT_SUB_LANG: 'ja-orig',
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await downloadYoutubeSubtitleTrack({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir: path.join(root, 'out'),
|
||||||
|
track: {
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
},
|
||||||
|
mode: 'download',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(path.extname(result.path), '.vtt');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTrack keeps manual subtitle flag for manual tracks', async () => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withFakeYtDlp('both', async (root) => {
|
||||||
|
await withFakeYtDlpExpectations(
|
||||||
|
{
|
||||||
|
YTDLP_EXPECT_MANUAL_SUBS: '1',
|
||||||
|
YTDLP_EXPECT_SUB_LANG: 'ja',
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await downloadYoutubeSubtitleTrack({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir: path.join(root, 'out'),
|
||||||
|
track: {
|
||||||
|
id: 'manual:ja',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja',
|
||||||
|
kind: 'manual',
|
||||||
|
label: 'Japanese (manual)',
|
||||||
|
},
|
||||||
|
mode: 'download',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(path.extname(result.path), '.vtt');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTrack prefers direct download URL when available', async () => {
|
||||||
|
await withTempDir(async (root) => {
|
||||||
|
await withStubFetch(
|
||||||
|
async (url) => {
|
||||||
|
assert.equal(url, 'https://example.com/subs/ja.vtt');
|
||||||
|
return new Response('WEBVTT\n', { status: 200 });
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await downloadYoutubeSubtitleTrack({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir: path.join(root, 'out'),
|
||||||
|
track: {
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
downloadUrl: 'https://example.com/subs/ja.vtt',
|
||||||
|
fileExtension: 'vtt',
|
||||||
|
},
|
||||||
|
mode: 'download',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(path.basename(result.path), 'auto-ja-orig.ja-orig.vtt');
|
||||||
|
assert.equal(fs.readFileSync(result.path, 'utf8'), 'WEBVTT\n');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTrack sanitizes metadata source language in filenames', async () => {
|
||||||
|
await withTempDir(async (root) => {
|
||||||
|
await withStubFetch(
|
||||||
|
async () => new Response('WEBVTT\n', { status: 200 }),
|
||||||
|
async () => {
|
||||||
|
const result = await downloadYoutubeSubtitleTrack({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir: path.join(root, 'out'),
|
||||||
|
track: {
|
||||||
|
id: 'auto:../../ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: '../ja-orig/../../evil',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
downloadUrl: 'https://example.com/subs/ja.vtt',
|
||||||
|
fileExtension: 'vtt',
|
||||||
|
},
|
||||||
|
mode: 'download',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(path.dirname(result.path), path.join(root, 'out'));
|
||||||
|
assert.equal(path.basename(result.path), 'auto-ja-orig.ja-orig-evil.vtt');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTrack converts srv3 auto subtitles into regular vtt', async () => {
|
||||||
|
await withTempDir(async (root) => {
|
||||||
|
await withStubFetch(
|
||||||
|
async (url) => {
|
||||||
|
assert.equal(url, 'https://example.com/subs/ja.srv3');
|
||||||
|
return new Response(
|
||||||
|
[
|
||||||
|
'<timedtext><body>',
|
||||||
|
'<p t="1000" d="2500">今日は</p>',
|
||||||
|
'<p t="2000" d="2500">今日はいい天気ですね</p>',
|
||||||
|
'<p t="3500" d="2500">今日はいい天気ですね本当に</p>',
|
||||||
|
'</body></timedtext>',
|
||||||
|
].join(''),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await downloadYoutubeSubtitleTrack({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir: path.join(root, 'out'),
|
||||||
|
track: {
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
downloadUrl: 'https://example.com/subs/ja.srv3',
|
||||||
|
fileExtension: 'srv3',
|
||||||
|
},
|
||||||
|
mode: 'download',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(path.basename(result.path), 'auto-ja-orig.ja-orig.vtt');
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(result.path, 'utf8'),
|
||||||
|
[
|
||||||
|
'WEBVTT',
|
||||||
|
'',
|
||||||
|
'00:00:01.000 --> 00:00:01.999',
|
||||||
|
'今日は',
|
||||||
|
'',
|
||||||
|
'00:00:02.000 --> 00:00:03.499',
|
||||||
|
'いい天気ですね',
|
||||||
|
'',
|
||||||
|
'00:00:03.500 --> 00:00:06.000',
|
||||||
|
'本当に',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTracks downloads primary and secondary in one invocation', async () => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withFakeYtDlp('multi', async (root) => {
|
||||||
|
const outputDir = path.join(root, 'out');
|
||||||
|
const result = await downloadYoutubeSubtitleTracks({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir,
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'auto:en',
|
||||||
|
language: 'en',
|
||||||
|
sourceLanguage: 'en',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'English (auto)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mode: 'download',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(path.basename(result.get('auto:ja-orig') ?? ''), /\.ja-orig\.vtt$/);
|
||||||
|
assert.match(path.basename(result.get('auto:en') ?? ''), /\.en\.vtt$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTracks preserves successfully downloaded primary file on partial failure', async () => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withFakeYtDlp('multi-primary-only-fail', async (root) => {
|
||||||
|
const outputDir = path.join(root, 'out');
|
||||||
|
const result = await downloadYoutubeSubtitleTracks({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir,
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'auto:en',
|
||||||
|
language: 'en',
|
||||||
|
sourceLanguage: 'en',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'English (auto)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mode: 'download',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(path.basename(result.get('auto:ja-orig') ?? ''), /\.ja-orig\.vtt$/);
|
||||||
|
assert.equal(result.has('auto:en'), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTracks prefers direct download URLs when available', async () => {
|
||||||
|
await withTempDir(async (root) => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
await withStubFetch(
|
||||||
|
async (url) => {
|
||||||
|
seen.push(url);
|
||||||
|
return new Response(`WEBVTT\n${url}\n`, { status: 200 });
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await downloadYoutubeSubtitleTracks({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir: path.join(root, 'out'),
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
downloadUrl: 'https://example.com/subs/ja.vtt',
|
||||||
|
fileExtension: 'vtt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'auto:en',
|
||||||
|
language: 'en',
|
||||||
|
sourceLanguage: 'en',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'English (auto)',
|
||||||
|
downloadUrl: 'https://example.com/subs/en.vtt',
|
||||||
|
fileExtension: 'vtt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mode: 'download',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(seen, [
|
||||||
|
'https://example.com/subs/ja.vtt',
|
||||||
|
'https://example.com/subs/en.vtt',
|
||||||
|
]);
|
||||||
|
assert.match(path.basename(result.get('auto:ja-orig') ?? ''), /\.ja-orig\.vtt$/);
|
||||||
|
assert.match(path.basename(result.get('auto:en') ?? ''), /\.en\.vtt$/);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTracks keeps duplicate source-language direct downloads distinct', async () => {
|
||||||
|
await withTempDir(async (root) => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
await withStubFetch(
|
||||||
|
async (url) => {
|
||||||
|
seen.push(url);
|
||||||
|
return new Response(`WEBVTT\n${url}\n`, { status: 200 });
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await downloadYoutubeSubtitleTracks({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir: path.join(root, 'out'),
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
downloadUrl: 'https://example.com/subs/ja-auto.vtt',
|
||||||
|
fileExtension: 'vtt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'manual:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'manual',
|
||||||
|
label: 'Japanese (manual)',
|
||||||
|
downloadUrl: 'https://example.com/subs/ja-manual.vtt',
|
||||||
|
fileExtension: 'vtt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mode: 'download',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(seen, [
|
||||||
|
'https://example.com/subs/ja-auto.vtt',
|
||||||
|
'https://example.com/subs/ja-manual.vtt',
|
||||||
|
]);
|
||||||
|
assert.notEqual(result.get('auto:ja-orig'), result.get('manual:ja-orig'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
298
src/core/services/youtube/track-download.ts
Normal file
298
src/core/services/youtube/track-download.ts
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import type { YoutubeFlowMode } from '../../../types';
|
||||||
|
import type { YoutubeTrackOption } from './track-probe';
|
||||||
|
import { convertYoutubeTimedTextToVtt, isYoutubeTimedTextExtension } from './timedtext';
|
||||||
|
|
||||||
|
const YOUTUBE_SUBTITLE_EXTENSIONS = new Set(['.srt', '.vtt', '.ass']);
|
||||||
|
const YOUTUBE_BATCH_PREFIX = 'youtube-batch';
|
||||||
|
const YOUTUBE_DOWNLOAD_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
function sanitizeFilenameSegment(value: string): string {
|
||||||
|
const sanitized = value.trim().replace(/[^a-z0-9_-]+/gi, '-').replace(/-+/g, '-');
|
||||||
|
return sanitized.replace(/^-+|-+$/g, '') || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFetchTimeoutSignal(timeoutMs: number): AbortSignal | undefined {
|
||||||
|
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
||||||
|
return AbortSignal.timeout(timeoutMs);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCapture(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
timeoutMs = YOUTUBE_DOWNLOAD_TIMEOUT_MS,
|
||||||
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
proc.kill();
|
||||||
|
reject(new Error(`yt-dlp timed out after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
proc.stdout.setEncoding('utf8');
|
||||||
|
proc.stderr.setEncoding('utf8');
|
||||||
|
proc.stdout.on('data', (chunk) => {
|
||||||
|
stdout += String(chunk);
|
||||||
|
});
|
||||||
|
proc.stderr.on('data', (chunk) => {
|
||||||
|
stderr += String(chunk);
|
||||||
|
});
|
||||||
|
proc.once('error', (error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
proc.once('close', (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (code === 0) {
|
||||||
|
resolve({ stdout, stderr });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(stderr.trim() || `yt-dlp exited with status ${code ?? 'unknown'}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCaptureDetailed(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
timeoutMs = YOUTUBE_DOWNLOAD_TIMEOUT_MS,
|
||||||
|
): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
proc.kill();
|
||||||
|
reject(new Error(`yt-dlp timed out after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
proc.stdout.setEncoding('utf8');
|
||||||
|
proc.stderr.setEncoding('utf8');
|
||||||
|
proc.stdout.on('data', (chunk) => {
|
||||||
|
stdout += String(chunk);
|
||||||
|
});
|
||||||
|
proc.stderr.on('data', (chunk) => {
|
||||||
|
stderr += String(chunk);
|
||||||
|
});
|
||||||
|
proc.once('error', (error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
proc.once('close', (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve({ stdout, stderr, code: code ?? 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickLatestSubtitleFile(dir: string, prefix: string): string | null {
|
||||||
|
const entries = fs.readdirSync(dir).map((name) => path.join(dir, name));
|
||||||
|
const candidates = entries.filter((candidate) => {
|
||||||
|
const basename = path.basename(candidate);
|
||||||
|
const ext = path.extname(basename).toLowerCase();
|
||||||
|
return basename.startsWith(prefix) && YOUTUBE_SUBTITLE_EXTENSIONS.has(ext);
|
||||||
|
});
|
||||||
|
candidates.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
||||||
|
return candidates[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickLatestSubtitleFileForLanguage(
|
||||||
|
dir: string,
|
||||||
|
prefix: string,
|
||||||
|
sourceLanguage: string,
|
||||||
|
): string | null {
|
||||||
|
const entries = fs.readdirSync(dir).map((name) => path.join(dir, name));
|
||||||
|
const candidates = entries.filter((candidate) => {
|
||||||
|
const basename = path.basename(candidate);
|
||||||
|
const ext = path.extname(basename).toLowerCase();
|
||||||
|
return (
|
||||||
|
basename.startsWith(`${prefix}.`) &&
|
||||||
|
basename.includes(`.${sourceLanguage}.`) &&
|
||||||
|
YOUTUBE_SUBTITLE_EXTENSIONS.has(ext)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
candidates.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
||||||
|
return candidates[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDownloadArgs(input: {
|
||||||
|
targetUrl: string;
|
||||||
|
outputTemplate: string;
|
||||||
|
sourceLanguages: string[];
|
||||||
|
includeAutoSubs: boolean;
|
||||||
|
includeManualSubs: boolean;
|
||||||
|
}): string[] {
|
||||||
|
const args = ['--skip-download', '--no-warnings'];
|
||||||
|
if (input.includeAutoSubs) {
|
||||||
|
args.push('--write-auto-subs');
|
||||||
|
}
|
||||||
|
if (input.includeManualSubs) {
|
||||||
|
args.push('--write-subs');
|
||||||
|
}
|
||||||
|
args.push(
|
||||||
|
'--sub-format',
|
||||||
|
'srt/vtt/best',
|
||||||
|
'--sub-langs',
|
||||||
|
input.sourceLanguages.join(','),
|
||||||
|
'-o',
|
||||||
|
input.outputTemplate,
|
||||||
|
input.targetUrl,
|
||||||
|
);
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadSubtitleFromUrl(input: {
|
||||||
|
outputDir: string;
|
||||||
|
prefix: string;
|
||||||
|
track: YoutubeTrackOption;
|
||||||
|
}): Promise<{ path: string }> {
|
||||||
|
if (!input.track.downloadUrl) {
|
||||||
|
throw new Error(`No direct subtitle URL available for ${input.track.sourceLanguage}`);
|
||||||
|
}
|
||||||
|
const ext = (input.track.fileExtension?.trim().toLowerCase() || 'vtt').replace(/[^a-z0-9]+/g, '');
|
||||||
|
const safeExt = isYoutubeTimedTextExtension(ext)
|
||||||
|
? 'vtt'
|
||||||
|
: YOUTUBE_SUBTITLE_EXTENSIONS.has(`.${ext}`)
|
||||||
|
? ext
|
||||||
|
: 'vtt';
|
||||||
|
const safeSourceLanguage = sanitizeFilenameSegment(input.track.sourceLanguage);
|
||||||
|
const targetPath = path.join(
|
||||||
|
input.outputDir,
|
||||||
|
`${input.prefix}.${safeSourceLanguage}.${safeExt}`,
|
||||||
|
);
|
||||||
|
const response = await fetch(input.track.downloadUrl, {
|
||||||
|
signal: createFetchTimeoutSignal(YOUTUBE_DOWNLOAD_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status} while downloading ${input.track.sourceLanguage}`);
|
||||||
|
}
|
||||||
|
const body = await response.text();
|
||||||
|
const normalizedBody = isYoutubeTimedTextExtension(ext) ? convertYoutubeTimedTextToVtt(body) : body;
|
||||||
|
fs.writeFileSync(targetPath, normalizedBody, 'utf8');
|
||||||
|
return { path: targetPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function canDownloadSubtitleFromUrl(track: YoutubeTrackOption): boolean {
|
||||||
|
if (!track.downloadUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = (track.fileExtension?.trim().toLowerCase() || 'vtt').replace(/[^a-z0-9]+/g, '');
|
||||||
|
return isYoutubeTimedTextExtension(ext) || YOUTUBE_SUBTITLE_EXTENSIONS.has(`.${ext}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadYoutubeSubtitleTrack(input: {
|
||||||
|
targetUrl: string;
|
||||||
|
outputDir: string;
|
||||||
|
track: YoutubeTrackOption;
|
||||||
|
mode: YoutubeFlowMode;
|
||||||
|
}): Promise<{ path: string }> {
|
||||||
|
fs.mkdirSync(input.outputDir, { recursive: true });
|
||||||
|
const prefix = input.track.id.replace(/[^a-z0-9_-]+/gi, '-');
|
||||||
|
for (const name of fs.readdirSync(input.outputDir)) {
|
||||||
|
if (name.startsWith(prefix)) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(path.join(input.outputDir, name), { force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore stale files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (canDownloadSubtitleFromUrl(input.track)) {
|
||||||
|
return await downloadSubtitleFromUrl({
|
||||||
|
outputDir: input.outputDir,
|
||||||
|
prefix,
|
||||||
|
track: input.track,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const outputTemplate = path.join(input.outputDir, `${prefix}.%(ext)s`);
|
||||||
|
const args = [
|
||||||
|
...buildDownloadArgs({
|
||||||
|
targetUrl: input.targetUrl,
|
||||||
|
outputTemplate,
|
||||||
|
sourceLanguages: [input.track.sourceLanguage],
|
||||||
|
includeAutoSubs: input.mode === 'generate' || input.track.kind === 'auto',
|
||||||
|
includeManualSubs: input.track.kind === 'manual',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
await runCapture('yt-dlp', args);
|
||||||
|
const subtitlePath = pickLatestSubtitleFile(input.outputDir, prefix);
|
||||||
|
if (!subtitlePath) {
|
||||||
|
throw new Error(`No subtitle file was downloaded for ${input.track.sourceLanguage}`);
|
||||||
|
}
|
||||||
|
return { path: subtitlePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadYoutubeSubtitleTracks(input: {
|
||||||
|
targetUrl: string;
|
||||||
|
outputDir: string;
|
||||||
|
tracks: YoutubeTrackOption[];
|
||||||
|
mode: YoutubeFlowMode;
|
||||||
|
}): Promise<Map<string, string>> {
|
||||||
|
fs.mkdirSync(input.outputDir, { recursive: true });
|
||||||
|
const hasDuplicateSourceLanguages =
|
||||||
|
new Set(input.tracks.map((track) => track.sourceLanguage)).size !== input.tracks.length;
|
||||||
|
for (const name of fs.readdirSync(input.outputDir)) {
|
||||||
|
if (name.startsWith(`${YOUTUBE_BATCH_PREFIX}.`)) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(path.join(input.outputDir, name), { force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore stale files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasDuplicateSourceLanguages || input.tracks.every(canDownloadSubtitleFromUrl)) {
|
||||||
|
const results = new Map<string, string>();
|
||||||
|
for (const track of input.tracks) {
|
||||||
|
const download = await downloadSubtitleFromUrl({
|
||||||
|
outputDir: input.outputDir,
|
||||||
|
prefix: track.id.replace(/[^a-z0-9_-]+/gi, '-'),
|
||||||
|
track,
|
||||||
|
});
|
||||||
|
results.set(track.id, download.path);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputTemplate = path.join(input.outputDir, `${YOUTUBE_BATCH_PREFIX}.%(ext)s`);
|
||||||
|
const includeAutoSubs =
|
||||||
|
input.mode === 'generate' || input.tracks.some((track) => track.kind === 'auto');
|
||||||
|
const includeManualSubs = input.tracks.some((track) => track.kind === 'manual');
|
||||||
|
|
||||||
|
const result = await runCaptureDetailed(
|
||||||
|
'yt-dlp',
|
||||||
|
buildDownloadArgs({
|
||||||
|
targetUrl: input.targetUrl,
|
||||||
|
outputTemplate,
|
||||||
|
sourceLanguages: input.tracks.map((track) => track.sourceLanguage),
|
||||||
|
includeAutoSubs,
|
||||||
|
includeManualSubs,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = new Map<string, string>();
|
||||||
|
for (const track of input.tracks) {
|
||||||
|
const subtitlePath = pickLatestSubtitleFileForLanguage(
|
||||||
|
input.outputDir,
|
||||||
|
YOUTUBE_BATCH_PREFIX,
|
||||||
|
track.sourceLanguage,
|
||||||
|
);
|
||||||
|
if (subtitlePath) {
|
||||||
|
results.set(track.id, subtitlePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (results.size > 0) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
if (result.code !== 0) {
|
||||||
|
throw new Error(result.stderr.trim() || `yt-dlp exited with status ${result.code}`);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`No subtitle file was downloaded for ${input.tracks.map((track) => track.sourceLanguage).join(',')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
99
src/core/services/youtube/track-probe.test.ts
Normal file
99
src/core/services/youtube/track-probe.test.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
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 { probeYoutubeTracks } from './track-probe';
|
||||||
|
|
||||||
|
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-track-probe-'));
|
||||||
|
try {
|
||||||
|
return await fn(dir);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakeYtDlpScript(dir: string, payload: unknown, rawScript = false): void {
|
||||||
|
const scriptPath = path.join(dir, 'yt-dlp');
|
||||||
|
const stdoutBody = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
||||||
|
const script = rawScript
|
||||||
|
? stdoutBody
|
||||||
|
: `#!/usr/bin/env node
|
||||||
|
process.stdout.write(${JSON.stringify(stdoutBody)});
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
fs.chmodSync(scriptPath, 0o755);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(scriptPath + '.cmd', `@echo off\r\nnode "${scriptPath}"\r\n`, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withFakeYtDlp<T>(
|
||||||
|
payload: unknown,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
options: { rawScript?: boolean } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
return await withTempDir(async (root) => {
|
||||||
|
const binDir = path.join(root, 'bin');
|
||||||
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
|
makeFakeYtDlpScript(binDir, payload, options.rawScript === true);
|
||||||
|
const originalPath = process.env.PATH ?? '';
|
||||||
|
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
process.env.PATH = originalPath;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('probeYoutubeTracks prefers srv3 over vtt for automatic captions', async () => {
|
||||||
|
await withFakeYtDlp(
|
||||||
|
{
|
||||||
|
id: 'abc123',
|
||||||
|
title: 'Example',
|
||||||
|
automatic_captions: {
|
||||||
|
'ja-orig': [
|
||||||
|
{ ext: 'vtt', url: 'https://example.com/ja.vtt', name: 'Japanese auto' },
|
||||||
|
{ ext: 'srv3', url: 'https://example.com/ja.srv3', name: 'Japanese auto' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await probeYoutubeTracks('https://www.youtube.com/watch?v=abc123');
|
||||||
|
assert.equal(result.videoId, 'abc123');
|
||||||
|
assert.equal(result.tracks[0]?.downloadUrl, 'https://example.com/ja.srv3');
|
||||||
|
assert.equal(result.tracks[0]?.fileExtension, 'srv3');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('probeYoutubeTracks keeps preferring srt for manual captions', async () => {
|
||||||
|
await withFakeYtDlp(
|
||||||
|
{
|
||||||
|
id: 'abc123',
|
||||||
|
title: 'Example',
|
||||||
|
subtitles: {
|
||||||
|
ja: [
|
||||||
|
{ ext: 'srv3', url: 'https://example.com/ja.srv3', name: 'Japanese manual' },
|
||||||
|
{ ext: 'srt', url: 'https://example.com/ja.srt', name: 'Japanese manual' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await probeYoutubeTracks('https://www.youtube.com/watch?v=abc123');
|
||||||
|
assert.equal(result.tracks[0]?.downloadUrl, 'https://example.com/ja.srt');
|
||||||
|
assert.equal(result.tracks[0]?.fileExtension, 'srt');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('probeYoutubeTracks reports malformed yt-dlp JSON with context', async () => {
|
||||||
|
await withFakeYtDlp('not-json', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
async () => await probeYoutubeTracks('https://www.youtube.com/watch?v=abc123'),
|
||||||
|
/Failed to parse yt-dlp output as JSON/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
136
src/core/services/youtube/track-probe.ts
Normal file
136
src/core/services/youtube/track-probe.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import type { YoutubeTrackOption } from '../../../types';
|
||||||
|
import { formatYoutubeTrackLabel, normalizeYoutubeLangCode, type YoutubeTrackKind } from './labels';
|
||||||
|
|
||||||
|
const YOUTUBE_TRACK_PROBE_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
export type YoutubeTrackProbeResult = {
|
||||||
|
videoId: string;
|
||||||
|
title: string;
|
||||||
|
tracks: YoutubeTrackOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type YtDlpSubtitleEntry = Array<{ ext?: string; name?: string; url?: string }>;
|
||||||
|
|
||||||
|
type YtDlpInfo = {
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
subtitles?: Record<string, YtDlpSubtitleEntry>;
|
||||||
|
automatic_captions?: Record<string, YtDlpSubtitleEntry>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function runCapture(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
timeoutMs = YOUTUBE_TRACK_PROBE_TIMEOUT_MS,
|
||||||
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
proc.kill();
|
||||||
|
reject(new Error(`yt-dlp timed out after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
proc.stdout.setEncoding('utf8');
|
||||||
|
proc.stderr.setEncoding('utf8');
|
||||||
|
proc.stdout.on('data', (chunk) => {
|
||||||
|
stdout += String(chunk);
|
||||||
|
});
|
||||||
|
proc.stderr.on('data', (chunk) => {
|
||||||
|
stderr += String(chunk);
|
||||||
|
});
|
||||||
|
proc.once('error', (error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
proc.once('close', (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (code === 0) {
|
||||||
|
resolve({ stdout, stderr });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(stderr.trim() || `yt-dlp exited with status ${code ?? 'unknown'}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function choosePreferredFormat(
|
||||||
|
formats: YtDlpSubtitleEntry,
|
||||||
|
kind: YoutubeTrackKind,
|
||||||
|
): { ext: string; url: string; title?: string } | null {
|
||||||
|
const preferredOrder =
|
||||||
|
kind === 'auto'
|
||||||
|
? ['srv3', 'srv2', 'srv1', 'vtt', 'srt', 'ttml', 'json3']
|
||||||
|
: ['srt', 'vtt', 'srv3', 'srv2', 'srv1', 'ttml', 'json3'];
|
||||||
|
for (const ext of preferredOrder) {
|
||||||
|
const match = formats.find(
|
||||||
|
(format) => typeof format.url === 'string' && format.url && format.ext === ext,
|
||||||
|
);
|
||||||
|
if (match?.url) {
|
||||||
|
return { ext, url: match.url, title: match.name?.trim() || undefined };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = formats.find((format) => typeof format.url === 'string' && format.url);
|
||||||
|
if (!fallback?.url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ext: fallback.ext?.trim() || 'vtt',
|
||||||
|
url: fallback.url,
|
||||||
|
title: fallback.name?.trim() || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTracks(entries: Record<string, YtDlpSubtitleEntry> | undefined, kind: YoutubeTrackKind) {
|
||||||
|
const tracks: YoutubeTrackOption[] = [];
|
||||||
|
if (!entries) return tracks;
|
||||||
|
for (const [language, formats] of Object.entries(entries)) {
|
||||||
|
if (!Array.isArray(formats) || formats.length === 0) continue;
|
||||||
|
const preferredFormat = choosePreferredFormat(formats, kind);
|
||||||
|
if (!preferredFormat) continue;
|
||||||
|
const sourceLanguage = language.trim() || language;
|
||||||
|
const normalizedLanguage = normalizeYoutubeLangCode(sourceLanguage) || sourceLanguage;
|
||||||
|
const title = preferredFormat.title;
|
||||||
|
tracks.push({
|
||||||
|
id: `${kind}:${sourceLanguage}`,
|
||||||
|
language: normalizedLanguage,
|
||||||
|
sourceLanguage,
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
label: formatYoutubeTrackLabel({ language: normalizedLanguage, kind, title }),
|
||||||
|
downloadUrl: preferredFormat.url,
|
||||||
|
fileExtension: preferredFormat.ext,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { YoutubeTrackOption };
|
||||||
|
|
||||||
|
export async function probeYoutubeTracks(targetUrl: string): Promise<YoutubeTrackProbeResult> {
|
||||||
|
const { stdout } = await runCapture('yt-dlp', ['--dump-single-json', '--no-warnings', targetUrl]);
|
||||||
|
const trimmedStdout = stdout.trim();
|
||||||
|
if (!trimmedStdout) {
|
||||||
|
throw new Error('yt-dlp returned empty output while probing subtitle tracks');
|
||||||
|
}
|
||||||
|
let info: YtDlpInfo;
|
||||||
|
try {
|
||||||
|
info = JSON.parse(trimmedStdout) as YtDlpInfo;
|
||||||
|
} catch (error) {
|
||||||
|
const snippet = trimmedStdout.slice(0, 200);
|
||||||
|
throw new Error(
|
||||||
|
`Failed to parse yt-dlp output as JSON: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}${snippet ? `; stdout=${snippet}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const tracks = [...toTracks(info.subtitles, 'manual'), ...toTracks(info.automatic_captions, 'auto')];
|
||||||
|
return {
|
||||||
|
videoId: info.id || '',
|
||||||
|
title: info.title || '',
|
||||||
|
tracks,
|
||||||
|
};
|
||||||
|
}
|
||||||
63
src/core/services/youtube/track-selection.ts
Normal file
63
src/core/services/youtube/track-selection.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { isEnglishYoutubeLang, isJapaneseYoutubeLang } from './labels';
|
||||||
|
import type { YoutubeTrackOption } from './track-probe';
|
||||||
|
|
||||||
|
function pickTrack(
|
||||||
|
tracks: YoutubeTrackOption[],
|
||||||
|
matcher: (value: string) => boolean,
|
||||||
|
excludeId?: string,
|
||||||
|
): YoutubeTrackOption | null {
|
||||||
|
const matching = tracks.filter((track) => matcher(track.language) && track.id !== excludeId);
|
||||||
|
return matching[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chooseDefaultYoutubeTrackIds(
|
||||||
|
tracks: YoutubeTrackOption[],
|
||||||
|
): { primaryTrackId: string | null; secondaryTrackId: string | null } {
|
||||||
|
const primary =
|
||||||
|
pickTrack(
|
||||||
|
tracks.filter((track) => track.kind === 'manual'),
|
||||||
|
isJapaneseYoutubeLang,
|
||||||
|
) ||
|
||||||
|
pickTrack(
|
||||||
|
tracks.filter((track) => track.kind === 'auto'),
|
||||||
|
isJapaneseYoutubeLang,
|
||||||
|
) ||
|
||||||
|
tracks.find((track) => track.kind === 'manual') ||
|
||||||
|
tracks[0] ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
const secondary =
|
||||||
|
pickTrack(
|
||||||
|
tracks.filter((track) => track.kind === 'manual'),
|
||||||
|
isEnglishYoutubeLang,
|
||||||
|
primary?.id ?? undefined,
|
||||||
|
) ||
|
||||||
|
pickTrack(
|
||||||
|
tracks.filter((track) => track.kind === 'auto'),
|
||||||
|
isEnglishYoutubeLang,
|
||||||
|
primary?.id ?? undefined,
|
||||||
|
) ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
primaryTrackId: primary?.id ?? null,
|
||||||
|
secondaryTrackId: secondary?.id ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeYoutubeTrackSelection(input: {
|
||||||
|
primaryTrackId: string | null;
|
||||||
|
secondaryTrackId: string | null;
|
||||||
|
}): {
|
||||||
|
primaryTrackId: string | null;
|
||||||
|
secondaryTrackId: string | null;
|
||||||
|
} {
|
||||||
|
if (input.primaryTrackId && input.secondaryTrackId && input.primaryTrackId === input.secondaryTrackId) {
|
||||||
|
return {
|
||||||
|
primaryTrackId: input.primaryTrackId,
|
||||||
|
secondaryTrackId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ test('resolveDefaultLogFilePath uses APPDATA on windows', () => {
|
|||||||
'C:\\Users\\tester\\AppData\\Roaming',
|
'C:\\Users\\tester\\AppData\\Roaming',
|
||||||
'SubMiner',
|
'SubMiner',
|
||||||
'logs',
|
'logs',
|
||||||
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`,
|
`app-${new Date().toISOString().slice(0, 10)}.log`,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -36,7 +36,7 @@ test('resolveDefaultLogFilePath uses .config on linux', () => {
|
|||||||
'.config',
|
'.config',
|
||||||
'SubMiner',
|
'SubMiner',
|
||||||
'logs',
|
'logs',
|
||||||
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`,
|
`app-${new Date().toISOString().slice(0, 10)}.log`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import fs from 'node:fs';
|
import { appendLogLine, resolveDefaultLogFilePath as resolveSharedDefaultLogFilePath } from './shared/log-files';
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||||
export type LogLevelSource = 'cli' | 'config';
|
export type LogLevelSource = 'cli' | 'config';
|
||||||
@@ -112,15 +110,11 @@ function safeStringify(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveLogFilePath(): string {
|
function resolveLogFilePath(): string {
|
||||||
const envPath = process.env.SUBMINER_MPV_LOG?.trim();
|
const envPath = process.env.SUBMINER_APP_LOG?.trim();
|
||||||
if (envPath) {
|
if (envPath) {
|
||||||
return envPath;
|
return envPath;
|
||||||
}
|
}
|
||||||
return resolveDefaultLogFilePath({
|
return resolveDefaultLogFilePath();
|
||||||
platform: process.platform,
|
|
||||||
homeDir: os.homedir(),
|
|
||||||
appDataDir: process.env.APPDATA,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDefaultLogFilePath(options?: {
|
export function resolveDefaultLogFilePath(options?: {
|
||||||
@@ -128,27 +122,11 @@ export function resolveDefaultLogFilePath(options?: {
|
|||||||
homeDir?: string;
|
homeDir?: string;
|
||||||
appDataDir?: string;
|
appDataDir?: string;
|
||||||
}): string {
|
}): string {
|
||||||
const date = new Date().toISOString().slice(0, 10);
|
return resolveSharedDefaultLogFilePath('app', options);
|
||||||
const platform = options?.platform ?? process.platform;
|
|
||||||
const homeDir = options?.homeDir ?? os.homedir();
|
|
||||||
const baseDir =
|
|
||||||
platform === 'win32'
|
|
||||||
? path.join(
|
|
||||||
options?.appDataDir?.trim() || path.join(homeDir, 'AppData', 'Roaming'),
|
|
||||||
'SubMiner',
|
|
||||||
)
|
|
||||||
: path.join(homeDir, '.config', 'SubMiner');
|
|
||||||
return path.join(baseDir, 'logs', `SubMiner-${date}.log`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendToLogFile(line: string): void {
|
function appendToLogFile(line: string): void {
|
||||||
try {
|
appendLogLine(resolveLogFilePath(), line);
|
||||||
const logPath = resolveLogFilePath();
|
|
||||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
||||||
fs.appendFileSync(logPath, `${line}\n`, { encoding: 'utf8' });
|
|
||||||
} catch {
|
|
||||||
// never break runtime due to logging sink failures
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function emit(level: LogLevel, scope: string, message: string, meta: unknown[]): void {
|
function emit(level: LogLevel, scope: string, message: string, meta: unknown[]): void {
|
||||||
|
|||||||
242
src/main.ts
242
src/main.ts
@@ -113,6 +113,7 @@ import {
|
|||||||
} from './cli/args';
|
} from './cli/args';
|
||||||
import type { CliArgs, CliCommandSource } from './cli/args';
|
import type { CliArgs, CliCommandSource } from './cli/args';
|
||||||
import { printHelp } from './cli/help';
|
import { printHelp } from './cli/help';
|
||||||
|
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||||
import {
|
import {
|
||||||
buildConfigParseErrorDetails,
|
buildConfigParseErrorDetails,
|
||||||
buildConfigWarningDialogDetails,
|
buildConfigWarningDialogDetails,
|
||||||
@@ -279,6 +280,7 @@ import {
|
|||||||
handleMultiCopyDigit as handleMultiCopyDigitCore,
|
handleMultiCopyDigit as handleMultiCopyDigitCore,
|
||||||
hasMpvWebsocketPlugin,
|
hasMpvWebsocketPlugin,
|
||||||
importYomitanDictionaryFromZip,
|
importYomitanDictionaryFromZip,
|
||||||
|
initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore,
|
||||||
initializeOverlayRuntime as initializeOverlayRuntimeCore,
|
initializeOverlayRuntime as initializeOverlayRuntimeCore,
|
||||||
jellyfinTicksToSecondsRuntime,
|
jellyfinTicksToSecondsRuntime,
|
||||||
listJellyfinItemsRuntime,
|
listJellyfinItemsRuntime,
|
||||||
@@ -309,12 +311,19 @@ import {
|
|||||||
upsertYomitanDictionarySettings,
|
upsertYomitanDictionarySettings,
|
||||||
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
|
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
|
||||||
} from './core/services';
|
} from './core/services';
|
||||||
|
import {
|
||||||
|
acquireYoutubeSubtitleTrack,
|
||||||
|
acquireYoutubeSubtitleTracks,
|
||||||
|
} from './core/services/youtube/generate';
|
||||||
|
import { retimeYoutubeSubtitle } from './core/services/youtube/retime';
|
||||||
|
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
||||||
import { startStatsServer } from './core/services/stats-server';
|
import { startStatsServer } from './core/services/stats-server';
|
||||||
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js';
|
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js';
|
||||||
import {
|
import {
|
||||||
createFirstRunSetupService,
|
createFirstRunSetupService,
|
||||||
shouldAutoOpenFirstRunSetup,
|
shouldAutoOpenFirstRunSetup,
|
||||||
} from './main/runtime/first-run-setup-service';
|
} from './main/runtime/first-run-setup-service';
|
||||||
|
import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow';
|
||||||
import { resolveAutoplayReadyMaxReleaseAttempts } from './main/runtime/startup-autoplay-release-policy';
|
import { resolveAutoplayReadyMaxReleaseAttempts } from './main/runtime/startup-autoplay-release-policy';
|
||||||
import {
|
import {
|
||||||
buildFirstRunSetupHtml,
|
buildFirstRunSetupHtml,
|
||||||
@@ -332,6 +341,7 @@ import {
|
|||||||
detectWindowsMpvShortcuts,
|
detectWindowsMpvShortcuts,
|
||||||
resolveWindowsMpvShortcutPaths,
|
resolveWindowsMpvShortcutPaths,
|
||||||
} from './main/runtime/windows-mpv-shortcuts';
|
} from './main/runtime/windows-mpv-shortcuts';
|
||||||
|
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
||||||
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
|
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
|
||||||
import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
|
import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
|
||||||
import {
|
import {
|
||||||
@@ -442,7 +452,7 @@ import {
|
|||||||
resolveSubtitleSourcePath,
|
resolveSubtitleSourcePath,
|
||||||
} from './main/runtime/subtitle-prefetch-source';
|
} from './main/runtime/subtitle-prefetch-source';
|
||||||
import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init';
|
import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init';
|
||||||
import { codecToExtension } from './subsync/utils';
|
import { codecToExtension, getSubsyncConfig } from './subsync/utils';
|
||||||
|
|
||||||
if (process.platform === 'linux') {
|
if (process.platform === 'linux') {
|
||||||
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
|
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
|
||||||
@@ -787,6 +797,186 @@ const appState = createAppState({
|
|||||||
mpvSocketPath: getDefaultSocketPath(),
|
mpvSocketPath: getDefaultSocketPath(),
|
||||||
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
|
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
|
||||||
});
|
});
|
||||||
|
const startBackgroundWarmupsIfAllowed = (): void => {
|
||||||
|
if (appState.youtubePlaybackFlowPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startBackgroundWarmups();
|
||||||
|
};
|
||||||
|
const youtubeFlowRuntime = createYoutubeFlowRuntime({
|
||||||
|
probeYoutubeTracks: (url: string) => probeYoutubeTracks(url),
|
||||||
|
acquireYoutubeSubtitleTrack: (input) => acquireYoutubeSubtitleTrack(input),
|
||||||
|
acquireYoutubeSubtitleTracks: (input) => acquireYoutubeSubtitleTracks(input),
|
||||||
|
retimeYoutubePrimaryTrack: async ({ primaryTrack, primaryPath, secondaryTrack, secondaryPath }) => {
|
||||||
|
if (primaryTrack.kind !== 'auto') {
|
||||||
|
return primaryPath;
|
||||||
|
}
|
||||||
|
const result = await retimeYoutubeSubtitle({
|
||||||
|
primaryPath,
|
||||||
|
secondaryPath: secondaryTrack ? secondaryPath : null,
|
||||||
|
});
|
||||||
|
logger.info(`Using YouTube subtitle path: ${result.path} (${result.strategy})`);
|
||||||
|
return result.path;
|
||||||
|
},
|
||||||
|
openPicker: async (payload) => {
|
||||||
|
const preferDedicatedModalWindow = false;
|
||||||
|
const sendPickerOpen = (preferModalWindow: boolean): boolean =>
|
||||||
|
overlayModalRuntime.sendToActiveOverlayWindow('youtube:picker-open', payload, {
|
||||||
|
restoreOnModalClose: 'youtube-track-picker',
|
||||||
|
preferModalWindow,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!sendPickerOpen(preferDedicatedModalWindow)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (await overlayModalRuntime.waitForModalOpen('youtube-track-picker', 1500)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying on visible overlay.',
|
||||||
|
);
|
||||||
|
if (!sendPickerOpen(!preferDedicatedModalWindow)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return await overlayModalRuntime.waitForModalOpen('youtube-track-picker', 1500);
|
||||||
|
},
|
||||||
|
pauseMpv: () => {
|
||||||
|
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'yes']);
|
||||||
|
},
|
||||||
|
resumeMpv: () => {
|
||||||
|
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'no']);
|
||||||
|
},
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
sendMpvCommandRuntime(appState.mpvClient, command);
|
||||||
|
},
|
||||||
|
requestMpvProperty: async (name: string) => {
|
||||||
|
const client = appState.mpvClient;
|
||||||
|
if (!client) return null;
|
||||||
|
return await client.requestProperty(name);
|
||||||
|
},
|
||||||
|
refreshCurrentSubtitle: (text: string) => {
|
||||||
|
subtitleProcessingController.refreshCurrentSubtitle(text);
|
||||||
|
},
|
||||||
|
startTokenizationWarmups: async () => {
|
||||||
|
await startTokenizationWarmups();
|
||||||
|
},
|
||||||
|
waitForTokenizationReady: async () => {
|
||||||
|
await currentMediaTokenizationGate.waitUntilReady(
|
||||||
|
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
waitForAnkiReady: async () => {
|
||||||
|
const integration = appState.ankiIntegration;
|
||||||
|
if (!integration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
integration.waitUntilReady(),
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error('Timed out waiting for AnkiConnect integration')), 2500);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
'Continuing YouTube playback before AnkiConnect integration reported ready:',
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
wait: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||||
|
waitForPlaybackWindowReady: async () => {
|
||||||
|
const deadline = Date.now() + 4000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const tracker = appState.windowTracker;
|
||||||
|
if (tracker && tracker.isTracking() && tracker.getGeometry()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
logger.warn('Timed out waiting for tracked playback window before opening YouTube subtitle picker.');
|
||||||
|
},
|
||||||
|
waitForOverlayGeometryReady: async () => {
|
||||||
|
const deadline = Date.now() + 4000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const tracker = appState.windowTracker;
|
||||||
|
const trackerGeometry = tracker?.getGeometry() ?? null;
|
||||||
|
if (trackerGeometry && geometryMatches(lastOverlayWindowGeometry, trackerGeometry)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
logger.warn('Timed out waiting for overlay geometry to match tracked playback window.');
|
||||||
|
},
|
||||||
|
focusOverlayWindow: () => {
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mainWindow.setIgnoreMouseEvents(false);
|
||||||
|
if (!mainWindow.isFocused()) {
|
||||||
|
mainWindow.focus();
|
||||||
|
}
|
||||||
|
if (!mainWindow.webContents.isFocused()) {
|
||||||
|
mainWindow.webContents.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||||
|
warn: (message: string) => logger.warn(message),
|
||||||
|
log: (message: string) => logger.info(message),
|
||||||
|
getYoutubeOutputDir: () => path.join(os.homedir(), '.cache', 'subminer', 'youtube-subs'),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function runYoutubePlaybackFlowMain(request: {
|
||||||
|
url: string;
|
||||||
|
mode: 'download' | 'generate';
|
||||||
|
source: CliCommandSource;
|
||||||
|
}): Promise<void> {
|
||||||
|
const wasYoutubePlaybackFlowPending = appState.youtubePlaybackFlowPending;
|
||||||
|
appState.youtubePlaybackFlowPending = true;
|
||||||
|
if (process.platform === 'win32' && !appState.mpvClient?.connected) {
|
||||||
|
const launchResult = launchWindowsMpv(
|
||||||
|
[request.url],
|
||||||
|
createWindowsMpvLaunchDeps({
|
||||||
|
showError: (title, content) => dialog.showErrorBox(title, content),
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
'--pause=yes',
|
||||||
|
'--sub-auto=no',
|
||||||
|
'--sid=no',
|
||||||
|
'--secondary-sid=no',
|
||||||
|
'--script-opts=subminer-auto_start_pause_until_ready=no',
|
||||||
|
`--input-ipc-server=${appState.mpvSocketPath}`,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (!launchResult.ok) {
|
||||||
|
logger.warn('Unable to bootstrap Windows mpv for YouTube playback.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!appState.mpvClient?.connected) {
|
||||||
|
appState.mpvClient?.connect();
|
||||||
|
}
|
||||||
|
await ensureOverlayRuntimeReady();
|
||||||
|
try {
|
||||||
|
await youtubeFlowRuntime.runYoutubePlaybackFlow({
|
||||||
|
url: request.url,
|
||||||
|
mode: request.mode,
|
||||||
|
});
|
||||||
|
logger.info(`YouTube playback flow completed from ${request.source}.`);
|
||||||
|
} finally {
|
||||||
|
appState.youtubePlaybackFlowPending = wasYoutubePlaybackFlowPending;
|
||||||
|
if (!wasYoutubePlaybackFlowPending) {
|
||||||
|
startBackgroundWarmupsIfAllowed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureOverlayRuntimeReady(): Promise<void> {
|
||||||
|
await ensureYomitanExtensionLoaded();
|
||||||
|
initializeOverlayRuntime();
|
||||||
|
}
|
||||||
|
|
||||||
let firstRunSetupMessage: string | null = null;
|
let firstRunSetupMessage: string | null = null;
|
||||||
const resolveWindowsMpvShortcutRuntimePaths = () =>
|
const resolveWindowsMpvShortcutRuntimePaths = () =>
|
||||||
resolveWindowsMpvShortcutPaths({
|
resolveWindowsMpvShortcutPaths({
|
||||||
@@ -1045,6 +1235,9 @@ function maybeSignalPluginAutoplayReady(
|
|||||||
payload: SubtitleData,
|
payload: SubtitleData,
|
||||||
options?: { forceWhilePaused?: boolean },
|
options?: { forceWhilePaused?: boolean },
|
||||||
): void {
|
): void {
|
||||||
|
if (appState.youtubePlaybackFlowPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!payload.text.trim()) {
|
if (!payload.text.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3064,7 +3257,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
|||||||
await prewarmSubtitleDictionaries();
|
await prewarmSubtitleDictionaries();
|
||||||
},
|
},
|
||||||
startBackgroundWarmups: () => {
|
startBackgroundWarmups: () => {
|
||||||
startBackgroundWarmups();
|
startBackgroundWarmupsIfAllowed();
|
||||||
},
|
},
|
||||||
texthookerOnlyMode: appState.texthookerOnlyMode,
|
texthookerOnlyMode: appState.texthookerOnlyMode,
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
|
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
|
||||||
@@ -3242,6 +3435,7 @@ const {
|
|||||||
createMecabTokenizerAndCheck,
|
createMecabTokenizerAndCheck,
|
||||||
prewarmSubtitleDictionaries,
|
prewarmSubtitleDictionaries,
|
||||||
startBackgroundWarmups,
|
startBackgroundWarmups,
|
||||||
|
startTokenizationWarmups,
|
||||||
isTokenizationWarmupReady,
|
isTokenizationWarmupReady,
|
||||||
} = composeMpvRuntimeHandlers<
|
} = composeMpvRuntimeHandlers<
|
||||||
MpvIpcClient,
|
MpvIpcClient,
|
||||||
@@ -3312,6 +3506,9 @@ const {
|
|||||||
immersionMediaRuntime.syncFromCurrentMediaState();
|
immersionMediaRuntime.syncFromCurrentMediaState();
|
||||||
},
|
},
|
||||||
signalAutoplayReadyIfWarm: () => {
|
signalAutoplayReadyIfWarm: () => {
|
||||||
|
if (appState.youtubePlaybackFlowPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isTokenizationWarmupReady()) {
|
if (!isTokenizationWarmupReady()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3513,7 +3710,19 @@ const {
|
|||||||
tokenizeSubtitleDeferred = tokenizeSubtitle;
|
tokenizeSubtitleDeferred = tokenizeSubtitle;
|
||||||
|
|
||||||
function createMpvClientRuntimeService(): MpvIpcClient {
|
function createMpvClientRuntimeService(): MpvIpcClient {
|
||||||
return createMpvClientRuntimeServiceHandler() as MpvIpcClient;
|
const client = createMpvClientRuntimeServiceHandler() as MpvIpcClient;
|
||||||
|
client.on('connection-change', ({ connected }) => {
|
||||||
|
if (connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!youtubeFlowRuntime.hasActiveSession()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
youtubeFlowRuntime.cancelActivePicker();
|
||||||
|
broadcastToOverlayWindows(IPC_CHANNELS.event.youtubePickerCancel, null);
|
||||||
|
overlayModalRuntime.handleOverlayModalClosed('youtube-track-picker');
|
||||||
|
});
|
||||||
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetSubtitleSidebarEmbeddedLayoutRuntime(): void {
|
function resetSubtitleSidebarEmbeddedLayoutRuntime(): void {
|
||||||
@@ -3546,6 +3755,11 @@ function getCurrentOverlayGeometry(): WindowGeometry {
|
|||||||
return getOverlayGeometryFallback();
|
return getOverlayGeometryFallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function geometryMatches(a: WindowGeometry | null, b: WindowGeometry | null): boolean {
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
||||||
|
}
|
||||||
|
|
||||||
function applyOverlayRegions(geometry: WindowGeometry): void {
|
function applyOverlayRegions(geometry: WindowGeometry): void {
|
||||||
lastOverlayWindowGeometry = geometry;
|
lastOverlayWindowGeometry = geometry;
|
||||||
overlayManager.setOverlayWindowBounds(geometry);
|
overlayManager.setOverlayWindowBounds(geometry);
|
||||||
@@ -3690,6 +3904,21 @@ function destroyTray(): void {
|
|||||||
|
|
||||||
function initializeOverlayRuntime(): void {
|
function initializeOverlayRuntime(): void {
|
||||||
initializeOverlayRuntimeHandler();
|
initializeOverlayRuntimeHandler();
|
||||||
|
initializeOverlayAnkiIntegrationCore({
|
||||||
|
getResolvedConfig: () => getResolvedConfig(),
|
||||||
|
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
|
||||||
|
getMpvClient: () => appState.mpvClient,
|
||||||
|
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
|
||||||
|
getAnkiIntegration: () => appState.ankiIntegration,
|
||||||
|
setAnkiIntegration: (integration) => {
|
||||||
|
appState.ankiIntegration = integration as AnkiIntegration | null;
|
||||||
|
},
|
||||||
|
showDesktopNotification,
|
||||||
|
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
||||||
|
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||||
|
shouldStartAnkiIntegration: () =>
|
||||||
|
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
||||||
|
});
|
||||||
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
||||||
syncOverlayMpvSubtitleSuppression();
|
syncOverlayMpvSubtitleSuppression();
|
||||||
}
|
}
|
||||||
@@ -4189,6 +4418,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
onOverlayModalOpened: (modal) => {
|
onOverlayModalOpened: (modal) => {
|
||||||
overlayModalRuntime.notifyOverlayModalOpened(modal);
|
overlayModalRuntime.notifyOverlayModalOpened(modal);
|
||||||
},
|
},
|
||||||
|
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
|
||||||
openYomitanSettings: () => openYomitanSettings(),
|
openYomitanSettings: () => openYomitanSettings(),
|
||||||
quitApp: () => requestAppQuit(),
|
quitApp: () => requestAppQuit(),
|
||||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||||
@@ -4403,6 +4633,7 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
|
|||||||
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
|
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
|
||||||
runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) =>
|
runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) =>
|
||||||
runStatsCliCommand(argsFromCommand, source),
|
runStatsCliCommand(argsFromCommand, source),
|
||||||
|
runYoutubePlaybackFlow: (request) => runYoutubePlaybackFlowMain(request),
|
||||||
openYomitanSettings: () => openYomitanSettings(),
|
openYomitanSettings: () => openYomitanSettings(),
|
||||||
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
||||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||||
@@ -4569,7 +4800,10 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
|||||||
appState.overlayRuntimeInitialized = initialized;
|
appState.overlayRuntimeInitialized = initialized;
|
||||||
},
|
},
|
||||||
startBackgroundWarmups: () => {
|
startBackgroundWarmups: () => {
|
||||||
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
|
if (
|
||||||
|
(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) ||
|
||||||
|
appState.youtubePlaybackFlowPending
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
startBackgroundWarmups();
|
startBackgroundWarmups();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { handleCliCommand, createCliCommandDepsRuntime } from '../core/services';
|
import { handleCliCommand, createCliCommandDepsRuntime } from '../core/services';
|
||||||
import type { CliArgs, CliCommandSource } from '../cli/args';
|
import type { CliArgs, CliCommandSource } from '../cli/args';
|
||||||
|
import type { YoutubeFlowMode } from '../types';
|
||||||
import {
|
import {
|
||||||
createCliCommandRuntimeServiceDeps,
|
createCliCommandRuntimeServiceDeps,
|
||||||
CliCommandRuntimeServiceDepsParams,
|
CliCommandRuntimeServiceDepsParams,
|
||||||
@@ -38,6 +39,11 @@ export interface CliCommandRuntimeServiceContext {
|
|||||||
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
|
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
|
||||||
runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand'];
|
runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand'];
|
||||||
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
|
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
|
||||||
|
runYoutubePlaybackFlow: (request: {
|
||||||
|
url: string;
|
||||||
|
mode: YoutubeFlowMode;
|
||||||
|
source: CliCommandSource;
|
||||||
|
}) => Promise<void>;
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
cycleSecondarySubMode: () => void;
|
cycleSecondarySubMode: () => void;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
@@ -105,6 +111,11 @@ function createCliCommandDepsFromContext(
|
|||||||
runStatsCommand: context.runStatsCommand,
|
runStatsCommand: context.runStatsCommand,
|
||||||
runCommand: context.runJellyfinCommand,
|
runCommand: context.runJellyfinCommand,
|
||||||
},
|
},
|
||||||
|
app: {
|
||||||
|
stop: context.stopApp,
|
||||||
|
hasMainWindow: context.hasMainWindow,
|
||||||
|
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
|
||||||
|
},
|
||||||
ui: {
|
ui: {
|
||||||
openFirstRunSetup: context.openFirstRunSetup,
|
openFirstRunSetup: context.openFirstRunSetup,
|
||||||
openYomitanSettings: context.openYomitanSettings,
|
openYomitanSettings: context.openYomitanSettings,
|
||||||
@@ -112,10 +123,6 @@ function createCliCommandDepsFromContext(
|
|||||||
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
|
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
|
||||||
printHelp: context.printHelp,
|
printHelp: context.printHelp,
|
||||||
},
|
},
|
||||||
app: {
|
|
||||||
stop: context.stopApp,
|
|
||||||
hasMainWindow: context.hasMainWindow,
|
|
||||||
},
|
|
||||||
getMultiCopyTimeoutMs: context.getMultiCopyTimeoutMs,
|
getMultiCopyTimeoutMs: context.getMultiCopyTimeoutMs,
|
||||||
schedule: context.schedule,
|
schedule: context.schedule,
|
||||||
log: context.log,
|
log: context.log,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
|||||||
getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility'];
|
getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility'];
|
||||||
onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed'];
|
onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed'];
|
||||||
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
|
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
|
||||||
|
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
|
||||||
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
||||||
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
||||||
toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay'];
|
toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay'];
|
||||||
@@ -166,6 +167,11 @@ export interface CliCommandRuntimeServiceDepsParams {
|
|||||||
runStatsCommand: CliCommandDepsRuntimeOptions['jellyfin']['runStatsCommand'];
|
runStatsCommand: CliCommandDepsRuntimeOptions['jellyfin']['runStatsCommand'];
|
||||||
runCommand: CliCommandDepsRuntimeOptions['jellyfin']['runCommand'];
|
runCommand: CliCommandDepsRuntimeOptions['jellyfin']['runCommand'];
|
||||||
};
|
};
|
||||||
|
app: {
|
||||||
|
stop: CliCommandDepsRuntimeOptions['app']['stop'];
|
||||||
|
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
|
||||||
|
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
|
||||||
|
};
|
||||||
ui: {
|
ui: {
|
||||||
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
|
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
|
||||||
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
|
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
|
||||||
@@ -173,10 +179,6 @@ export interface CliCommandRuntimeServiceDepsParams {
|
|||||||
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
|
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
|
||||||
printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp'];
|
printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp'];
|
||||||
};
|
};
|
||||||
app: {
|
|
||||||
stop: CliCommandDepsRuntimeOptions['app']['stop'];
|
|
||||||
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
|
|
||||||
};
|
|
||||||
getMultiCopyTimeoutMs: CliCommandDepsRuntimeOptions['getMultiCopyTimeoutMs'];
|
getMultiCopyTimeoutMs: CliCommandDepsRuntimeOptions['getMultiCopyTimeoutMs'];
|
||||||
schedule: CliCommandDepsRuntimeOptions['schedule'];
|
schedule: CliCommandDepsRuntimeOptions['schedule'];
|
||||||
log: CliCommandDepsRuntimeOptions['log'];
|
log: CliCommandDepsRuntimeOptions['log'];
|
||||||
@@ -207,6 +209,7 @@ export function createMainIpcRuntimeServiceDeps(
|
|||||||
getVisibleOverlayVisibility: params.getVisibleOverlayVisibility,
|
getVisibleOverlayVisibility: params.getVisibleOverlayVisibility,
|
||||||
onOverlayModalClosed: params.onOverlayModalClosed,
|
onOverlayModalClosed: params.onOverlayModalClosed,
|
||||||
onOverlayModalOpened: params.onOverlayModalOpened,
|
onOverlayModalOpened: params.onOverlayModalOpened,
|
||||||
|
onYoutubePickerResolve: params.onYoutubePickerResolve,
|
||||||
openYomitanSettings: params.openYomitanSettings,
|
openYomitanSettings: params.openYomitanSettings,
|
||||||
quitApp: params.quitApp,
|
quitApp: params.quitApp,
|
||||||
toggleVisibleOverlay: params.toggleVisibleOverlay,
|
toggleVisibleOverlay: params.toggleVisibleOverlay,
|
||||||
@@ -324,6 +327,11 @@ export function createCliCommandRuntimeServiceDeps(
|
|||||||
runStatsCommand: params.jellyfin.runStatsCommand,
|
runStatsCommand: params.jellyfin.runStatsCommand,
|
||||||
runCommand: params.jellyfin.runCommand,
|
runCommand: params.jellyfin.runCommand,
|
||||||
},
|
},
|
||||||
|
app: {
|
||||||
|
stop: params.app.stop,
|
||||||
|
hasMainWindow: params.app.hasMainWindow,
|
||||||
|
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
|
||||||
|
},
|
||||||
ui: {
|
ui: {
|
||||||
openFirstRunSetup: params.ui.openFirstRunSetup,
|
openFirstRunSetup: params.ui.openFirstRunSetup,
|
||||||
openYomitanSettings: params.ui.openYomitanSettings,
|
openYomitanSettings: params.ui.openYomitanSettings,
|
||||||
@@ -331,10 +339,6 @@ export function createCliCommandRuntimeServiceDeps(
|
|||||||
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
|
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
|
||||||
printHelp: params.ui.printHelp,
|
printHelp: params.ui.printHelp,
|
||||||
},
|
},
|
||||||
app: {
|
|
||||||
stop: params.app.stop,
|
|
||||||
hasMainWindow: params.app.hasMainWindow,
|
|
||||||
},
|
|
||||||
getMultiCopyTimeoutMs: params.getMultiCopyTimeoutMs,
|
getMultiCopyTimeoutMs: params.getMultiCopyTimeoutMs,
|
||||||
schedule: params.schedule,
|
schedule: params.schedule,
|
||||||
log: params.log,
|
log: params.log,
|
||||||
|
|||||||
@@ -275,6 +275,82 @@ test('sendToActiveOverlayWindow prefers visible main overlay window for modal op
|
|||||||
assert.deepEqual(mainWindow.sent, [['runtime-options:open']]);
|
assert.deepEqual(mainWindow.sent, [['runtime-options:open']]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('sendToActiveOverlayWindow can prefer modal window even when main overlay is visible', () => {
|
||||||
|
const mainWindow = createMockWindow();
|
||||||
|
mainWindow.visible = true;
|
||||||
|
const modalWindow = createMockWindow();
|
||||||
|
const runtime = createOverlayModalRuntimeService({
|
||||||
|
getMainWindow: () => mainWindow as never,
|
||||||
|
getModalWindow: () => modalWindow as never,
|
||||||
|
createModalWindow: () => modalWindow as never,
|
||||||
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
|
setModalWindowBounds: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sent = runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, {
|
||||||
|
restoreOnModalClose: 'youtube-track-picker',
|
||||||
|
preferModalWindow: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(sent, true);
|
||||||
|
assert.deepEqual(mainWindow.sent, []);
|
||||||
|
assert.deepEqual(modalWindow.sent, [['youtube:picker-open', { sessionId: 'yt-1' }]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modal window path makes visible main overlay click-through until modal closes', () => {
|
||||||
|
const mainWindow = createMockWindow();
|
||||||
|
mainWindow.visible = true;
|
||||||
|
const modalWindow = createMockWindow();
|
||||||
|
const runtime = createOverlayModalRuntimeService({
|
||||||
|
getMainWindow: () => mainWindow as never,
|
||||||
|
getModalWindow: () => modalWindow as never,
|
||||||
|
createModalWindow: () => modalWindow as never,
|
||||||
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
|
setModalWindowBounds: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sent = runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, {
|
||||||
|
restoreOnModalClose: 'youtube-track-picker',
|
||||||
|
preferModalWindow: true,
|
||||||
|
});
|
||||||
|
runtime.notifyOverlayModalOpened('youtube-track-picker');
|
||||||
|
|
||||||
|
assert.equal(sent, true);
|
||||||
|
assert.equal(mainWindow.ignoreMouseEvents, true);
|
||||||
|
assert.equal(modalWindow.ignoreMouseEvents, false);
|
||||||
|
|
||||||
|
runtime.handleOverlayModalClosed('youtube-track-picker');
|
||||||
|
|
||||||
|
assert.equal(mainWindow.ignoreMouseEvents, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modal window path hides visible main overlay until modal closes', () => {
|
||||||
|
const mainWindow = createMockWindow();
|
||||||
|
mainWindow.visible = true;
|
||||||
|
const modalWindow = createMockWindow();
|
||||||
|
const runtime = createOverlayModalRuntimeService({
|
||||||
|
getMainWindow: () => mainWindow as never,
|
||||||
|
getModalWindow: () => modalWindow as never,
|
||||||
|
createModalWindow: () => modalWindow as never,
|
||||||
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
|
setModalWindowBounds: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, {
|
||||||
|
restoreOnModalClose: 'youtube-track-picker',
|
||||||
|
preferModalWindow: true,
|
||||||
|
});
|
||||||
|
runtime.notifyOverlayModalOpened('youtube-track-picker');
|
||||||
|
|
||||||
|
assert.equal(mainWindow.getHideCount(), 1);
|
||||||
|
assert.equal(mainWindow.isVisible(), false);
|
||||||
|
|
||||||
|
runtime.handleOverlayModalClosed('youtube-track-picker');
|
||||||
|
|
||||||
|
assert.equal(mainWindow.getShowCount(), 1);
|
||||||
|
assert.equal(mainWindow.isVisible(), true);
|
||||||
|
});
|
||||||
|
|
||||||
test('modal runtime notifies callers when modal input state becomes active/inactive', () => {
|
test('modal runtime notifies callers when modal input state becomes active/inactive', () => {
|
||||||
const window = createMockWindow();
|
const window = createMockWindow();
|
||||||
const state: boolean[] = [];
|
const state: boolean[] = [];
|
||||||
@@ -430,3 +506,33 @@ test('modal fallback reveal keeps mouse events ignored until modal confirms open
|
|||||||
runtime.notifyOverlayModalOpened('jimaku');
|
runtime.notifyOverlayModalOpened('jimaku');
|
||||||
assert.equal(window.ignoreMouseEvents, false);
|
assert.equal(window.ignoreMouseEvents, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('waitForModalOpen resolves true after modal acknowledgement', async () => {
|
||||||
|
const runtime = createOverlayModalRuntimeService({
|
||||||
|
getMainWindow: () => null,
|
||||||
|
getModalWindow: () => null,
|
||||||
|
createModalWindow: () => null,
|
||||||
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
|
setModalWindowBounds: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, {
|
||||||
|
restoreOnModalClose: 'youtube-track-picker',
|
||||||
|
});
|
||||||
|
const pending = runtime.waitForModalOpen('youtube-track-picker', 1000);
|
||||||
|
runtime.notifyOverlayModalOpened('youtube-track-picker');
|
||||||
|
|
||||||
|
assert.equal(await pending, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('waitForModalOpen resolves false on timeout', async () => {
|
||||||
|
const runtime = createOverlayModalRuntimeService({
|
||||||
|
getMainWindow: () => null,
|
||||||
|
getModalWindow: () => null,
|
||||||
|
createModalWindow: () => null,
|
||||||
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
|
setModalWindowBounds: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(await runtime.waitForModalOpen('youtube-track-picker', 5), false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,11 +16,15 @@ export interface OverlayModalRuntime {
|
|||||||
sendToActiveOverlayWindow: (
|
sendToActiveOverlayWindow: (
|
||||||
channel: string,
|
channel: string,
|
||||||
payload?: unknown,
|
payload?: unknown,
|
||||||
runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal },
|
runtimeOptions?: {
|
||||||
|
restoreOnModalClose?: OverlayHostedModal;
|
||||||
|
preferModalWindow?: boolean;
|
||||||
|
},
|
||||||
) => boolean;
|
) => boolean;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||||
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
|
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
|
||||||
|
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||||
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +37,10 @@ export function createOverlayModalRuntimeService(
|
|||||||
options: OverlayModalRuntimeOptions = {},
|
options: OverlayModalRuntimeOptions = {},
|
||||||
): OverlayModalRuntime {
|
): OverlayModalRuntime {
|
||||||
const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>();
|
const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>();
|
||||||
|
const modalOpenWaiters = new Map<OverlayHostedModal, Array<(opened: boolean) => void>>();
|
||||||
let modalActive = false;
|
let modalActive = false;
|
||||||
|
let mainWindowMousePassthroughForcedByModal = false;
|
||||||
|
let mainWindowHiddenByModal = false;
|
||||||
let pendingModalWindowReveal: BrowserWindow | null = null;
|
let pendingModalWindowReveal: BrowserWindow | null = null;
|
||||||
let pendingModalWindowRevealTimeout: ReturnType<typeof setTimeout> | null = null;
|
let pendingModalWindowRevealTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
@@ -163,6 +170,54 @@ export function createOverlayModalRuntimeService(
|
|||||||
pendingModalWindowReveal = null;
|
pendingModalWindowReveal = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setMainWindowMousePassthroughForModal = (enabled: boolean): void => {
|
||||||
|
const mainWindow = deps.getMainWindow();
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
mainWindowMousePassthroughForcedByModal = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
if (!mainWindow.isVisible()) {
|
||||||
|
mainWindowMousePassthroughForcedByModal = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||||
|
mainWindowMousePassthroughForcedByModal = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mainWindowMousePassthroughForcedByModal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mainWindow.setIgnoreMouseEvents(false);
|
||||||
|
mainWindowMousePassthroughForcedByModal = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setMainWindowVisibilityForModal = (hidden: boolean): void => {
|
||||||
|
const mainWindow = deps.getMainWindow();
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
mainWindowHiddenByModal = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hidden) {
|
||||||
|
if (!mainWindow.isVisible()) {
|
||||||
|
mainWindowHiddenByModal = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mainWindow.hide();
|
||||||
|
mainWindowHiddenByModal = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mainWindowHiddenByModal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mainWindow.show();
|
||||||
|
mainWindowHiddenByModal = false;
|
||||||
|
};
|
||||||
|
|
||||||
const scheduleModalWindowReveal = (window: BrowserWindow): void => {
|
const scheduleModalWindowReveal = (window: BrowserWindow): void => {
|
||||||
pendingModalWindowReveal = window;
|
pendingModalWindowReveal = window;
|
||||||
if (pendingModalWindowRevealTimeout !== null) {
|
if (pendingModalWindowRevealTimeout !== null) {
|
||||||
@@ -182,9 +237,13 @@ export function createOverlayModalRuntimeService(
|
|||||||
const sendToActiveOverlayWindow = (
|
const sendToActiveOverlayWindow = (
|
||||||
channel: string,
|
channel: string,
|
||||||
payload?: unknown,
|
payload?: unknown,
|
||||||
runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal },
|
runtimeOptions?: {
|
||||||
|
restoreOnModalClose?: OverlayHostedModal;
|
||||||
|
preferModalWindow?: boolean;
|
||||||
|
},
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const restoreOnModalClose = runtimeOptions?.restoreOnModalClose;
|
const restoreOnModalClose = runtimeOptions?.restoreOnModalClose;
|
||||||
|
const preferModalWindow = runtimeOptions?.preferModalWindow === true;
|
||||||
|
|
||||||
const sendNow = (window: BrowserWindow): void => {
|
const sendNow = (window: BrowserWindow): void => {
|
||||||
ensureModalWindowInteractive(window);
|
ensureModalWindowInteractive(window);
|
||||||
@@ -198,7 +257,7 @@ export function createOverlayModalRuntimeService(
|
|||||||
if (restoreOnModalClose) {
|
if (restoreOnModalClose) {
|
||||||
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
|
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
|
||||||
const mainWindow = getTargetOverlayWindow();
|
const mainWindow = getTargetOverlayWindow();
|
||||||
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) {
|
if (!preferModalWindow && mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) {
|
||||||
sendOrQueueForWindow(mainWindow, (window) => {
|
sendOrQueueForWindow(mainWindow, (window) => {
|
||||||
if (payload === undefined) {
|
if (payload === undefined) {
|
||||||
window.webContents.send(channel);
|
window.webContents.send(channel);
|
||||||
@@ -255,6 +314,8 @@ export function createOverlayModalRuntimeService(
|
|||||||
if (restoreVisibleOverlayOnModalClose.size === 0) {
|
if (restoreVisibleOverlayOnModalClose.size === 0) {
|
||||||
clearPendingModalWindowReveal();
|
clearPendingModalWindowReveal();
|
||||||
notifyModalStateChange(false);
|
notifyModalStateChange(false);
|
||||||
|
setMainWindowMousePassthroughForModal(false);
|
||||||
|
setMainWindowVisibilityForModal(false);
|
||||||
if (modalWindow && !modalWindow.isDestroyed()) {
|
if (modalWindow && !modalWindow.isDestroyed()) {
|
||||||
modalWindow.hide();
|
modalWindow.hide();
|
||||||
}
|
}
|
||||||
@@ -263,6 +324,11 @@ export function createOverlayModalRuntimeService(
|
|||||||
|
|
||||||
const notifyOverlayModalOpened = (modal: OverlayHostedModal): void => {
|
const notifyOverlayModalOpened = (modal: OverlayHostedModal): void => {
|
||||||
if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
|
if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
|
||||||
|
const waiters = modalOpenWaiters.get(modal) ?? [];
|
||||||
|
modalOpenWaiters.delete(modal);
|
||||||
|
for (const resolve of waiters) {
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
notifyModalStateChange(true);
|
notifyModalStateChange(true);
|
||||||
const targetWindow = getActiveOverlayWindowForModalInput();
|
const targetWindow = getActiveOverlayWindowForModalInput();
|
||||||
clearPendingModalWindowReveal();
|
clearPendingModalWindowReveal();
|
||||||
@@ -270,6 +336,12 @@ export function createOverlayModalRuntimeService(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modalWindow = deps.getModalWindow();
|
||||||
|
if (modalWindow && !modalWindow.isDestroyed() && targetWindow === modalWindow) {
|
||||||
|
setMainWindowMousePassthroughForModal(true);
|
||||||
|
setMainWindowVisibilityForModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
if (targetWindow.isVisible()) {
|
if (targetWindow.isVisible()) {
|
||||||
targetWindow.setIgnoreMouseEvents(false);
|
targetWindow.setIgnoreMouseEvents(false);
|
||||||
elevateModalWindow(targetWindow);
|
elevateModalWindow(targetWindow);
|
||||||
@@ -285,11 +357,34 @@ export function createOverlayModalRuntimeService(
|
|||||||
showModalWindow(targetWindow);
|
showModalWindow(targetWindow);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const waitForModalOpen = async (
|
||||||
|
modal: OverlayHostedModal,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<boolean> =>
|
||||||
|
await new Promise<boolean>((resolve) => {
|
||||||
|
const waiters = modalOpenWaiters.get(modal) ?? [];
|
||||||
|
const finish = (opened: boolean): void => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(opened);
|
||||||
|
};
|
||||||
|
waiters.push(finish);
|
||||||
|
modalOpenWaiters.set(modal, waiters);
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
const current = modalOpenWaiters.get(modal) ?? [];
|
||||||
|
modalOpenWaiters.set(
|
||||||
|
modal,
|
||||||
|
current.filter((candidate) => candidate !== finish),
|
||||||
|
);
|
||||||
|
resolve(false);
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sendToActiveOverlayWindow,
|
sendToActiveOverlayWindow,
|
||||||
openRuntimeOptionsPalette,
|
openRuntimeOptionsPalette,
|
||||||
handleOverlayModalClosed,
|
handleOverlayModalClosed,
|
||||||
notifyOverlayModalOpened,
|
notifyOverlayModalOpened,
|
||||||
|
waitForModalOpen,
|
||||||
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
|
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ test('build cli command context deps maps handlers and values', () => {
|
|||||||
runJellyfinCommand: async () => {
|
runJellyfinCommand: async () => {
|
||||||
calls.push('run-jellyfin');
|
calls.push('run-jellyfin');
|
||||||
},
|
},
|
||||||
|
runYoutubePlaybackFlow: async () => {
|
||||||
|
calls.push('run-youtube');
|
||||||
|
},
|
||||||
openYomitanSettings: () => calls.push('yomitan'),
|
openYomitanSettings: () => calls.push('yomitan'),
|
||||||
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
||||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
|||||||
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
|
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
|
||||||
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
|
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
|
||||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||||
|
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
cycleSecondarySubMode: () => void;
|
cycleSecondarySubMode: () => void;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
@@ -83,6 +84,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
|||||||
generateCharacterDictionary: deps.generateCharacterDictionary,
|
generateCharacterDictionary: deps.generateCharacterDictionary,
|
||||||
runStatsCommand: deps.runStatsCommand,
|
runStatsCommand: deps.runStatsCommand,
|
||||||
runJellyfinCommand: deps.runJellyfinCommand,
|
runJellyfinCommand: deps.runJellyfinCommand,
|
||||||
|
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||||
openYomitanSettings: deps.openYomitanSettings,
|
openYomitanSettings: deps.openYomitanSettings,
|
||||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
|||||||
mpvClient: null,
|
mpvClient: null,
|
||||||
texthookerPort: 5174,
|
texthookerPort: 5174,
|
||||||
overlayRuntimeInitialized: false,
|
overlayRuntimeInitialized: false,
|
||||||
|
youtubePlaybackFlowPending: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const createContext = createCliCommandContextFactory({
|
const createContext = createCliCommandContextFactory({
|
||||||
@@ -63,6 +64,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
|||||||
}),
|
}),
|
||||||
runStatsCommand: async () => {},
|
runStatsCommand: async () => {},
|
||||||
runJellyfinCommand: async () => {},
|
runJellyfinCommand: async () => {},
|
||||||
|
runYoutubePlaybackFlow: async () => {},
|
||||||
openYomitanSettings: () => {},
|
openYomitanSettings: () => {},
|
||||||
cycleSecondarySubMode: () => {},
|
cycleSecondarySubMode: () => {},
|
||||||
openRuntimeOptionsPalette: () => {},
|
openRuntimeOptionsPalette: () => {},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
|||||||
mpvClient: null,
|
mpvClient: null,
|
||||||
texthookerPort: 5174,
|
texthookerPort: 5174,
|
||||||
overlayRuntimeInitialized: false,
|
overlayRuntimeInitialized: false,
|
||||||
|
youtubePlaybackFlowPending: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const build = createBuildCliCommandContextMainDepsHandler({
|
const build = createBuildCliCommandContextMainDepsHandler({
|
||||||
@@ -84,6 +85,9 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
|||||||
runJellyfinCommand: async () => {
|
runJellyfinCommand: async () => {
|
||||||
calls.push('run-jellyfin');
|
calls.push('run-jellyfin');
|
||||||
},
|
},
|
||||||
|
runYoutubePlaybackFlow: async () => {
|
||||||
|
calls.push('run-youtube');
|
||||||
|
},
|
||||||
|
|
||||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||||
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CliArgs } from '../../cli/args';
|
import type { CliArgs } from '../../cli/args';
|
||||||
|
import type { YoutubeFlowMode } from '../../types';
|
||||||
import type { CliCommandContextFactoryDeps } from './cli-command-context';
|
import type { CliCommandContextFactoryDeps } from './cli-command-context';
|
||||||
|
|
||||||
type CliCommandContextMainState = {
|
type CliCommandContextMainState = {
|
||||||
@@ -41,6 +42,11 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
|||||||
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
|
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
|
||||||
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
|
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
|
||||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||||
|
runYoutubePlaybackFlow: (request: {
|
||||||
|
url: string;
|
||||||
|
mode: YoutubeFlowMode;
|
||||||
|
source: 'initial' | 'second-instance';
|
||||||
|
}) => Promise<void>;
|
||||||
|
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
cycleSecondarySubMode: () => void;
|
cycleSecondarySubMode: () => void;
|
||||||
@@ -95,6 +101,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
|||||||
deps.generateCharacterDictionary(targetPath),
|
deps.generateCharacterDictionary(targetPath),
|
||||||
runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source),
|
runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source),
|
||||||
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
|
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
|
||||||
|
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
|
||||||
openYomitanSettings: () => deps.openYomitanSettings(),
|
openYomitanSettings: () => deps.openYomitanSettings(),
|
||||||
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
||||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ function createDeps() {
|
|||||||
}),
|
}),
|
||||||
runStatsCommand: async () => {},
|
runStatsCommand: async () => {},
|
||||||
runJellyfinCommand: async () => {},
|
runJellyfinCommand: async () => {},
|
||||||
|
runYoutubePlaybackFlow: async () => {},
|
||||||
openYomitanSettings: () => {},
|
openYomitanSettings: () => {},
|
||||||
cycleSecondarySubMode: () => {},
|
cycleSecondarySubMode: () => {},
|
||||||
openRuntimeOptionsPalette: () => {},
|
openRuntimeOptionsPalette: () => {},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CliArgs } from '../../cli/args';
|
import type { CliArgs } from '../../cli/args';
|
||||||
|
import type { YoutubeFlowMode } from '../../types';
|
||||||
import type {
|
import type {
|
||||||
CliCommandRuntimeServiceContext,
|
CliCommandRuntimeServiceContext,
|
||||||
CliCommandRuntimeServiceContextHandlers,
|
CliCommandRuntimeServiceContextHandlers,
|
||||||
@@ -41,6 +42,11 @@ export type CliCommandContextFactoryDeps = {
|
|||||||
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
|
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
|
||||||
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
|
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
|
||||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||||
|
runYoutubePlaybackFlow: (request: {
|
||||||
|
url: string;
|
||||||
|
mode: YoutubeFlowMode;
|
||||||
|
source: 'initial' | 'second-instance';
|
||||||
|
}) => Promise<void>;
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
cycleSecondarySubMode: () => void;
|
cycleSecondarySubMode: () => void;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
@@ -95,6 +101,7 @@ export function createCliCommandContext(
|
|||||||
generateCharacterDictionary: deps.generateCharacterDictionary,
|
generateCharacterDictionary: deps.generateCharacterDictionary,
|
||||||
runStatsCommand: deps.runStatsCommand,
|
runStatsCommand: deps.runStatsCommand,
|
||||||
runJellyfinCommand: deps.runJellyfinCommand,
|
runJellyfinCommand: deps.runJellyfinCommand,
|
||||||
|
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||||
openYomitanSettings: deps.openYomitanSettings,
|
openYomitanSettings: deps.openYomitanSettings,
|
||||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
|||||||
getAnilistQueueStatus: () => ({}) as never,
|
getAnilistQueueStatus: () => ({}) as never,
|
||||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||||
|
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||||
},
|
},
|
||||||
ankiJimakuDeps: {
|
ankiJimakuDeps: {
|
||||||
patchAnkiConnectEnabled: () => {},
|
patchAnkiConnectEnabled: () => {},
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
|
|||||||
currentSubAssText: '',
|
currentSubAssText: '',
|
||||||
playbackPaused: null,
|
playbackPaused: null,
|
||||||
previousSecondarySubVisibility: null,
|
previousSecondarySubVisibility: null,
|
||||||
|
youtubePlaybackFlowPending: false,
|
||||||
},
|
},
|
||||||
getQuitOnDisconnectArmed: () => false,
|
getQuitOnDisconnectArmed: () => false,
|
||||||
scheduleQuitCheck: () => {},
|
scheduleQuitCheck: () => {},
|
||||||
@@ -280,6 +281,7 @@ test('composeMpvRuntimeHandlers skips MeCab warmup when all POS-dependent annota
|
|||||||
currentSubAssText: '',
|
currentSubAssText: '',
|
||||||
playbackPaused: null,
|
playbackPaused: null,
|
||||||
previousSecondarySubVisibility: null,
|
previousSecondarySubVisibility: null,
|
||||||
|
youtubePlaybackFlowPending: false,
|
||||||
},
|
},
|
||||||
getQuitOnDisconnectArmed: () => false,
|
getQuitOnDisconnectArmed: () => false,
|
||||||
scheduleQuitCheck: () => {},
|
scheduleQuitCheck: () => {},
|
||||||
@@ -411,6 +413,7 @@ test('composeMpvRuntimeHandlers runs tokenization warmup once across sequential
|
|||||||
currentSubAssText: '',
|
currentSubAssText: '',
|
||||||
playbackPaused: null,
|
playbackPaused: null,
|
||||||
previousSecondarySubVisibility: null,
|
previousSecondarySubVisibility: null,
|
||||||
|
youtubePlaybackFlowPending: false,
|
||||||
},
|
},
|
||||||
getQuitOnDisconnectArmed: () => false,
|
getQuitOnDisconnectArmed: () => false,
|
||||||
scheduleQuitCheck: () => {},
|
scheduleQuitCheck: () => {},
|
||||||
@@ -550,6 +553,7 @@ test('composeMpvRuntimeHandlers does not block first tokenization on dictionary
|
|||||||
currentSubAssText: '',
|
currentSubAssText: '',
|
||||||
playbackPaused: null,
|
playbackPaused: null,
|
||||||
previousSecondarySubVisibility: null,
|
previousSecondarySubVisibility: null,
|
||||||
|
youtubePlaybackFlowPending: false,
|
||||||
},
|
},
|
||||||
getQuitOnDisconnectArmed: () => false,
|
getQuitOnDisconnectArmed: () => false,
|
||||||
scheduleQuitCheck: () => {},
|
scheduleQuitCheck: () => {},
|
||||||
@@ -683,6 +687,7 @@ test('composeMpvRuntimeHandlers shows annotation loading OSD after tokenization-
|
|||||||
currentSubAssText: '',
|
currentSubAssText: '',
|
||||||
playbackPaused: null,
|
playbackPaused: null,
|
||||||
previousSecondarySubVisibility: null,
|
previousSecondarySubVisibility: null,
|
||||||
|
youtubePlaybackFlowPending: false,
|
||||||
},
|
},
|
||||||
getQuitOnDisconnectArmed: () => false,
|
getQuitOnDisconnectArmed: () => false,
|
||||||
scheduleQuitCheck: () => {},
|
scheduleQuitCheck: () => {},
|
||||||
@@ -830,6 +835,7 @@ test('composeMpvRuntimeHandlers reuses completed background tokenization warmups
|
|||||||
currentSubAssText: '',
|
currentSubAssText: '',
|
||||||
playbackPaused: null,
|
playbackPaused: null,
|
||||||
previousSecondarySubVisibility: null,
|
previousSecondarySubVisibility: null,
|
||||||
|
youtubePlaybackFlowPending: false,
|
||||||
},
|
},
|
||||||
getQuitOnDisconnectArmed: () => false,
|
getQuitOnDisconnectArmed: () => false,
|
||||||
scheduleQuitCheck: () => {},
|
scheduleQuitCheck: () => {},
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
|||||||
currentSubAssText: '',
|
currentSubAssText: '',
|
||||||
playbackPaused: null,
|
playbackPaused: null,
|
||||||
previousSecondarySubVisibility: false,
|
previousSecondarySubVisibility: false,
|
||||||
|
youtubePlaybackFlowPending: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({
|
const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
currentSubtitleData?: SubtitleData | null;
|
currentSubtitleData?: SubtitleData | null;
|
||||||
playbackPaused: boolean | null;
|
playbackPaused: boolean | null;
|
||||||
previousSecondarySubVisibility: boolean | null;
|
previousSecondarySubVisibility: boolean | null;
|
||||||
|
youtubePlaybackFlowPending: boolean;
|
||||||
};
|
};
|
||||||
getQuitOnDisconnectArmed: () => boolean;
|
getQuitOnDisconnectArmed: () => boolean;
|
||||||
scheduleQuitCheck: (callback: () => void) => void;
|
scheduleQuitCheck: (callback: () => void) => void;
|
||||||
|
|||||||
@@ -33,13 +33,17 @@ export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWindowsMpvLaunchArgs(targets: string[]): string[] {
|
export function buildWindowsMpvLaunchArgs(
|
||||||
return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...targets];
|
targets: string[],
|
||||||
|
extraArgs: string[] = [],
|
||||||
|
): string[] {
|
||||||
|
return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...extraArgs, ...targets];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function launchWindowsMpv(
|
export function launchWindowsMpv(
|
||||||
targets: string[],
|
targets: string[],
|
||||||
deps: WindowsMpvLaunchDeps,
|
deps: WindowsMpvLaunchDeps,
|
||||||
|
extraArgs: string[] = [],
|
||||||
): { ok: boolean; mpvPath: string } {
|
): { ok: boolean; mpvPath: string } {
|
||||||
const mpvPath = resolveWindowsMpvPath(deps);
|
const mpvPath = resolveWindowsMpvPath(deps);
|
||||||
if (!mpvPath) {
|
if (!mpvPath) {
|
||||||
@@ -51,7 +55,7 @@ export function launchWindowsMpv(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets));
|
deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets, extraArgs));
|
||||||
return { ok: true, mpvPath };
|
return { ok: true, mpvPath };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
574
src/main/runtime/youtube-flow.test.ts
Normal file
574
src/main/runtime/youtube-flow.test.ts
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createYoutubeFlowRuntime } from './youtube-flow';
|
||||||
|
import type { YoutubePickerOpenPayload, YoutubeTrackOption } from '../../types';
|
||||||
|
|
||||||
|
const primaryTrack: YoutubeTrackOption = {
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondaryTrack: YoutubeTrackOption = {
|
||||||
|
id: 'manual:en',
|
||||||
|
language: 'en',
|
||||||
|
sourceLanguage: 'en',
|
||||||
|
kind: 'manual',
|
||||||
|
label: 'English (manual)',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('youtube flow clears internal tracks and binds external primary+secondary subtitles', async () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
const osdMessages: string[] = [];
|
||||||
|
const order: string[] = [];
|
||||||
|
const refreshedSubtitles: string[] = [];
|
||||||
|
const waits: number[] = [];
|
||||||
|
const focusOverlayCalls: string[] = [];
|
||||||
|
let pickerPayload: YoutubePickerOpenPayload | null = null;
|
||||||
|
let trackListRequests = 0;
|
||||||
|
|
||||||
|
const runtime = createYoutubeFlowRuntime({
|
||||||
|
probeYoutubeTracks: async () => ({
|
||||||
|
videoId: 'video123',
|
||||||
|
title: 'Video 123',
|
||||||
|
tracks: [primaryTrack, secondaryTrack],
|
||||||
|
}),
|
||||||
|
acquireYoutubeSubtitleTracks: async ({ tracks }) => {
|
||||||
|
assert.deepEqual(
|
||||||
|
tracks.map((track) => track.id),
|
||||||
|
[primaryTrack.id, secondaryTrack.id],
|
||||||
|
);
|
||||||
|
return new Map<string, string>([
|
||||||
|
[primaryTrack.id, '/tmp/auto-ja-orig.vtt'],
|
||||||
|
[secondaryTrack.id, '/tmp/manual-en.vtt'],
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||||
|
if (track.id === primaryTrack.id) {
|
||||||
|
return { path: '/tmp/auto-ja-orig.vtt' };
|
||||||
|
}
|
||||||
|
return { path: '/tmp/manual-en.vtt' };
|
||||||
|
},
|
||||||
|
retimeYoutubePrimaryTrack: async ({ primaryPath, secondaryPath }) => {
|
||||||
|
assert.equal(primaryPath, '/tmp/auto-ja-orig.vtt');
|
||||||
|
assert.equal(secondaryPath, '/tmp/manual-en.vtt');
|
||||||
|
return '/tmp/auto-ja-orig_retimed.vtt';
|
||||||
|
},
|
||||||
|
startTokenizationWarmups: async () => {
|
||||||
|
order.push('start-tokenization-warmups');
|
||||||
|
},
|
||||||
|
waitForTokenizationReady: async () => {
|
||||||
|
order.push('wait-tokenization-ready');
|
||||||
|
},
|
||||||
|
waitForAnkiReady: async () => {
|
||||||
|
order.push('wait-anki-ready');
|
||||||
|
},
|
||||||
|
waitForPlaybackWindowReady: async () => {
|
||||||
|
order.push('wait-window-ready');
|
||||||
|
},
|
||||||
|
waitForOverlayGeometryReady: async () => {
|
||||||
|
order.push('wait-overlay-geometry');
|
||||||
|
},
|
||||||
|
focusOverlayWindow: () => {
|
||||||
|
focusOverlayCalls.push('focus-overlay');
|
||||||
|
},
|
||||||
|
openPicker: async (payload) => {
|
||||||
|
assert.deepEqual(waits, [150]);
|
||||||
|
order.push('open-picker');
|
||||||
|
pickerPayload = payload;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void runtime.resolveActivePicker({
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
action: 'use-selected',
|
||||||
|
primaryTrackId: primaryTrack.id,
|
||||||
|
secondaryTrackId: secondaryTrack.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
pauseMpv: () => {
|
||||||
|
commands.push(['set_property', 'pause', 'yes']);
|
||||||
|
},
|
||||||
|
resumeMpv: () => {
|
||||||
|
commands.push(['set_property', 'pause', 'no']);
|
||||||
|
},
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
commands.push(command);
|
||||||
|
},
|
||||||
|
requestMpvProperty: async (name) => {
|
||||||
|
if (name === 'sub-text') {
|
||||||
|
return '字幕です';
|
||||||
|
}
|
||||||
|
assert.equal(name, 'track-list');
|
||||||
|
trackListRequests += 1;
|
||||||
|
if (trackListRequests === 1) {
|
||||||
|
return [{ type: 'sub', id: 1, lang: 'ja', external: false, title: 'internal' }];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 5,
|
||||||
|
lang: 'ja-orig',
|
||||||
|
title: 'primary',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/auto-ja-orig_retimed.vtt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 6,
|
||||||
|
lang: 'en',
|
||||||
|
title: 'secondary',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/manual-en.vtt',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
refreshCurrentSubtitle: (text) => {
|
||||||
|
refreshedSubtitles.push(text);
|
||||||
|
},
|
||||||
|
wait: async (ms) => {
|
||||||
|
waits.push(ms);
|
||||||
|
},
|
||||||
|
showMpvOsd: (text) => {
|
||||||
|
osdMessages.push(text);
|
||||||
|
},
|
||||||
|
warn: (message) => {
|
||||||
|
throw new Error(message);
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
getYoutubeOutputDir: () => '/tmp',
|
||||||
|
});
|
||||||
|
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
|
||||||
|
|
||||||
|
assert.ok(pickerPayload);
|
||||||
|
assert.deepEqual(order, [
|
||||||
|
'start-tokenization-warmups',
|
||||||
|
'wait-window-ready',
|
||||||
|
'wait-overlay-geometry',
|
||||||
|
'open-picker',
|
||||||
|
'wait-tokenization-ready',
|
||||||
|
'wait-anki-ready',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(osdMessages, [
|
||||||
|
'Opening YouTube video',
|
||||||
|
'Getting subtitles...',
|
||||||
|
'Downloading subtitles...',
|
||||||
|
'Loading subtitles...',
|
||||||
|
'Primary and secondary subtitles loaded.',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(commands, [
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
['set_property', 'sub-delay', 0],
|
||||||
|
['set_property', 'sid', 'no'],
|
||||||
|
['set_property', 'secondary-sid', 'no'],
|
||||||
|
['sub-add', '/tmp/auto-ja-orig_retimed.vtt', 'select', 'auto-ja-orig_retimed.vtt', 'ja-orig'],
|
||||||
|
['sub-add', '/tmp/manual-en.vtt', 'cached', 'manual-en.vtt', 'en'],
|
||||||
|
['set_property', 'sid', 5],
|
||||||
|
['set_property', 'secondary-sid', 6],
|
||||||
|
['script-message', 'subminer-autoplay-ready'],
|
||||||
|
['set_property', 'pause', 'no'],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(focusOverlayCalls, ['focus-overlay']);
|
||||||
|
assert.deepEqual(refreshedSubtitles, ['字幕です']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('youtube flow can cancel active picker session', async () => {
|
||||||
|
const focusOverlayCalls: string[] = [];
|
||||||
|
const runtime = createYoutubeFlowRuntime({
|
||||||
|
probeYoutubeTracks: async () => ({
|
||||||
|
videoId: 'video123',
|
||||||
|
title: 'Video 123',
|
||||||
|
tracks: [primaryTrack],
|
||||||
|
}),
|
||||||
|
acquireYoutubeSubtitleTracks: async () => {
|
||||||
|
throw new Error('should not batch download after cancel');
|
||||||
|
},
|
||||||
|
acquireYoutubeSubtitleTrack: async () => {
|
||||||
|
throw new Error('should not download after cancel');
|
||||||
|
},
|
||||||
|
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
|
||||||
|
startTokenizationWarmups: async () => {},
|
||||||
|
waitForTokenizationReady: async () => {},
|
||||||
|
waitForAnkiReady: async () => {},
|
||||||
|
waitForPlaybackWindowReady: async () => {},
|
||||||
|
waitForOverlayGeometryReady: async () => {},
|
||||||
|
focusOverlayWindow: () => {
|
||||||
|
focusOverlayCalls.push('focus-overlay');
|
||||||
|
},
|
||||||
|
openPicker: async (payload) => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
assert.equal(runtime.cancelActivePicker(), true);
|
||||||
|
void runtime.resolveActivePicker({
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
action: 'use-selected',
|
||||||
|
primaryTrackId: primaryTrack.id,
|
||||||
|
secondaryTrackId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
pauseMpv: () => {},
|
||||||
|
resumeMpv: () => {},
|
||||||
|
sendMpvCommand: () => {},
|
||||||
|
requestMpvProperty: async () => null,
|
||||||
|
refreshCurrentSubtitle: () => {},
|
||||||
|
wait: async () => {},
|
||||||
|
showMpvOsd: () => {},
|
||||||
|
warn: () => {},
|
||||||
|
log: () => {},
|
||||||
|
getYoutubeOutputDir: () => '/tmp',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
|
||||||
|
assert.equal(runtime.hasActiveSession(), false);
|
||||||
|
assert.equal(runtime.cancelActivePicker(), false);
|
||||||
|
assert.deepEqual(focusOverlayCalls, ['focus-overlay']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('youtube flow retries secondary after partial batch subtitle failure', async () => {
|
||||||
|
const acquireSingleCalls: string[] = [];
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
const focusOverlayCalls: string[] = [];
|
||||||
|
const refreshedSubtitles: string[] = [];
|
||||||
|
const warns: string[] = [];
|
||||||
|
const waits: number[] = [];
|
||||||
|
let trackListRequests = 0;
|
||||||
|
|
||||||
|
const runtime = createYoutubeFlowRuntime({
|
||||||
|
probeYoutubeTracks: async () => ({
|
||||||
|
videoId: 'video123',
|
||||||
|
title: 'Video 123',
|
||||||
|
tracks: [primaryTrack, secondaryTrack],
|
||||||
|
}),
|
||||||
|
acquireYoutubeSubtitleTracks: async () => {
|
||||||
|
return new Map<string, string>([[primaryTrack.id, '/tmp/auto-ja-orig.vtt']]);
|
||||||
|
},
|
||||||
|
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||||
|
acquireSingleCalls.push(track.id);
|
||||||
|
return { path: `/tmp/${track.id}.vtt` };
|
||||||
|
},
|
||||||
|
retimeYoutubePrimaryTrack: async ({ primaryPath, secondaryPath }) => {
|
||||||
|
assert.equal(primaryPath, '/tmp/auto-ja-orig.vtt');
|
||||||
|
assert.equal(secondaryPath, '/tmp/manual:en.vtt');
|
||||||
|
return primaryPath;
|
||||||
|
},
|
||||||
|
startTokenizationWarmups: async () => {},
|
||||||
|
waitForTokenizationReady: async () => {},
|
||||||
|
waitForAnkiReady: async () => {},
|
||||||
|
waitForPlaybackWindowReady: async () => {},
|
||||||
|
waitForOverlayGeometryReady: async () => {},
|
||||||
|
focusOverlayWindow: () => {
|
||||||
|
focusOverlayCalls.push('focus-overlay');
|
||||||
|
},
|
||||||
|
openPicker: async (payload) => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void runtime.resolveActivePicker({
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
action: 'use-selected',
|
||||||
|
primaryTrackId: primaryTrack.id,
|
||||||
|
secondaryTrackId: secondaryTrack.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
pauseMpv: () => {},
|
||||||
|
resumeMpv: () => {},
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
commands.push(command);
|
||||||
|
},
|
||||||
|
requestMpvProperty: async (name) => {
|
||||||
|
if (name === 'sub-text') {
|
||||||
|
return '字幕です';
|
||||||
|
}
|
||||||
|
assert.equal(name, 'track-list');
|
||||||
|
trackListRequests += 1;
|
||||||
|
if (trackListRequests === 1) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 5,
|
||||||
|
lang: 'ja-orig',
|
||||||
|
title: 'primary',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/auto-ja-orig.vtt',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 5,
|
||||||
|
lang: 'ja-orig',
|
||||||
|
title: 'primary',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/auto-ja-orig.vtt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 6,
|
||||||
|
lang: 'en',
|
||||||
|
title: 'secondary',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/manual:en.vtt',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
refreshCurrentSubtitle: (text) => {
|
||||||
|
refreshedSubtitles.push(text);
|
||||||
|
},
|
||||||
|
wait: async (ms) => {
|
||||||
|
waits.push(ms);
|
||||||
|
},
|
||||||
|
showMpvOsd: () => {},
|
||||||
|
warn: (message) => {
|
||||||
|
warns.push(message);
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
getYoutubeOutputDir: () => '/tmp',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
|
||||||
|
|
||||||
|
assert.deepEqual(acquireSingleCalls, [secondaryTrack.id]);
|
||||||
|
assert.ok(waits.includes(150));
|
||||||
|
assert.deepEqual(focusOverlayCalls, ['focus-overlay']);
|
||||||
|
assert.deepEqual(refreshedSubtitles, ['字幕です']);
|
||||||
|
assert.ok(
|
||||||
|
commands.some(
|
||||||
|
(command) =>
|
||||||
|
command[0] === 'sub-add' &&
|
||||||
|
command[1] === '/tmp/manual:en.vtt' &&
|
||||||
|
command[2] === 'cached',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(warns.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('youtube flow waits for tokenization readiness before releasing playback', async () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
const releaseOrder: string[] = [];
|
||||||
|
let tokenizationReadyRegistered = false;
|
||||||
|
let resolveTokenizationReady: () => void = () => {
|
||||||
|
throw new Error('expected tokenization readiness waiter');
|
||||||
|
};
|
||||||
|
|
||||||
|
const runtime = createYoutubeFlowRuntime({
|
||||||
|
probeYoutubeTracks: async () => ({
|
||||||
|
videoId: 'video123',
|
||||||
|
title: 'Video 123',
|
||||||
|
tracks: [primaryTrack],
|
||||||
|
}),
|
||||||
|
acquireYoutubeSubtitleTracks: async () => new Map<string, string>(),
|
||||||
|
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }),
|
||||||
|
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
|
||||||
|
startTokenizationWarmups: async () => {
|
||||||
|
releaseOrder.push('start-warmups');
|
||||||
|
},
|
||||||
|
waitForTokenizationReady: async () => {
|
||||||
|
releaseOrder.push('wait-tokenization-ready:start');
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
tokenizationReadyRegistered = true;
|
||||||
|
resolveTokenizationReady = resolve;
|
||||||
|
});
|
||||||
|
releaseOrder.push('wait-tokenization-ready:end');
|
||||||
|
},
|
||||||
|
waitForAnkiReady: async () => {
|
||||||
|
releaseOrder.push('wait-anki-ready');
|
||||||
|
},
|
||||||
|
waitForPlaybackWindowReady: async () => {},
|
||||||
|
waitForOverlayGeometryReady: async () => {},
|
||||||
|
focusOverlayWindow: () => {
|
||||||
|
releaseOrder.push('focus-overlay');
|
||||||
|
},
|
||||||
|
openPicker: async (payload) => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void runtime.resolveActivePicker({
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
action: 'use-selected',
|
||||||
|
primaryTrackId: primaryTrack.id,
|
||||||
|
secondaryTrackId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
pauseMpv: () => {},
|
||||||
|
resumeMpv: () => {
|
||||||
|
commands.push(['set_property', 'pause', 'no']);
|
||||||
|
releaseOrder.push('resume');
|
||||||
|
},
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
commands.push(command);
|
||||||
|
if (command[0] === 'script-message' && command[1] === 'subminer-autoplay-ready') {
|
||||||
|
releaseOrder.push('autoplay-ready');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requestMpvProperty: async (name) => {
|
||||||
|
if (name === 'sub-text') {
|
||||||
|
return '字幕です';
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 5,
|
||||||
|
lang: 'ja-orig',
|
||||||
|
title: 'primary',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/auto-ja-orig.vtt',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
refreshCurrentSubtitle: () => {},
|
||||||
|
wait: async () => {},
|
||||||
|
showMpvOsd: () => {},
|
||||||
|
warn: (message) => {
|
||||||
|
throw new Error(message);
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
getYoutubeOutputDir: () => '/tmp',
|
||||||
|
});
|
||||||
|
|
||||||
|
const flowPromise = runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.equal(tokenizationReadyRegistered, true);
|
||||||
|
assert.deepEqual(releaseOrder, ['start-warmups', 'wait-tokenization-ready:start']);
|
||||||
|
assert.equal(commands.some((command) => command[1] === 'subminer-autoplay-ready'), false);
|
||||||
|
|
||||||
|
resolveTokenizationReady();
|
||||||
|
await flowPromise;
|
||||||
|
|
||||||
|
assert.deepEqual(releaseOrder, [
|
||||||
|
'start-warmups',
|
||||||
|
'wait-tokenization-ready:start',
|
||||||
|
'wait-tokenization-ready:end',
|
||||||
|
'wait-anki-ready',
|
||||||
|
'autoplay-ready',
|
||||||
|
'resume',
|
||||||
|
'focus-overlay',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('youtube flow cleans up paused picker state when opening the picker throws', async () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
const warns: string[] = [];
|
||||||
|
const focusOverlayCalls: string[] = [];
|
||||||
|
|
||||||
|
const runtime = createYoutubeFlowRuntime({
|
||||||
|
probeYoutubeTracks: async () => ({
|
||||||
|
videoId: 'video123',
|
||||||
|
title: 'Video 123',
|
||||||
|
tracks: [primaryTrack],
|
||||||
|
}),
|
||||||
|
acquireYoutubeSubtitleTracks: async () => new Map<string, string>(),
|
||||||
|
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }),
|
||||||
|
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
|
||||||
|
startTokenizationWarmups: async () => {},
|
||||||
|
waitForTokenizationReady: async () => {},
|
||||||
|
waitForAnkiReady: async () => {},
|
||||||
|
waitForPlaybackWindowReady: async () => {},
|
||||||
|
waitForOverlayGeometryReady: async () => {},
|
||||||
|
focusOverlayWindow: () => {
|
||||||
|
focusOverlayCalls.push('focus-overlay');
|
||||||
|
},
|
||||||
|
openPicker: async () => {
|
||||||
|
throw new Error('picker boom');
|
||||||
|
},
|
||||||
|
pauseMpv: () => {
|
||||||
|
commands.push(['set_property', 'pause', 'yes']);
|
||||||
|
},
|
||||||
|
resumeMpv: () => {
|
||||||
|
commands.push(['set_property', 'pause', 'no']);
|
||||||
|
},
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
commands.push(command);
|
||||||
|
},
|
||||||
|
requestMpvProperty: async () => null,
|
||||||
|
refreshCurrentSubtitle: () => {},
|
||||||
|
wait: async () => {},
|
||||||
|
showMpvOsd: () => {},
|
||||||
|
warn: (message) => {
|
||||||
|
warns.push(message);
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
getYoutubeOutputDir: () => '/tmp',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
|
||||||
|
|
||||||
|
assert.deepEqual(commands, [
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
['script-message', 'subminer-autoplay-ready'],
|
||||||
|
['set_property', 'pause', 'no'],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(focusOverlayCalls, ['focus-overlay']);
|
||||||
|
assert.equal(warns.some((message) => message.includes('picker boom')), true);
|
||||||
|
assert.equal(runtime.hasActiveSession(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('youtube flow reports failure when the primary subtitle never binds', async () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
const osdMessages: string[] = [];
|
||||||
|
const warns: string[] = [];
|
||||||
|
|
||||||
|
const runtime = createYoutubeFlowRuntime({
|
||||||
|
probeYoutubeTracks: async () => ({
|
||||||
|
videoId: 'video123',
|
||||||
|
title: 'Video 123',
|
||||||
|
tracks: [primaryTrack],
|
||||||
|
}),
|
||||||
|
acquireYoutubeSubtitleTracks: async () => new Map<string, string>(),
|
||||||
|
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }),
|
||||||
|
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
|
||||||
|
startTokenizationWarmups: async () => {},
|
||||||
|
waitForTokenizationReady: async () => {},
|
||||||
|
waitForAnkiReady: async () => {},
|
||||||
|
waitForPlaybackWindowReady: async () => {},
|
||||||
|
waitForOverlayGeometryReady: async () => {},
|
||||||
|
focusOverlayWindow: () => {},
|
||||||
|
openPicker: async (payload) => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void runtime.resolveActivePicker({
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
action: 'use-selected',
|
||||||
|
primaryTrackId: primaryTrack.id,
|
||||||
|
secondaryTrackId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
pauseMpv: () => {},
|
||||||
|
resumeMpv: () => {},
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
commands.push(command);
|
||||||
|
},
|
||||||
|
requestMpvProperty: async (name) => {
|
||||||
|
if (name === 'track-list') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected property request: ${name}`);
|
||||||
|
},
|
||||||
|
refreshCurrentSubtitle: () => {
|
||||||
|
throw new Error('should not refresh subtitle text on bind failure');
|
||||||
|
},
|
||||||
|
wait: async () => {},
|
||||||
|
showMpvOsd: (text) => {
|
||||||
|
osdMessages.push(text);
|
||||||
|
},
|
||||||
|
warn: (message) => {
|
||||||
|
warns.push(message);
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
getYoutubeOutputDir: () => '/tmp',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
commands.some((command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] !== 'no'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.deepEqual(osdMessages.slice(-1), ['Primary subtitles failed to load.']);
|
||||||
|
assert.equal(warns.some((message) => message.includes('Unable to bind downloaded primary subtitle track')), true);
|
||||||
|
});
|
||||||
583
src/main/runtime/youtube-flow.ts
Normal file
583
src/main/runtime/youtube-flow.ts
Normal file
@@ -0,0 +1,583 @@
|
|||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type {
|
||||||
|
YoutubeFlowMode,
|
||||||
|
YoutubePickerOpenPayload,
|
||||||
|
YoutubePickerResolveRequest,
|
||||||
|
YoutubePickerResolveResult,
|
||||||
|
} from '../../types';
|
||||||
|
import type {
|
||||||
|
YoutubeTrackOption,
|
||||||
|
YoutubeTrackProbeResult,
|
||||||
|
} from '../../core/services/youtube/track-probe';
|
||||||
|
import {
|
||||||
|
chooseDefaultYoutubeTrackIds,
|
||||||
|
normalizeYoutubeTrackSelection,
|
||||||
|
} from '../../core/services/youtube/track-selection';
|
||||||
|
import {
|
||||||
|
acquireYoutubeSubtitleTrack,
|
||||||
|
acquireYoutubeSubtitleTracks,
|
||||||
|
} from '../../core/services/youtube/generate';
|
||||||
|
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
|
||||||
|
|
||||||
|
type YoutubeFlowOpenPicker = (payload: YoutubePickerOpenPayload) => Promise<boolean>;
|
||||||
|
|
||||||
|
type YoutubeFlowDeps = {
|
||||||
|
probeYoutubeTracks: (url: string) => Promise<YoutubeTrackProbeResult>;
|
||||||
|
acquireYoutubeSubtitleTrack: typeof acquireYoutubeSubtitleTrack;
|
||||||
|
acquireYoutubeSubtitleTracks: typeof acquireYoutubeSubtitleTracks;
|
||||||
|
retimeYoutubePrimaryTrack: (input: {
|
||||||
|
targetUrl: string;
|
||||||
|
primaryTrack: YoutubeTrackOption;
|
||||||
|
primaryPath: string;
|
||||||
|
secondaryTrack: YoutubeTrackOption | null;
|
||||||
|
secondaryPath: string | null;
|
||||||
|
}) => Promise<string>;
|
||||||
|
openPicker: YoutubeFlowOpenPicker;
|
||||||
|
pauseMpv: () => void;
|
||||||
|
resumeMpv: () => void;
|
||||||
|
sendMpvCommand: (command: Array<string | number>) => void;
|
||||||
|
requestMpvProperty: (name: string) => Promise<unknown>;
|
||||||
|
refreshCurrentSubtitle: (text: string) => void;
|
||||||
|
startTokenizationWarmups: () => Promise<void>;
|
||||||
|
waitForTokenizationReady: () => Promise<void>;
|
||||||
|
waitForAnkiReady: () => Promise<void>;
|
||||||
|
wait: (ms: number) => Promise<void>;
|
||||||
|
waitForPlaybackWindowReady: () => Promise<void>;
|
||||||
|
waitForOverlayGeometryReady: () => Promise<void>;
|
||||||
|
focusOverlayWindow: () => void;
|
||||||
|
showMpvOsd: (text: string) => void;
|
||||||
|
warn: (message: string) => void;
|
||||||
|
log: (message: string) => void;
|
||||||
|
getYoutubeOutputDir: () => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type YoutubeFlowSession = {
|
||||||
|
sessionId: string;
|
||||||
|
resolve: (request: YoutubePickerResolveRequest) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const YOUTUBE_PICKER_SETTLE_DELAY_MS = 150;
|
||||||
|
const YOUTUBE_SECONDARY_RETRY_DELAY_MS = 350;
|
||||||
|
|
||||||
|
function createSessionId(): string {
|
||||||
|
return `yt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTrackById(tracks: YoutubeTrackOption[], id: string | null): YoutubeTrackOption | null {
|
||||||
|
if (!id) return null;
|
||||||
|
return tracks.find((track) => track.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOutputPath(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed || path.join(os.tmpdir(), 'subminer-youtube-subs');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createYoutubeFlowOsdProgress(showMpvOsd: (text: string) => void) {
|
||||||
|
const frames = ['|', '/', '-', '\\'];
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let frame = 0;
|
||||||
|
|
||||||
|
const stop = (): void => {
|
||||||
|
if (!timer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setMessage = (message: string): void => {
|
||||||
|
stop();
|
||||||
|
frame = 0;
|
||||||
|
showMpvOsd(message);
|
||||||
|
timer = setInterval(() => {
|
||||||
|
showMpvOsd(`${message} ${frames[frame % frames.length]}`);
|
||||||
|
frame += 1;
|
||||||
|
}, 180);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
setMessage,
|
||||||
|
stop,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function releasePlaybackGate(deps: YoutubeFlowDeps): void {
|
||||||
|
deps.sendMpvCommand(['script-message', 'subminer-autoplay-ready']);
|
||||||
|
deps.resumeMpv();
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreOverlayInputFocus(deps: YoutubeFlowDeps): void {
|
||||||
|
deps.focusOverlayWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTrackId(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value.trim());
|
||||||
|
return Number.isInteger(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTrackListEntry(track: Record<string, unknown>): {
|
||||||
|
id: number | null;
|
||||||
|
lang: string;
|
||||||
|
title: string;
|
||||||
|
external: boolean;
|
||||||
|
externalFilename: string | null;
|
||||||
|
} {
|
||||||
|
const externalFilenameRaw =
|
||||||
|
typeof track['external-filename'] === 'string'
|
||||||
|
? track['external-filename']
|
||||||
|
: typeof track.external_filename === 'string'
|
||||||
|
? track.external_filename
|
||||||
|
: '';
|
||||||
|
const externalFilename = externalFilenameRaw.trim()
|
||||||
|
? resolveSubtitleSourcePath(externalFilenameRaw.trim())
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
id: parseTrackId(track.id),
|
||||||
|
lang: String(track.lang || '').trim(),
|
||||||
|
title: String(track.title || '').trim(),
|
||||||
|
external: track.external === true,
|
||||||
|
externalFilename,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchExternalTrackId(
|
||||||
|
trackListRaw: unknown,
|
||||||
|
filePath: string,
|
||||||
|
trackOption: YoutubeTrackOption,
|
||||||
|
excludeId: number | null = null,
|
||||||
|
): number | null {
|
||||||
|
if (!Array.isArray(trackListRaw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedFilePath = resolveSubtitleSourcePath(filePath);
|
||||||
|
const basename = path.basename(normalizedFilePath);
|
||||||
|
const externalTracks = trackListRaw
|
||||||
|
.filter(
|
||||||
|
(track): track is Record<string, unknown> => Boolean(track) && typeof track === 'object',
|
||||||
|
)
|
||||||
|
.filter((track) => track.type === 'sub')
|
||||||
|
.map(normalizeTrackListEntry)
|
||||||
|
.filter((track) => track.external && track.id !== null && track.id !== excludeId);
|
||||||
|
|
||||||
|
const exactPathMatch = externalTracks.find(
|
||||||
|
(track) => track.externalFilename === normalizedFilePath,
|
||||||
|
);
|
||||||
|
if (exactPathMatch?.id !== null && exactPathMatch?.id !== undefined) {
|
||||||
|
return exactPathMatch.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basenameMatch = externalTracks.find(
|
||||||
|
(track) => track.externalFilename && path.basename(track.externalFilename) === basename,
|
||||||
|
);
|
||||||
|
if (basenameMatch?.id !== null && basenameMatch?.id !== undefined) {
|
||||||
|
return basenameMatch.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageMatch = externalTracks.find((track) => track.lang === trackOption.sourceLanguage);
|
||||||
|
if (languageMatch?.id !== null && languageMatch?.id !== undefined) {
|
||||||
|
return languageMatch.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLanguageMatch = externalTracks.find(
|
||||||
|
(track) => track.lang === trackOption.language,
|
||||||
|
);
|
||||||
|
if (normalizedLanguageMatch?.id !== null && normalizedLanguageMatch?.id !== undefined) {
|
||||||
|
return normalizedLanguageMatch.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function injectDownloadedSubtitles(
|
||||||
|
deps: YoutubeFlowDeps,
|
||||||
|
primaryTrack: YoutubeTrackOption,
|
||||||
|
primaryPath: string,
|
||||||
|
secondaryTrack: YoutubeTrackOption | null,
|
||||||
|
secondaryPath: string | null,
|
||||||
|
): Promise<boolean> {
|
||||||
|
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
|
||||||
|
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||||
|
deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']);
|
||||||
|
deps.sendMpvCommand([
|
||||||
|
'sub-add',
|
||||||
|
primaryPath,
|
||||||
|
'select',
|
||||||
|
path.basename(primaryPath),
|
||||||
|
primaryTrack.sourceLanguage,
|
||||||
|
]);
|
||||||
|
if (secondaryPath && secondaryTrack) {
|
||||||
|
deps.sendMpvCommand([
|
||||||
|
'sub-add',
|
||||||
|
secondaryPath,
|
||||||
|
'cached',
|
||||||
|
path.basename(secondaryPath),
|
||||||
|
secondaryTrack.sourceLanguage,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let trackListRaw: unknown = null;
|
||||||
|
let primaryTrackId: number | null = null;
|
||||||
|
let secondaryTrackId: number | null = null;
|
||||||
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||||
|
await deps.wait(attempt === 0 ? 150 : 100);
|
||||||
|
trackListRaw = await deps.requestMpvProperty('track-list');
|
||||||
|
primaryTrackId = matchExternalTrackId(trackListRaw, primaryPath, primaryTrack);
|
||||||
|
secondaryTrackId =
|
||||||
|
secondaryPath && secondaryTrack
|
||||||
|
? matchExternalTrackId(trackListRaw, secondaryPath, secondaryTrack, primaryTrackId)
|
||||||
|
: null;
|
||||||
|
if (primaryTrackId !== null && (!secondaryPath || secondaryTrackId !== null)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primaryTrackId !== null) {
|
||||||
|
deps.sendMpvCommand(['set_property', 'sid', primaryTrackId]);
|
||||||
|
} else {
|
||||||
|
deps.warn(
|
||||||
|
`Unable to bind downloaded primary subtitle track in mpv: ${path.basename(primaryPath)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (secondaryPath && secondaryTrack) {
|
||||||
|
if (secondaryTrackId !== null) {
|
||||||
|
deps.sendMpvCommand(['set_property', 'secondary-sid', secondaryTrackId]);
|
||||||
|
} else {
|
||||||
|
deps.warn(
|
||||||
|
`Unable to bind downloaded secondary subtitle track in mpv: ${path.basename(secondaryPath)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primaryTrackId === null) {
|
||||||
|
deps.showMpvOsd('Primary subtitles failed to load.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSubText = await deps.requestMpvProperty('sub-text');
|
||||||
|
if (typeof currentSubText === 'string' && currentSubText.trim().length > 0) {
|
||||||
|
deps.refreshCurrentSubtitle(currentSubText);
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.showMpvOsd(
|
||||||
|
secondaryPath && secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.',
|
||||||
|
);
|
||||||
|
return typeof currentSubText === 'string' && currentSubText.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||||
|
let activeSession: YoutubeFlowSession | null = null;
|
||||||
|
|
||||||
|
const acquireSelectedTracks = async (input: {
|
||||||
|
targetUrl: string;
|
||||||
|
outputDir: string;
|
||||||
|
primaryTrack: YoutubeTrackOption;
|
||||||
|
secondaryTrack: YoutubeTrackOption | null;
|
||||||
|
mode: YoutubeFlowMode;
|
||||||
|
secondaryFailureLabel: string;
|
||||||
|
}): Promise<{ primaryPath: string; secondaryPath: string | null }> => {
|
||||||
|
if (!input.secondaryTrack) {
|
||||||
|
const primaryPath = (
|
||||||
|
await deps.acquireYoutubeSubtitleTrack({
|
||||||
|
targetUrl: input.targetUrl,
|
||||||
|
outputDir: input.outputDir,
|
||||||
|
track: input.primaryTrack,
|
||||||
|
mode: input.mode,
|
||||||
|
})
|
||||||
|
).path;
|
||||||
|
return { primaryPath, secondaryPath: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const batchResult = await deps.acquireYoutubeSubtitleTracks({
|
||||||
|
targetUrl: input.targetUrl,
|
||||||
|
outputDir: input.outputDir,
|
||||||
|
tracks: [input.primaryTrack, input.secondaryTrack],
|
||||||
|
mode: input.mode,
|
||||||
|
});
|
||||||
|
const primaryPath = batchResult.get(input.primaryTrack.id) ?? null;
|
||||||
|
const secondaryPath = batchResult.get(input.secondaryTrack.id) ?? null;
|
||||||
|
if (primaryPath) {
|
||||||
|
if (secondaryPath) {
|
||||||
|
return { primaryPath, secondaryPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.log(
|
||||||
|
`${
|
||||||
|
input.secondaryFailureLabel
|
||||||
|
}: No subtitle file was downloaded for ${input.secondaryTrack.sourceLanguage}; retrying secondary separately after delay.`,
|
||||||
|
);
|
||||||
|
await deps.wait(YOUTUBE_SECONDARY_RETRY_DELAY_MS);
|
||||||
|
try {
|
||||||
|
const retriedSecondaryPath = (
|
||||||
|
await deps.acquireYoutubeSubtitleTrack({
|
||||||
|
targetUrl: input.targetUrl,
|
||||||
|
outputDir: input.outputDir,
|
||||||
|
track: input.secondaryTrack,
|
||||||
|
mode: input.mode,
|
||||||
|
})
|
||||||
|
).path;
|
||||||
|
return { primaryPath, secondaryPath: retriedSecondaryPath };
|
||||||
|
} catch (error) {
|
||||||
|
deps.warn(
|
||||||
|
`${input.secondaryFailureLabel}: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
return { primaryPath, secondaryPath: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to primary-only recovery
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const primaryPath = (
|
||||||
|
await deps.acquireYoutubeSubtitleTrack({
|
||||||
|
targetUrl: input.targetUrl,
|
||||||
|
outputDir: input.outputDir,
|
||||||
|
track: input.primaryTrack,
|
||||||
|
mode: input.mode,
|
||||||
|
})
|
||||||
|
).path;
|
||||||
|
return { primaryPath, secondaryPath: null };
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveActivePicker = async (
|
||||||
|
request: YoutubePickerResolveRequest,
|
||||||
|
): Promise<YoutubePickerResolveResult> => {
|
||||||
|
if (!activeSession || activeSession.sessionId !== request.sessionId) {
|
||||||
|
return { ok: false, message: 'No active YouTube subtitle picker session.' };
|
||||||
|
}
|
||||||
|
activeSession.resolve(request);
|
||||||
|
return { ok: true, message: 'Picker selection accepted.' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelActivePicker = (): boolean => {
|
||||||
|
if (!activeSession) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
activeSession.resolve({
|
||||||
|
sessionId: activeSession.sessionId,
|
||||||
|
action: 'continue-without-subtitles',
|
||||||
|
primaryTrackId: null,
|
||||||
|
secondaryTrackId: null,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPickerSelectionPromise = (sessionId: string): Promise<YoutubePickerResolveRequest> =>
|
||||||
|
new Promise<YoutubePickerResolveRequest>((resolve, reject) => {
|
||||||
|
activeSession = { sessionId, resolve, reject };
|
||||||
|
}).finally(() => {
|
||||||
|
activeSession = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function runYoutubePlaybackFlow(input: {
|
||||||
|
url: string;
|
||||||
|
mode: YoutubeFlowMode;
|
||||||
|
}): Promise<void> {
|
||||||
|
deps.showMpvOsd('Opening YouTube video');
|
||||||
|
const tokenizationWarmupPromise = deps.startTokenizationWarmups().catch((error) => {
|
||||||
|
deps.warn(
|
||||||
|
`Failed to warm subtitle tokenization prerequisites: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
deps.pauseMpv();
|
||||||
|
const outputDir = normalizeOutputPath(deps.getYoutubeOutputDir());
|
||||||
|
|
||||||
|
let probe: YoutubeTrackProbeResult;
|
||||||
|
try {
|
||||||
|
probe = await deps.probeYoutubeTracks(input.url);
|
||||||
|
} catch (error) {
|
||||||
|
deps.warn(
|
||||||
|
`Failed to probe YouTube subtitle tracks: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
releasePlaybackGate(deps);
|
||||||
|
restoreOverlayInputFocus(deps);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults = chooseDefaultYoutubeTrackIds(probe.tracks);
|
||||||
|
const sessionId = createSessionId();
|
||||||
|
|
||||||
|
const openPayload: YoutubePickerOpenPayload = {
|
||||||
|
sessionId,
|
||||||
|
url: input.url,
|
||||||
|
mode: input.mode,
|
||||||
|
tracks: probe.tracks,
|
||||||
|
defaultPrimaryTrackId: defaults.primaryTrackId,
|
||||||
|
defaultSecondaryTrackId: defaults.secondaryTrackId,
|
||||||
|
hasTracks: probe.tracks.length > 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (input.mode === 'download') {
|
||||||
|
await deps.waitForPlaybackWindowReady();
|
||||||
|
await deps.waitForOverlayGeometryReady();
|
||||||
|
await deps.wait(YOUTUBE_PICKER_SETTLE_DELAY_MS);
|
||||||
|
deps.showMpvOsd('Getting subtitles...');
|
||||||
|
const pickerSelection = createPickerSelectionPromise(sessionId);
|
||||||
|
void pickerSelection.catch(() => undefined);
|
||||||
|
let opened = false;
|
||||||
|
try {
|
||||||
|
opened = await deps.openPicker(openPayload);
|
||||||
|
} catch (error) {
|
||||||
|
activeSession?.reject(
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
);
|
||||||
|
deps.warn(
|
||||||
|
`Unable to open YouTube subtitle picker: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
releasePlaybackGate(deps);
|
||||||
|
restoreOverlayInputFocus(deps);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!opened) {
|
||||||
|
activeSession?.reject(new Error('Unable to open YouTube subtitle picker.'));
|
||||||
|
activeSession = null;
|
||||||
|
deps.warn('Unable to open YouTube subtitle picker; continuing without subtitles.');
|
||||||
|
releasePlaybackGate(deps);
|
||||||
|
restoreOverlayInputFocus(deps);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = await pickerSelection;
|
||||||
|
if (request.action === 'continue-without-subtitles') {
|
||||||
|
releasePlaybackGate(deps);
|
||||||
|
restoreOverlayInputFocus(deps);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const osdProgress = createYoutubeFlowOsdProgress(deps.showMpvOsd);
|
||||||
|
osdProgress.setMessage('Downloading subtitles...');
|
||||||
|
try {
|
||||||
|
const primaryTrack = getTrackById(probe.tracks, request.primaryTrackId);
|
||||||
|
if (!primaryTrack) {
|
||||||
|
deps.warn('No primary YouTube subtitle track selected; continuing without subtitles.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = normalizeYoutubeTrackSelection({
|
||||||
|
primaryTrackId: primaryTrack.id,
|
||||||
|
secondaryTrackId: request.secondaryTrackId,
|
||||||
|
});
|
||||||
|
const secondaryTrack = getTrackById(probe.tracks, selected.secondaryTrackId);
|
||||||
|
|
||||||
|
const acquired = await acquireSelectedTracks({
|
||||||
|
targetUrl: input.url,
|
||||||
|
outputDir,
|
||||||
|
primaryTrack,
|
||||||
|
secondaryTrack,
|
||||||
|
mode: input.mode,
|
||||||
|
secondaryFailureLabel: 'Failed to download secondary YouTube subtitle track',
|
||||||
|
});
|
||||||
|
const resolvedPrimaryPath = await deps.retimeYoutubePrimaryTrack({
|
||||||
|
targetUrl: input.url,
|
||||||
|
primaryTrack,
|
||||||
|
primaryPath: acquired.primaryPath,
|
||||||
|
secondaryTrack,
|
||||||
|
secondaryPath: acquired.secondaryPath,
|
||||||
|
});
|
||||||
|
osdProgress.setMessage('Loading subtitles...');
|
||||||
|
const refreshedActiveSubtitle = await injectDownloadedSubtitles(
|
||||||
|
deps,
|
||||||
|
primaryTrack,
|
||||||
|
resolvedPrimaryPath,
|
||||||
|
secondaryTrack,
|
||||||
|
acquired.secondaryPath,
|
||||||
|
);
|
||||||
|
await tokenizationWarmupPromise;
|
||||||
|
if (refreshedActiveSubtitle) {
|
||||||
|
await deps.waitForTokenizationReady();
|
||||||
|
}
|
||||||
|
await deps.waitForAnkiReady();
|
||||||
|
} catch (error) {
|
||||||
|
deps.warn(
|
||||||
|
`Failed to download primary YouTube subtitle track: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
osdProgress.stop();
|
||||||
|
releasePlaybackGate(deps);
|
||||||
|
restoreOverlayInputFocus(deps);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryTrack = getTrackById(probe.tracks, defaults.primaryTrackId);
|
||||||
|
const secondaryTrack = getTrackById(probe.tracks, defaults.secondaryTrackId);
|
||||||
|
if (!primaryTrack) {
|
||||||
|
deps.showMpvOsd('No usable YouTube subtitles found.');
|
||||||
|
releasePlaybackGate(deps);
|
||||||
|
restoreOverlayInputFocus(deps);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
deps.showMpvOsd('Getting subtitles...');
|
||||||
|
const acquired = await acquireSelectedTracks({
|
||||||
|
targetUrl: input.url,
|
||||||
|
outputDir,
|
||||||
|
primaryTrack,
|
||||||
|
secondaryTrack,
|
||||||
|
mode: input.mode,
|
||||||
|
secondaryFailureLabel: 'Failed to generate secondary YouTube subtitle track',
|
||||||
|
});
|
||||||
|
const resolvedPrimaryPath = await deps.retimeYoutubePrimaryTrack({
|
||||||
|
targetUrl: input.url,
|
||||||
|
primaryTrack,
|
||||||
|
primaryPath: acquired.primaryPath,
|
||||||
|
secondaryTrack,
|
||||||
|
secondaryPath: acquired.secondaryPath,
|
||||||
|
});
|
||||||
|
deps.showMpvOsd('Loading subtitles...');
|
||||||
|
const refreshedActiveSubtitle = await injectDownloadedSubtitles(
|
||||||
|
deps,
|
||||||
|
primaryTrack,
|
||||||
|
resolvedPrimaryPath,
|
||||||
|
secondaryTrack,
|
||||||
|
acquired.secondaryPath,
|
||||||
|
);
|
||||||
|
await tokenizationWarmupPromise;
|
||||||
|
if (refreshedActiveSubtitle) {
|
||||||
|
await deps.waitForTokenizationReady();
|
||||||
|
}
|
||||||
|
await deps.waitForAnkiReady();
|
||||||
|
} catch (error) {
|
||||||
|
deps.warn(
|
||||||
|
`Failed to generate primary YouTube subtitle track: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
releasePlaybackGate(deps);
|
||||||
|
restoreOverlayInputFocus(deps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
runYoutubePlaybackFlow,
|
||||||
|
resolveActivePicker,
|
||||||
|
cancelActivePicker,
|
||||||
|
hasActiveSession: () => Boolean(activeSession),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -188,6 +188,7 @@ export interface AppState {
|
|||||||
overlayDebugVisualizationEnabled: boolean;
|
overlayDebugVisualizationEnabled: boolean;
|
||||||
statsOverlayVisible: boolean;
|
statsOverlayVisible: boolean;
|
||||||
subsyncInProgress: boolean;
|
subsyncInProgress: boolean;
|
||||||
|
youtubePlaybackFlowPending: boolean;
|
||||||
initialArgs: CliArgs | null;
|
initialArgs: CliArgs | null;
|
||||||
mpvSocketPath: string;
|
mpvSocketPath: string;
|
||||||
texthookerPort: number;
|
texthookerPort: number;
|
||||||
@@ -272,6 +273,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
|||||||
fieldGroupingResolver: null,
|
fieldGroupingResolver: null,
|
||||||
fieldGroupingResolverSequence: 0,
|
fieldGroupingResolverSequence: 0,
|
||||||
subsyncInProgress: false,
|
subsyncInProgress: false,
|
||||||
|
youtubePlaybackFlowPending: false,
|
||||||
initialArgs: null,
|
initialArgs: null,
|
||||||
mpvSocketPath: values.mpvSocketPath,
|
mpvSocketPath: values.mpvSocketPath,
|
||||||
texthookerPort: values.texthookerPort,
|
texthookerPort: values.texthookerPort,
|
||||||
@@ -291,6 +293,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
|||||||
|
|
||||||
export function applyStartupState(appState: AppState, startupState: StartupState): void {
|
export function applyStartupState(appState: AppState, startupState: StartupState): void {
|
||||||
appState.initialArgs = startupState.initialArgs;
|
appState.initialArgs = startupState.initialArgs;
|
||||||
|
appState.youtubePlaybackFlowPending = Boolean(startupState.initialArgs.youtubePlay);
|
||||||
appState.mpvSocketPath = startupState.mpvSocketPath;
|
appState.mpvSocketPath = startupState.mpvSocketPath;
|
||||||
appState.texthookerPort = startupState.texthookerPort;
|
appState.texthookerPort = startupState.texthookerPort;
|
||||||
appState.backendOverride = startupState.backendOverride;
|
appState.backendOverride = startupState.backendOverride;
|
||||||
|
|||||||
25
src/preload-args.test.ts
Normal file
25
src/preload-args.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { resolveOverlayLayerFromArgv } from './preload-args';
|
||||||
|
|
||||||
|
test('resolveOverlayLayerFromArgv returns null when argv is unavailable', () => {
|
||||||
|
assert.equal(resolveOverlayLayerFromArgv(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveOverlayLayerFromArgv returns null for undefined argv', () => {
|
||||||
|
assert.equal(resolveOverlayLayerFromArgv(undefined), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveOverlayLayerFromArgv returns null for empty argv', () => {
|
||||||
|
assert.equal(resolveOverlayLayerFromArgv([]), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveOverlayLayerFromArgv returns parsed overlay layer when present', () => {
|
||||||
|
assert.equal(resolveOverlayLayerFromArgv(['electron', '--overlay-layer=modal']), 'modal');
|
||||||
|
assert.equal(resolveOverlayLayerFromArgv(['electron', '--overlay-layer=visible']), 'visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveOverlayLayerFromArgv ignores unsupported overlay layers', () => {
|
||||||
|
assert.equal(resolveOverlayLayerFromArgv(['electron', '--overlay-layer=secondary']), null);
|
||||||
|
assert.equal(resolveOverlayLayerFromArgv(['electron', '--overlay-layer=']), null);
|
||||||
|
});
|
||||||
10
src/preload-args.ts
Normal file
10
src/preload-args.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export function resolveOverlayLayerFromArgv(
|
||||||
|
argv: readonly string[] | null | undefined,
|
||||||
|
): 'visible' | 'modal' | null {
|
||||||
|
const overlayLayerArg = argv?.find((arg) => arg.startsWith('--overlay-layer='));
|
||||||
|
const overlayLayerFromArg = overlayLayerArg?.slice('--overlay-layer='.length);
|
||||||
|
|
||||||
|
return overlayLayerFromArg === 'visible' || overlayLayerFromArg === 'modal'
|
||||||
|
? overlayLayerFromArg
|
||||||
|
: null;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user