diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c5ba2a..da28bc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,42 @@ # Changelog +## v0.9.0 (2026-03-23) + +### Added +- Docs: Added a new WebSocket / Texthooker API and integration guide covering WebSocket payloads, custom client patterns, mpv plugin automation, and webhook-style relay examples. Linked from configuration and mining workflow docs for easier discovery. + +### Changed +- Launcher: Added an app-owned YouTube subtitle flow that pauses mpv, uses absPlayer-style YouTube timedtext parsing/conversion to download subtitle tracks, and injects them as external files before playback resumes. +- Launcher: Changed YouTube subtitle startup to auto-load the best-available primary and secondary subtitle tracks at launch instead of forcing the picker modal first. Secondary subtitle failures no longer block playback resume. +- Launcher: Added `Ctrl+Alt+C` as the default keybinding to manually open the YouTube subtitle picker during active YouTube playback. +- Launcher: Added yt-dlp metadata probing so YouTube playback and immersion tracking record canonical video title and channel metadata. +- Launcher: Stopped forcing `--ytdl-raw-options=` before user-provided mpv options so existing YouTube cookie integrations in user `--args` are no longer clobbered. +- Launcher: Disabled mpv native YouTube subtitle auto-loading for the app-owned flow so injected external subtitle files remain 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. +- Subtitle Sidebar: Added startup-auto-open controls and resume positioning improvements so the sidebar jumps directly to the first resolved active cue. +- Subtitle Sidebar: Improved subtitle prefetch and embedded overlay passthrough sync so sidebar and overlay subtitle states stay consistent across media transitions. +- Subtitle Sidebar: Updated scroll handling, embedded layout styling, and active-cue visual behavior. +- Stats: Stats Library tab now displays YouTube video title, channel name, and channel thumbnail for YouTube media entries, with retry logic to fill in metadata that arrives after initial load. + +### Fixed +- Launcher: Fixed Anki media mining for mpv YouTube streams by unwrapping the stream URL so audio and screenshot capture work correctly for YouTube playback sessions. +- Immersion: Fixed YouTube media path handling in the immersion runtime and tracking so YouTube sessions record correct media references, AniList guessing skips YouTube URLs, and post-watch state transitions do not fire for YouTube media. +- Launcher: Fixed startup-launched YouTube playback so primary subtitle overlay updates continue after auto-load completes. +- Launcher: Fixed auto-loaded YouTube primary subtitles so parsed cues appear in the subtitle sidebar without needing a manual picker retry. +- Launcher: Fixed the YouTube picker to guard against duplicate subtitle submissions and tightened YouTube URL detection so follow-up runtime flows only treat real YouTube hosts as YouTube playback. +- Launcher: Fixed primary subtitle failure notifications being shown while app-owned YouTube subtitle probing and downloads are still in flight. +- Launcher: Preserved existing authoritative YouTube subtitle tracks when available; downloaded tracks are used only to fill missing sides, and native mpv secondary subtitle rendering is hidden so the overlay remains the sole secondary display. + ## v0.8.0 (2026-03-22) ### Added -- Overlay: Added the subtitle sidebar feature with a new `subtitleSidebar` configuration surface. -- Overlay: Added a sidebar modal with cue list rendering, click-to-seek, active-cue highlighting, and embedded layout support. +- 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. - IPC: Added sidebar snapshot plumbing between renderer and main process for overlay/sidebar synchronization. ### Changed - 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. -- 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 - Overlay: Kept sidebar cue tracking stable across playback transitions and timing edge cases. diff --git a/README.md b/README.md index 50a795b..4db4de0 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,163 @@
- SubMiner logo + +SubMiner logo # SubMiner -**Sentence-mine from mpv — look up words, one-key Anki export, immersion tracking.** +## Turn mpv into a sentence-mining workstation. -[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) -[![Linux](https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-informational)](https://github.com/ksyasuda/SubMiner) -[![Docs](https://img.shields.io/badge/docs-docs.subminer.moe-blueviolet)](https://docs.subminer.moe) -[![AUR](https://img.shields.io/aur/version/subminer-bin)](https://aur.archlinux.org/packages/subminer-bin) +Look up words with Yomitan, export to Anki in one key, track your immersion — all without leaving mpv. + +[![License: GPL v3](https://img.shields.io/badge/license-GPLv3-1a1a2e?style=flat-square)](https://www.gnu.org/licenses/gpl-3.0) +[![Platform](https://img.shields.io/badge/platform-Linux%20·%20macOS%20·%20Windows-1a1a2e?style=flat-square)](https://github.com/ksyasuda/SubMiner) +[![Docs](https://img.shields.io/badge/docs-docs.subminer.moe-e6a817?style=flat-square)](https://docs.subminer.moe) +[![AUR](https://img.shields.io/aur/version/subminer-bin?style=flat-square&color=1a1a2e)](https://aur.archlinux.org/packages/subminer-bin) + +[![SubMiner demo](./assets/minecard.webp)](./assets/minecard.mp4)
---- +## 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 demo (Animated preview)](./assets/minecard.webp)](./assets/minecard.mp4) - -
+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. ## 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.
- Yomitan popup with dictionary entry and mine button over annotated subtitles in mpv + Yomitan dictionary popup over annotated subtitles in mpv
-**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. +
+ +### 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.
- Annotated subtitles with frequency highlighting, JLPT underlines, known words, and N+1 targets + Anki card created from SubMiner with sentence, audio, and screenshot
-**Immersion dashboard** — Local stats dashboard with watch time, anime progress, vocabulary growth, mining throughput, and session history. +
+ +### 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.
- Stats dashboard with watch time, cards mined, streaks, and tracking snapshot + Annotated subtitles with frequency coloring, JLPT underlines, and N+1 targets
-**Integrations** — AniList episode tracking, Jellyfin remote playback, Jimaku subtitle downloads, alass/ffsubsync, and an annotated websocket feed for external clients. +
+ +### Immersion Dashboard + +Local stats dashboard — watch time, anime library, vocabulary growth, mining throughput, session history, and trends. All stored locally, no third-party tracking.
- Texthooker page with annotated subtitle lines and frequency highlighting + Stats dashboard showing watch time, cards mined, streaks, and tracking data
+
+ +### Integrations + + + + + + + + + + + + + + + + + + + + + + + + + + +
YouTubeAuto-loaded yt-dlp subtitle tracks at startup with a manual overlay picker on demand (Ctrl+Alt+C)
AniListAutomatic episode tracking and progress sync
JellyfinBrowse and launch media from your Jellyfin server
JimakuSearch and download Japanese subtitles
alass / ffsubsyncAutomatic subtitle retiming
WebSocketAnnotated subtitle feed for external clients (texthooker pages, custom tools)
+ +
+ Texthooker page receiving annotated subtitle lines via WebSocket +
+ +
+ +--- + +## 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 | + +
+Arch Linux + +```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 +``` + +
+ +
+macOS + +```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**. + +
+ +
+Windows + +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. + +
+ --- ## Quick Start -### Install +### 1. Install
Arch Linux (AUR) @@ -88,53 +191,63 @@ wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~
-macOS / Windows / From source +macOS -**macOS** — Download the latest DMG/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). +Download the latest DMG or ZIP from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) and drag `SubMiner.app` into `/Applications`.
-### First Launch +
+Windows -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 +
+ +
+From source + +See the [build-from-source guide](https://docs.subminer.moe/installation#from-source). + +
+ +### 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 -subminer video.mkv # auto-starts overlay + resumes playback -subminer --start video.mkv # explicit overlay start (if plugin auto_start=no) -subminer stats # open the immersion dashboard -subminer stats -b # keep the stats daemon running in background -subminer stats -s # stop the dedicated stats daemon -subminer stats cleanup # repair/prune stored stats vocabulary rows +subminer video.mkv # play video with overlay +subminer --start video.mkv # explicit overlay start +subminer stats # open immersion dashboard +subminer stats -b # stats daemon in background +subminer stats -s # stop background stats daemon ``` --- -## 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 -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 -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 diff --git a/backlog/tasks/task-143 - Keep-character-dictionary-auto-sync-non-blocking-during-startup.md b/backlog/tasks/task-143 - Keep-character-dictionary-auto-sync-non-blocking-during-startup.md index 6800031..6fcf149 100644 --- a/backlog/tasks/task-143 - Keep-character-dictionary-auto-sync-non-blocking-during-startup.md +++ b/backlog/tasks/task-143 - Keep-character-dictionary-auto-sync-non-blocking-during-startup.md @@ -1,11 +1,11 @@ --- id: TASK-143 title: Keep character dictionary auto-sync non-blocking during startup -status: In Progress +status: Done assignee: - codex created_date: '2026-03-09 01:45' -updated_date: '2026-03-20 09:22' +updated_date: '2026-03-23 03:22' labels: - dictionary - startup @@ -18,7 +18,7 @@ references: - >- /home/sudacode/projects/japanese/SubMiner/src/main/runtime/current-media-tokenization-gate.ts priority: high -ordinal: 38500 +ordinal: 144500 --- ## Description diff --git a/backlog/tasks/task-177.1 - Fix-overview-lookup-rate-metric.md b/backlog/tasks/task-177.1 - Fix-overview-lookup-rate-metric.md index 0d943c7..00ff385 100644 --- a/backlog/tasks/task-177.1 - Fix-overview-lookup-rate-metric.md +++ b/backlog/tasks/task-177.1 - Fix-overview-lookup-rate-metric.md @@ -5,7 +5,7 @@ status: Done assignee: - '@codex' created_date: '2026-03-19 17:46' -updated_date: '2026-03-19 17:54' +updated_date: '2026-03-23 03:22' labels: - stats - immersion-tracking @@ -19,6 +19,7 @@ references: - src/core/services/stats-server.ts parent_task_id: TASK-177 priority: medium +ordinal: 132500 --- ## Description diff --git a/backlog/tasks/task-177.2 - Count-homepage-new-words-by-headword.md b/backlog/tasks/task-177.2 - Count-homepage-new-words-by-headword.md index fed11c1..8194fb1 100644 --- a/backlog/tasks/task-177.2 - Count-homepage-new-words-by-headword.md +++ b/backlog/tasks/task-177.2 - Count-homepage-new-words-by-headword.md @@ -5,7 +5,7 @@ status: Done assignee: - '@codex' created_date: '2026-03-19 19:38' -updated_date: '2026-03-19 19:40' +updated_date: '2026-03-23 03:22' labels: - stats - immersion-tracking @@ -17,6 +17,7 @@ references: - stats/src/lib/dashboard-data.ts parent_task_id: TASK-177 priority: medium +ordinal: 130500 --- ## Description diff --git a/backlog/tasks/task-177.3 - Fix-attached-stats-command-flow-and-browser-config.md b/backlog/tasks/task-177.3 - Fix-attached-stats-command-flow-and-browser-config.md index e04e84a..3758747 100644 --- a/backlog/tasks/task-177.3 - Fix-attached-stats-command-flow-and-browser-config.md +++ b/backlog/tasks/task-177.3 - Fix-attached-stats-command-flow-and-browser-config.md @@ -5,7 +5,7 @@ status: Done assignee: - '@codex' created_date: '2026-03-19 20:15' -updated_date: '2026-03-19 20:17' +updated_date: '2026-03-23 03:22' labels: - launcher - stats @@ -19,6 +19,7 @@ references: - src/main/runtime/stats-cli-command.test.ts parent_task_id: TASK-177 priority: medium +ordinal: 129500 --- ## Description diff --git a/backlog/tasks/task-182.2 - Improve-session-detail-known-word-chart-scaling.md b/backlog/tasks/task-182.2 - Improve-session-detail-known-word-chart-scaling.md index 840111e..9abc17c 100644 --- a/backlog/tasks/task-182.2 - Improve-session-detail-known-word-chart-scaling.md +++ b/backlog/tasks/task-182.2 - Improve-session-detail-known-word-chart-scaling.md @@ -5,7 +5,7 @@ status: Done assignee: - codex created_date: '2026-03-19 20:31' -updated_date: '2026-03-19 20:52' +updated_date: '2026-03-23 03:22' labels: - bug - stats @@ -17,6 +17,7 @@ references: - >- /Users/sudacode/projects/japanese/SubMiner/stats/src/lib/session-detail.test.tsx parent_task_id: TASK-182 +ordinal: 128500 --- ## Description diff --git a/backlog/tasks/task-188 - Refactor-stats-chart-data-pipeline-to-use-backend-aggregated-series.md b/backlog/tasks/task-188 - Refactor-stats-chart-data-pipeline-to-use-backend-aggregated-series.md index e0a3282..cc85efe 100644 --- a/backlog/tasks/task-188 - Refactor-stats-chart-data-pipeline-to-use-backend-aggregated-series.md +++ b/backlog/tasks/task-188 - Refactor-stats-chart-data-pipeline-to-use-backend-aggregated-series.md @@ -5,7 +5,7 @@ status: Done assignee: - codex created_date: '2026-03-18 00:29' -updated_date: '2026-03-18 00:55' +updated_date: '2026-03-23 03:22' labels: - stats - performance @@ -22,6 +22,7 @@ references: - stats/src/types/stats.ts - stats/src/lib/dashboard-data.ts priority: medium +ordinal: 138500 --- ## Description diff --git a/backlog/tasks/task-191 - Assess-PR-19-CodeRabbit-review-follow-ups.md b/backlog/tasks/task-191 - Assess-PR-19-CodeRabbit-review-follow-ups.md index 44ed52d..494e107 100644 --- a/backlog/tasks/task-191 - Assess-PR-19-CodeRabbit-review-follow-ups.md +++ b/backlog/tasks/task-191 - Assess-PR-19-CodeRabbit-review-follow-ups.md @@ -5,7 +5,7 @@ status: Done assignee: - codex created_date: '2026-03-17 23:15' -updated_date: '2026-03-17 23:18' +updated_date: '2026-03-23 03:22' labels: - pr-review - stats @@ -16,6 +16,7 @@ references: - src/core/services/immersion-tracker-service.ts - src/core/services/immersion-tracker-service.test.ts priority: medium +ordinal: 139500 --- ## Description diff --git a/backlog/tasks/task-192 - Assess-remaining-PR-19-review-batch.md b/backlog/tasks/task-192 - Assess-remaining-PR-19-review-batch.md deleted file mode 100644 index 692a4fe..0000000 --- a/backlog/tasks/task-192 - Assess-remaining-PR-19-review-batch.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -id: TASK-192 -title: 'Assess remaining PR #19 review batch' -status: Done -assignee: - - codex -created_date: '2026-03-17 23:24' -updated_date: '2026-03-17 23:42' -labels: - - pr-review - - stats - - docs -milestone: m-1 -dependencies: [] -references: - - docs/superpowers/plans/2026-03-12-immersion-stats-page.md - - src/core/services/immersion-tracker/__tests__/query.test.ts - - src/core/services/ipc.ts - - src/core/services/stats-server.ts - - src/main.ts - - src/renderer/handlers/keyboard.ts - - stats/src -priority: medium ---- - -## Description - - -Validate the remaining PR #19 automated review findings against the current branch, implement only the technically correct fixes, and document which comments are stale, already addressed, or not warranted. - - -## Acceptance Criteria - -- [x] #1 Each remaining review comment is classified as actionable, already fixed, stale, or not warranted -- [x] #2 Confirmed bugs or correctness issues are fixed with focused regression coverage where it fits -- [x] #3 Final notes record which comments were intentionally not applied and why - - -## Implementation Plan - - -1. Inspect the referenced files in batches and compare each comment against current branch behavior. -2. Separate correctness/security regressions from stylistic nitpicks and already-fixed items. -3. Add tests first for confirmed behavior bugs where practical, apply the smallest safe fixes, and rerun targeted verification. - - -## Implementation Notes - - -Swept the pasted PR #19 review batch against the current branch. - -Classification: -- Already fixed on current branch: `src/core/services/immersion-tracker/__tests__/query.test.ts` cleanup rethrow, `src/core/services/ipc.ts` limit validation, `src/core/services/stats-server.ts` max-limit parsing and CORS removal, `src/main.ts` quit-path TDZ issue, `src/renderer/handlers/keyboard.ts` stats-toggle shortcut ordering/config usage, `stats/src/components/vocabulary/WordList.tsx`, `stats/src/hooks/useSessions.ts`, `stats/src/hooks/useTrends.ts` stale-error reset, `src/core/services/__tests__/stats-server.test.ts` kanji endpoint/readability notes, `src/core/services/stats-window.ts`, `stats/src/App.tsx`, `stats/src/components/layout/TabBar.tsx`, `stats/src/components/overview/QuickStats.tsx`, `stats/src/components/overview/WatchTimeChart.tsx`, `stats/src/components/sessions/SessionDetail.tsx`, `stats/src/components/sessions/SessionRow.tsx`, `stats/src/components/trends/DateRangeSelector.tsx`, `stats/src/components/vocabulary/KanjiBreakdown.tsx`, `stats/src/components/vocabulary/VocabularyTab.tsx`, `stats/src/hooks/useVocabulary.ts`, `stats/src/lib/api-client.ts`, `stats/src/types/stats.ts`. -- Stale / obsolete against current architecture: `docs/superpowers/plans/2026-03-12-immersion-stats-page.md` path does not exist on this branch; `stats/src/components/trends/TrendsTab.tsx` / monthly-range comments describe older client-side aggregation code that is no longer present because trends now come from `getTrendsDashboard`. -- Not warranted as written: `stats/src/lib/formatters.ts` no longer emits negative `Xd ago`; current code short-circuits future timestamps to `just now`, so the reported bug condition is gone even though the suggested wording differs. -- Actionable and fixed now: `src/core/services/ipc.ts` no-tracker `statsGetOverview` fallback omitted required hint fields (`totalLookupCount`, `totalLookupHits`, `newWordsToday`, `newWordsThisWeek`). Added the missing fields in the fallback object and updated IPC tests to assert the full shape. - -Verification: -- `bun test src/core/services/ipc.test.ts` -- `bun test src/core/services/ipc.test.ts --test-name-pattern "empty stats overview shape without a tracker|validates and clamps stats request limits"` -- `bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh src/core/services/ipc.ts src/core/services/ipc.test.ts` - -Repo verifier note: -- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core src/core/services/ipc.ts src/core/services/ipc.test.ts` -- That verifier run captured a temporary `bun run typecheck` failure in `src/anki-integration.test.ts` and `src/core/services/__tests__/stats-server.test.ts`, but a fresh rerun after the follow-up validation no longer reproduces those diagnostics. -- Fresh verification: `bun run typecheck` passes locally. -- artifact dir from the earlier failed verifier snapshot: `.tmp/skill-verification/subminer-verify-20260317-234027-i6QJ3n` - - -## Final Summary - - -The larger pasted PR #19 review batch was not mostly new work on the current branch. After verifying each item against the live code, almost all were already fixed or stale. One additional item was still actionable: the no-tracker fallback returned by `statsGetOverview` in `src/core/services/ipc.ts` omitted required hint fields, which made the fallback shape inconsistent with the normal overview payload. That fallback is now fixed and covered by IPC tests. - -Count-wise: the earlier open CodeRabbit service comments contributed 2 actionable fixes, and this larger pasted batch contributed 1 additional actionable fix on top of those. - diff --git a/backlog/tasks/task-192 - Fix-stale-anime-cover-art-after-AniList-reassignment.md b/backlog/tasks/task-192 - Fix-stale-anime-cover-art-after-AniList-reassignment.md index 81a5adc..3106c62 100644 --- a/backlog/tasks/task-192 - Fix-stale-anime-cover-art-after-AniList-reassignment.md +++ b/backlog/tasks/task-192 - Fix-stale-anime-cover-art-after-AniList-reassignment.md @@ -5,7 +5,7 @@ status: Done assignee: - codex created_date: '2026-03-20 00:12' -updated_date: '2026-03-20 00:14' +updated_date: '2026-03-23 03:22' labels: - stats - immersion-tracker @@ -17,6 +17,7 @@ references: - src/core/services/immersion-tracker/query.ts - src/core/services/immersion-tracker-service.test.ts priority: medium +ordinal: 127500 --- ## Description diff --git a/backlog/tasks/task-194 - App-owned-YouTube-subtitle-picker-flow.md b/backlog/tasks/task-194 - App-owned-YouTube-subtitle-picker-flow.md new file mode 100644 index 0000000..ff6d0d9 --- /dev/null +++ b/backlog/tasks/task-194 - App-owned-YouTube-subtitle-picker-flow.md @@ -0,0 +1,35 @@ +--- +id: TASK-194 +title: App-owned YouTube subtitle picker flow +status: Done +assignee: [] +created_date: '2026-03-18 07:52' +updated_date: '2026-03-23 03:22' +labels: [] +dependencies: [] +references: + - /home/sudacode/projects/japanese/SubMiner/launcher/youtube/orchestrator.ts + - /home/sudacode/projects/japanese/SubMiner/launcher/youtube/manual-subs.ts + - /home/sudacode/projects/japanese/SubMiner/src/core/services/tokenizer.ts +documentation: + - /home/sudacode/projects/japanese/SubMiner/youtube.md +priority: medium +ordinal: 137500 +--- + +## Description + + +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. + + +## Acceptance Criteria + +- [x] #1 Launcher and app expose YouTube subtitle acquisition modes `download` and `generate`, with `download` as the default. +- [x] #2 YouTube playback boots mpv paused and presents an overlay selection UI for primary and secondary subtitle choices. +- [x] #3 Selected YouTube subtitle tracks are downloaded to external subtitle files and loaded into mpv before playback resumes. +- [x] #4 `generate` mode preserves the existing subtitle 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. +- [x] #6 Tests cover mode selection, subtitle-track enumeration/selection flow, and the paused bootstrap plus app handoff path. +- [x] #7 User-facing config and launcher docs are updated to describe the new modes and default behavior. + diff --git a/backlog/tasks/task-194 - Redesign-YouTube-subtitle-acquisition-around-download-first-track-selection.md b/backlog/tasks/task-194 - Redesign-YouTube-subtitle-acquisition-around-download-first-track-selection.md deleted file mode 100644 index 3df0052..0000000 --- a/backlog/tasks/task-194 - Redesign-YouTube-subtitle-acquisition-around-download-first-track-selection.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -id: TASK-194 -title: Redesign YouTube subtitle acquisition around download-first track selection -status: To Do -assignee: [] -created_date: '2026-03-18 07:52' -labels: [] -dependencies: [] -references: - - /home/sudacode/projects/japanese/SubMiner/launcher/youtube/orchestrator.ts - - /home/sudacode/projects/japanese/SubMiner/launcher/youtube/manual-subs.ts - - /home/sudacode/projects/japanese/SubMiner/src/core/services/tokenizer.ts -documentation: - - /home/sudacode/projects/japanese/SubMiner/youtube.md -priority: medium ---- - -## Description - - -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. - - -## Acceptance Criteria - -- [ ] #1 Launcher and config expose YouTube subtitle acquisition modes `download`, `generate`, and `auto`, with `download` as the default for launcher YouTube playback. -- [ ] #2 YouTube playback enumerates available subtitle tracks before mpv launch and presents a selection UI that supports 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. -- [ ] #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. -- [ ] #5 `generate` mode preserves the existing whisper/AI generation path as an explicit opt-in behavior. -- [ ] #6 Downloaded YouTube subtitle files integrate with the existing SubMiner subtitle/tokenization/annotation pipeline without regressing current overlay behavior. -- [ ] #7 Tests cover mode selection, subtitle-track enumeration/selection flow, download-first success path, and fallback behavior for auto mode. -- [ ] #8 User-facing config and launcher docs are updated to describe the new modes and default behavior. - diff --git a/backlog/tasks/task-196 - Fix-subtitle-prefetch-cache-key-mismatch-and-active-cue-window.md b/backlog/tasks/task-196 - Fix-subtitle-prefetch-cache-key-mismatch-and-active-cue-window.md index bff32ca..19c6019 100644 --- a/backlog/tasks/task-196 - Fix-subtitle-prefetch-cache-key-mismatch-and-active-cue-window.md +++ b/backlog/tasks/task-196 - Fix-subtitle-prefetch-cache-key-mismatch-and-active-cue-window.md @@ -4,13 +4,16 @@ title: Fix subtitle prefetch cache-key mismatch and active-cue window status: Done assignee: [] created_date: '2026-03-18 16:05' +updated_date: '2026-03-23 03:22' labels: [] dependencies: [] references: - - /home/sudacode/projects/japanese/SubMiner/src/core/services/subtitle-processing-controller.ts - - /home/sudacode/projects/japanese/SubMiner/src/core/services/subtitle-prefetch.ts -documentation: [] + - >- + /home/sudacode/projects/japanese/SubMiner/src/core/services/subtitle-processing-controller.ts + - >- + /home/sudacode/projects/japanese/SubMiner/src/core/services/subtitle-prefetch.ts priority: high +ordinal: 136500 --- ## Description diff --git a/backlog/tasks/task-197 - Eliminate-per-line-plain-subtitle-flash-on-prefetch-cache-hit.md b/backlog/tasks/task-197 - Eliminate-per-line-plain-subtitle-flash-on-prefetch-cache-hit.md index 8414d1e..51a1130 100644 --- a/backlog/tasks/task-197 - Eliminate-per-line-plain-subtitle-flash-on-prefetch-cache-hit.md +++ b/backlog/tasks/task-197 - Eliminate-per-line-plain-subtitle-flash-on-prefetch-cache-hit.md @@ -4,15 +4,19 @@ title: Eliminate per-line plain subtitle flash on prefetch cache hit status: Done assignee: [] created_date: '2026-03-18 16:28' +updated_date: '2026-03-23 03:22' labels: [] dependencies: - TASK-196 references: - - /home/sudacode/projects/japanese/SubMiner/src/core/services/subtitle-processing-controller.ts - - /home/sudacode/projects/japanese/SubMiner/src/main/runtime/mpv-main-event-actions.ts - - /home/sudacode/projects/japanese/SubMiner/src/main/runtime/mpv-main-event-main-deps.ts -documentation: [] + - >- + /home/sudacode/projects/japanese/SubMiner/src/core/services/subtitle-processing-controller.ts + - >- + /home/sudacode/projects/japanese/SubMiner/src/main/runtime/mpv-main-event-actions.ts + - >- + /home/sudacode/projects/japanese/SubMiner/src/main/runtime/mpv-main-event-main-deps.ts priority: high +ordinal: 135500 --- ## Description diff --git a/backlog/tasks/task-199 - Forward-launcher-log-level-into-mpv-plugin-script-opts.md b/backlog/tasks/task-199 - Forward-launcher-log-level-into-mpv-plugin-script-opts.md index c7c05ae..b42489b 100644 --- a/backlog/tasks/task-199 - Forward-launcher-log-level-into-mpv-plugin-script-opts.md +++ b/backlog/tasks/task-199 - Forward-launcher-log-level-into-mpv-plugin-script-opts.md @@ -4,6 +4,7 @@ title: Forward launcher log level into mpv plugin script opts status: Done assignee: [] created_date: '2026-03-18 21:16' +updated_date: '2026-03-23 03:22' labels: [] dependencies: - TASK-198 @@ -12,8 +13,8 @@ references: - /home/sudacode/projects/japanese/SubMiner/launcher/mpv.ts - /home/sudacode/projects/japanese/SubMiner/launcher/main.test.ts - /home/sudacode/projects/japanese/SubMiner/launcher/aniskip-metadata.test.ts -documentation: [] priority: medium +ordinal: 134500 --- ## Description diff --git a/backlog/tasks/task-200 - Address-latest-PR-19-CodeRabbit-follow-ups.md b/backlog/tasks/task-200 - Address-latest-PR-19-CodeRabbit-follow-ups.md index 17031d0..fa7dce9 100644 --- a/backlog/tasks/task-200 - Address-latest-PR-19-CodeRabbit-follow-ups.md +++ b/backlog/tasks/task-200 - Address-latest-PR-19-CodeRabbit-follow-ups.md @@ -5,7 +5,7 @@ status: Done assignee: - '@codex' created_date: '2026-03-19 07:18' -updated_date: '2026-03-19 07:28' +updated_date: '2026-03-23 03:22' labels: - pr-review - anki-integration @@ -19,6 +19,7 @@ references: - src/anki-integration/runtime.ts - src/anki-integration/known-word-cache.ts priority: medium +ordinal: 133500 --- ## Description diff --git a/backlog/tasks/task-201 - Suppress-repeated-macOS-overlay-loading-OSD-during-fullscreen-tracker-flaps.md b/backlog/tasks/task-201 - Suppress-repeated-macOS-overlay-loading-OSD-during-fullscreen-tracker-flaps.md index 1db1c48..8700bee 100644 --- a/backlog/tasks/task-201 - Suppress-repeated-macOS-overlay-loading-OSD-during-fullscreen-tracker-flaps.md +++ b/backlog/tasks/task-201 - Suppress-repeated-macOS-overlay-loading-OSD-during-fullscreen-tracker-flaps.md @@ -5,7 +5,7 @@ status: Done assignee: - '@codex' created_date: '2026-03-19 18:47' -updated_date: '2026-03-19 19:01' +updated_date: '2026-03-23 03:22' labels: - bug - macos @@ -20,6 +20,7 @@ references: - >- /Users/sudacode/projects/japanese/SubMiner/src/core/services/overlay-visibility.test.ts priority: high +ordinal: 131500 --- ## Description diff --git a/backlog/tasks/task-204.1 - Restore-stale-only-startup-known-word-cache-refresh.md b/backlog/tasks/task-204.1 - Restore-stale-only-startup-known-word-cache-refresh.md index be6388e..21ea213 100644 --- a/backlog/tasks/task-204.1 - Restore-stale-only-startup-known-word-cache-refresh.md +++ b/backlog/tasks/task-204.1 - Restore-stale-only-startup-known-word-cache-refresh.md @@ -5,7 +5,7 @@ status: Done assignee: - '@Codex' created_date: '2026-03-20 02:52' -updated_date: '2026-03-20 03:02' +updated_date: '2026-03-23 03:22' labels: - anki - cache @@ -17,6 +17,7 @@ references: - docs/plans/2026-03-19-known-word-cache-incremental-sync-design.md parent_task_id: TASK-204 priority: high +ordinal: 124500 --- ## Description diff --git a/backlog/tasks/task-205 - Address-PR-19-Claude-frontend-review-follow-ups.md b/backlog/tasks/task-205 - Address-PR-19-Claude-frontend-review-follow-ups.md index 77fe71e..7e5c81d 100644 --- a/backlog/tasks/task-205 - Address-PR-19-Claude-frontend-review-follow-ups.md +++ b/backlog/tasks/task-205 - Address-PR-19-Claude-frontend-review-follow-ups.md @@ -5,7 +5,7 @@ status: Done assignee: - codex created_date: '2026-03-20 02:41' -updated_date: '2026-03-20 02:46' +updated_date: '2026-03-23 03:22' labels: [] milestone: m-1 dependencies: [] @@ -14,6 +14,7 @@ references: - stats/src/hooks/useSessions.ts - stats/src/hooks/useTrends.ts priority: medium +ordinal: 126500 --- ## Description diff --git a/backlog/tasks/task-206 - Assess-latest-PR-19-CodeRabbit-review-comments.md b/backlog/tasks/task-206 - Assess-latest-PR-19-CodeRabbit-review-comments.md index 2f71157..57923a4 100644 --- a/backlog/tasks/task-206 - Assess-latest-PR-19-CodeRabbit-review-comments.md +++ b/backlog/tasks/task-206 - Assess-latest-PR-19-CodeRabbit-review-comments.md @@ -5,7 +5,7 @@ status: Done assignee: - '@codex' created_date: '2026-03-20 02:51' -updated_date: '2026-03-20 02:59' +updated_date: '2026-03-23 03:22' labels: - pr-review - launcher @@ -22,6 +22,7 @@ references: - src/anki-integration.ts - src/anki-integration/known-word-cache.ts priority: medium +ordinal: 125500 --- ## Description diff --git a/backlog/tasks/task-207 - Verify-PR-19-follow-up-typecheck-blocker-is-cleared.md b/backlog/tasks/task-207 - Verify-PR-19-follow-up-typecheck-blocker-is-cleared.md index 0818bc4..604345c 100644 --- a/backlog/tasks/task-207 - Verify-PR-19-follow-up-typecheck-blocker-is-cleared.md +++ b/backlog/tasks/task-207 - Verify-PR-19-follow-up-typecheck-blocker-is-cleared.md @@ -5,7 +5,7 @@ status: Done assignee: - '@codex' created_date: '2026-03-20 03:03' -updated_date: '2026-03-20 03:04' +updated_date: '2026-03-23 03:22' labels: - pr-review - anki-integration @@ -15,6 +15,7 @@ dependencies: [] references: - src/anki-integration/anki-connect-proxy.test.ts priority: medium +ordinal: 123500 --- ## Description diff --git a/backlog/tasks/task-208 - Assess-newest-PR-19-CodeRabbit-round-after-1227706.md b/backlog/tasks/task-208 - Assess-newest-PR-19-CodeRabbit-round-after-1227706.md index fc07469..9ca5b96 100644 --- a/backlog/tasks/task-208 - Assess-newest-PR-19-CodeRabbit-round-after-1227706.md +++ b/backlog/tasks/task-208 - Assess-newest-PR-19-CodeRabbit-round-after-1227706.md @@ -5,7 +5,7 @@ status: Done assignee: - '@codex' created_date: '2026-03-20 03:37' -updated_date: '2026-03-20 03:47' +updated_date: '2026-03-23 03:22' labels: - pr-review - launcher @@ -17,6 +17,7 @@ references: - launcher/mpv.ts - src/anki-integration.ts priority: medium +ordinal: 122500 --- ## Description diff --git a/backlog/tasks/task-209 - Exclude-grammar-tail-そうだ-from-subtitle-annotations.md b/backlog/tasks/task-209 - Exclude-grammar-tail-そうだ-from-subtitle-annotations.md index 6660f56..28372ad 100644 --- a/backlog/tasks/task-209 - Exclude-grammar-tail-そうだ-from-subtitle-annotations.md +++ b/backlog/tasks/task-209 - Exclude-grammar-tail-そうだ-from-subtitle-annotations.md @@ -5,7 +5,7 @@ status: Done assignee: - codex created_date: '2026-03-20 04:06' -updated_date: '2026-03-20 04:33' +updated_date: '2026-03-23 03:22' labels: - bug - tokenizer @@ -18,6 +18,7 @@ references: - >- /Users/sudacode/projects/japanese/SubMiner/src/core/services/tokenizer.test.ts priority: high +ordinal: 120500 --- ## Description diff --git a/backlog/tasks/task-210 - Show-latest-session-position-in-anime-episode-progress.md b/backlog/tasks/task-210 - Show-latest-session-position-in-anime-episode-progress.md index 376f7ec..43dfe30 100644 --- a/backlog/tasks/task-210 - Show-latest-session-position-in-anime-episode-progress.md +++ b/backlog/tasks/task-210 - Show-latest-session-position-in-anime-episode-progress.md @@ -5,7 +5,7 @@ status: Done assignee: - '@Codex' created_date: '2026-03-20 04:09' -updated_date: '2026-03-20 04:25' +updated_date: '2026-03-23 03:22' labels: - stats - bug @@ -17,6 +17,7 @@ references: - src/core/services/immersion-tracker/query.ts - src/core/services/immersion-tracker/session.ts - src/core/services/immersion-tracker-service.ts +ordinal: 121500 --- ## Description diff --git a/backlog/tasks/task-211 - Recover-anime-episode-progress-from-subtitle-timing-when-checkpoints-are-missing.md b/backlog/tasks/task-211 - Recover-anime-episode-progress-from-subtitle-timing-when-checkpoints-are-missing.md index 2e41459..a29119e 100644 --- a/backlog/tasks/task-211 - Recover-anime-episode-progress-from-subtitle-timing-when-checkpoints-are-missing.md +++ b/backlog/tasks/task-211 - Recover-anime-episode-progress-from-subtitle-timing-when-checkpoints-are-missing.md @@ -1,11 +1,13 @@ --- id: TASK-211 -title: Recover anime episode progress from subtitle timing when checkpoints are missing +title: >- + Recover anime episode progress from subtitle timing when checkpoints are + missing status: Done assignee: - '@Codex' created_date: '2026-03-20 10:15' -updated_date: '2026-03-20 10:22' +updated_date: '2026-03-23 03:22' labels: - stats - bug @@ -14,20 +16,26 @@ dependencies: [] references: - src/core/services/immersion-tracker/query.ts - src/core/services/immersion-tracker/__tests__/query.test.ts +ordinal: 119500 --- ## Description + Anime episode progress can still show `0%` for older sessions that have watch-time and subtitle timing but no persisted `ended_media_ms` checkpoint. Recover progress from the latest retained subtitle/event segment end so already-recorded sessions render a useful progress percentage. + ## Acceptance Criteria - -- [x] `getAnimeEpisodes` returns the latest known session position even when `ended_media_ms` is null but subtitle/event timing exists. -- [x] Existing ended-session metrics and aggregation totals do not regress. -- [x] Regression coverage locks the fallback behavior. + +- [x] #1 `getAnimeEpisodes` returns the latest known session position even when `ended_media_ms` is null but subtitle/event timing exists. +- [x] #2 Existing ended-session metrics and aggregation totals do not regress. +- [x] #3 Regression coverage locks the fallback behavior. + ## Implementation Notes + Added a query-side fallback for anime episode progress: when the newest session for a video has no persisted `ended_media_ms`, `getAnimeEpisodes` now uses the latest retained subtitle-line or session-event `segment_end_ms` from that same session. This recovers useful progress for already-recorded sessions that have timing data but predate or missed checkpoint persistence. Verification: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts` passed. `bun run typecheck` passed. + diff --git a/backlog/tasks/task-212 - Fix-mac-texthooker-helper-startup-blocking-mpv-launch.md b/backlog/tasks/task-212 - Fix-mac-texthooker-helper-startup-blocking-mpv-launch.md index e1f88f5..cc05b45 100644 --- a/backlog/tasks/task-212 - Fix-mac-texthooker-helper-startup-blocking-mpv-launch.md +++ b/backlog/tasks/task-212 - Fix-mac-texthooker-helper-startup-blocking-mpv-launch.md @@ -1,10 +1,10 @@ --- id: TASK-212 title: Fix mac texthooker helper startup blocking mpv launch -status: In Progress +status: Done assignee: [] created_date: '2026-03-20 08:27' -updated_date: '2026-03-20 08:45' +updated_date: '2026-03-23 03:22' labels: - bug - macos @@ -15,6 +15,7 @@ references: - /Users/sudacode/projects/japanese/SubMiner/src/main.ts - /Users/sudacode/projects/japanese/SubMiner/plugin/subminer/process.lua priority: high +ordinal: 140500 --- ## Description diff --git a/backlog/tasks/task-213 - Show-character-dictionary-progress-during-paused-startup-waits.md b/backlog/tasks/task-213 - Show-character-dictionary-progress-during-paused-startup-waits.md index 1eb5c71..f3f2782 100644 --- a/backlog/tasks/task-213 - Show-character-dictionary-progress-during-paused-startup-waits.md +++ b/backlog/tasks/task-213 - Show-character-dictionary-progress-during-paused-startup-waits.md @@ -1,10 +1,10 @@ --- id: TASK-213 title: Show character dictionary progress during paused startup waits -status: In Progress +status: Done assignee: [] created_date: '2026-03-20 08:59' -updated_date: '2026-03-20 09:22' +updated_date: '2026-03-23 03:22' labels: - bug - ux @@ -18,6 +18,7 @@ references: /Users/sudacode/projects/japanese/SubMiner/src/main/runtime/character-dictionary-auto-sync-notifications.ts - /Users/sudacode/projects/japanese/SubMiner/src/main.ts priority: medium +ordinal: 141500 --- ## Description diff --git a/backlog/tasks/task-214 - Jump-subtitle-sidebar-directly-to-resume-position-on-first-resolved-cue.md b/backlog/tasks/task-214 - Jump-subtitle-sidebar-directly-to-resume-position-on-first-resolved-cue.md index b44c1af..229efe5 100644 --- a/backlog/tasks/task-214 - Jump-subtitle-sidebar-directly-to-resume-position-on-first-resolved-cue.md +++ b/backlog/tasks/task-214 - Jump-subtitle-sidebar-directly-to-resume-position-on-first-resolved-cue.md @@ -1,10 +1,10 @@ --- id: TASK-214 title: Jump subtitle sidebar directly to resume position on first resolved cue -status: In Progress +status: Done assignee: [] created_date: '2026-03-21 11:15' -updated_date: '2026-03-21 11:15' +updated_date: '2026-03-23 03:22' labels: - bug - ux @@ -12,9 +12,12 @@ labels: - subtitles dependencies: [] references: - - /Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.ts - - /Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.test.ts + - >- + /Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.ts + - >- + /Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.test.ts priority: medium +ordinal: 142500 --- ## Description diff --git a/backlog/tasks/task-215 - Add-startup-auto-open-option-for-subtitle-sidebar.md b/backlog/tasks/task-215 - Add-startup-auto-open-option-for-subtitle-sidebar.md index f3434ed..904786f 100644 --- a/backlog/tasks/task-215 - Add-startup-auto-open-option-for-subtitle-sidebar.md +++ b/backlog/tasks/task-215 - Add-startup-auto-open-option-for-subtitle-sidebar.md @@ -1,10 +1,10 @@ --- id: TASK-215 title: Add startup auto-open option for subtitle sidebar -status: In Progress +status: Done assignee: [] created_date: '2026-03-21 11:35' -updated_date: '2026-03-21 11:35' +updated_date: '2026-03-23 03:22' labels: - feature - ux @@ -13,11 +13,15 @@ labels: dependencies: [] references: - /Users/sudacode/projects/japanese/SubMiner/src/types.ts - - /Users/sudacode/projects/japanese/SubMiner/src/config/definitions/defaults-subtitle.ts - - /Users/sudacode/projects/japanese/SubMiner/src/config/resolve/subtitle-domains.ts - - /Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.ts + - >- + /Users/sudacode/projects/japanese/SubMiner/src/config/definitions/defaults-subtitle.ts + - >- + /Users/sudacode/projects/japanese/SubMiner/src/config/resolve/subtitle-domains.ts + - >- + /Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.ts - /Users/sudacode/projects/japanese/SubMiner/src/renderer/renderer.ts priority: medium +ordinal: 143500 --- ## Description diff --git a/backlog/tasks/task-217 - Fix-embedded-overlay-passthrough-sync-between-subtitle-and-sidebar.md b/backlog/tasks/task-217 - Fix-embedded-overlay-passthrough-sync-between-subtitle-and-sidebar.md index 287151c..f9ab82e 100644 --- a/backlog/tasks/task-217 - Fix-embedded-overlay-passthrough-sync-between-subtitle-and-sidebar.md +++ b/backlog/tasks/task-217 - Fix-embedded-overlay-passthrough-sync-between-subtitle-and-sidebar.md @@ -5,7 +5,7 @@ status: Done assignee: - codex created_date: '2026-03-21 23:16' -updated_date: '2026-03-21 23:28' +updated_date: '2026-03-23 03:22' labels: - bug - overlay @@ -18,6 +18,7 @@ references: documentation: - docs/workflow/verification.md priority: high +ordinal: 118500 --- ## Description diff --git a/backlog/tasks/task-218 - Delete-zero-session-media-from-stats-library-and-trends.md b/backlog/tasks/task-218 - Delete-zero-session-media-from-stats-library-and-trends.md new file mode 100644 index 0000000..ee8fc40 --- /dev/null +++ b/backlog/tasks/task-218 - Delete-zero-session-media-from-stats-library-and-trends.md @@ -0,0 +1,69 @@ +--- +id: TASK-218 +title: Delete zero-session media from stats library and trends +status: Done +assignee: + - codex +created_date: '2026-03-22 16:20' +updated_date: '2026-03-24 06:41' +labels: + - stats + - immersion-tracker +dependencies: [] +references: + - >- + /Users/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker/query.ts + - >- + /Users/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker/lifetime.ts + - >- + /Users/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker/maintenance.ts + - >- + /Users/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker/__tests__/query.test.ts +priority: medium +ordinal: 153500 +--- + +## Description + + +Deleting the last retained session for a video still left stale lifetime media rows and trend rollups behind, so the stats dashboard could continue showing ghost entries in Library and Trends after all sessions were gone. + + +## Acceptance Criteria + +- [x] #1 Deleting the final session for a video removes that media from Library queries and detail reads +- [x] #2 Deleting the final session for a video removes stale daily/monthly trend rollups for that media +- [x] #3 Regression coverage proves zero-session media disappears from affected stats surfaces after deletion + + +## Implementation Plan + + +1. Add a failing regression around deleting the only retained session for a video while preexisting lifetime and rollup rows exist. +2. Patch the deletion path to rebuild lifetime and rollup state from retained sessions inside the same transaction. +3. Run focused immersion-tracker tests plus the repo-native verifier core lane and record results. + + +## Implementation Notes + + +Added a query regression that seeds a finished session plus stale lifetime media/anime rows and daily/monthly rollups, deletes that only session, and asserts Library, Anime detail, and Trends all drop the media immediately. + +Refactored lifetime rebuild logic so it can run inside an existing delete transaction, then reused that helper from `deleteSession`, `deleteSessions`, and `deleteVideo`. + +Added a rollup rebuild helper that clears existing daily/monthly rollups and reconstructs them from retained telemetry inside the current transaction so deleted sessions cannot leave ghost trend points behind. + +Verification passed: +- `bun test src/core/services/immersion-tracker/__tests__/query.test.ts` +- `bun test src/core/services/immersion-tracker-service.test.ts` +- `bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh src/core/services/immersion-tracker/query.ts src/core/services/immersion-tracker/lifetime.ts src/core/services/immersion-tracker/maintenance.ts src/core/services/immersion-tracker/__tests__/query.test.ts` +- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core src/core/services/immersion-tracker/query.ts src/core/services/immersion-tracker/lifetime.ts src/core/services/immersion-tracker/maintenance.ts src/core/services/immersion-tracker/__tests__/query.test.ts` + +Verifier artifact dir: `.tmp/skill-verification/subminer-verify-20260322-210718-n6sGL8` + + +## Final Summary + + +Delete paths now rebuild lifetime summaries and trend rollups after removing sessions, so when the last session for a video disappears the stats database also drops that media from Library, related detail reads, and chart data. Added a regression proving a video with only stale lifetime/rollup rows vanishes after its final session is deleted, and verified the change with focused immersion-tracker tests plus the SubMiner core verification lane. + diff --git a/backlog/tasks/task-219 - Restore-streamed-video-progress-in-anime-episodes.md b/backlog/tasks/task-219 - Restore-streamed-video-progress-in-anime-episodes.md new file mode 100644 index 0000000..e86bee0 --- /dev/null +++ b/backlog/tasks/task-219 - Restore-streamed-video-progress-in-anime-episodes.md @@ -0,0 +1,43 @@ +--- +id: TASK-219 +title: Restore streamed video progress in anime episodes +status: Done +assignee: + - codex +created_date: '2026-03-22 21:25' +updated_date: '2026-03-24 06:44' +labels: + - stats + - immersion-tracker + - youtube +dependencies: [] +references: + - >- + /Users/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker/query.ts + - >- + /Users/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker-service.ts + - >- + /Users/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker/__tests__/query.test.ts + - >- + /Users/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker-service.test.ts +priority: medium +--- + +## Description + + +Episode progress for streamed media can stay at `0%` because some remote sessions persist `ended_media_ms = 0` even when subtitle timing and watch activity clearly advanced, and the anime episode query currently treats `0` as a valid progress checkpoint. + + +## Acceptance Criteria + +- [x] #1 Anime episode progress ignores zero-valued session checkpoints and falls back to subtitle/event timing +- [x] #2 New streamed sessions persist meaningful progress even when playback-position updates are missing or sparse +- [x] #3 Regression tests cover the zero-checkpoint remote-session case + + +## Final Summary + + +Restored anime episode progress handling for streamed sessions by ignoring zero-valued `ended_media_ms` checkpoints and falling back to subtitle/event timing, with regression coverage for the remote-session zero-checkpoint case. + diff --git a/backlog/tasks/task-220 - Restore-YouTube-overlay-mpv-keybindings-after-picker-routing.md b/backlog/tasks/task-220 - Restore-YouTube-overlay-mpv-keybindings-after-picker-routing.md new file mode 100644 index 0000000..099fcea --- /dev/null +++ b/backlog/tasks/task-220 - Restore-YouTube-overlay-mpv-keybindings-after-picker-routing.md @@ -0,0 +1,66 @@ +--- +id: TASK-220 +title: Restore YouTube overlay mpv keybindings after picker routing +status: Done +assignee: + - codex +created_date: '2026-03-22 00:00' +updated_date: '2026-03-22 23:49' +labels: + - bug + - overlay + - youtube + - keyboard +dependencies: [] +references: + - src/renderer/handlers/keyboard.ts + - src/renderer/modals/youtube-track-picker.ts + - src/renderer/handlers/keyboard.test.ts + - src/renderer/modals/youtube-track-picker.test.ts +documentation: + - docs/workflow/verification.md +priority: high +ordinal: 118800 +--- + +## Description + + +Regression: after adding the YouTube subtitle picker modal path, visible-overlay keydown handling can stop before reaching the shared mpv keybinding dispatch path. Result: default overlay mpv bindings like `Space` pause/play and `q` quit stop working while the overlay owns focus during YouTube playback. + + +## Acceptance Criteria + +- [x] #1 Unhandled keys while the YouTube track picker state is active still fall through to the shared overlay mpv keybinding dispatcher. +- [x] #2 The YouTube picker continues to consume `Enter` and `Escape` for its own actions. +- [x] #3 Renderer regression tests cover both the picker modal key contract and the shared keyboard dispatch fallback. + + +## Implementation Plan + + +1. Add a failing renderer keyboard regression test covering YouTube picker state plus shared mpv keybinding fallback. +2. Update the global keyboard handler to return early only when the YouTube picker actually handles the key event. +3. Update the picker modal handler to return false for unhandled keys while preserving `Enter`/`Escape`. +4. Run the cheap renderer verification lane and record results. + + +## Implementation Notes + + +Fixed the regression by making the global renderer keyboard handler stop early for the YouTube picker only when the picker actually consumes the key. The picker modal now returns `false` for unrelated keys, so shared overlay mpv bindings like `Space` and `KeyQ` still dispatch while the visible overlay has focus. + +Added regression coverage in the keyboard handler suite for mpv keybinding fallback during YouTube picker state, plus a picker-modal contract test that keeps `Escape` handled but leaves unrelated keys unclaimed. + +Verification: +- `bun test src/renderer/handlers/keyboard.test.ts src/renderer/modals/youtube-track-picker.test.ts` +- `bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh src/renderer/handlers/keyboard.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/youtube-track-picker.ts src/renderer/modals/youtube-track-picker.test.ts` +- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core src/renderer/handlers/keyboard.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/youtube-track-picker.ts src/renderer/modals/youtube-track-picker.test.ts` +- verifier artifact: `.tmp/skill-verification/subminer-verify-20260322-234831-b2m6nJ` + + +## Final Summary + + +Restored YouTube-session overlay mpv keybindings by removing an unconditional early return added to the renderer keyboard path for the YouTube subtitle picker modal. Unhandled keys now fall through to the shared mpv keybinding dispatcher, while handled picker keys (`Enter`, `Escape`) still stay local to the picker. Added renderer regression tests for both the keyboard fallback path and the picker modal key-consumption contract. + diff --git a/backlog/tasks/task-221 - Assess-and-address-PR-31-latest-CodeRabbit-review.md b/backlog/tasks/task-221 - Assess-and-address-PR-31-latest-CodeRabbit-review.md new file mode 100644 index 0000000..dc03033 --- /dev/null +++ b/backlog/tasks/task-221 - Assess-and-address-PR-31-latest-CodeRabbit-review.md @@ -0,0 +1,58 @@ +--- +id: TASK-221 +title: 'Assess and address PR #31 latest CodeRabbit review' +status: Done +assignee: [] +created_date: '2026-03-23 07:53' +updated_date: '2026-03-24 06:41' +labels: + - pr-review + - coderabbit +dependencies: [] +references: + - >- + PR #31 feat: add app-owned YouTube subtitle flow with absPlayer-style + parsing +priority: medium +ordinal: 152500 +--- + +## Description + + +Inspect the latest CodeRabbit review on PR #31, evaluate each actionable comment against the current branch, implement valid fixes, verify the changes, and prepare PR thread updates. + + +## Implementation Plan + + +1. Inspect latest CodeRabbit review on PR #31 and separate valid action items from non-blocking suggestions. +2. Add regression coverage for any real bugs before changing production code. +3. Implement the minimal fixes for confirmed issues in runtime, renderer modal flow, and test fixtures. +4. Run targeted tests plus repo-native verification lanes. +5. Update PR threads with fix status and rationale for any comments not actioned yet. + + +## Implementation Notes + + +Implemented and pushed commit 207151db to PR #31. + +Replied in-thread to the CodeRabbit comments for YouTube host matching, duplicate picker submissions, and missing MediaDetailView test fixture videoId fields. + +Follow-up scope added: update release-facing docs/changelog for the YouTube subtitle picker work and run a release-readiness gate before handoff. + +Added release-facing docs/changelog updates in commit b7e0026d and pushed them to PR #31. + +Ran the release-readiness gate: changelog:lint, changelog:pr-check, verify:config-example, typecheck, test:fast, test:env, build, test:smoke:dist, docs:test, docs:build. + + +## Final Summary + + +Assessed the latest CodeRabbit review on PR #31 and applied the confirmed fixes. Tightened `isYoutubeMediaPath()` to match only exact YouTube hosts or subdomains with a regression test for `notyoutube.com`, added an in-flight guard plus temporary control disabling to the YouTube track picker with a duplicate-submit regression test, replaced the picker empty-state `innerHTML` fallback with explicit DOM construction, and added the missing `videoId` fields to the `MediaDetailView` test fixtures. Verified with targeted Bun tests and the `runtime-compat` verification lane (`build`, `test:runtime:compat`, `test:smoke:dist`). + +Updated `README.md`, `docs-site/usage.md`, and `changes/2026-03-23-immersion-youtube.md` so the PR is release-facing and user-visible surfaces describe the YouTube subtitle picker flow plus its latest hardening. + +Release-readiness checks passed locally: `bun run changelog:lint`, `bun run changelog:pr-check`, `bun run verify:config-example`, `bun run typecheck`, `bun run test:fast`, `bun run test:env`, `bun run build`, `bun run test:smoke:dist`, `bun run docs:test`, and `bun run docs:build`. + diff --git a/backlog/tasks/task-222 - Fix-YouTube-overlay-keybindings-in-subtitle-path.md b/backlog/tasks/task-222 - Fix-YouTube-overlay-keybindings-in-subtitle-path.md new file mode 100644 index 0000000..027a070 --- /dev/null +++ b/backlog/tasks/task-222 - Fix-YouTube-overlay-keybindings-in-subtitle-path.md @@ -0,0 +1,56 @@ +--- +id: TASK-222 +title: Fix YouTube overlay keybindings in subtitle path +status: Done +assignee: + - codex +created_date: '2026-03-23 08:32' +updated_date: '2026-03-24 06:41' +labels: + - bug +dependencies: [] +references: + - /Users/sudacode/projects/japanese/SubMiner/src/main/runtime + - /Users/sudacode/projects/japanese/SubMiner/src/core/services +priority: high +ordinal: 151500 +--- + +## Description + + +Users watching video through the YouTube subtitle path cannot use some overlay keyboard controls such as quit and pause/play. Restore expected overlay keybinding behavior for that playback path without regressing other overlay input handling. + + +## Acceptance Criteria + +- [x] #1 Overlay quit and pause/play keybindings work while using the YouTube subtitle path. +- [x] #2 Existing overlay keybinding behavior for non-YouTube playback remains unchanged. +- [x] #3 Regression coverage exercises the YouTube subtitle path keyboard handling. + + +## Implementation Plan + + +1. Add a regression test around YouTube track-picker close to verify it requests main-process main-window focus restoration before returning overlay focus locally. +2. Update the YouTube track-picker close flow to call `window.electronAPI.focusMainWindow()` alongside the existing `window.focus()` and `overlay.focus()` restoration. +3. Run targeted tests for the picker/keyboard paths to verify YouTube playback regains overlay keybindings without regressing existing overlay behavior. + + +## Implementation Notes + + +Investigated overlay input path. Renderer already maps Space/KeyQ to mpv commands, but YouTube track-picker close only restores DOM focus (`window.focus` + `overlay.focus`) and does not invoke main-process window focus recovery, unlike the keyboard-mode focus reclaim path. Suspected root cause: overlay BrowserWindow focus is not restored after the YouTube picker closes, so playback keybindings stop reaching renderer keydown handlers. + +User approved implementation plan on 2026-03-23. Proceeding with TDD: add failing regression first, then minimal fix, then targeted verification. + +Implemented fix in the YouTube track-picker close path: request main-process `focusMainWindow()` before restoring renderer window/overlay focus so overlay keydown handlers regain input after YouTube subtitle selection. + +Verification: `bun test src/renderer/modals/youtube-track-picker.test.ts` and `bun test src/renderer/handlers/keyboard.test.ts` both pass. + + +## Final Summary + + +Restored overlay keyboard focus after closing the YouTube subtitle picker by invoking the main-process `focusMainWindow()` recovery path before local window/overlay focus restoration. Added regression coverage to the YouTube picker modal test and verified existing keyboard handler coverage for YouTube picker passthrough keys (`Space`, `KeyQ`) remains green. + diff --git a/backlog/tasks/task-223 - Fix-YouTube-overlay-Anki-initialization-regression.md b/backlog/tasks/task-223 - Fix-YouTube-overlay-Anki-initialization-regression.md new file mode 100644 index 0000000..650edd4 --- /dev/null +++ b/backlog/tasks/task-223 - Fix-YouTube-overlay-Anki-initialization-regression.md @@ -0,0 +1,109 @@ +--- +id: TASK-223 +title: Fix YouTube overlay Anki initialization regression +status: Done +assignee: + - codex +created_date: '2026-03-23 08:41' +updated_date: '2026-03-24 06:41' +labels: + - bug + - youtube + - anki +dependencies: [] +references: + - /Users/sudacode/projects/japanese/SubMiner/src/main.ts + - >- + /Users/sudacode/projects/japanese/SubMiner/src/core/services/overlay-runtime-init.ts + - >- + /Users/sudacode/projects/japanese/SubMiner/src/main/runtime/cli-command-runtime-handler.ts +documentation: + - /Users/sudacode/projects/japanese/SubMiner/docs/workflow/verification.md +priority: high +ordinal: 154500 +--- + +## Description + + +Restore Anki-backed lookup and known-word behavior during YouTube playback. Recent startup changes appear to let the YouTube flow initialize the overlay before runtime prerequisites exist, leaving the Anki integration unavailable for popup Mine actions and known-word highlighting. + + +## Acceptance Criteria + +- [x] #1 YouTube playback initializes Anki integration once overlay startup prerequisites are available so lookup can offer card-add actions again +- [x] #2 Known-word / N+1 state is available during YouTube playback when the user has Anki-backed known-word highlighting enabled +- [x] #3 Regression coverage fails before the fix and passes after it for the YouTube startup path + + +## Implementation Plan + + +1. Add a regression test covering the YouTube playback command path and assert overlay startup prerequisites are established before the flow runs. +2. Reuse the overlay startup prerequisite bootstrap for the YouTube playback path so Anki integration sees subtitle tracker, mpv client, and runtime options manager before initialization. +3. Verify with focused runtime/CLI tests, then run the cheapest sufficient verification lane for the touched files. + + +## Implementation Notes + + +Identified regression path in CLI command runtime: YouTube playback commands could reach overlay initialization without first materializing overlay startup prerequisites, leaving Anki integration unavailable during the initial startup attempt. + +Added a regression test at src/main/runtime/cli-command-runtime-handler.test.ts covering youtubePlay command dispatch outside texthooker-only mode. + +Verified with bun test src/main/runtime/cli-command-runtime-handler.test.ts src/main/runtime/cli-command-prechecks.test.ts src/main/runtime/cli-command-prechecks-main-deps.test.ts and bun test src/core/services/cli-command.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/cli-command-prechecks-main-deps.test.ts src/main/runtime/cli-command-runtime-handler.test.ts. + +Removed the YouTube-only ensureOverlayRuntimeReady path from src/main.ts after confirming regular app startup already loads Yomitan and the shared CLI overlay pre-dispatch bootstrap now covers overlay prerequisites. + +Moved overlay bootstrap into the generic initial-args startup path for any initial command that needs overlay runtime, so overlay prerequisites and overlay initialization happen before CLI dispatch instead of inside a YouTube-only or last-moment command path. + +Additional verification passed: bun test src/main/runtime/initial-args-runtime-handler.test.ts src/main/runtime/initial-args-handler.test.ts src/main/runtime/initial-args-main-deps.test.ts, bun run typecheck, bun run test:runtime:compat + +Follow-up regression: subtitle picker was still auto-submitting the default selection during YouTube startup. Investigating renderer-side immediate Enter key bleed-through on picker open. + +Root cause for remaining picker regression: the YouTube track picker accepted Enter immediately on open, so the launch keypress could auto-submit the default track selection before the modal was visible to the user. + +Added renderer regression coverage in src/renderer/modals/youtube-track-picker.test.ts proving immediate Enter after open is ignored and a later Enter still submits normally. + +Implemented a 200ms open-key guard in src/renderer/modals/youtube-track-picker.ts for Enter-based submission only; Escape/click behavior unchanged. + +New follow-up regression report: YouTube subtitle picker can open before the mpv playback window is ready, leaving the picker behind the overlay after geometry snaps into place. Investigating picker-open gating and modal-targeting timing. + +Identified likely cause of picker-behind-overlay regression: YouTube picker open logic mixed overlay targets. First attempt preferred the visible main overlay, timeout retry switched to the dedicated modal window, allowing a late first open to cover the modal. + +Extracted picker-open policy into src/main/runtime/youtube-picker-open.ts and changed YouTube picker startup to always target the dedicated modal window, including retries. This keeps the picker on a single window path and lets overlay-runtime hide/click-through the main overlay while the modal is active. + +Added regression tests in src/main/runtime/youtube-picker-open.test.ts covering dedicated modal first-open, dedicated-modal retry, and failure when no modal target is available. + +User reports overlay flow still feels wrong: YouTube path appears to preload subtitles before mandatory selection and may open the picker before mpv window readiness. Re-evaluating flow design against regular video startup before further implementation. + +New follow-up regression report: duplicate overlay windows appear during YouTube playback and only one window shows subtitles. Investigating main-overlay versus dedicated modal-window handoff/cleanup. + + +## Final Summary + + +Fixed the YouTube Anki initialization regression by making CLI commands that require overlay runtime bootstrap overlay startup prerequisites before command dispatch when not in texthooker-only mode. This ensures the YouTube playback flow has the mpv client, runtime options manager, and subtitle timing tracker ready before overlay/Anki initialization runs, restoring Mine actions and known-word-backed behavior. + +Added a regression test covering youtubePlay command dispatch in src/main/runtime/cli-command-runtime-handler.test.ts. + +Verification: +- bun test src/main/runtime/cli-command-runtime-handler.test.ts src/main/runtime/cli-command-prechecks.test.ts src/main/runtime/cli-command-prechecks-main-deps.test.ts +- bun test src/core/services/cli-command.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/cli-command-prechecks-main-deps.test.ts src/main/runtime/cli-command-runtime-handler.test.ts + +Updated the fix to avoid a YouTube-specific startup path: removed the dedicated ensureOverlayRuntimeReady helper from src/main.ts and relied on the shared CLI overlay prerequisite bootstrap instead. + +Additional verification: bun run typecheck + +Follow-up adjustment: initial overlay-runtime commands now bootstrap overlay prerequisites and initialize overlay during the shared initial-args startup path, rather than waiting for command dispatch. This keeps YouTube on the regular startup path while preserving earlier overlay availability. + +Additional verification: bun test src/main/runtime/initial-args-runtime-handler.test.ts src/main/runtime/initial-args-handler.test.ts src/main/runtime/initial-args-main-deps.test.ts; bun run test:runtime:compat + +Follow-up fix: the YouTube subtitle picker now ignores immediate Enter key bleed-through right after opening, preventing the startup keypress from auto-submitting the default track selection before the modal is visible. + +Added renderer regression coverage for immediate Enter suppression and verified with bun test src/renderer/modals/youtube-track-picker.test.ts plus the runtime-compat verification lane for the touched files. + +Follow-up fix: YouTube subtitle picker startup now uses a dedicated modal-window path consistently instead of mixing main-overlay first-open with modal-window retry. That prevents late overlay opens from covering the interactive picker while mpv/window tracking settles. + +Verified with bun test src/main/runtime/youtube-picker-open.test.ts, bun test src/renderer/modals/youtube-track-picker.test.ts, and the runtime-compat verification lane for src/main.ts plus the touched picker files. + diff --git a/backlog/tasks/task-224 - Auto-load-default-YouTube-subtitles-at-playback-start-and-make-picker-manual-only.md b/backlog/tasks/task-224 - Auto-load-default-YouTube-subtitles-at-playback-start-and-make-picker-manual-only.md new file mode 100644 index 0000000..4eba01b --- /dev/null +++ b/backlog/tasks/task-224 - Auto-load-default-YouTube-subtitles-at-playback-start-and-make-picker-manual-only.md @@ -0,0 +1,61 @@ +--- +id: TASK-224 +title: >- + Auto-load default YouTube subtitles at playback start and make picker + manual-only +status: Done +assignee: + - Codex +created_date: '2026-03-23 18:51' +updated_date: '2026-03-24 06:41' +labels: + - youtube + - mpv + - overlay + - keybindings +dependencies: [] +references: + - /Users/sudacode/projects/japanese/SubMiner/src/main/runtime/youtube-flow.ts + - >- + /Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/youtube-track-picker.ts + - /Users/sudacode/projects/japanese/SubMiner/src/config/definitions/shared.ts +priority: high +ordinal: 150500 +--- + +## Description + + +Replace the mandatory YouTube subtitle picker startup flow with automatic default-track loading. On YouTube playback start, attempt to load the default primary subtitle and best-effort secondary subtitle without prompting. Gate playback only on primary subtitle load/tokenization readiness. If primary subtitle probing/download/loading fails, resume playback and report the failure through the configured notification/output path. Keep the YouTube subtitle picker as a regular overlay modal opened by a new default keybinding during active YouTube playback. + + +## Acceptance Criteria + +- [x] #1 Opening a YouTube URL auto-selects and attempts to load the default primary subtitle without opening the picker modal. +- [x] #2 Opening a YouTube URL also attempts to load the default secondary subtitle when available, but playback never waits on secondary success. +- [x] #3 Playback remains gated only until the primary subtitle is loaded and tokenization is ready; primary failure resumes playback immediately. +- [x] #4 Primary auto-load failures report through the existing configured notification/output path and keep playback running. +- [x] #5 The YouTube subtitle picker can be opened manually during active YouTube playback via a new default keybinding. +- [x] #6 Regression tests cover startup auto-load success, primary failure fallback, and the manual picker keybinding flow. + + +## Implementation Plan + + +1. Add failing tests for YouTube startup auto-load success, primary failure fallback, and manual picker keybinding flow. +2. Refactor the YouTube runtime to auto-select default tracks on startup, gate playback only on primary subtitle/tokenization readiness, and route failures through the configured notification/output path. +3. Add a new default keybinding and command path to open the YouTube picker manually during active YouTube playback. +4. Run targeted tests, then SubMiner verification lanes for launcher/runtime changes; update docs/changelog if required by the final behavior change. + + +## Implementation Notes + + +Verification blocker outside this change: `bun run test:fast` still fails at `scripts/update-aur-package.test.ts` on macOS because `scripts/update-aur-package.sh` uses `mapfile`, which is unavailable in the system Bash 3.x environment used here. + + +## Final Summary + + +Reworked app-owned YouTube playback to auto-load the default primary subtitle plus a best-effort secondary subtitle at startup instead of forcing the picker modal first. Playback now waits only on primary subtitle load/tokenization readiness, routes startup primary-failure messaging through the configured notification output path, and keeps the YouTube subtitle picker available on demand via a new default `Ctrl+Shift+J` keybinding during active YouTube playback. Updated the runtime/IPC/config plumbing, user-facing help/docs, and added regression coverage for startup auto-load, primary-failure fallback, and manual picker invocation. + diff --git a/backlog/tasks/task-225 - Fix-frozen-primary-YouTube-subtitle-display-after-auto-load-startup.md b/backlog/tasks/task-225 - Fix-frozen-primary-YouTube-subtitle-display-after-auto-load-startup.md new file mode 100644 index 0000000..e3e674e --- /dev/null +++ b/backlog/tasks/task-225 - Fix-frozen-primary-YouTube-subtitle-display-after-auto-load-startup.md @@ -0,0 +1,41 @@ +--- +id: TASK-225 +title: Fix frozen primary YouTube subtitle display after auto-load startup +status: Done +assignee: [] +created_date: '2026-03-23 20:07' +updated_date: '2026-03-24 06:41' +labels: + - bug + - youtube + - subtitles +dependencies: + - TASK-224 +priority: high +ordinal: 149500 +--- + +## Description + + +After the new YouTube auto-load startup flow, the primary subtitle overlay can stay stuck on an older line while the subtitle sidebar continues advancing. Investigate startup suppression / subtitle refresh timing and restore live primary overlay updates after auto-loaded subtitles are injected. + + +## Acceptance Criteria + +- [x] #1 When YouTube auto-load succeeds, the visible primary subtitle continues advancing after playback resumes. +- [x] #2 Startup suppression does not leave the primary subtitle display stuck on a stale line. +- [x] #3 A regression test covers the startup path that previously froze the visible primary subtitle while sidebar timing continued advancing. + + +## Implementation Notes + + +Root cause: applyStartupState seeded youtubePlaybackFlowPending from initialArgs.youtubePlay, and runYoutubePlaybackFlowMain restored that preexisting true value after startup auto-load. Result: primary subtitle events stayed suppressed for startup-launched YouTube playback while sidebar timing still advanced. + + +## Final Summary + + +Stopped pre-seeding youtubePlaybackFlowPending from startup CLI args so only the actual YouTube playback bootstrap window suppresses subtitle events. Added a regression test covering startup YouTube args and re-ran targeted YouTube/runtime subtitle tests plus typecheck. + diff --git a/backlog/tasks/task-226 - Restore-subtitle-sidebar-cues-for-auto-loaded-YouTube-subtitles.md b/backlog/tasks/task-226 - Restore-subtitle-sidebar-cues-for-auto-loaded-YouTube-subtitles.md new file mode 100644 index 0000000..e597203 --- /dev/null +++ b/backlog/tasks/task-226 - Restore-subtitle-sidebar-cues-for-auto-loaded-YouTube-subtitles.md @@ -0,0 +1,42 @@ +--- +id: TASK-226 +title: Restore subtitle sidebar cues for auto-loaded YouTube subtitles +status: Done +assignee: [] +created_date: '2026-03-23 20:21' +updated_date: '2026-03-24 06:41' +labels: + - bug + - youtube + - subtitle-sidebar +dependencies: + - TASK-224 + - TASK-225 +priority: high +ordinal: 148500 +--- + +## Description + + +After fixing startup subtitle event suppression, the primary subtitle overlay updates for auto-loaded YouTube playback but the subtitle sidebar reports no parsed subtitle cues available. Investigate parsed subtitle source registration / refresh for auto-loaded YouTube subtitle files and restore sidebar cue population. + + +## Acceptance Criteria + +- [x] #1 When YouTube auto-load succeeds, the subtitle sidebar receives parsed cues for the active primary subtitle source. +- [x] #2 Auto-loaded YouTube subtitle source changes refresh the sidebar snapshot without requiring manual picker interaction. +- [x] #3 A regression test covers the startup auto-load path where live primary subtitles render but sidebar cues remain empty. + + +## Implementation Notes + + +Root cause: successful YouTube auto-load refreshed visible primary subtitle state, but did not explicitly initialize parsed subtitle cues from the resolved downloaded primary subtitle file. Sidebar cue population depended on later mpv source rediscovery, which could leave snapshots empty. + + +## Final Summary + + +Added a regression test for YouTube auto-load sidebar cue refresh and wired the YouTube subtitle flow to explicitly refresh parsed subtitle cues from the resolved primary subtitle path after a successful load. Verified with targeted YouTube/sidebar/runtime tests plus typecheck. + diff --git a/backlog/tasks/task-227 - Assess-and-address-PR-31-latest-CodeRabbit-review-round.md b/backlog/tasks/task-227 - Assess-and-address-PR-31-latest-CodeRabbit-review-round.md new file mode 100644 index 0000000..3fd7000 --- /dev/null +++ b/backlog/tasks/task-227 - Assess-and-address-PR-31-latest-CodeRabbit-review-round.md @@ -0,0 +1,65 @@ +--- +id: TASK-227 +title: 'Assess and address PR #31 latest CodeRabbit review round' +status: Done +assignee: + - codex +created_date: '2026-03-24 03:53' +updated_date: '2026-03-24 06:41' +labels: + - pr-review + - coderabbit +dependencies: [] +references: + - >- + PR #31 feat: add app-owned YouTube subtitle flow with absPlayer-style + parsing +priority: medium +ordinal: 147500 +--- + +## Description + + +Inspect the latest CodeRabbit review round on PR #31, verify each actionable comment against the current branch, implement only the valid fixes, add regression coverage where appropriate, and prepare thread replies for resolved or declined items. + + +## Acceptance Criteria + +- [x] #1 Latest CodeRabbit comments on PR #31 are triaged into valid fixes vs non-actioned suggestions with rationale. +- [x] #2 Confirmed issues are fixed with regression coverage where appropriate. +- [x] #3 Relevant verification passes for the touched areas. +- [x] #4 PR reply notes are ready for each addressed or declined latest-review comment. + + +## Implementation Plan + + +1. Verify the five latest CodeRabbit inline comments against the current branch and separate valid bugs from non-actioned suggestions. +2. Add failing regression coverage for confirmed issues in launcher playback tests, CLI YouTube flow error handling, and renderer YouTube picker disabled-state behavior. +3. Implement the minimal production fixes for the confirmed issues, plus remove the duplicate overlay Anki initialization if still redundant. +4. Inspect the YouTube primary-subtitle failure timer wiring to decide whether a code change is warranted in this round or whether a technical reply declining the comment is more correct. +5. Run targeted Bun tests for the touched files and prepare concise PR thread replies for each latest-review comment. + + +## Implementation Notes + + +Triaged the latest PR #31 CodeRabbit round: five inline comments were current action items; implemented all five. Strengthened the launcher playback test fixture so YouTube pause coverage no longer piggybacks on generic overlay auto-pause settings. + +Added regression tests for CLI YouTube flow rejection handling, no-track picker disabled-state restoration, and app-owned YouTube notification suppression while subtitle acquisition is still in flight. + +Implemented `runAsyncWithOsd(...)` handling for `args.youtubePlay`, kept no-track picker controls disabled after failed continue attempts, added `setAppOwnedFlowInFlight(...)` to the YouTube primary-subtitle notification runtime with main-process wiring around `runYoutubePlaybackFlowMain(...)`, and removed the duplicate `initializeOverlayAnkiIntegrationCore(...)` call from `initializeOverlayRuntime()`. + +Verification passed: `bun test launcher/commands/playback-command.test.ts src/core/services/cli-command.test.ts src/renderer/modals/youtube-track-picker.test.ts src/main/runtime/youtube-primary-subtitle-notification.test.ts` and `bun run typecheck`. + + +## Final Summary + + +Assessed the latest CodeRabbit review round on PR #31 and implemented all five current inline action items. Strengthened the launcher playback regression test so app-owned YouTube pause behavior is asserted independently from generic overlay auto-pause settings, wrapped the CLI `youtubePlay` branch in the existing `runAsyncWithOsd(...)` path so probe/download/startup failures surface in logs and OSD, kept the no-track YouTube picker controls disabled after rejected continue attempts, suppressed the generic primary-subtitle failure timer while the app-owned YouTube flow is still probing/downloading and restarted it only after the flow settles, and removed the duplicate overlay Anki initialization from `initializeOverlayRuntime()`. + +Verification passed with `bun test launcher/commands/playback-command.test.ts src/core/services/cli-command.test.ts src/renderer/modals/youtube-track-picker.test.ts src/main/runtime/youtube-primary-subtitle-notification.test.ts` and `bun run typecheck`. + +Prepared thread-reply notes for the five latest inline comments; did not post them because GitHub replies are an external side effect. + diff --git a/backlog/tasks/task-228 - Assess-and-address-PR-31-subsequent-CodeRabbit-review-round.md b/backlog/tasks/task-228 - Assess-and-address-PR-31-subsequent-CodeRabbit-review-round.md new file mode 100644 index 0000000..dd2d077 --- /dev/null +++ b/backlog/tasks/task-228 - Assess-and-address-PR-31-subsequent-CodeRabbit-review-round.md @@ -0,0 +1,64 @@ +--- +id: TASK-228 +title: 'Assess and address PR #31 subsequent CodeRabbit review round' +status: Done +assignee: + - codex +created_date: '2026-03-24 04:10' +updated_date: '2026-03-24 06:41' +labels: + - pr-review + - coderabbit +dependencies: [] +references: + - >- + PR #31 feat: add app-owned YouTube subtitle flow with absPlayer-style + parsing + - 'commit cdb12827 fix: address PR #31 latest review follow-ups' +priority: medium +ordinal: 146500 +--- + +## Description + + +Inspect the subsequent CodeRabbit review round on PR #31 after commit cdb12827, verify each newly reported issue against the current branch, implement the valid fixes with regression coverage where appropriate, and prepare/update PR thread replies. + + +## Acceptance Criteria + +- [x] #1 New CodeRabbit comments after cdb12827 are triaged into valid fixes vs declined suggestions with rationale. +- [x] #2 Confirmed issues are fixed with regression coverage where appropriate. +- [x] #3 Relevant verification passes for the touched areas. +- [x] #4 PR threads are updated for the addressed comments. + + +## Implementation Plan + + +1. Verify the new CodeRabbit comments after cdb12827 and separate valid bugs from refactor-only suggestions. +2. Add failing regression coverage for the valid runtime issues: `track.selected` fallback in the YouTube primary-subtitle notifier and consistent no-track handling in the picker. +3. Inspect existing test seams for the `main.ts` flow-entry guards; if lightweight coverage exists, add it before patching. Otherwise apply the minimal `main.ts` fixes and rely on typecheck plus targeted regression tests around the affected runtime helpers. +4. Implement the confirmed fixes: picker re-entry guard, broader `inFlight` cleanup, `track.selected` fallback, and a single canonical `hasTracks` check. +5. Run targeted tests/typecheck and update the new PR threads with landed fix refs. + + +## Implementation Notes + + +Triaged the post-cdb12827 CodeRabbit round. Implemented the 4 concrete follow-ups: manual picker re-entry guard, broader `setAppOwnedFlowInFlight(...)` cleanup, `track.selected` fallback in the YouTube primary-subtitle notifier, and a single canonical `payloadHasTracks(...)` helper in the picker. Also took the adjacent `replaceChildren()` cleanup while touching the same picker paths. + +Verification passed: `bun test src/main/runtime/youtube-primary-subtitle-notification.test.ts src/renderer/modals/youtube-track-picker.test.ts launcher/commands/playback-command.test.ts src/core/services/cli-command.test.ts` and `bun run typecheck`. + +Updated the new CodeRabbit inline threads with landed fix refs and left a top-level PR comment noting the large refactor suggestions are intentionally out of scope for this bugfix round. + + +## Final Summary + + +Assessed the subsequent CodeRabbit review round on PR #31 after cdb12827 and applied the valid follow-ups in commit 5f6f93cd. Added a guard in `openYoutubeTrackPickerFromPlayback()` so the manual picker cannot re-enter while another YouTube flow session is active, widened the app-owned in-flight suppression to cover synchronous Windows mpv bootstrap and connect failures, taught the primary-subtitle notifier to honor `track.selected` before `sid` arrives, and unified the picker’s subtitle-availability logic behind `payloadHasTracks(...)` while swapping node clearing to `replaceChildren()`. + +Verification passed with `bun test src/main/runtime/youtube-primary-subtitle-notification.test.ts src/renderer/modals/youtube-track-picker.test.ts launcher/commands/playback-command.test.ts src/core/services/cli-command.test.ts` and `bun run typecheck`. + +Updated the latest inline CodeRabbit threads plus a top-level PR comment summarizing the round and explicitly deferred the large refactor suggestions as non-blocking maintainability nits. + diff --git a/backlog/tasks/task-229 - Address-PR-31-final-CodeRabbit-picker-test-follow-up.md b/backlog/tasks/task-229 - Address-PR-31-final-CodeRabbit-picker-test-follow-up.md new file mode 100644 index 0000000..315a92c --- /dev/null +++ b/backlog/tasks/task-229 - Address-PR-31-final-CodeRabbit-picker-test-follow-up.md @@ -0,0 +1,55 @@ +--- +id: TASK-229 +title: 'Address PR #31 final CodeRabbit picker test follow-up' +status: Done +assignee: + - codex +created_date: '2026-03-24 04:27' +updated_date: '2026-03-24 06:41' +labels: + - pr-review + - coderabbit +dependencies: [] +references: + - >- + PR #31 feat: add app-owned YouTube subtitle flow with absPlayer-style + parsing + - >- + CodeRabbit comment on src/renderer/modals/youtube-track-picker.test.ts + global restoration / harness duplication +priority: medium +ordinal: 145500 +--- + +## Description + + +Fix the remaining CodeRabbit comment on the YouTube picker test file by restoring absent globals correctly and reducing repeated test harness setup so global stubbing is consistent and isolated. + + +## Acceptance Criteria + +- [x] #1 Picker tests restore `window`, `document`, and `CustomEvent` without leaving undefined-valued globals behind. +- [x] #2 Repeated picker test setup is consolidated enough to remove the current review complaint. +- [x] #3 Relevant picker tests pass and PR thread is updated. + + +## Implementation Plan + + +1. Add a failing regression around global restoration semantics in the YouTube picker test harness. +2. Extract shared DOM/environment helpers and restore logic using delete when globals were originally absent. +3. Re-run focused tests and typecheck, then commit/push and reply on the PR thread. + + +## Implementation Notes + + +Latest CodeRabbit comment targets youtube-track-picker.test.ts harness cleanup and correct restoration of global properties. + + +## Final Summary + + +Addressed the last PR #31 CodeRabbit comment by refactoring the YouTube picker test harness to use shared DOM/env helpers, restoring absent globals via delete semantics, adding a regression for cleanup behavior, and pushing commit 039e2f56 with focused picker tests plus typecheck passing. + diff --git a/changes/2026-03-22-subtitle-sidebar-config.md b/changes/2026-03-22-subtitle-sidebar-config.md deleted file mode 100644 index 89f18d9..0000000 --- a/changes/2026-03-22-subtitle-sidebar-config.md +++ /dev/null @@ -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. diff --git a/changes/2026-03-22-websocket-texthooker-docs.md b/changes/2026-03-22-websocket-texthooker-docs.md deleted file mode 100644 index 6322acc..0000000 --- a/changes/2026-03-22-websocket-texthooker-docs.md +++ /dev/null @@ -1,5 +0,0 @@ -type: docs -area: docs - -- Added a new WebSocket / Texthooker API and integration guide covering websocket payloads, custom client patterns, mpv plugin automation, and webhook-style relay examples. -- Linked the new integration guide from configuration and mining workflow docs for easier discovery. diff --git a/config.example.jsonc b/config.example.jsonc index ab7af4a..b94e0c2 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -187,7 +187,7 @@ // ========================================== // Secondary Subtitles // Dual subtitle track options. - // Used by subminer YouTube subtitle generation as secondary language preferences. + // Used by the YouTube subtitle loading flow as secondary language preferences. // Hot-reload: defaultMode updates live while SubMiner is running. // ========================================== "secondarySub": { @@ -414,24 +414,24 @@ }, // Jimaku API configuration and defaults. // ========================================== - // YouTube Subtitle Generation - // Defaults for SubMiner YouTube subtitle generation. + // YouTube Playback Settings + // Defaults for SubMiner YouTube subtitle loading and languages. // ========================================== "youtubeSubgen": { - "whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine. - "whisperModel": "", // Path to whisper model used for fallback transcription. - "whisperVadModel": "", // Path to optional whisper VAD model used for subtitle generation. - "whisperThreads": 4, // Thread count passed to whisper.cpp subtitle generation runs. - "fixWithAi": false, // Use shared AI provider to post-process whisper-generated YouTube subtitles. Values: true | false + "whisperBin": "", // Legacy compatibility path kept for external subtitle fallback tools; not used by default. + "whisperModel": "", // Legacy compatibility model path kept for external subtitle fallback tooling; not used by default. + "whisperVadModel": "", // Legacy compatibility VAD path kept for external subtitle fallback tooling; not used by default. + "whisperThreads": 4, // Legacy thread tuning for subtitle fallback tooling; not used by default. + "fixWithAi": false, // Legacy subtitle fallback post-processing switch kept for compatibility; use is currently disabled by default. Values: true | false "ai": { - "model": "", // Optional model override for YouTube subtitle AI post-processing. - "systemPrompt": "" // Optional system prompt override for YouTube subtitle AI post-processing. + "model": "", // Optional model override for legacy subtitle fallback post-processing; not used by default. + "systemPrompt": "" // Optional system prompt override for legacy subtitle fallback post-processing; not used by default. }, // Ai setting. "primarySubLanguages": [ "ja", "jpn" ] // Comma-separated primary subtitle language priority used by the launcher. - }, // Defaults for SubMiner YouTube subtitle generation. + }, // Defaults for SubMiner YouTube subtitle loading and languages. // ========================================== // Anilist diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index ca4f4a2..faf4b8f 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -29,7 +29,8 @@ In both modes, the enrichment workflow is the same: 4. Fills the translation field from the secondary subtitle or AI. 5. Writes metadata to the miscInfo field. -Polling mode uses the query `"deck:" added:1` to find recently added cards. If no deck is configured, it searches all decks. +Polling mode uses the query `"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) diff --git a/docs-site/changelog.md b/docs-site/changelog.md index 0c1f489..af67d37 100644 --- a/docs-site/changelog.md +++ b/docs-site/changelog.md @@ -1,11 +1,29 @@ # Changelog +## v0.9.0 (2026-03-23) +- Added an app-owned YouTube subtitle flow with absPlayer-style timedtext parsing that auto-loads the default primary subtitle plus a best-effort secondary at startup and resumes once the primary is ready. +- Added a manual YouTube subtitle picker on `Ctrl+Alt+C` so subtitle selection can be retried on demand during active YouTube playback. +- Added yt-dlp metadata probing so YouTube playback and immersion tracking record canonical video title and channel metadata. +- Disabled conflicting mpv native subtitle auto-selection for the app-owned flow so injected explicit tracks stay authoritative. +- Added OSD status updates covering YouTube playback startup, subtitle acquisition, and subtitle loading. +- Stopped forcing `--ytdl-raw-options=` before user-provided mpv options so existing YouTube cookie integrations are preserved. +- Improved sidebar startup/resume behavior, scroll handling, and overlay/sidebar subtitle synchronization. +- Stats Library tab now shows YouTube video title, channel name, and thumbnail for YouTube media entries. +- Added a new WebSocket / Texthooker API integration guide covering payload formats, custom client patterns, and mpv plugin automation. +- Fixed Anki media mining for mpv YouTube streams so audio and screenshot capture work correctly during YouTube playback sessions. +- Fixed YouTube media path handling in immersion tracking so YouTube sessions record correct media references and AniList state transitions do not fire for YouTube media. +- Reused existing authoritative YouTube subtitle tracks when present, fell back only for missing sides, and kept native mpv secondary subtitle rendering hidden so the overlay remains the visible secondary subtitle surface. + ## 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 release docs updates for sidebar configuration, including options, sample config, and toggle shortcut behavior. -- Synced sidebar and overlay subtitle states during playback transitions via IPC-backed snapshot plumbing. -- Fixed sidebar cue tracking to remain stable across timing edge cases and stale subtitle refreshes. -- Improved sidebar resume/start behavior by jumping directly to the first resolved active cue. +- Added a rendered sidebar modal with cue list display, click-to-seek, active-cue highlighting, and embedded layout support. +- Added sidebar snapshot plumbing between main and renderer for overlay/sidebar synchronization. +- Added sidebar configuration options for visibility and behavior (enabled, layout, toggle key, autoOpen, pauseOnHover, autoScroll) plus typography and sizing controls. +- 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) - Added a full local immersion dashboard release line with Overview, Library, Trends, Vocabulary, and Sessions drill-down views backed by SQLite tracking data. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 99788bd..85ad227 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -17,6 +17,11 @@ For most users, start with this minimal configuration: "ankiConnect": { "enabled": true, "deck": "YourDeckName", + "knownWords": { + "decks": { + "YourDeckName": ["Word", "Word Reading", "Expression"] + } + }, "fields": { "sentence": "Sentence", "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. ## Configuration File @@ -120,7 +127,7 @@ The configuration file includes several main sections: - [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates - [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite - [**Stats Dashboard**](#stats-dashboard) - Local dashboard and overlay for immersion progress -- [**YouTube Subtitle Generation**](#youtube-subtitle-generation) - Launcher defaults for yt-dlp + local whisper fallback +- [**YouTube Playback Settings**](#youtube-playback-settings) - Defaults for YouTube subtitle loading ## Core Settings @@ -348,7 +355,8 @@ Configure the parsed-subtitle sidebar modal. ```json { "subtitleSidebar": { - "enabled": true, + "enabled": false, + "autoOpen": false, "layout": "overlay", "toggleKey": "Backslash", "pauseVideoOnHover": false, @@ -362,12 +370,13 @@ Configure the parsed-subtitle sidebar modal. | Option | Values | Description | | --------------------------- | ---------------- | -------------------------------------------------------------------------------- | | `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 | | `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) | | `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list | | `autoScroll` | boolean | Keep the active cue in view while playback advances | | `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 | | `textColor` | hex color | Default cue text color | | `fontFamily` | string | CSS `font-family` value applied to sidebar cue text | @@ -460,6 +469,7 @@ See `config.example.jsonc` for detailed configuration options and more examples. | `Space` | `["cycle", "pause"]` | Toggle pause | | `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track | | `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track | +| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker | | `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds | | `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds | | `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds | @@ -731,7 +741,7 @@ Palette controls: ### Shared AI Provider Shared OpenAI-compatible transport settings live at the top level under `ai`. -Anki and YouTube subtitle cleanup both read this provider, then apply feature-local overrides where supported. +Anki reads this provider directly. Legacy subtitle fallback keeps the same provider shape for compatibility, then applies feature-local overrides where supported. ```json { @@ -751,12 +761,14 @@ Anki and YouTube subtitle cleanup both read this provider, then apply feature-lo | `apiKey` | string | Static API key for the shared provider | | `apiKeyCommand` | string | Shell command used to resolve the API key | | `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`) | -SubMiner uses the shared provider in two places: +SubMiner uses the shared provider for: - Anki translation/enrichment when `ankiConnect.ai.enabled` is `true` -- YouTube whisper subtitle post-processing when `youtubeSubgen.fixWithAi` is `true` +- Legacy subtitle fallback compatibility when `youtubeSubgen.fixWithAi` is `true` ### AnkiConnect @@ -840,8 +852,8 @@ This example is intentionally compact. The option table below documents availabl | `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`) | | `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.knownWords.decks` | array of strings | Decks used for known-word cache lookups. When omitted/empty, falls back to `ankiConnect.deck`. | +| `ankiConnect.deck` | string | Legacy Anki polling/compatibility scope. Newer known-word cache scoping should use `ankiConnect.knownWords.decks`. | +| `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.audio` | string | Card field for audio files (default: `ExpressionAudio`) | | `fields.image` | string | Card field for images (default: `Picture`) | @@ -862,6 +874,7 @@ This example is intentionally compact. The option table below documents availabl | `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.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.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) | @@ -870,10 +883,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.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.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.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.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.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"`) | @@ -919,7 +933,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.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.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. - 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. @@ -1263,6 +1277,14 @@ Enable or disable local immersion analytics stored in SQLite for mined subtitles | `retention.monthlyRollupsDays` | integer (`0`-`36500`) | Monthly rollup retention window. Default `0` (keep all). | | `retention.vacuumIntervalDays` | integer (`0`-`3650`) | Minimum spacing between `VACUUM` passes. `0` disables vacuum. Default `0` (disabled). | +You can also disable immersion tracking for a single session using: + +```bash +SUBMINER_DISABLE_IMMERSION_TRACKING=1 subminer +``` + +When this is set, SubMiner skips immersion-tracker startup and does not initialize or read the immersion SQLite database for that session. + Default behavior keeps raw events, telemetry, sessions, and rollups forever while still maintaining lifetime summary tables and daily/monthly rollups for faster reads. If you later want bounded retention, switch `retentionMode` or set explicit `retention.*` values. When `dbPath` is blank or omitted, SubMiner writes telemetry and session summaries to the default app-data location: @@ -1283,7 +1305,7 @@ Configure the local stats UI served from SubMiner and the in-app stats overlay t { "stats": { "toggleKey": "Backquote", - "serverPort": 5175, + "serverPort": 6969, "autoStartServer": true, "autoOpenBrowser": true } @@ -1293,7 +1315,7 @@ Configure the local stats UI served from SubMiner and the in-app stats overlay t | Option | Values | Description | | ----------------- | ----------------- | --------------------------------------------------------------------------- | | `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`. | | `autoOpenBrowser` | `true`, `false` | When `subminer stats` starts the server on demand, also open the dashboard in your default browser. Default `true`. | @@ -1304,22 +1326,13 @@ Usage notes: - The dashboard reads from the same immersion-tracking database, so keep `immersionTracking.enabled` on if you want data to appear. - The UI includes Overview, Library, Trends, Vocabulary, and Sessions tabs. -### YouTube Subtitle Generation +### YouTube Playback Settings -Set defaults used by the `subminer` launcher for YouTube subtitle generation: +Set defaults used by the `subminer` launcher for YouTube subtitle loading: ```json { "youtubeSubgen": { - "whisperBin": "/path/to/whisper-cli", - "whisperModel": "/path/to/ggml-model.bin", - "whisperVadModel": "/path/to/ggml-vad.bin", - "whisperThreads": 4, - "fixWithAi": false, - "ai": { - "model": "openai/gpt-4o-mini", - "systemPrompt": "Fix subtitle mistakes only." - }, "primarySubLanguages": ["ja", "jpn"] } } @@ -1327,27 +1340,22 @@ Set defaults used by the `subminer` launcher for YouTube subtitle generation: | Option | Values | Description | | --------------------- | -------------------- | ---------------------------------------------------------------------------------------------- | -| `whisperBin` | string path | Path to `whisper.cpp` CLI binary used as fallback transcription engine | -| `whisperModel` | string path | Path to whisper model used by fallback transcription | -| `whisperVadModel` | string path | Optional whisper VAD model path | -| `whisperThreads` | integer | Thread count passed to whisper runs | -| `fixWithAi` | `true`, `false` | Run shared AI post-processing on whisper-generated subtitles | -| `ai.model` | string | Optional model override for YouTube AI subtitle cleanup | -| `ai.systemPrompt` | string | Optional system prompt override for YouTube AI subtitle cleanup | -| `primarySubLanguages` | string[] | Primary subtitle language priority for YouTube subtitle generation (default `["ja", "jpn"]`) | +| `primarySubLanguages` | string[] | Primary subtitle language priority for YouTube auto-loading (default `["ja", "jpn"]`) | -Launcher behavior: +Current launcher behavior: -- For YouTube URLs, subtitle generation now runs before mpv launch. -- SubMiner probes manual/native YouTube subtitle tracks first. -- Missing tracks fall back to local `whisper.cpp`. -- English secondary subtitles can use whisper translate fallback when no manual track exists. -- If `fixWithAi` is enabled, only whisper-generated `.srt` output is post-processed with the shared top-level `ai` provider. +- For YouTube URLs, SubMiner probes subtitle tracks with yt-dlp after mpv bootstrap and binds auto-selected tracks before normal playback resumes. +- If YouTube/mpv already exposes an authoritative matching subtitle track, SubMiner reuses it; otherwise it downloads and injects only the missing side. +- SubMiner loads the primary subtitle plus a best-effort secondary subtitle. +- Playback waits only for primary subtitle readiness; secondary failures do not block playback. +- English secondary subtitles are selected from `secondarySub.secondarySubLanguages` when primary language matches are unavailable. +- Native mpv secondary subtitle rendering stays hidden during this flow so the SubMiner overlay remains the visible secondary subtitle surface. +- If primary subtitle loading fails, use `Ctrl+Alt+C` to open the subtitle modal and pick a track. Language targets are derived from subtitle config: - primary track: `youtubeSubgen.primarySubLanguages` (falls back to `["ja","jpn"]`) - secondary track: `secondarySub.secondarySubLanguages` (falls back to English when empty) -- Subtitle files are generated or downloaded before mpv starts; the older launcher mode switch has been removed. +- Tracks are resolved and loaded before mpv starts; the older launcher mode switch has been removed. Precedence for launcher defaults is: CLI flag > environment variable > `config.jsonc` > built-in default. diff --git a/docs-site/development.md b/docs-site/development.md index 435b239..62c78be 100644 --- a/docs-site/development.md +++ b/docs-site/development.md @@ -231,13 +231,13 @@ Run `make help` for a full list of targets. Key ones: | `SUBMINER_ROFI_THEME` | Override rofi theme path for launcher picker | | `SUBMINER_LOG_LEVEL` | Override app logger level (`debug`, `info`, `warn`, `error`) | | `SUBMINER_MPV_LOG` | Override mpv/app shared log file path | -| `SUBMINER_WHISPER_BIN` | Override `youtubeSubgen.whisperBin` for launcher | -| `SUBMINER_WHISPER_MODEL` | Override `youtubeSubgen.whisperModel` for launcher | -| `SUBMINER_WHISPER_VAD_MODEL` | Override `youtubeSubgen.whisperVadModel` for launcher | -| `SUBMINER_WHISPER_THREADS` | Override `youtubeSubgen.whisperThreads` for launcher | -| `SUBMINER_YT_SUBGEN_OUT_DIR` | Override generated subtitle output directory | -| `SUBMINER_YT_SUBGEN_AUDIO_FORMAT` | Override extraction format used for whisper fallback | -| `SUBMINER_YT_SUBGEN_KEEP_TEMP` | Set to `1` to keep temporary subtitle-generation workspace | +| `SUBMINER_WHISPER_BIN` | Override legacy `youtubeSubgen.whisperBin` fallback compatibility path | +| `SUBMINER_WHISPER_MODEL` | Override legacy `youtubeSubgen.whisperModel` fallback compatibility path | +| `SUBMINER_WHISPER_VAD_MODEL` | Override legacy `youtubeSubgen.whisperVadModel` fallback compatibility path | +| `SUBMINER_WHISPER_THREADS` | Override legacy `youtubeSubgen.whisperThreads` fallback compatibility value | +| `SUBMINER_YT_SUBGEN_OUT_DIR` | Override legacy fallback subtitle output directory | +| `SUBMINER_YT_SUBGEN_AUDIO_FORMAT` | Override extraction format used by legacy fallback subtitle path | +| `SUBMINER_YT_SUBGEN_KEEP_TEMP` | Set to `1` to keep legacy fallback subtitle workspace | | `SUBMINER_JIMAKU_API_KEY` | Override Jimaku API key for launcher subtitle downloads | | `SUBMINER_JIMAKU_API_KEY_COMMAND` | Command used to resolve Jimaku API key at runtime | | `SUBMINER_JIMAKU_API_BASE_URL` | Override Jimaku API base URL | diff --git a/docs-site/docs-sync.test.ts b/docs-site/docs-sync.test.ts index a83f153..94786d8 100644 --- a/docs-site/docs-sync.test.ts +++ b/docs-site/docs-sync.test.ts @@ -20,7 +20,7 @@ function extractReleaseHeadings(content: string, count: number): string[] { test('docs reflect current launcher and release surfaces', () => { expect(usageContents).not.toContain('--mode preprocess'); expect(usageContents).not.toContain('"automatic" (default)'); - expect(usageContents).toContain('before mpv starts'); + expect(usageContents).toContain('during startup while mpv is paused'); expect(installationContents).toContain('bun run build:appimage'); expect(installationContents).toContain('bun run build:win'); diff --git a/docs-site/immersion-tracking.md b/docs-site/immersion-tracking.md index 0614dd2..9d38c26 100644 --- a/docs-site/immersion-tracking.md +++ b/docs-site/immersion-tracking.md @@ -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. - 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. -- 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 @@ -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. +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. + ![Stats Library](/screenshots/stats-library.png) #### Trends @@ -68,7 +70,7 @@ Stats server config lives under `stats`: { "stats": { "toggleKey": "Backquote", - "serverPort": 5175, + "serverPort": 6969, "autoStartServer": true, "autoOpenBrowser": true } diff --git a/docs-site/index.md b/docs-site/index.md index 6a1e377..62c98e2 100644 --- a/docs-site/index.md +++ b/docs-site/index.md @@ -51,8 +51,8 @@ features: - icon: src: /assets/video.svg alt: Video playback icon - title: YouTube & Whisper - details: Play YouTube URLs or searches with native subtitles, or generate them with whisper.cpp and optional AI cleanup. + title: YouTube Playback + details: Play YouTube URLs or ytsearch targets directly — SubMiner automatically selects and loads subtitles for the video. link: /usage#youtube-playback linkText: YouTube playback - icon: @@ -72,10 +72,10 @@ features: - icon: src: /assets/tokenization.svg alt: Tracking chart icon - title: Immersion Tracking - details: Logs watch time, words encountered, and cards mined to SQLite, then surfaces the same data in a local stats dashboard with rollups and session drill-down. + title: Stats Dashboard + details: Browse session history, streak calendars, vocabulary frequency, and per-series progress in a local dashboard — then mine cards straight from your viewing history. link: /immersion-tracking - linkText: Stats details + linkText: Dashboard & tracking - icon: src: /assets/cross-platform.svg alt: Cross-platform icon @@ -120,7 +120,7 @@ const demoAssetVersion = '20260223-2';
05
Track
-
Review immersion history and repeat high-value patterns over time.
+
Open the stats dashboard to review sessions, vocabulary trends, and mine cards from past viewing history.
diff --git a/docs-site/launcher-script.md b/docs-site/launcher-script.md index c65b7d8..f64ed62 100644 --- a/docs-site/launcher-script.md +++ b/docs-site/launcher-script.md @@ -58,24 +58,30 @@ subminer --start video.mkv # optional explicit overlay start when plugin au subminer -S video.mkv # same as above via --start-overlay subminer https://youtu.be/... # YouTube playback (requires yt-dlp) subminer ytsearch:"jp news" # YouTube search +subminer stats # open immersion dashboard +subminer stats -b # start background stats daemon +subminer stats -s # stop background stats daemon subminer --setup # Open first-run setup popup ``` ## Subcommands -| Subcommand | Purpose | -| -------------------------- | ---------------------------------------------------------- | -| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login) | -| `subminer yt` / `youtube` | YouTube shorthand (`-o`, `-m`) | -| `subminer doctor` | Dependency + config + socket diagnostics | -| `subminer config path` | Print active config file path | -| `subminer config show` | Print active config contents | -| `subminer mpv status` | Check mpv socket readiness | -| `subminer mpv socket` | Print active socket path | -| `subminer mpv idle` | Launch detached idle mpv instance | -| `subminer dictionary ` | Generate character dictionary ZIP from file/dir target | -| `subminer texthooker` | Launch texthooker-only mode | -| `subminer app` | Pass arguments directly to SubMiner binary | +| Subcommand | Purpose | +| ---------------------------- | ---------------------------------------------------------- | +| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login) | +| `subminer stats` | Start stats server and open immersion dashboard in browser | +| `subminer stats -b` | Start or reuse background stats daemon (non-blocking) | +| `subminer stats -s` | Stop the background stats daemon | +| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows | +| `subminer doctor` | Dependency + config + socket diagnostics | +| `subminer config path` | Print active config file path | +| `subminer config show` | Print active config contents | +| `subminer mpv status` | Check mpv socket readiness | +| `subminer mpv socket` | Print active socket path | +| `subminer mpv idle` | Launch detached idle mpv instance | +| `subminer dictionary ` | Generate character dictionary ZIP from file/dir target | +| `subminer texthooker` | Launch texthooker-only mode | +| `subminer app` | Pass arguments directly to SubMiner binary | Use `subminer -h` for command-specific help. diff --git a/docs-site/mining-workflow.md b/docs-site/mining-workflow.md index 4d1a500..0d17b65 100644 --- a/docs-site/mining-workflow.md +++ b/docs-site/mining-workflow.md @@ -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. -```text -Watch video → See subtitle → Hover word + trigger lookup → Yomitan popup → Add to Anki - ↓ - SubMiner auto-fills: - sentence, audio, image, translation +```mermaid +flowchart LR + classDef step fill:#c6a0f6,stroke:#494d64,color:#24273a,stroke-width:1.5px + classDef action fill:#8aadf4,stroke:#494d64,color:#24273a,stroke-width:1.5px + 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) @@ -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. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index ab7af4a..b94e0c2 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -187,7 +187,7 @@ // ========================================== // Secondary Subtitles // Dual subtitle track options. - // Used by subminer YouTube subtitle generation as secondary language preferences. + // Used by the YouTube subtitle loading flow as secondary language preferences. // Hot-reload: defaultMode updates live while SubMiner is running. // ========================================== "secondarySub": { @@ -414,24 +414,24 @@ }, // Jimaku API configuration and defaults. // ========================================== - // YouTube Subtitle Generation - // Defaults for SubMiner YouTube subtitle generation. + // YouTube Playback Settings + // Defaults for SubMiner YouTube subtitle loading and languages. // ========================================== "youtubeSubgen": { - "whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine. - "whisperModel": "", // Path to whisper model used for fallback transcription. - "whisperVadModel": "", // Path to optional whisper VAD model used for subtitle generation. - "whisperThreads": 4, // Thread count passed to whisper.cpp subtitle generation runs. - "fixWithAi": false, // Use shared AI provider to post-process whisper-generated YouTube subtitles. Values: true | false + "whisperBin": "", // Legacy compatibility path kept for external subtitle fallback tools; not used by default. + "whisperModel": "", // Legacy compatibility model path kept for external subtitle fallback tooling; not used by default. + "whisperVadModel": "", // Legacy compatibility VAD path kept for external subtitle fallback tooling; not used by default. + "whisperThreads": 4, // Legacy thread tuning for subtitle fallback tooling; not used by default. + "fixWithAi": false, // Legacy subtitle fallback post-processing switch kept for compatibility; use is currently disabled by default. Values: true | false "ai": { - "model": "", // Optional model override for YouTube subtitle AI post-processing. - "systemPrompt": "" // Optional system prompt override for YouTube subtitle AI post-processing. + "model": "", // Optional model override for legacy subtitle fallback post-processing; not used by default. + "systemPrompt": "" // Optional system prompt override for legacy subtitle fallback post-processing; not used by default. }, // Ai setting. "primarySubLanguages": [ "ja", "jpn" ] // Comma-separated primary subtitle language priority used by the launcher. - }, // Defaults for SubMiner YouTube subtitle generation. + }, // Defaults for SubMiner YouTube subtitle loading and languages. // ========================================== // Anilist diff --git a/docs-site/public/screenshots/anki-mining.png b/docs-site/public/screenshots/anki-mining.png deleted file mode 100644 index f535597..0000000 Binary files a/docs-site/public/screenshots/anki-mining.png and /dev/null differ diff --git a/docs-site/public/screenshots/one-key-mining.png b/docs-site/public/screenshots/one-key-mining.png new file mode 100644 index 0000000..655f93e Binary files /dev/null and b/docs-site/public/screenshots/one-key-mining.png differ diff --git a/docs-site/public/screenshots/yomitan-lookup.png b/docs-site/public/screenshots/yomitan-lookup.png index de309bf..c8b678d 100644 Binary files a/docs-site/public/screenshots/yomitan-lookup.png and b/docs-site/public/screenshots/yomitan-lookup.png differ diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index 503aa17..73521e2 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -67,6 +67,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle | `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` | | `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | | `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | +| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | | `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | | `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` | | `` ` `` | Toggle stats overlay | `stats.toggleKey` | diff --git a/docs-site/subtitle-annotations.md b/docs-site/subtitle-annotations.md index 686908c..1f00422 100644 --- a/docs-site/subtitle-annotations.md +++ b/docs-site/subtitle-annotations.md @@ -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.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.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger | | `ankiConnect.nPlusOne.nPlusOne` | `#c6a0f6` | Color for the single unknown target word | diff --git a/docs-site/troubleshooting.md b/docs-site/troubleshooting.md index c9e5094..cadca60 100644 --- a/docs-site/troubleshooting.md +++ b/docs-site/troubleshooting.md @@ -29,7 +29,7 @@ SubMiner retries the connection automatically with increasing delays (200 ms, 50 - Common spikes come from: - first subtitle parse/tokenization bursts - media generation (`ffmpeg` audio/image and AVIF paths) - - media sync and subtitle tooling (`alass`, `ffsubsync`, `whisper` fallback path) + - media sync and subtitle tooling (`alass`, `ffsubsync`) - `ankiConnect` enrichment (plus polling overhead when proxy mode is disabled) ### If playback feels sluggish @@ -57,7 +57,7 @@ SubMiner retries the connection automatically with increasing delays (200 ms, 50 - disable AI translation when not needed (`ankiConnect.ai.enabled: false`) - if needed, run immersion telemetry with lower duration expectations (`immersionTracking.enabled: false` for constrained sessions) -- prefer YouTube `--mode automatic` over `preprocess` on low-resource systems +- favor the default lightweight YouTube subtitle startup settings on low-resource systems ### Practical low-impact profile diff --git a/docs-site/usage.md b/docs-site/usage.md index 7c938f3..40de30b 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -78,8 +78,6 @@ subminer mpv idle # Launch detached idle mpv with SubMiner defau subminer dictionary /path/to/file-or-directory # Generate character dictionary ZIP from target (manual Yomitan import) subminer texthooker # Launch texthooker-only mode subminer app --anilist # Pass args directly to SubMiner binary (example: AniList login flow) -subminer yt -o ~/subs https://youtu.be/... # YouTube subcommand: output directory shortcut -subminer yt --keep-temp --whisper-bin /path/to/whisper-cli --whisper-model /path/to/model.bin --whisper-vad-model /path/to/ggml-vad.bin https://youtu.be/... # Keep generated subtitle workspace for debugging # Direct packaged app control SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs) @@ -137,14 +135,13 @@ This flow requires `mpv.exe` to be on `PATH`. If it is installed elsewhere, set ### Launcher Subcommands - `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases. -- `subminer yt` / `subminer youtube`: YouTube-focused shorthand flags (`-o`, `--keep-temp`, `--whisper-*`). - `subminer doctor`: health checks for core dependencies and runtime paths. - `subminer config`: config helpers (`path`, `show`). - `subminer mpv`: mpv helpers (`status`, `socket`, `idle`). - `subminer dictionary `: generates a Yomitan-importable character dictionary ZIP from a file/directory target. - `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`). - `subminer app` / `subminer bin`: direct passthrough to the SubMiner binary/AppImage. -- Subcommand help pages are available (for example `subminer jellyfin -h`, `subminer yt -h`). +- Subcommand help pages are available (for example `subminer jellyfin -h`). ### First-Run Setup @@ -174,8 +171,8 @@ AniList character dictionary auto-sync (optional): - SubMiner syncs the currently watched AniList media into a per-media snapshot, then rebuilds one merged `SubMiner Character Dictionary` from the most recently used snapshots. - Rotation limit defaults to 3 recent media snapshots in that merged dictionary (`maxLoaded`). -Use subcommands for Jellyfin/YouTube command families (`subminer jellyfin ...`, `subminer yt ...`). -Top-level launcher flags like `--jellyfin-*` and `--yt-subgen-*` are intentionally rejected. +Use subcommands for Jellyfin workflows (`subminer jellyfin ...`). +Top-level launcher flags like `--jellyfin-*` are intentionally rejected. ### MPV Profile Example (mpv.conf) @@ -228,26 +225,18 @@ If you also use Yomitan in a browser, configure that browser profile separately; ### YouTube Playback `subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets. -For YouTube playback, SubMiner now generates or downloads subtitle tracks before mpv starts, then launches mpv with the resolved subtitle files attached. +For YouTube playback, SubMiner resolves subtitle selection during startup while mpv is paused: it auto-selects the default primary subtitle track plus a best-effort secondary track, then resumes when primary subtitles are ready. Notes: - Install `yt-dlp` so mpv can resolve YouTube streams and subtitle tracks reliably. -- For YouTube URLs, `subminer` now generates any missing subtitles before mpv launch. -- It probes manual/native YouTube subtitle tracks first, then falls back to local `whisper.cpp` only for missing tracks. +- For YouTube URLs, startup no longer requires opening the picker first; SubMiner loads subtitles and keeps the overlay available for retries. +- Press `Ctrl+Alt+C` during active YouTube playback to open the manual YouTube subtitle picker and retry track selection. +- For YouTube URLs, `subminer` probes available YouTube subtitle tracks, reuses existing authoritative tracks when available, and downloads only missing sides. +- Native mpv secondary subtitle rendering stays hidden so the overlay remains the visible secondary subtitle surface. - Primary subtitle target languages come from `youtubeSubgen.primarySubLanguages` (defaults to `["ja","jpn"]`). - Secondary target languages come from `secondarySub.secondarySubLanguages` (defaults to English if unset). -- Whisper translation fallback currently only supports English secondary targets; non-English secondary targets rely on native/manual subtitle availability. -- Optional AI cleanup for whisper-generated subtitles is controlled by `youtubeSubgen.fixWithAi` plus the shared top-level `ai` config (with optional `youtubeSubgen.ai` overrides). -- Configure defaults in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`) under `youtubeSubgen`, `secondarySub`, and `ai`. -- CLI overrides are available through `subminer yt` / `subminer youtube`: - - `-o, --out-dir` - - `--keep-temp` - - `--whisper-bin` - - `--whisper-model` - - `--whisper-vad-model` - - `--whisper-threads` - - `--yt-subgen-audio-format` +- Configure defaults in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`) under `youtubeSubgen` and `secondarySub`. ## Controller Support diff --git a/docs/architecture/domains.md b/docs/architecture/domains.md index c756686..0a910ac 100644 --- a/docs/architecture/domains.md +++ b/docs/architecture/domains.md @@ -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/` - Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts` - 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-*` - Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*` - Window trackers: `src/window-trackers/` diff --git a/launcher/aniskip-metadata.ts b/launcher/aniskip-metadata.ts index 047b03e..24ce56f 100644 --- a/launcher/aniskip-metadata.ts +++ b/launcher/aniskip-metadata.ts @@ -553,10 +553,12 @@ export function buildSubminerScriptOpts( socketPath: string, aniSkipMetadata: AniSkipMetadata | null, logLevel: LogLevel = 'info', + extraParts: string[] = [], ): string { const parts = [ `subminer-binary_path=${sanitizeScriptOptValue(appPath)}`, `subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`, + ...extraParts.map(sanitizeScriptOptValue), ]; if (logLevel !== 'info') { parts.push(`subminer-log_level=${sanitizeScriptOptValue(logLevel)}`); diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts index 5844d52..751f1ac 100644 --- a/launcher/commands/command-modules.test.ts +++ b/launcher/commands/command-modules.test.ts @@ -149,20 +149,16 @@ test('doctor command forwards refresh-known-words to app binary', () => { context.args.doctorRefreshKnownWords = true; const forwarded: string[][] = []; - assert.throws( - () => - runDoctorCommand(context, { - commandExists: () => false, - configExists: () => true, - resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc', - runAppCommandWithInherit: (_appPath, appArgs) => { - forwarded.push(appArgs); - throw new ExitSignal(0); - }, - }), - (error: unknown) => error instanceof ExitSignal && error.code === 0, - ); + const handled = runDoctorCommand(context, { + commandExists: () => false, + configExists: () => true, + resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc', + runAppCommandWithInherit: (_appPath, appArgs) => { + forwarded.push(appArgs); + }, + }); + assert.equal(handled, true); 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'; const forwarded: string[][] = []; - assert.throws( - () => - runDictionaryCommand(context, { - runAppCommandWithInherit: (_appPath, appArgs) => { - forwarded.push(appArgs); - throw new ExitSignal(0); - }, - }), - (error: unknown) => error instanceof ExitSignal && error.code === 0, - ); + const handled = runDictionaryCommand(context, { + runAppCommandWithInherit: (_appPath, appArgs) => { + forwarded.push(appArgs); + }, + }); + assert.equal(handled, true); 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(); context.args.dictionary = true; - assert.throws( - () => - runDictionaryCommand(context, { - runAppCommandWithInherit: () => undefined as never, - }), - /unexpectedly returned/, - ); + const handled = runDictionaryCommand(context, { + runAppCommandWithInherit: () => undefined, + }); + + assert.equal(handled, true); }); test('stats command launches attached app command with response path', async () => { diff --git a/launcher/commands/dictionary-command.ts b/launcher/commands/dictionary-command.ts index da820bd..02b0785 100644 --- a/launcher/commands/dictionary-command.ts +++ b/launcher/commands/dictionary-command.ts @@ -2,7 +2,7 @@ import { runAppCommandWithInherit } from '../mpv.js'; import type { LauncherCommandContext } from './context.js'; interface DictionaryCommandDeps { - runAppCommandWithInherit: (appPath: string, appArgs: string[]) => never; + runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void; } const defaultDeps: DictionaryCommandDeps = { @@ -27,5 +27,5 @@ export function runDictionaryCommand( } deps.runAppCommandWithInherit(appPath, forwarded); - throw new Error('Dictionary command app handoff unexpectedly returned.'); + return true; } diff --git a/launcher/commands/doctor-command.ts b/launcher/commands/doctor-command.ts index 6931bea..2ea2090 100644 --- a/launcher/commands/doctor-command.ts +++ b/launcher/commands/doctor-command.ts @@ -9,7 +9,7 @@ interface DoctorCommandDeps { commandExists(command: string): boolean; configExists(path: string): boolean; resolveMainConfigPath(): string; - runAppCommandWithInherit(appPath: string, appArgs: string[]): never; + runAppCommandWithInherit(appPath: string, appArgs: string[]): void; } const defaultDeps: DoctorCommandDeps = { @@ -51,7 +51,7 @@ export function runDoctorCommand( ok: deps.commandExists('ffmpeg'), detail: deps.commandExists('ffmpeg') ? 'found' - : 'missing (optional unless subtitle generation)', + : 'missing (optional unless legacy subtitle fallback is enabled)', }, { label: 'fzf', @@ -85,6 +85,7 @@ export function runDoctorCommand( return true; } deps.runAppCommandWithInherit(appPath, ['--refresh-known-words']); + return true; } const hasHardFailure = checks.some((entry) => diff --git a/launcher/commands/jellyfin-command.ts b/launcher/commands/jellyfin-command.ts index e359fef..f6696bd 100644 --- a/launcher/commands/jellyfin-command.ts +++ b/launcher/commands/jellyfin-command.ts @@ -21,6 +21,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); appendPasswordStore(forwarded); runAppCommandWithInherit(appPath, forwarded); + return true; } if (args.jellyfinLogin) { @@ -44,6 +45,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); appendPasswordStore(forwarded); runAppCommandWithInherit(appPath, forwarded); + return true; } if (args.jellyfinLogout) { @@ -51,6 +53,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); appendPasswordStore(forwarded); runAppCommandWithInherit(appPath, forwarded); + return true; } if (args.jellyfinPlay) { @@ -69,13 +72,8 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); appendPasswordStore(forwarded); runAppCommandWithInherit(appPath, forwarded); + return true; } - return Boolean( - args.jellyfin || - args.jellyfinLogin || - args.jellyfinLogout || - args.jellyfinPlay || - args.jellyfinDiscovery, - ); + return false; } diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts new file mode 100644 index 0000000..adb68c4 --- /dev/null +++ b/launcher/commands/playback-command.test.ts @@ -0,0 +1,133 @@ +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 app-owned youtube flow args', async () => { + const calls: string[] = []; + const context = createContext(); + context.pluginRuntimeConfig = { + ...context.pluginRuntimeConfig, + autoStart: false, + autoStartVisibleOverlay: false, + autoStartPauseUntilReady: false, + }; + let receivedStartMpvOptions: Record | null = null; + + await runPlaybackCommandWithDeps(context, { + ensurePlaybackSetupReady: async () => {}, + chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }), + checkDependencies: () => {}, + registerCleanup: () => {}, + startMpv: async ( + _target, + _targetKind, + _args, + _socketPath, + _appPath, + _preloadedSubtitles, + options, + ) => { + receivedStartMpvOptions = options ?? null; + 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', + ]); + assert.deepEqual(receivedStartMpvOptions, { + startPaused: true, + disableYoutubeSubtitleAutoLoad: true, + }); +}); diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts index e82af32..81840a3 100644 --- a/launcher/commands/playback-command.ts +++ b/launcher/commands/playback-command.ts @@ -6,13 +6,13 @@ import { commandExists, isYoutubeTarget, realpathMaybe, resolvePathMaybe } from import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js'; import { cleanupPlaybackSession, + launchAppCommandDetached, startMpv, startOverlay, state, stopOverlay, waitForUnixSocketReady, } from '../mpv.js'; -import { generateYoutubeSubtitles } from '../youtube.js'; import type { Args } from '../types.js'; import type { LauncherCommandContext } from './context.js'; import { ensureLauncherSetupReady } from '../setup-gate.js'; @@ -31,11 +31,12 @@ function checkDependencies(args: Args): void { if (!commandExists('mpv')) missing.push('mpv'); - if (args.targetKind === 'url' && isYoutubeTarget(args.target) && !commandExists('yt-dlp')) { + const isYoutubeUrl = args.targetKind === 'url' && isYoutubeTarget(args.target); + if (args.targetKind === 'url' && !isYoutubeUrl && !commandExists('yt-dlp')) { missing.push('yt-dlp'); } - if (args.targetKind === 'url' && isYoutubeTarget(args.target) && !commandExists('ffmpeg')) { + if (args.targetKind === 'url' && !isYoutubeUrl && !commandExists('ffmpeg')) { missing.push('ffmpeg'); } @@ -126,30 +127,66 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis } export async function runPlaybackCommand(context: LauncherCommandContext): Promise { + return runPlaybackCommandWithDeps(context, { + ensurePlaybackSetupReady, + chooseTarget, + checkDependencies, + registerCleanup, + startMpv, + waitForUnixSocketReady, + startOverlay, + launchAppCommandDetached, + log, + cleanupPlaybackSession, + getMpvProc: () => state.mpvProc, + }); +} + +type PlaybackCommandDeps = { + ensurePlaybackSetupReady: (context: LauncherCommandContext) => Promise; + 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 { const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig, processAdapter } = context; if (!appPath) { fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.'); } - await ensurePlaybackSetupReady(context); + await deps.ensurePlaybackSetupReady(context); if (!args.target) { checkPickerDependencies(args); } - const targetChoice = await chooseTarget(args, scriptPath); + const targetChoice = await deps.chooseTarget(args, scriptPath); if (!targetChoice) { - log('info', args.logLevel, 'No video selected, exiting'); + deps.log('info', args.logLevel, 'No video selected, exiting'); processAdapter.exit(0); } - checkDependencies({ + deps.checkDependencies({ ...args, target: targetChoice ? targetChoice.target : args.target, targetKind: targetChoice ? targetChoice.kind : 'url', }); - registerCleanup(context); + deps.registerCleanup(context); const selectedTarget = targetChoice ? { @@ -159,30 +196,11 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi : { target: args.target, kind: 'url' as const }; 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) { - log('info', args.logLevel, 'YouTube subtitle generation: preload before mpv'); - 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}`, - ); + deps.log('info', args.logLevel, 'YouTube subtitle flow: app-owned picker after mpv bootstrap'); } const shouldPauseUntilOverlayReady = @@ -191,47 +209,57 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi pluginRuntimeConfig.autoStartPauseUntilReady; 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.kind, args, mpvSocketPath, appPath, - preloadedSubtitles, - { startPaused: shouldPauseUntilOverlayReady }, + undefined, + { + startPaused: shouldPauseUntilOverlayReady || isAppOwnedYoutubeFlow, + disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow, + }, ); - const ready = await waitForUnixSocketReady(mpvSocketPath, 10000); + const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 10000); const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart; - const shouldStartOverlay = args.startOverlay || args.autoStartOverlay; + const shouldStartOverlay = args.startOverlay || args.autoStartOverlay || isAppOwnedYoutubeFlow; if (shouldStartOverlay) { 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 { - log( + deps.log( 'info', args.logLevel, '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) { 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 { - 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) { - log( + deps.log( 'info', args.logLevel, 'MPV IPC socket ready, overlay auto-start disabled (use y-s to start)', ); } else { - log( + deps.log( 'info', args.logLevel, 'MPV IPC socket not ready yet, overlay auto-start disabled (use y-s to start)', @@ -239,7 +267,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi } await new Promise((resolve) => { - const mpvProc = state.mpvProc; + const mpvProc = deps.getMpvProc(); if (!mpvProc) { stopOverlay(args); resolve(); @@ -247,7 +275,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi } const finalize = (code: number | null | undefined) => { - void cleanupPlaybackSession(args).finally(() => { + void deps.cleanupPlaybackSession(args).finally(() => { processAdapter.setExitCode(code ?? 0); resolve(); }); diff --git a/launcher/config.test.ts b/launcher/config.test.ts index 0af357a..2962e55 100644 --- a/launcher/config.test.ts +++ b/launcher/config.test.ts @@ -10,7 +10,6 @@ test('launcher root help lists subcommands', () => { assert.match(output, /Commands:/); assert.match(output, /jellyfin\|jf/); - assert.match(output, /yt\|youtube/); assert.match(output, /doctor/); assert.match(output, /config/); assert.match(output, /mpv/); diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index 08e4e2e..91aadd7 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -249,26 +249,6 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations parsed.jellyfinLogout = Boolean(modeFlags.logout); } - if (invocations.ytInvocation) { - if (invocations.ytInvocation.logLevel) - parsed.logLevel = parseLogLevel(invocations.ytInvocation.logLevel); - if (invocations.ytInvocation.outDir) - parsed.youtubeSubgenOutDir = invocations.ytInvocation.outDir; - if (invocations.ytInvocation.keepTemp) parsed.youtubeSubgenKeepTemp = true; - if (invocations.ytInvocation.whisperBin) - parsed.whisperBin = invocations.ytInvocation.whisperBin; - if (invocations.ytInvocation.whisperModel) - parsed.whisperModel = invocations.ytInvocation.whisperModel; - if (invocations.ytInvocation.whisperVadModel) - parsed.whisperVadModel = invocations.ytInvocation.whisperVadModel; - if (invocations.ytInvocation.whisperThreads) - parsed.whisperThreads = invocations.ytInvocation.whisperThreads; - if (invocations.ytInvocation.ytSubgenAudioFormat) { - parsed.youtubeSubgenAudioFormat = invocations.ytInvocation.ytSubgenAudioFormat; - } - if (invocations.ytInvocation.target) ensureTarget(invocations.ytInvocation.target, parsed); - } - if (invocations.dictionaryLogLevel) { parsed.logLevel = parseLogLevel(invocations.dictionaryLogLevel); } diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts index 40ea761..75221f7 100644 --- a/launcher/config/cli-parser-builder.ts +++ b/launcher/config/cli-parser-builder.ts @@ -14,18 +14,6 @@ export interface JellyfinInvocation { logLevel?: string; } -export interface YtInvocation { - target?: string; - outDir?: string; - keepTemp?: boolean; - whisperBin?: string; - whisperModel?: string; - whisperVadModel?: string; - whisperThreads?: number; - ytSubgenAudioFormat?: string; - logLevel?: string; -} - export interface CommandActionInvocation { action: string; logLevel?: string; @@ -33,7 +21,6 @@ export interface CommandActionInvocation { export interface CliInvocations { jellyfinInvocation: JellyfinInvocation | null; - ytInvocation: YtInvocation | null; configInvocation: CommandActionInvocation | null; mpvInvocation: CommandActionInvocation | null; appInvocation: { appArgs: string[] } | null; @@ -89,8 +76,6 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n const commandNames = new Set([ 'jellyfin', 'jf', - 'yt', - 'youtube', 'doctor', 'config', 'mpv', @@ -142,7 +127,6 @@ export function parseCliPrograms( invocations: CliInvocations; } { let jellyfinInvocation: JellyfinInvocation | null = null; - let ytInvocation: YtInvocation | null = null; let configInvocation: CommandActionInvocation | null = null; let mpvInvocation: CommandActionInvocation | null = null; let appInvocation: { appArgs: string[] } | null = null; @@ -217,38 +201,6 @@ export function parseCliPrograms( }; }); - commandProgram - .command('yt') - .alias('youtube') - .description('YouTube workflows') - .argument('[target]', 'YouTube URL or ytsearch: query') - .option('-o, --out-dir ', 'Subtitle output dir') - .option('--keep-temp', 'Keep temp files') - .option('--whisper-bin ', 'whisper.cpp CLI path') - .option('--whisper-model ', 'whisper model path') - .option('--whisper-vad-model ', 'whisper.cpp VAD model path') - .option('--whisper-threads ', 'whisper.cpp thread count') - .option('--yt-subgen-audio-format ', 'Audio extraction format') - .option('--log-level ', 'Log level') - .action((target: string | undefined, options: Record) => { - ytInvocation = { - target, - outDir: typeof options.outDir === 'string' ? options.outDir : undefined, - keepTemp: options.keepTemp === true, - whisperBin: typeof options.whisperBin === 'string' ? options.whisperBin : undefined, - whisperModel: typeof options.whisperModel === 'string' ? options.whisperModel : undefined, - whisperVadModel: - typeof options.whisperVadModel === 'string' ? options.whisperVadModel : undefined, - whisperThreads: - typeof options.whisperThreads === 'number' && Number.isFinite(options.whisperThreads) - ? Math.floor(options.whisperThreads) - : undefined, - ytSubgenAudioFormat: - typeof options.ytSubgenAudioFormat === 'string' ? options.ytSubgenAudioFormat : undefined, - logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined, - }; - }); - commandProgram .command('dictionary') .alias('dict') @@ -382,7 +334,6 @@ export function parseCliPrograms( rootTarget: rootProgram.processedArgs[0], invocations: { jellyfinInvocation, - ytInvocation, configInvocation, mpvInvocation, appInvocation, diff --git a/launcher/log.test.ts b/launcher/log.test.ts index 3e97dbe..615934b 100644 --- a/launcher/log.test.ts +++ b/launcher/log.test.ts @@ -1,9 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import path from 'node:path'; -import { getDefaultMpvLogFile } from './types.js'; +import { getDefaultLauncherLogFile, getDefaultMpvLogFile } from './types.js'; test('getDefaultMpvLogFile uses APPDATA on windows', () => { + const today = new Date().toISOString().slice(0, 10); const resolved = getDefaultMpvLogFile({ platform: 'win32', homeDir: 'C:\\Users\\tester', @@ -17,8 +18,27 @@ test('getDefaultMpvLogFile uses APPDATA on windows', () => { 'C:\\Users\\tester\\AppData\\Roaming', 'SubMiner', '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`, + ), + ); +}); diff --git a/launcher/log.ts b/launcher/log.ts index 10aca67..c0cc3a2 100644 --- a/launcher/log.ts +++ b/launcher/log.ts @@ -1,7 +1,6 @@ -import fs from 'node:fs'; -import path from 'node:path'; 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 = { red: '\x1b[0;31m', @@ -28,14 +27,32 @@ export function getMpvLogPath(): string { 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 { - const logPath = getMpvLogPath(); - try { - fs.mkdirSync(path.dirname(logPath), { recursive: true }); - fs.appendFileSync(logPath, `[${new Date().toISOString()}] ${message}\n`, { encoding: 'utf8' }); - } catch { - // ignore logging failures - } + appendTimestampedLog(getMpvLogPath(), message); +} + +export function appendToLauncherLog(message: string): void { + appendTimestampedLog(getLauncherLogPath(), message); +} + +export function appendToAppLog(message: string): void { + appendTimestampedLog(getAppLogPath(), message); } 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.cyan; process.stdout.write(`${color}[${level.toUpperCase()}]${COLORS.reset} ${message}\n`); - appendToMpvLog(`[${level.toUpperCase()}] ${message}`); + appendToLauncherLog(`[${level.toUpperCase()}] ${message}`); } export function fail(message: string): never { process.stderr.write(`${COLORS.red}[ERROR]${COLORS.reset} ${message}\n`); - appendToMpvLog(`[ERROR] ${message}`); + appendToLauncherLog(`[ERROR] ${message}`); process.exit(1); } diff --git a/launcher/main.test.ts b/launcher/main.test.ts index de06557..7644544 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -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 }, () => { withTempDir((root) => { const homeDir = path.join(root, 'home'); @@ -387,6 +257,10 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con '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), @@ -466,6 +340,10 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con '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), @@ -484,6 +362,87 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con }); }); +test('launcher routes youtube urls through regular playback startup', { 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(['https://www.youtube.com/watch?v=abc123'], env); + + assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + const forwardedArgs = fs + .readFileSync(mpvArgsPath, 'utf8') + .trim() + .split('\n') + .map((item) => item.trim()) + .filter(Boolean); + assert.equal(forwardedArgs.includes('https://www.youtube.com/watch?v=abc123'), true); + }); +}); + test('dictionary command forwards --dictionary and --dictionary-target to app command path', () => { withTempDir((root) => { const homeDir = path.join(root, 'home'); diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index a60dc66..be848a3 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -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 appPath = path.join(dir, 'fake-subminer.sh'); const appInvocationsPath = path.join(dir, 'app-invocations.log'); @@ -345,8 +385,8 @@ test('cleanupPlaybackSession preserves background app while stopping mpv-owned c try { await cleanupPlaybackSession(makeArgs()); - assert.deepEqual(calls, ['mpv-kill', 'helper-kill']); - assert.equal(fs.existsSync(appInvocationsPath), false); + assert.deepEqual(calls, ['overlay-kill', 'mpv-kill', 'helper-kill']); + assert.match(fs.readFileSync(appInvocationsPath, 'utf8'), /--stop/); } finally { state.overlayProc = null; state.mpvProc = null; diff --git a/launcher/mpv.ts b/launcher/mpv.ts index fa945c4..6305351 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -5,7 +5,7 @@ import net from 'node:net'; import { spawn, spawnSync } from 'node:child_process'; import type { LogLevel, Backend, Args, MpvTrack } 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 { commandExists, @@ -542,7 +542,7 @@ export async function startMpv( socketPath: string, appPath: string, preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string }, - options?: { startPaused?: boolean }, + options?: { startPaused?: boolean; disableYoutubeSubtitleAutoLoad?: boolean }, ): Promise { if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) { fail(`Video file not found: ${target}`); @@ -557,24 +557,19 @@ export async function startMpv( const mpvArgs: string[] = []; if (args.profile) mpvArgs.push(`--profile=${args.profile}`); mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS); - if (args.mpvArgs) { - mpvArgs.push(...parseMpvArgString(args.mpvArgs)); - } - if (targetKind === 'url' && isYoutubeTarget(target)) { log('info', args.logLevel, 'Applying URL playback options'); - mpvArgs.push('--ytdl=yes', '--ytdl-raw-options='); - - if (isYoutubeTarget(target)) { - const subtitleLangs = uniqueNormalizedLangCodes([ - ...args.youtubePrimarySubLangs, - ...args.youtubeSecondarySubLangs, - ]).join(','); - const audioLangs = uniqueNormalizedLangCodes(args.youtubeAudioLangs).join(','); - log('info', args.logLevel, 'Applying YouTube playback options'); - log('debug', args.logLevel, `YouTube subtitle langs: ${subtitleLangs}`); - log('debug', args.logLevel, `YouTube audio langs: ${audioLangs}`); - mpvArgs.push(`--ytdl-format=${DEFAULT_YOUTUBE_YTDL_FORMAT}`, `--alang=${audioLangs}`); + mpvArgs.push('--ytdl=yes'); + const subtitleLangs = uniqueNormalizedLangCodes([ + ...args.youtubePrimarySubLangs, + ...args.youtubeSecondarySubLangs, + ]).join(','); + const audioLangs = uniqueNormalizedLangCodes(args.youtubeAudioLangs).join(','); + log('info', args.logLevel, 'Applying YouTube playback options'); + log('debug', args.logLevel, `YouTube subtitle langs: ${subtitleLangs}`); + log('debug', args.logLevel, `YouTube audio langs: ${audioLangs}`); + mpvArgs.push(`--ytdl-format=${DEFAULT_YOUTUBE_YTDL_FORMAT}`, `--alang=${audioLangs}`); + if (options?.disableYoutubeSubtitleAutoLoad !== true) { mpvArgs.push( '--sub-auto=fuzzy', `--slang=${subtitleLangs}`, @@ -582,8 +577,13 @@ export async function startMpv( '--ytdl-raw-options-append=sub-format=vtt/best', `--ytdl-raw-options-append=sub-langs=${subtitleLangs}`, ); + } else { + mpvArgs.push('--sub-auto=no'); } } + if (args.mpvArgs) { + mpvArgs.push(...parseMpvArgString(args.mpvArgs)); + } if (preloadedSubtitles?.primaryPath) { mpvArgs.push(`--sub-file=${preloadedSubtitles.primaryPath}`); @@ -597,7 +597,17 @@ export async function startMpv( const aniSkipMetadata = shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles) ? await resolveAniSkipMetadataForFile(target) : 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) { log( 'debug', @@ -661,19 +671,25 @@ async function waitForOverlayStartCommandSettled( }); } -export async function startOverlay(appPath: string, args: Args, socketPath: string): Promise { +export async function startOverlay( + appPath: string, + args: Args, + socketPath: string, + extraAppArgs: string[] = [], +): Promise { const backend = detectBackend(args.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.useTexthooker) overlayArgs.push('--texthooker'); const target = resolveAppSpawnTarget(appPath, overlayArgs); state.overlayProc = spawn(target.command, target.args, { - stdio: 'inherit', + stdio: ['ignore', 'pipe', 'pipe'], env: buildAppEnv(), }); + attachAppProcessLogging(state.overlayProc); state.overlayManagedByLauncher = true; const [socketReady] = await Promise.all([ @@ -699,10 +715,7 @@ export function launchTexthookerOnly(appPath: string, args: Args): never { if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel); log('info', args.logLevel, 'Launching texthooker mode...'); - const result = spawnSync(appPath, overlayArgs, { - stdio: 'inherit', - env: buildAppEnv(), - }); + const result = runSyncAppCommand(appPath, overlayArgs, true); if (result.error) { fail(`Failed to launch texthooker mode: ${result.error.message}`); } @@ -713,30 +726,7 @@ export function stopOverlay(args: Args): void { if (state.stopRequested) return; state.stopRequested = true; - if (state.overlayManagedByLauncher && state.appPath) { - 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 - } - } - } + stopManagedOverlayApp(args); if (state.mpvProc && !state.mpvProc.killed) { try { @@ -761,6 +751,8 @@ export function stopOverlay(args: Args): void { } export async function cleanupPlaybackSession(args: Args): Promise { + stopManagedOverlayApp(args); + if (state.mpvProc && !state.mpvProc.killed) { try { state.mpvProc.kill('SIGTERM'); @@ -783,9 +775,40 @@ export async function cleanupPlaybackSession(args: Args): Promise { 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 { const env: Record = { ...process.env, + SUBMINER_APP_LOG: getAppLogPath(), SUBMINER_MPV_LOG: getMpvLogPath(), }; delete env.ELECTRON_RUN_AS_NODE; @@ -804,6 +827,64 @@ function buildAppEnv(): NodeJS.ProcessEnv { 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, + 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 { const capturePath = process.env.SUBMINER_TEST_CAPTURE?.trim(); if (!capturePath) { @@ -821,20 +902,23 @@ function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget return resolveCommandInvocation(appPath, appArgs); } -export function runAppCommandWithInherit(appPath: string, appArgs: string[]): never { +export function runAppCommandWithInherit(appPath: string, appArgs: string[]): void { if (maybeCaptureAppArgs(appArgs)) { process.exit(0); } const target = resolveAppSpawnTarget(appPath, appArgs); - const result = spawnSync(target.command, target.args, { - stdio: 'inherit', + const proc = spawn(target.command, target.args, { + stdio: ['ignore', 'pipe', 'pipe'], env: buildAppEnv(), }); - if (result.error) { - fail(`Failed to run app command: ${result.error.message}`); - } - process.exit(result.status ?? 0); + attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true }); + proc.once('error', (error) => { + fail(`Failed to run app command: ${error.message}`); + }); + proc.once('close', (code) => { + process.exit(code ?? 0); + }); } export function runAppCommandCaptureOutput( @@ -854,18 +938,7 @@ export function runAppCommandCaptureOutput( }; } - const target = resolveAppSpawnTarget(appPath, appArgs); - 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, - }; + return runSyncAppCommand(appPath, appArgs, false); } export function runAppCommandAttached( @@ -887,13 +960,14 @@ export function runAppCommandAttached( return new Promise((resolve, reject) => { const proc = spawn(target.command, target.args, { - stdio: 'inherit', + stdio: ['ignore', 'pipe', 'pipe'], env: buildAppEnv(), }); + attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true }); proc.once('error', (error) => { reject(error); }); - proc.once('exit', (code, signal) => { + proc.once('close', (code, signal) => { if (code !== null) { resolve(code); } else if (signal) { @@ -921,10 +995,7 @@ export function runAppCommandWithInheritLogged( logLevel, `${label}: launching app with args: ${[target.command, ...target.args].join(' ')}`, ); - const result = spawnSync(target.command, target.args, { - stdio: 'inherit', - env: buildAppEnv(), - }); + const result = runSyncAppCommand(appPath, appArgs, true); if (result.error) { fail(`Failed to run app command: ${result.error.message}`); } @@ -953,15 +1024,24 @@ export function launchAppCommandDetached( logLevel, `${label}: launching detached app with args: ${[target.command, ...target.args].join(' ')}`, ); - const proc = spawn(target.command, target.args, { - stdio: 'ignore', - detached: true, - env: buildAppEnv(), - }); - proc.once('error', (error) => { - log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`); - }); - proc.unref(); + 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, { + stdio: ['ignore', stdoutFd, stderrFd], + detached: true, + env: buildAppEnv(), + }); + proc.once('error', (error) => { + log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`); + }); + proc.unref(); + } finally { + fs.closeSync(stdoutFd); + fs.closeSync(stderrFd); + } } export function launchMpvIdleDetached( diff --git a/launcher/smoke.e2e.test.ts b/launcher/smoke.e2e.test.ts index 14e840f..82798fd 100644 --- a/launcher/smoke.e2e.test.ts +++ b/launcher/smoke.e2e.test.ts @@ -310,6 +310,7 @@ test( const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log'); const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log'); await waitForJsonLines(appStartPath, 1); + await waitForJsonLines(appStopPath, 1); const appStartEntries = readJsonLines(appStartPath); const appStopEntries = readJsonLines(appStopPath); @@ -324,7 +325,7 @@ test( assert.match(result.stdout, /Starting SubMiner overlay/i); assert.equal(appStartEntries.length, 1); - assert.equal(appStopEntries.length, 0); + assert.equal(appStopEntries.length, 1); assert.equal(mpvEntries.length >= 1, true); const appStartArgs = appStartEntries[0]?.argv; diff --git a/launcher/types.ts b/launcher/types.ts index 375494f..598cc44 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import os from 'node:os'; +import { resolveDefaultLogFilePath } from '../src/shared/log-files.js'; export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js'; export const ROFI_THEME_FILE = 'subminer.rasi'; @@ -29,21 +30,28 @@ export const DEFAULT_YOUTUBE_SUBGEN_OUT_DIR = path.join( 'subminer', '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?: { platform?: NodeJS.Platform; homeDir?: string; appDataDir?: string; }): string { - 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-${new Date().toISOString().slice(0, 10)}.log`); + return resolveDefaultLogFilePath('mpv', { + platform: options?.platform ?? process.platform, + homeDir: options?.homeDir ?? os.homedir(), + appDataDir: options?.appDataDir, + }); } export const DEFAULT_MPV_LOG_FILE = getDefaultMpvLogFile(); @@ -79,6 +87,7 @@ export interface Args { recursive: boolean; profile: string; startOverlay: boolean; + youtubeMode?: 'download' | 'generate'; whisperBin: string; whisperModel: string; whisperVadModel: string; diff --git a/package.json b/package.json index 43a8830..33a72a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "subminer", - "version": "0.8.0", + "version": "0.9.0", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "packageManager": "bun@1.3.5", "main": "dist/main-entry.js", diff --git a/plugin/subminer/options.lua b/plugin/subminer/options.lua index d30a11d..f084314 100644 --- a/plugin/subminer/options.lua +++ b/plugin/subminer/options.lua @@ -33,6 +33,7 @@ function M.load(options_lib, default_socket_path) auto_start = true, auto_start_visible_overlay = true, auto_start_pause_until_ready = true, + auto_start_pause_until_ready_timeout_seconds = 15, osd_messages = true, log_level = "info", aniskip_enabled = true, diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index 3c35055..532f65f 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -2,9 +2,9 @@ local M = {} local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2 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_READY_OSD = "Subtitle tokenization ready" +local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15 function M.create(ctx) local mp = ctx.mp @@ -34,6 +34,23 @@ function M.create(ctx) return options_helper.coerce_bool(raw_pause_until_ready, false) 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) if type(path) ~= "string" then return nil @@ -118,17 +135,20 @@ function M.create(ctx) end) end 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() - if not state.auto_play_ready_gate_armed then - return - end - subminer_log( - "warn", - "process", - "Startup readiness signal timed out; resuming playback to avoid stalled pause" - ) - release_auto_play_ready_gate("timeout") - end) + 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 + return + end + subminer_log( + "warn", + "process", + "Startup readiness signal timed out; resuming playback to avoid stalled pause" + ) + release_auto_play_ready_gate("timeout") + end) + end end local function notify_auto_play_ready() diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 1eda157..5ba86b7 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -58,6 +58,7 @@ import { NoteUpdateWorkflow } from './anki-integration/note-update-workflow'; import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow'; import { resolveAnimatedImageLeadInSeconds } from './anki-integration/animated-image-sync'; import { AnkiIntegrationRuntime, normalizeAnkiIntegrationConfig } from './anki-integration/runtime'; +import { resolveMediaGenerationInputPath } from './anki-integration/media-source'; const log = createLogger('anki').child('integration'); @@ -597,6 +598,10 @@ export class AnkiIntegration { this.runtime.start(); } + waitUntilReady(): Promise { + return this.runtime.waitUntilReady(); + } + stop(): void { this.runtime.stop(); } @@ -647,7 +652,10 @@ export class AnkiIntegration { return null; } - const videoPath = mpvClient.currentVideoPath; + const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'audio'); + if (!videoPath) { + return null; + } let startTime = mpvClient.currentSubStart; let endTime = mpvClient.currentSubEnd; @@ -672,7 +680,10 @@ export class AnkiIntegration { return null; } - const videoPath = this.mpvClient.currentVideoPath; + const videoPath = await resolveMediaGenerationInputPath(this.mpvClient, 'video'); + if (!videoPath) { + return null; + } const timestamp = this.mpvClient.currentTimePos || 0; if (this.config.media?.imageType === 'avif') { @@ -946,8 +957,15 @@ export class AnkiIntegration { if (this.mpvClient && this.mpvClient.currentVideoPath) { try { 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( - this.mpvClient.currentVideoPath, + notificationIconSource, timestamp, ); if (iconBuffer && iconBuffer.length > 0) { diff --git a/src/anki-integration/anki-connect-proxy.ts b/src/anki-integration/anki-connect-proxy.ts index 4ba236c..0d98a55 100644 --- a/src/anki-integration/anki-connect-proxy.ts +++ b/src/anki-integration/anki-connect-proxy.ts @@ -35,6 +35,9 @@ export class AnkiConnectProxyServer { private pendingNoteIdSet = new Set(); private inFlightNoteIds = new Set(); private processingQueue = false; + private readyPromise: Promise | null = null; + private resolveReady: (() => void) | null = null; + private rejectReady: ((error: Error) => void) | null = null; constructor(private readonly deps: AnkiConnectProxyServerDeps) { this.client = axios.create({ @@ -48,6 +51,13 @@ export class AnkiConnectProxyServer { return this.server !== null; } + waitUntilReady(): Promise { + if (!this.server || this.server.listening) { + return Promise.resolve(); + } + return this.readyPromise ?? Promise.resolve(); + } + start(options: StartProxyOptions): void { this.stop(); @@ -58,15 +68,26 @@ export class AnkiConnectProxyServer { return; } + this.readyPromise = new Promise((resolve, reject) => { + this.resolveReady = resolve; + this.rejectReady = reject; + }); + this.server = http.createServer((req, res) => { void this.handleRequest(req, res, options.upstreamUrl); }); 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.server.listen(options.port, options.host, () => { + this.resolveReady?.(); + this.resolveReady = null; + this.rejectReady = null; this.deps.logInfo( `[anki-proxy] Listening on http://${options.host}:${options.port} -> ${options.upstreamUrl}`, ); @@ -79,6 +100,10 @@ export class AnkiConnectProxyServer { this.server = null; 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.pendingNoteIdSet.clear(); this.inFlightNoteIds.clear(); diff --git a/src/anki-integration/card-creation.test.ts b/src/anki-integration/card-creation.test.ts index abfab36..5cbe245 100644 --- a/src/anki-integration/card-creation.test.ts +++ b/src/anki-integration/card-creation.test.ts @@ -283,3 +283,117 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws', assert.equal(calls.notesInfo, 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']); +}); diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index 85bd9c3..6495fa8 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -8,6 +8,7 @@ import { createLogger } from '../logger'; import { SubtitleTimingTracker } from '../subtitle-timing-tracker'; import { MpvClient } from '../types'; import { resolveSentenceBackText } from './ai'; +import { resolveMediaGenerationInputPath } from './media-source'; const log = createLogger('anki').child('integration.card-creation'); @@ -501,7 +502,12 @@ export class CardCreationService { this.deps.showOsdNotification('Creating sentence card...'); try { 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 = {}; const errors: string[] = []; let miscInfoFilename: string | null = null; @@ -605,7 +611,9 @@ export class CardCreationService { try { const audioFilename = this.generateAudioFilename(); - const audioBuffer = await this.mediaGenerateAudio(videoPath, startTime, endTime); + const audioBuffer = audioSourcePath + ? await this.mediaGenerateAudio(audioSourcePath, startTime, endTime) + : null; if (audioBuffer) { await this.deps.client.storeMediaFile(audioFilename, audioBuffer); diff --git a/src/anki-integration/media-source.test.ts b/src/anki-integration/media-source.test.ts new file mode 100644 index 0000000..7fec898 --- /dev/null +++ b/src/anki-integration/media-source.test.ts @@ -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'); +}); diff --git a/src/anki-integration/media-source.ts b/src/anki-integration/media-source.ts new file mode 100644 index 0000000..36adaa2 --- /dev/null +++ b/src/anki-integration/media-source.ts @@ -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 | null | undefined, + kind: MediaGenerationKind = 'video', +): Promise { + 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; +} diff --git a/src/anki-integration/runtime.test.ts b/src/anki-integration/runtime.test.ts index 017686f..e234f15 100644 --- a/src/anki-integration/runtime.test.ts +++ b/src/anki-integration/runtime.test.ts @@ -26,6 +26,7 @@ function createRuntime( start: ({ host, port, upstreamUrl }) => calls.push(`proxy:start:${host}:${port}:${upstreamUrl}`), stop: () => calls.push('proxy:stop'), + waitUntilReady: async () => undefined, }), logInfo: () => 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']); }); +test('AnkiIntegrationRuntime waits for proxy readiness when proxy mode is enabled', async () => { + let releaseReady!: () => void; + const waitUntilReadyCalls: string[] = []; + const readyPromise = new Promise((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', () => { const { runtime, calls } = createRuntime({ knownWords: { diff --git a/src/anki-integration/runtime.ts b/src/anki-integration/runtime.ts index 2661d02..df1ef9f 100644 --- a/src/anki-integration/runtime.ts +++ b/src/anki-integration/runtime.ts @@ -9,6 +9,7 @@ import { export interface AnkiIntegrationRuntimeProxyServer { start(options: { host: string; port: number; upstreamUrl: string }): void; stop(): void; + waitUntilReady(): Promise; } interface AnkiIntegrationRuntimeDeps { @@ -131,6 +132,13 @@ export class AnkiIntegrationRuntime { return this.config; } + waitUntilReady(): Promise { + if (!this.started || !this.isProxyTransportEnabled()) { + return Promise.resolve(); + } + return this.getOrCreateProxyServer().waitUntilReady(); + } + start(): void { if (this.started) { this.stop(); diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index cbc9d70..1e24531 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -56,6 +56,20 @@ test('parseArgs captures launch-mpv targets and keeps it out of app startup', () assert.equal(shouldStartApp(args), false); }); +test('parseArgs captures youtube startup forwarding flags', () => { + 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', () => { const args = parseArgs([ '--jellyfin-items', diff --git a/src/cli/args.ts b/src/cli/args.ts index ad05bc5..d9dd468 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -3,6 +3,8 @@ export interface CliArgs { start: boolean; launchMpv: boolean; launchMpvTargets: string[]; + youtubePlay?: string; + youtubeMode?: 'download' | 'generate'; stop: boolean; toggle: boolean; toggleVisibleOverlay: boolean; @@ -79,6 +81,8 @@ export function parseArgs(argv: string[]): CliArgs { start: false, launchMpv: false, launchMpvTargets: [], + youtubePlay: undefined, + youtubeMode: undefined, stop: false, toggle: false, toggleVisibleOverlay: false, @@ -140,7 +144,19 @@ export function parseArgs(argv: string[]): CliArgs { if (arg === '--background') args.background = 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.launchMpvTargets = argv.slice(i + 1).filter((value) => value && !value.startsWith('--')); break; @@ -334,6 +350,7 @@ export function hasExplicitCommand(args: CliArgs): boolean { return ( args.background || args.start || + Boolean(args.youtubePlay) || args.launchMpv || args.stop || args.toggle || @@ -385,6 +402,7 @@ export function shouldStartApp(args: CliArgs): boolean { if ( args.background || args.start || + Boolean(args.youtubePlay) || args.launchMpv || args.toggle || args.toggleVisibleOverlay || @@ -452,6 +470,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean { !args.jellyfinItems && !args.jellyfinSubtitles && !args.jellyfinPlay && + !args.youtubePlay && !args.jellyfinRemoteAnnounce && !args.jellyfinPreviewAuth && !args.texthooker && @@ -480,6 +499,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean { args.triggerFieldGrouping || args.triggerSubsync || args.markAudioCard || - args.openRuntimeOptions + args.openRuntimeOptions || + Boolean(args.youtubePlay) ); } diff --git a/src/config/config.test.ts b/src/config/config.test.ts index e53283c..f9f8212 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -1735,7 +1735,7 @@ test('accepts top-level ai config', () => { assert.equal(config.ai.requestTimeoutMs, 20000); }); -test('accepts per-feature ai overrides for anki and youtube subtitle generation', () => { +test('accepts per-feature ai overrides for anki and YouTube subtitles', () => { const dir = makeTempDir(); fs.writeFileSync( path.join(dir, 'config.jsonc'), @@ -2074,16 +2074,16 @@ test('template generator includes known keys', () => { ); assert.match( output, - /"fixWithAi": false,? \/\/ Use shared AI provider to post-process whisper-generated YouTube subtitles\. Values: true \| false/, + /"fixWithAi": false,? \/\/ Legacy subtitle fallback post-processing switch kept for compatibility; use is currently disabled by default\. Values: true \| false/, ); assert.match( output, - /"systemPrompt": "",? \/\/ Optional system prompt override for YouTube subtitle AI post-processing\./, + /"systemPrompt": "",? \/\/ Optional system prompt override for legacy subtitle fallback post-processing; not used by default\./, ); assert.doesNotMatch(output, /"mode": "automatic"/); assert.match( output, - /"whisperThreads": 4,? \/\/ Thread count passed to whisper\.cpp subtitle generation runs\./, + /"whisperThreads": 4,? \/\/ Legacy thread tuning for subtitle fallback tooling; not used by default\./, ); assert.match( output, diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts index fdce816..8051e73 100644 --- a/src/config/definitions/domain-registry.test.ts +++ b/src/config/definitions/domain-registry.test.ts @@ -77,6 +77,7 @@ test('default keybindings include primary and secondary subtitle track cycling o ); assert.deepEqual(keybindingMap.get('KeyJ'), ['cycle', 'sid']); assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-sid']); + assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyC'), ['__youtube-picker-open']); }); test('default keybindings include fullscreen on F', () => { diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index e884aa1..3656091 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -369,43 +369,47 @@ export function buildIntegrationConfigOptionRegistry( path: 'youtubeSubgen.whisperBin', kind: 'string', defaultValue: defaultConfig.youtubeSubgen.whisperBin, - description: 'Path to whisper.cpp CLI used as fallback transcription engine.', + description: 'Legacy compatibility path kept for external subtitle fallback tools; not used by default.', }, { path: 'youtubeSubgen.whisperModel', kind: 'string', defaultValue: defaultConfig.youtubeSubgen.whisperModel, - description: 'Path to whisper model used for fallback transcription.', + description: 'Legacy compatibility model path kept for external subtitle fallback tooling; not used by default.', }, { path: 'youtubeSubgen.whisperVadModel', kind: 'string', defaultValue: defaultConfig.youtubeSubgen.whisperVadModel, - description: 'Path to optional whisper VAD model used for subtitle generation.', + description: + 'Legacy compatibility VAD path kept for external subtitle fallback tooling; not used by default.', }, { path: 'youtubeSubgen.whisperThreads', kind: 'number', defaultValue: defaultConfig.youtubeSubgen.whisperThreads, - description: 'Thread count passed to whisper.cpp subtitle generation runs.', + description: 'Legacy thread tuning for subtitle fallback tooling; not used by default.', }, { path: 'youtubeSubgen.fixWithAi', kind: 'boolean', defaultValue: defaultConfig.youtubeSubgen.fixWithAi, - description: 'Use shared AI provider to post-process whisper-generated YouTube subtitles.', + description: + 'Legacy subtitle fallback post-processing switch kept for compatibility; use is currently disabled by default.', }, { path: 'youtubeSubgen.ai.model', kind: 'string', defaultValue: defaultConfig.youtubeSubgen.ai.model, - description: 'Optional model override for YouTube subtitle AI post-processing.', + description: + 'Optional model override for legacy subtitle fallback post-processing; not used by default.', }, { path: 'youtubeSubgen.ai.systemPrompt', kind: 'string', defaultValue: defaultConfig.youtubeSubgen.ai.systemPrompt, - description: 'Optional system prompt override for YouTube subtitle AI post-processing.', + description: + 'Optional system prompt override for legacy subtitle fallback post-processing; not used by default.', }, { path: 'youtubeSubgen.primarySubLanguages', diff --git a/src/config/definitions/shared.ts b/src/config/definitions/shared.ts index 25ad9c2..9a0c636 100644 --- a/src/config/definitions/shared.ts +++ b/src/config/definitions/shared.ts @@ -46,6 +46,7 @@ export const SPECIAL_COMMANDS = { PLAY_NEXT_SUBTITLE: '__play-next-subtitle', SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line', SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line', + YOUTUBE_PICKER_OPEN: '__youtube-picker-open', } as const; export const DEFAULT_KEYBINDINGS: NonNullable = [ @@ -64,6 +65,7 @@ export const DEFAULT_KEYBINDINGS: NonNullable = [ key: 'Shift+BracketLeft', command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START], }, + { key: 'Ctrl+Alt+KeyC', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] }, { key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] }, { key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] }, { key: 'KeyQ', command: ['quit'] }, diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index d988402..8be0158 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -74,7 +74,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ title: 'Secondary Subtitles', description: [ 'Dual subtitle track options.', - 'Used by subminer YouTube subtitle generation as secondary language preferences.', + 'Used by the YouTube subtitle loading flow as secondary language preferences.', ], notes: ['Hot-reload: defaultMode updates live while SubMiner is running.'], key: 'secondarySub', @@ -130,8 +130,8 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ key: 'jimaku', }, { - title: 'YouTube Subtitle Generation', - description: ['Defaults for SubMiner YouTube subtitle generation.'], + title: 'YouTube Playback Settings', + description: ['Defaults for SubMiner YouTube subtitle loading and languages.'], key: 'youtubeSubgen', }, { diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index a2539ab..0e1bf40 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -9,6 +9,8 @@ function makeArgs(overrides: Partial = {}): CliArgs { start: false, launchMpv: false, launchMpvTargets: [], + youtubePlay: undefined, + youtubeMode: undefined, stop: false, toggle: false, toggleVisibleOverlay: false, @@ -184,6 +186,9 @@ function createDeps(overrides: Partial = {}) { runJellyfinCommand: async () => { calls.push('runJellyfinCommand'); }, + runYoutubePlaybackFlow: async (request) => { + calls.push(`runYoutubePlaybackFlow:${request.url}:${request.mode}:${request.source}`); + }, printHelp: () => { calls.push('printHelp'); }, @@ -207,6 +212,58 @@ function createDeps(overrides: Partial = {}) { return { deps, calls, osd }; } +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 reports youtube playback flow failures to logs and OSD', async () => { + const { deps, calls, osd } = createDeps({ + runYoutubePlaybackFlow: async () => { + throw new Error('yt failed'); + }, + }); + + handleCliCommand( + makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'download' }), + 'initial', + deps, + ); + await new Promise((resolve) => setImmediate(resolve)); + + assert.ok(calls.some((value) => value.startsWith('error:runYoutubePlaybackFlow failed:'))); + assert.ok(osd.includes('YouTube playback failed: yt failed')); +}); + test('handleCliCommand reconnects MPV for second-instance --start when overlay runtime is already initialized', () => { const { deps, calls } = createDeps({ isOverlayRuntimeInitialized: () => true, diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index 53fd819..95e32bb 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -63,6 +63,11 @@ export interface CliCommandServiceDeps { }>; runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise; runJellyfinCommand: (args: CliArgs) => Promise; + runYoutubePlaybackFlow: (request: { + url: string; + mode: NonNullable; + source: CliCommandSource; + }) => Promise; printHelp: () => void; hasMainWindow: () => boolean; getMultiCopyTimeoutMs: () => number; @@ -135,6 +140,7 @@ interface AnilistCliRuntime { interface AppCliRuntime { stop: () => void; hasMainWindow: () => boolean; + runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow']; } export interface CliCommandDepsRuntimeOptions { @@ -226,6 +232,7 @@ export function createCliCommandDepsRuntime( generateCharacterDictionary: options.dictionary.generate, runStatsCommand: options.jellyfin.runStatsCommand, runJellyfinCommand: options.jellyfin.runCommand, + runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow, printHelp: options.ui.printHelp, hasMainWindow: options.app.hasMainWindow, getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs, @@ -396,6 +403,19 @@ export function handleCliCommand( } else if (args.jellyfin) { deps.openJellyfinSetup(); 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) { const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow(); deps.log('Generating character dictionary for current anime...'); diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index a974621..0fe0b5b 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -37,6 +37,21 @@ async function waitForPendingAnimeMetadata(tracker: ImmersionTrackerService): Pr await privateApi.pendingAnimeMetadataUpdates?.get(videoId); } +async function waitForCondition( + predicate: () => boolean, + timeoutMs = 1_000, + intervalMs = 10, +): Promise { + 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 { return { surface: '', @@ -1269,6 +1284,40 @@ test('flushTelemetry checkpoints latest playback position on the active session } }); +test('recordSubtitleLine advances session checkpoint progress when playback position is unavailable', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }); + + tracker.handleMediaChange('https://stream.example.com/subtitle-progress.m3u8', 'Subtitle Progress'); + tracker.recordSubtitleLine('line one', 170, 185, [], null); + + const privateApi = tracker as unknown as { + db: DatabaseSync; + sessionState: { sessionId: number } | null; + flushTelemetry: (force?: boolean) => void; + flushNow: () => void; + }; + const sessionId = privateApi.sessionState?.sessionId; + assert.ok(sessionId); + + privateApi.flushTelemetry(true); + privateApi.flushNow(); + + const row = privateApi.db + .prepare('SELECT ended_media_ms FROM imm_sessions WHERE session_id = ?') + .get(sessionId) as { ended_media_ms: number | null } | null; + + assert.equal(row?.ended_media_ms, 185_000); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + test('deleteSession ignores the currently active session and keeps new writes flushable', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; @@ -2297,6 +2346,565 @@ 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; + const animeRow = privateApi.db + .prepare( + ` + SELECT + a.canonical_title AS canonicalTitle, + v.parsed_title AS parsedTitle, + v.parser_source AS parserSource + FROM imm_videos v + JOIN imm_anime a ON a.anime_id = v.anime_id + WHERE v.video_id = 1 + `, + ) + .get() as { + canonicalTitle: string; + parsedTitle: string | null; + parserSource: string | null; + } | 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'); + assert.equal(animeRow?.canonicalTitle, 'Creator Name'); + assert.equal(animeRow?.parsedTitle, 'Creator Name'); + assert.equal(animeRow?.parserSource, 'youtube'); + } finally { + process.env.PATH = originalPath; + globalThis.fetch = originalFetch; + tracker?.destroy(); + cleanupDbPath(dbPath); + if (fakeBinDir) { + fs.rmSync(fakeBinDir, { recursive: true, force: true }); + } + } +}); + +test('getMediaLibrary lazily backfills missing youtube metadata for existing rows', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + 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":"backfill123","title":"Backfilled Video Title","webpage_url":"https://www.youtube.com/watch?v=backfill123","thumbnail":"https://i.ytimg.com/vi/backfill123/hqdefault.jpg","channel_id":"UCbackfill123","channel":"Backfill Creator","channel_url":"https://www.youtube.com/channel/UCbackfill123","uploader_id":"@backfill","uploader_url":"https://www.youtube.com/@backfill","description":"Backfilled description","thumbnails":[{"url":"https://i.ytimg.com/vi/backfill123/hqdefault.jpg"},{"url":"https://yt3.googleusercontent.com/backfill-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 ?? ''}`; + + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }); + const privateApi = tracker as unknown as { db: DatabaseSync }; + const nowMs = Date.now(); + + privateApi.db + .prepare( + ` + INSERT INTO imm_videos ( + video_key, + canonical_title, + source_type, + source_path, + source_url, + duration_ms, + file_size_bytes, + codec_id, + container_id, + width_px, + height_px, + fps_x100, + bitrate_kbps, + audio_codec_id, + hash_sha256, + screenshot_path, + metadata_json, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'remote:https://www.youtube.com/watch?v=backfill123', + 'watch?v=backfill123', + 2, + null, + 'https://www.youtube.com/watch?v=backfill123', + 0, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + nowMs, + nowMs, + ); + privateApi.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(1, 1, 5_000, 0, 0, 50, 0, nowMs, nowMs, nowMs, nowMs); + + const before = await tracker.getMediaLibrary(); + assert.equal(before[0]?.channelName ?? null, null); + + await waitForCondition(() => { + const row = privateApi.db + .prepare( + ` + SELECT + video_title AS videoTitle, + channel_name AS channelName, + channel_thumbnail_url AS channelThumbnailUrl + FROM imm_youtube_videos + WHERE video_id = 1 + `, + ) + .get() as { + videoTitle: string | null; + channelName: string | null; + channelThumbnailUrl: string | null; + } | null; + return ( + row?.videoTitle === 'Backfilled Video Title' && + row.channelName === 'Backfill Creator' && + row.channelThumbnailUrl === 'https://yt3.googleusercontent.com/backfill-avatar=s88' + ); + }, 5_000); + + const after = await tracker.getMediaLibrary(); + assert.equal(after[0]?.videoTitle, 'Backfilled Video Title'); + assert.equal(after[0]?.channelName, 'Backfill Creator'); + assert.equal( + after[0]?.channelThumbnailUrl, + 'https://yt3.googleusercontent.com/backfill-avatar=s88', + ); + } finally { + process.env.PATH = originalPath; + tracker?.destroy(); + cleanupDbPath(dbPath); + if (fakeBinDir) { + fs.rmSync(fakeBinDir, { recursive: true, force: true }); + } + } +}); + +test('getAnimeLibrary lazily relinks youtube rows to channel groupings', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }); + const privateApi = tracker as unknown as { db: DatabaseSync }; + const nowMs = Date.now(); + + privateApi.db.exec(` + INSERT INTO imm_anime ( + anime_id, + normalized_title_key, + canonical_title, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES + (1, 'watch v first', 'watch?v first', ${nowMs}, ${nowMs}), + (2, 'watch v second', 'watch?v second', ${nowMs}, ${nowMs}); + + INSERT INTO imm_videos ( + video_id, + anime_id, + video_key, + canonical_title, + parsed_title, + parser_source, + source_type, + source_path, + source_url, + duration_ms, + file_size_bytes, + codec_id, + container_id, + width_px, + height_px, + fps_x100, + bitrate_kbps, + audio_codec_id, + hash_sha256, + screenshot_path, + metadata_json, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES + ( + 1, + 1, + 'remote:https://www.youtube.com/watch?v=first', + 'watch?v first', + 'watch?v first', + 'fallback', + 2, + NULL, + 'https://www.youtube.com/watch?v=first', + 0, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + ${nowMs}, + ${nowMs} + ), + ( + 2, + 2, + 'remote:https://www.youtube.com/watch?v=second', + 'watch?v second', + 'watch?v second', + 'fallback', + 2, + NULL, + 'https://www.youtube.com/watch?v=second', + 0, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + ${nowMs}, + ${nowMs} + ); + + 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 + ( + 1, + 'first', + 'https://www.youtube.com/watch?v=first', + 'First Video', + 'https://i.ytimg.com/vi/first/hqdefault.jpg', + 'UCchannel1', + 'Shared Channel', + 'https://www.youtube.com/channel/UCchannel1', + 'https://yt3.googleusercontent.com/shared=s88', + '@shared', + 'https://www.youtube.com/@shared', + NULL, + '{}', + ${nowMs}, + ${nowMs}, + ${nowMs} + ), + ( + 2, + 'second', + 'https://www.youtube.com/watch?v=second', + 'Second Video', + 'https://i.ytimg.com/vi/second/hqdefault.jpg', + 'UCchannel1', + 'Shared Channel', + 'https://www.youtube.com/channel/UCchannel1', + 'https://yt3.googleusercontent.com/shared=s88', + '@shared', + 'https://www.youtube.com/@shared', + NULL, + '{}', + ${nowMs}, + ${nowMs}, + ${nowMs} + ); + + INSERT INTO imm_sessions ( + session_id, + session_uuid, + video_id, + started_at_ms, + ended_at_ms, + status, + total_watched_ms, + active_watched_ms, + lines_seen, + tokens_seen, + cards_mined, + lookup_count, + lookup_hits, + yomitan_lookup_count, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES + ( + 1, + 'session-youtube-1', + 1, + ${nowMs - 70000}, + ${nowMs - 10000}, + 2, + 65000, + 60000, + 0, + 100, + 0, + 0, + 0, + 0, + ${nowMs}, + ${nowMs} + ), + ( + 2, + 'session-youtube-2', + 2, + ${nowMs - 50000}, + ${nowMs - 5000}, + 2, + 35000, + 30000, + 0, + 50, + 0, + 0, + 0, + 0, + ${nowMs}, + ${nowMs} + ); + + INSERT INTO imm_lifetime_anime ( + anime_id, + total_sessions, + total_active_ms, + total_cards, + total_lines_seen, + total_tokens_seen, + episodes_started, + episodes_completed, + first_watched_ms, + last_watched_ms, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES + (1, 1, 60000, 0, 0, 100, 1, 0, ${nowMs}, ${nowMs}, ${nowMs}, ${nowMs}), + (2, 1, 30000, 0, 0, 50, 1, 0, ${nowMs}, ${nowMs}, ${nowMs}, ${nowMs}); + + 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 + (1, 1, 60000, 0, 0, 100, 0, ${nowMs}, ${nowMs}, ${nowMs}, ${nowMs}), + (2, 1, 30000, 0, 0, 50, 0, ${nowMs}, ${nowMs}, ${nowMs}, ${nowMs}); + `); + + const rows = await tracker.getAnimeLibrary(); + const sharedRows = rows.filter((row) => row.canonicalTitle === 'Shared Channel'); + + assert.equal(sharedRows.length, 1); + assert.equal(sharedRows[0]?.episodeCount, 2); + + const relinked = privateApi.db + .prepare( + ` + SELECT a.canonical_title AS canonicalTitle, COUNT(*) AS total + FROM imm_videos v + JOIN imm_anime a ON a.anime_id = v.anime_id + GROUP BY a.anime_id, a.canonical_title + ORDER BY total DESC, a.anime_id ASC + `, + ) + .all() as Array<{ canonicalTitle: string; total: number }>; + + assert.equal(relinked[0]?.canonicalTitle, 'Shared Channel'); + assert.equal(relinked[0]?.total, 2); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + test('reassignAnimeAnilist clears description when description is explicitly null', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index 97df132..cbd28f6 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import * as fs from 'node:fs'; import { createLogger } from '../../logger'; +import { MediaGenerator } from '../../media-generator'; import type { CoverArtFetcher } from './anilist/cover-art-fetcher'; import { getLocalVideoMetadata, guessAnimeVideoMetadata } from './immersion-tracker/metadata'; import { @@ -19,9 +20,11 @@ import { getOrCreateAnimeRecord, getOrCreateVideoRecord, linkVideoToAnimeRecord, + linkYoutubeVideoToAnimeRecord, type TrackerPreparedStatements, updateVideoMetadataRecord, updateVideoTitleRecord, + upsertYoutubeVideoMetadata, } from './immersion-tracker/storage'; import { applySessionLifetimeSummary, @@ -153,6 +156,105 @@ import { import type { MergedToken } from '../../types'; import { shouldExcludeTokenFromVocabularyPersistence } from './tokenizer/annotation-stage'; 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,}$/; +const YOUTUBE_METADATA_REFRESH_MS = 24 * 60 * 60 * 1000; + +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 { + 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 { + 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 { AnimeAnilistEntryRow, @@ -212,9 +314,11 @@ export class ImmersionTrackerService { private sessionState: SessionState | null = null; private currentVideoKey = ''; private currentMediaPathOrUrl = ''; + private readonly mediaGenerator = new MediaGenerator(); private readonly preparedStatements: TrackerPreparedStatements; private coverArtFetcher: CoverArtFetcher | null = null; private readonly pendingCoverFetches = new Map>(); + private readonly pendingYoutubeMetadataFetches = new Map>(); private readonly recordedSubtitleKeys = new Set(); private readonly pendingAnimeMetadataUpdates = new Map>(); private readonly resolveLegacyVocabularyPos: @@ -433,11 +537,15 @@ export class ImmersionTrackerService { } async getMediaLibrary(): Promise { - return getMediaLibrary(this.db); + const rows = getMediaLibrary(this.db); + this.backfillYoutubeMetadataForLibrary(); + return rows; } async getMediaDetail(videoId: number): Promise { - return getMediaDetail(this.db, videoId); + const detail = getMediaDetail(this.db, videoId); + this.backfillYoutubeMetadataForVideo(videoId); + return detail; } async getMediaSessions(videoId: number, limit = 100): Promise { @@ -453,10 +561,12 @@ export class ImmersionTrackerService { } async getAnimeLibrary(): Promise { + this.relinkYoutubeAnimeLibrary(); return getAnimeLibrary(this.db); } async getAnimeDetail(animeId: number): Promise { + this.relinkYoutubeAnimeLibrary(); return getAnimeDetail(this.db, animeId); } @@ -647,6 +757,17 @@ export class ImmersionTrackerService { if (existing?.coverBlob) { 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) { return false; } @@ -677,6 +798,312 @@ export class ImmersionTrackerService { } } + private ensureYouTubeCoverArt(videoId: number, sourceUrl: string, youtubeVideoId: string): Promise { + 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 { + 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); + linkYoutubeVideoToAnimeRecord(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); + }); + } + + private backfillYoutubeMetadataForLibrary(): void { + const candidate = this.db + .prepare( + ` + SELECT + v.video_id AS videoId, + v.source_url AS sourceUrl + FROM imm_videos v + 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 + WHERE + v.source_type = ? + AND v.source_url IS NOT NULL + AND ( + LOWER(v.source_url) LIKE 'https://www.youtube.com/%' + OR LOWER(v.source_url) LIKE 'https://youtube.com/%' + OR LOWER(v.source_url) LIKE 'https://m.youtube.com/%' + OR LOWER(v.source_url) LIKE 'https://youtu.be/%' + ) + AND ( + yv.video_id IS NULL + OR yv.video_title IS NULL + OR yv.channel_name IS NULL + OR yv.channel_thumbnail_url IS NULL + ) + AND ( + yv.fetched_at_ms IS NULL + OR yv.fetched_at_ms <= ? + ) + ORDER BY lm.last_watched_ms DESC, v.video_id DESC + LIMIT 1 + `, + ) + .get( + SOURCE_TYPE_REMOTE, + Date.now() - YOUTUBE_METADATA_REFRESH_MS, + ) as { videoId: number; sourceUrl: string | null } | null; + if (!candidate?.sourceUrl) { + return; + } + this.captureYoutubeMetadataAsync(candidate.videoId, candidate.sourceUrl); + } + + private backfillYoutubeMetadataForVideo(videoId: number): void { + const candidate = this.db + .prepare( + ` + SELECT + v.source_url AS sourceUrl + FROM imm_videos v + LEFT JOIN imm_youtube_videos yv ON yv.video_id = v.video_id + WHERE + v.video_id = ? + AND v.source_type = ? + AND v.source_url IS NOT NULL + AND ( + LOWER(v.source_url) LIKE 'https://www.youtube.com/%' + OR LOWER(v.source_url) LIKE 'https://youtube.com/%' + OR LOWER(v.source_url) LIKE 'https://m.youtube.com/%' + OR LOWER(v.source_url) LIKE 'https://youtu.be/%' + ) + AND ( + yv.video_id IS NULL + OR yv.video_title IS NULL + OR yv.channel_name IS NULL + OR yv.channel_thumbnail_url IS NULL + ) + AND ( + yv.fetched_at_ms IS NULL + OR yv.fetched_at_ms <= ? + ) + `, + ) + .get( + videoId, + SOURCE_TYPE_REMOTE, + Date.now() - YOUTUBE_METADATA_REFRESH_MS, + ) as { sourceUrl: string | null } | null; + if (!candidate?.sourceUrl) { + return; + } + this.captureYoutubeMetadataAsync(videoId, candidate.sourceUrl); + } + + private relinkYoutubeAnimeLibrary(): void { + const candidates = this.db + .prepare( + ` + SELECT + v.video_id AS videoId, + 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, + yv.metadata_json AS metadataJson + FROM imm_videos v + JOIN imm_youtube_videos yv ON yv.video_id = v.video_id + LEFT JOIN imm_anime a ON a.anime_id = v.anime_id + LEFT JOIN imm_lifetime_media lm ON lm.video_id = v.video_id + WHERE + v.source_type = ? + AND v.source_url IS NOT NULL + AND ( + LOWER(v.source_url) LIKE 'https://www.youtube.com/%' + OR LOWER(v.source_url) LIKE 'https://youtube.com/%' + OR LOWER(v.source_url) LIKE 'https://m.youtube.com/%' + OR LOWER(v.source_url) LIKE 'https://youtu.be/%' + ) + AND yv.channel_name IS NOT NULL + AND ( + v.anime_id IS NULL + OR a.metadata_json IS NULL + OR a.metadata_json NOT LIKE '%"source":"youtube-channel"%' + OR a.canonical_title IS NULL + OR TRIM(a.canonical_title) != TRIM(yv.channel_name) + ) + ORDER BY lm.last_watched_ms DESC, v.video_id DESC + `, + ) + .all(SOURCE_TYPE_REMOTE) as Array<{ + videoId: 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; + metadataJson: string | null; + }>; + + if (candidates.length === 0) { + return; + } + + for (const candidate of candidates) { + if (!candidate.youtubeVideoId || !candidate.videoUrl) { + continue; + } + linkYoutubeVideoToAnimeRecord(this.db, candidate.videoId, { + youtubeVideoId: candidate.youtubeVideoId, + videoUrl: candidate.videoUrl, + videoTitle: candidate.videoTitle, + videoThumbnailUrl: candidate.videoThumbnailUrl, + channelId: candidate.channelId, + channelName: candidate.channelName, + channelUrl: candidate.channelUrl, + channelThumbnailUrl: candidate.channelThumbnailUrl, + uploaderId: candidate.uploaderId, + uploaderUrl: candidate.uploaderUrl, + description: candidate.description, + metadataJson: candidate.metadataJson, + }); + } + rebuildLifetimeSummaryTables(this.db); + } + handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void { const normalizedPath = normalizeMediaPath(mediaPath); const normalizedTitle = normalizeText(mediaTitle); @@ -721,7 +1148,14 @@ export class ImmersionTrackerService { `Starting immersion session for path=${normalizedPath} videoId=${sessionInfo.videoId}`, ); this.startSession(sessionInfo.videoId, sessionInfo.startedAtMs); - this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null); + const youtubeVideoId = + sourceType === SOURCE_TYPE_REMOTE ? extractYouTubeVideoId(normalizedPath) : null; + if (youtubeVideoId) { + void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId); + this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath); + } else { + this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null); + } this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath); } @@ -749,6 +1183,7 @@ export class ImmersionTrackerService { } const startMs = secToMs(startSec); + const endMs = secToMs(endSec); const subtitleKey = `${startMs}:${cleaned}`; if (this.recordedSubtitleKeys.has(subtitleKey)) { return; @@ -762,6 +1197,9 @@ export class ImmersionTrackerService { this.sessionState.currentLineIndex += 1; this.sessionState.linesSeen += 1; this.sessionState.tokensSeen += tokenCount; + if (this.sessionState.lastMediaMs === null || endMs > this.sessionState.lastMediaMs) { + this.sessionState.lastMediaMs = endMs; + } this.sessionState.pendingTelemetry = true; const wordOccurrences = new Map(); @@ -811,8 +1249,8 @@ export class ImmersionTrackerService { sessionId: this.sessionState.sessionId, videoId: this.sessionState.videoId, lineIndex: this.sessionState.currentLineIndex, - segmentStartMs: secToMs(startSec), - segmentEndMs: secToMs(endSec), + segmentStartMs: startMs, + segmentEndMs: endMs, text: cleaned, secondaryText: secondaryText ?? null, wordOccurrences: Array.from(wordOccurrences.values()), diff --git a/src/core/services/immersion-tracker/__tests__/query.test.ts b/src/core/services/immersion-tracker/__tests__/query.test.ts index d1f0cce..345c6c1 100644 --- a/src/core/services/immersion-tracker/__tests__/query.test.ts +++ b/src/core/services/immersion-tracker/__tests__/query.test.ts @@ -39,6 +39,7 @@ import { } from '../query.js'; import { SOURCE_TYPE_LOCAL, + SOURCE_TYPE_REMOTE, EVENT_CARD_MINED, EVENT_SUBTITLE_LINE, EVENT_YOMITAN_LOOKUP, @@ -279,6 +280,78 @@ test('getAnimeEpisodes falls back to the latest subtitle segment end when sessio } }); +test('getAnimeEpisodes ignores zero-valued session checkpoints and falls back to subtitle progress', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + const stmts = createTrackerPreparedStatements(db); + const videoId = getOrCreateVideoRecord(db, 'remote:https://www.youtube.com/watch?v=zero123', { + canonicalTitle: 'Zero Checkpoint Stream', + sourcePath: null, + sourceUrl: 'https://www.youtube.com/watch?v=zero123', + sourceType: SOURCE_TYPE_REMOTE, + }); + const animeId = getOrCreateAnimeRecord(db, { + parsedTitle: 'Zero Checkpoint Anime', + canonicalTitle: 'Zero Checkpoint Anime', + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }); + linkVideoToAnimeRecord(db, videoId, { + animeId, + parsedBasename: 'watch?v=zero123', + parsedTitle: 'Zero Checkpoint Anime', + parsedSeason: 1, + parsedEpisode: 1, + parserSource: 'fallback', + parserConfidence: 1, + parseMetadataJson: '{"episode":1}', + }); + db.prepare('UPDATE imm_videos SET duration_ms = ? WHERE video_id = ?').run(600_000, videoId); + + const startedAtMs = 1_200_000; + const sessionId = startSessionRecord(db, videoId, startedAtMs).sessionId; + db.prepare( + ` + UPDATE imm_sessions + SET + ended_at_ms = ?, + status = 2, + ended_media_ms = 0, + active_watched_ms = ?, + LAST_UPDATE_DATE = ? + WHERE session_id = ? + `, + ).run(startedAtMs + 30_000, 180_000, startedAtMs + 30_000, sessionId); + stmts.eventInsertStmt.run( + sessionId, + startedAtMs + 29_000, + EVENT_SUBTITLE_LINE, + 1, + 170_000, + 185_000, + 4, + 0, + '{"line":"stream progress"}', + startedAtMs + 29_000, + startedAtMs + 29_000, + ); + + const [episode] = getAnimeEpisodes(db, animeId); + assert.ok(episode); + assert.equal(episode?.endedMediaMs, 185_000); + assert.equal(episode?.durationMs, 600_000); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); + test('getSessionTimeline returns the full session when no limit is provided', () => { const dbPath = makeDbPath(); const db = new Database(dbPath); @@ -1956,6 +2029,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', () => { const dbPath = makeDbPath(); const db = new Database(dbPath); @@ -2679,3 +2846,200 @@ test('deleteSession rebuilds word and kanji aggregates from retained subtitle li cleanupDbPath(dbPath); } }); + +test('deleteSession removes zero-session media from library and trends', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + + const animeId = getOrCreateAnimeRecord(db, { + parsedTitle: 'Delete Me Anime', + canonicalTitle: 'Delete Me Anime', + anilistId: 404_404, + titleRomaji: 'Delete Me Anime', + titleEnglish: 'Delete Me Anime', + titleNative: 'Delete Me Anime', + metadataJson: null, + }); + const videoId = getOrCreateVideoRecord(db, 'local:/tmp/delete-last-session.mkv', { + canonicalTitle: 'Delete Last Session', + sourcePath: '/tmp/delete-last-session.mkv', + sourceUrl: null, + sourceType: SOURCE_TYPE_LOCAL, + }); + linkVideoToAnimeRecord(db, videoId, { + animeId, + parsedBasename: 'Delete Last Session', + parsedTitle: 'Delete Me Anime', + parsedSeason: 1, + parsedEpisode: 1, + parserSource: 'fallback', + parserConfidence: 1, + parseMetadataJson: '{"episode":1}', + }); + + const startedAtMs = 9_000_000; + const endedAtMs = startedAtMs + 120_000; + const rollupDay = Math.floor(startedAtMs / 86_400_000); + const rollupMonth = 197001; + const { sessionId } = startSessionRecord(db, videoId, startedAtMs); + + db.prepare( + ` + UPDATE imm_sessions + SET + ended_at_ms = ?, + ended_media_ms = ?, + total_watched_ms = ?, + active_watched_ms = ?, + lines_seen = ?, + tokens_seen = ?, + cards_mined = ?, + LAST_UPDATE_DATE = ? + WHERE session_id = ? + `, + ).run(endedAtMs, 120000, 120000, 120000, 12, 120, 3, endedAtMs, sessionId); + + db.prepare( + ` + INSERT INTO imm_lifetime_applied_sessions ( + session_id, + applied_at_ms, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES (?, ?, ?, ?) + `, + ).run(sessionId, endedAtMs, endedAtMs, endedAtMs); + 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(videoId, 1, 120_000, 3, 12, 120, 0, startedAtMs, endedAtMs, endedAtMs, endedAtMs); + db.prepare( + ` + INSERT INTO imm_lifetime_anime ( + anime_id, + total_sessions, + total_active_ms, + total_cards, + total_lines_seen, + total_tokens_seen, + episodes_started, + episodes_completed, + first_watched_ms, + last_watched_ms, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run(animeId, 1, 120000, 3, 12, 120, 1, 0, startedAtMs, endedAtMs, endedAtMs, endedAtMs); + db.prepare( + ` + UPDATE imm_lifetime_global + SET + total_sessions = 1, + total_active_ms = 120000, + total_cards = 3, + active_days = 1, + episodes_started = 1, + episodes_completed = 0, + anime_completed = 0, + last_rebuilt_ms = ?, + LAST_UPDATE_DATE = ? + WHERE global_id = 1 + `, + ).run(endedAtMs, endedAtMs); + db.prepare( + ` + INSERT INTO imm_daily_rollups ( + rollup_day, + video_id, + total_sessions, + total_active_min, + total_lines_seen, + total_tokens_seen, + total_cards, + cards_per_hour, + tokens_per_min, + lookup_hit_rate, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run(rollupDay, videoId, 1, 2, 12, 120, 3, 90, 60, null, endedAtMs, endedAtMs); + db.prepare( + ` + INSERT INTO imm_monthly_rollups ( + rollup_month, + video_id, + total_sessions, + total_active_min, + total_lines_seen, + total_tokens_seen, + total_cards, + CREATED_DATE, + LAST_UPDATE_DATE + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run(rollupMonth, videoId, 1, 2, 12, 120, 3, endedAtMs, endedAtMs); + + deleteSession(db, sessionId); + + assert.deepEqual(getMediaLibrary(db), []); + assert.equal(getMediaDetail(db, videoId) ?? null, null); + assert.deepEqual(getAnimeLibrary(db), []); + assert.equal(getAnimeDetail(db, animeId) ?? null, null); + + const trends = getTrendsDashboard(db, 'all', 'day'); + assert.deepEqual(trends.activity.watchTime, []); + assert.deepEqual(trends.activity.sessions, []); + + const dailyRollups = getDailyRollups(db, 30); + const monthlyRollups = getMonthlyRollups(db, 30); + assert.deepEqual(dailyRollups, []); + assert.deepEqual(monthlyRollups, []); + + const lifetimeMediaCount = Number( + ( + db.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_media WHERE video_id = ?').get( + videoId, + ) as { total: number } + ).total, + ); + const lifetimeAnimeCount = Number( + ( + db.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_anime WHERE anime_id = ?').get( + animeId, + ) as { total: number } + ).total, + ); + const appliedSessionCount = Number( + ( + db + .prepare('SELECT COUNT(*) AS total FROM imm_lifetime_applied_sessions WHERE session_id = ?') + .get(sessionId) as { total: number } + ).total, + ); + + assert.equal(lifetimeMediaCount, 0); + assert.equal(lifetimeAnimeCount, 0); + assert.equal(appliedSessionCount, 0); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); diff --git a/src/core/services/immersion-tracker/lifetime.ts b/src/core/services/immersion-tracker/lifetime.ts index f277bef..1119ea5 100644 --- a/src/core/services/immersion-tracker/lifetime.ts +++ b/src/core/services/immersion-tracker/lifetime.ts @@ -134,6 +134,49 @@ function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void { ).run(nowMs, nowMs); } +function rebuildLifetimeSummariesInternal( + db: DatabaseSync, + rebuiltAtMs: number, +): LifetimeRebuildSummary { + const sessions = db + .prepare( + ` + SELECT + session_id AS sessionId, + video_id AS videoId, + started_at_ms AS startedAtMs, + ended_at_ms AS endedAtMs, + total_watched_ms AS totalWatchedMs, + active_watched_ms AS activeWatchedMs, + lines_seen AS linesSeen, + tokens_seen AS tokensSeen, + cards_mined AS cardsMined, + lookup_count AS lookupCount, + lookup_hits AS lookupHits, + yomitan_lookup_count AS yomitanLookupCount, + pause_count AS pauseCount, + pause_ms AS pauseMs, + seek_forward_count AS seekForwardCount, + seek_backward_count AS seekBackwardCount, + media_buffer_events AS mediaBufferEvents + FROM imm_sessions + WHERE ended_at_ms IS NOT NULL + ORDER BY started_at_ms ASC, session_id ASC + `, + ) + .all() as RetainedSessionRow[]; + + resetLifetimeSummaries(db, rebuiltAtMs); + for (const session of sessions) { + applySessionLifetimeSummary(db, toRebuildSessionState(session), session.endedAtMs); + } + + return { + appliedSessions: sessions.length, + rebuiltAtMs, + }; +} + function toRebuildSessionState(row: RetainedSessionRow): SessionState { return { sessionId: row.sessionId, @@ -482,50 +525,22 @@ export function applySessionLifetimeSummary( export function rebuildLifetimeSummaries(db: DatabaseSync): LifetimeRebuildSummary { const rebuiltAtMs = Date.now(); - const sessions = db - .prepare( - ` - SELECT - session_id AS sessionId, - video_id AS videoId, - started_at_ms AS startedAtMs, - ended_at_ms AS endedAtMs, - total_watched_ms AS totalWatchedMs, - active_watched_ms AS activeWatchedMs, - lines_seen AS linesSeen, - tokens_seen AS tokensSeen, - cards_mined AS cardsMined, - lookup_count AS lookupCount, - lookup_hits AS lookupHits, - yomitan_lookup_count AS yomitanLookupCount, - pause_count AS pauseCount, - pause_ms AS pauseMs, - seek_forward_count AS seekForwardCount, - seek_backward_count AS seekBackwardCount, - media_buffer_events AS mediaBufferEvents - FROM imm_sessions - WHERE ended_at_ms IS NOT NULL - ORDER BY started_at_ms ASC, session_id ASC - `, - ) - .all() as RetainedSessionRow[]; - db.exec('BEGIN'); try { - resetLifetimeSummaries(db, rebuiltAtMs); - for (const session of sessions) { - applySessionLifetimeSummary(db, toRebuildSessionState(session), session.endedAtMs); - } + const summary = rebuildLifetimeSummariesInTransaction(db, rebuiltAtMs); db.exec('COMMIT'); + return summary; } catch (error) { db.exec('ROLLBACK'); throw error; } +} - return { - appliedSessions: sessions.length, - rebuiltAtMs, - }; +export function rebuildLifetimeSummariesInTransaction( + db: DatabaseSync, + rebuiltAtMs = Date.now(), +): LifetimeRebuildSummary { + return rebuildLifetimeSummariesInternal(db, rebuiltAtMs); } export function reconcileStaleActiveSessions(db: DatabaseSync): number { diff --git a/src/core/services/immersion-tracker/maintenance.ts b/src/core/services/immersion-tracker/maintenance.ts index 0f767bf..13f7e39 100644 --- a/src/core/services/immersion-tracker/maintenance.ts +++ b/src/core/services/immersion-tracker/maintenance.ts @@ -113,6 +113,14 @@ function setLastRollupSampleMs(db: DatabaseSync, sampleMs: number): void { ).run(ROLLUP_STATE_KEY, sampleMs); } +function resetRollups(db: DatabaseSync): void { + db.exec(` + DELETE FROM imm_daily_rollups; + DELETE FROM imm_monthly_rollups; + `); + setLastRollupSampleMs(db, ZERO_ID); +} + function upsertDailyRollupsForGroups( db: DatabaseSync, groups: Array<{ rollupDay: number; videoId: number }>, @@ -281,8 +289,20 @@ function dedupeGroups ({ + rollupDay: group.rollupDay, + videoId: group.videoId, + })), + ); + const monthlyGroups = dedupeGroups( + affectedGroups.map((group) => ({ + rollupMonth: group.rollupMonth, + videoId: group.videoId, + })), + ); + + upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs); + upsertMonthlyRollupsForGroups(db, monthlyGroups, rollupNowMs); + setLastRollupSampleMs(db, Number(maxSampleRow.maxSampleMs)); +} + export function runOptimizeMaintenance(db: DatabaseSync): void { db.exec('PRAGMA optimize'); } diff --git a/src/core/services/immersion-tracker/query.ts b/src/core/services/immersion-tracker/query.ts index d796724..8ca52ae 100644 --- a/src/core/services/immersion-tracker/query.ts +++ b/src/core/services/immersion-tracker/query.ts @@ -31,6 +31,8 @@ import type { VocabularyStatsRow, } from './types'; import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage'; +import { rebuildLifetimeSummariesInTransaction } from './lifetime'; +import { rebuildRollupsInTransaction } from './maintenance'; import { PartOfSpeech, type MergedToken } from '../../../types'; import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage'; import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech'; @@ -1746,7 +1748,7 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod v.duration_ms AS durationMs, ( SELECT COALESCE( - s_recent.ended_media_ms, + NULLIF(s_recent.ended_media_ms, 0), ( SELECT MAX(line.segment_end_ms) FROM imm_subtitle_lines line @@ -1817,6 +1819,17 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] { COALESCE(lm.total_cards, 0) AS totalCards, COALESCE(lm.total_tokens_seen, 0) AS totalTokensSeen, 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 WHEN ma.cover_blob_hash IS NOT NULL OR ma.cover_blob IS NOT NULL THEN 1 ELSE 0 @@ -1824,6 +1837,7 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] { FROM imm_videos v 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_youtube_videos yv ON yv.video_id = v.video_id ORDER BY lm.last_watched_ms DESC `, ) @@ -1846,9 +1860,21 @@ export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRo COALESCE(lm.total_lines_seen, 0) AS totalLinesSeen, 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.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 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 active_session_metrics asm ON asm.sessionId = s.session_id WHERE v.video_id = ? @@ -2443,6 +2469,8 @@ export function deleteSession(db: DatabaseSync, sessionId: number): void { try { deleteSessionsByIds(db, sessionIds); refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds); + rebuildLifetimeSummariesInTransaction(db); + rebuildRollupsInTransaction(db); db.exec('COMMIT'); } catch (error) { db.exec('ROLLBACK'); @@ -2459,6 +2487,8 @@ export function deleteSessions(db: DatabaseSync, sessionIds: number[]): void { try { deleteSessionsByIds(db, sessionIds); refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds); + rebuildLifetimeSummariesInTransaction(db); + rebuildRollupsInTransaction(db); db.exec('COMMIT'); } catch (error) { db.exec('ROLLBACK'); @@ -2495,6 +2525,8 @@ export function deleteVideo(db: DatabaseSync, videoId: number): void { cleanupUnusedCoverArtBlobHash(db, artRow?.coverBlobHash ?? null); db.prepare('DELETE FROM imm_videos WHERE video_id = ?').run(videoId); refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds); + rebuildLifetimeSummariesInTransaction(db); + rebuildRollupsInTransaction(db); db.exec('COMMIT'); } catch (error) { db.exec('ROLLBACK'); diff --git a/src/core/services/immersion-tracker/storage-session.test.ts b/src/core/services/immersion-tracker/storage-session.test.ts index edbcb4e..21b404b 100644 --- a/src/core/services/immersion-tracker/storage-session.test.ts +++ b/src/core/services/immersion-tracker/storage-session.test.ts @@ -15,8 +15,14 @@ import { getOrCreateAnimeRecord, getOrCreateVideoRecord, linkVideoToAnimeRecord, + linkYoutubeVideoToAnimeRecord, } from './storage'; -import { EVENT_SUBTITLE_LINE, SESSION_STATUS_ENDED, SOURCE_TYPE_LOCAL } from './types'; +import { + EVENT_SUBTITLE_LINE, + SESSION_STATUS_ENDED, + SOURCE_TYPE_LOCAL, + SOURCE_TYPE_REMOTE, +} from './types'; function makeDbPath(): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-imm-storage-session-')); @@ -106,6 +112,7 @@ test('ensureSchema creates immersion core tables', () => { assert.ok(tableNames.has('imm_kanji_line_occurrences')); assert.ok(tableNames.has('imm_rollup_state')); assert.ok(tableNames.has('imm_cover_art_blobs')); + assert.ok(tableNames.has('imm_youtube_videos')); const videoColumns = new Set( ( @@ -146,6 +153,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', () => { const dbPath = makeDbPath(); const db = new Database(dbPath); @@ -169,6 +284,8 @@ test('ensureSchema creates large-history performance indexes', () => { assert.ok(indexNames.has('idx_kanji_frequency')); assert.ok(indexNames.has('idx_media_art_anilist_id')); 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 { db.close(); cleanupDbPath(dbPath); @@ -706,6 +823,123 @@ test('anime rows are reused by normalized parsed title and upgraded with AniList } }); +test('youtube videos can be regrouped under a shared channel anime identity', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + + const firstVideoId = getOrCreateVideoRecord( + db, + 'remote:https://www.youtube.com/watch?v=video-1', + { + canonicalTitle: 'watch?v video-1', + sourcePath: null, + sourceUrl: 'https://www.youtube.com/watch?v=video-1', + sourceType: SOURCE_TYPE_REMOTE, + }, + ); + const secondVideoId = getOrCreateVideoRecord( + db, + 'remote:https://www.youtube.com/watch?v=video-2', + { + canonicalTitle: 'watch?v video-2', + sourcePath: null, + sourceUrl: 'https://www.youtube.com/watch?v=video-2', + sourceType: SOURCE_TYPE_REMOTE, + }, + ); + + const firstAnimeId = getOrCreateAnimeRecord(db, { + parsedTitle: 'watch?v video-1', + canonicalTitle: 'watch?v video-1', + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }); + linkVideoToAnimeRecord(db, firstVideoId, { + animeId: firstAnimeId, + parsedBasename: null, + parsedTitle: 'watch?v video-1', + parsedSeason: null, + parsedEpisode: null, + parserSource: 'fallback', + parserConfidence: 0.2, + parseMetadataJson: '{"source":"fallback"}', + }); + + const secondAnimeId = getOrCreateAnimeRecord(db, { + parsedTitle: 'watch?v video-2', + canonicalTitle: 'watch?v video-2', + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: null, + }); + linkVideoToAnimeRecord(db, secondVideoId, { + animeId: secondAnimeId, + parsedBasename: null, + parsedTitle: 'watch?v video-2', + parsedSeason: null, + parsedEpisode: null, + parserSource: 'fallback', + parserConfidence: 0.2, + parseMetadataJson: '{"source":"fallback"}', + }); + + linkYoutubeVideoToAnimeRecord(db, firstVideoId, { + youtubeVideoId: 'video-1', + videoUrl: 'https://www.youtube.com/watch?v=video-1', + videoTitle: 'Video One', + videoThumbnailUrl: 'https://i.ytimg.com/vi/video-1/hqdefault.jpg', + channelId: 'UC123', + channelName: 'Channel Name', + channelUrl: 'https://www.youtube.com/channel/UC123', + channelThumbnailUrl: 'https://yt3.googleusercontent.com/channel-123=s176-c-k-c0x00ffffff-no-rj', + uploaderId: '@channelname', + uploaderUrl: 'https://www.youtube.com/@channelname', + description: null, + metadataJson: '{"id":"video-1"}', + }); + linkYoutubeVideoToAnimeRecord(db, secondVideoId, { + youtubeVideoId: 'video-2', + videoUrl: 'https://www.youtube.com/watch?v=video-2', + videoTitle: 'Video Two', + videoThumbnailUrl: 'https://i.ytimg.com/vi/video-2/hqdefault.jpg', + channelId: 'UC123', + channelName: 'Channel Name', + channelUrl: 'https://www.youtube.com/channel/UC123', + channelThumbnailUrl: 'https://yt3.googleusercontent.com/channel-123=s176-c-k-c0x00ffffff-no-rj', + uploaderId: '@channelname', + uploaderUrl: 'https://www.youtube.com/@channelname', + description: null, + metadataJson: '{"id":"video-2"}', + }); + + const animeRows = db.prepare('SELECT anime_id, canonical_title FROM imm_anime').all() as Array<{ + anime_id: number; + canonical_title: string; + }>; + const videoRows = db + .prepare('SELECT video_id, anime_id, parsed_title FROM imm_videos ORDER BY video_id ASC') + .all() as Array<{ video_id: number; anime_id: number | null; parsed_title: string | null }>; + + const channelAnimeRows = animeRows.filter((row) => row.canonical_title === 'Channel Name'); + assert.equal(channelAnimeRows.length, 1); + assert.equal(videoRows[0]?.anime_id, channelAnimeRows[0]?.anime_id); + assert.equal(videoRows[1]?.anime_id, channelAnimeRows[0]?.anime_id); + assert.equal(videoRows[0]?.parsed_title, 'Channel Name'); + assert.equal(videoRows[1]?.parsed_title, 'Channel Name'); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); + test('start/finalize session updates ended_at and status', () => { const dbPath = makeDbPath(); const db = new Database(dbPath); diff --git a/src/core/services/immersion-tracker/storage.ts b/src/core/services/immersion-tracker/storage.ts index 98f3ae8..5e4b85e 100644 --- a/src/core/services/immersion-tracker/storage.ts +++ b/src/core/services/immersion-tracker/storage.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import { parseMediaInfo } from '../../../jimaku/utils'; import type { DatabaseSync } from './sqlite'; import { SCHEMA_VERSION } from './types'; -import type { QueuedWrite, VideoMetadata } from './types'; +import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types'; export interface TrackerPreparedStatements { telemetryInsertStmt: ReturnType; @@ -39,6 +39,41 @@ export interface VideoAnimeLinkInput { parseMetadataJson: string | null; } +function buildYoutubeChannelAnimeIdentity(metadata: YoutubeVideoMetadata): { + parsedTitle: string; + canonicalTitle: string; + metadataJson: string; +} | null { + const channelId = metadata.channelId?.trim() || null; + const channelUrl = metadata.channelUrl?.trim() || null; + const channelName = metadata.channelName?.trim() || null; + const uploaderId = metadata.uploaderId?.trim() || null; + const videoTitle = metadata.videoTitle?.trim() || null; + + const parsedTitle = channelId + ? `youtube-channel:${channelId}` + : channelUrl + ? `youtube-channel-url:${channelUrl}` + : channelName + ? `youtube-channel-name:${channelName}` + : null; + if (!parsedTitle) { + return null; + } + + return { + parsedTitle, + canonicalTitle: channelName || uploaderId || videoTitle || parsedTitle, + metadataJson: JSON.stringify({ + source: 'youtube-channel', + channelId, + channelUrl, + channelName, + uploaderId, + }), + }; +} + const COVER_BLOB_REFERENCE_PREFIX = '__subminer_cover_blob_ref__:'; const WAL_JOURNAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024; @@ -439,6 +474,38 @@ export function linkVideoToAnimeRecord( ); } +export function linkYoutubeVideoToAnimeRecord( + db: DatabaseSync, + videoId: number, + metadata: YoutubeVideoMetadata, +): number | null { + const identity = buildYoutubeChannelAnimeIdentity(metadata); + if (!identity) { + return null; + } + + const animeId = getOrCreateAnimeRecord(db, { + parsedTitle: identity.parsedTitle, + canonicalTitle: identity.canonicalTitle, + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson: identity.metadataJson, + }); + linkVideoToAnimeRecord(db, videoId, { + animeId, + parsedBasename: null, + parsedTitle: identity.canonicalTitle, + parsedSeason: null, + parsedEpisode: null, + parserSource: 'youtube', + parserConfidence: 1, + parseMetadataJson: identity.metadataJson, + }); + return animeId; +} + function migrateLegacyAnimeMetadata(db: DatabaseSync): void { addColumnIfMissing(db, 'imm_videos', 'anime_id', 'INTEGER REFERENCES imm_anime(anime_id)'); addColumnIfMissing(db, 'imm_videos', 'parsed_basename', 'TEXT'); @@ -743,6 +810,27 @@ export function ensureSchema(db: DatabaseSync): void { 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(` CREATE TABLE IF NOT EXISTS imm_cover_art_blobs( blob_hash TEXT PRIMARY KEY, @@ -1134,6 +1222,14 @@ export function ensureSchema(db: DatabaseSync): void { CREATE INDEX IF NOT EXISTS idx_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) { db.exec('DELETE FROM imm_daily_rollups'); @@ -1506,3 +1602,65 @@ export function updateVideoTitleRecord( `, ).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, + ); +} diff --git a/src/core/services/immersion-tracker/types.ts b/src/core/services/immersion-tracker/types.ts index d07790d..515d510 100644 --- a/src/core/services/immersion-tracker/types.ts +++ b/src/core/services/immersion-tracker/types.ts @@ -1,4 +1,4 @@ -export const SCHEMA_VERSION = 15; +export const SCHEMA_VERSION = 16; export const DEFAULT_QUEUE_CAP = 1_000; export const DEFAULT_BATCH_SIZE = 25; export const DEFAULT_FLUSH_INTERVAL_MS = 500; @@ -420,6 +420,17 @@ export interface MediaLibraryRow { totalTokensSeen: number; lastWatchedMs: 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 { @@ -434,6 +445,32 @@ export interface MediaDetailRow { totalLookupCount: number; totalLookupHits: 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 { diff --git a/src/core/services/index.ts b/src/core/services/index.ts index c46adb1..b63e7a7 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -79,7 +79,10 @@ export { handleOverlayWindowBeforeInputEvent, isTabInputForMpvForwarding, } from './overlay-window-input'; -export { initializeOverlayRuntime } from './overlay-runtime-init'; +export { + initializeOverlayAnkiIntegration, + initializeOverlayRuntime, +} from './overlay-runtime-init'; export { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility'; export { MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, diff --git a/src/core/services/ipc-command.test.ts b/src/core/services/ipc-command.test.ts index 6fb468a..f9c63b2 100644 --- a/src/core/services/ipc-command.test.ts +++ b/src/core/services/ipc-command.test.ts @@ -15,6 +15,7 @@ function createOptions(overrides: Partial { calls.push('subsync'); @@ -22,6 +23,9 @@ function createOptions(overrides: Partial { calls.push('runtime-options'); }, + openYoutubeTrackPicker: () => { + calls.push('youtube-picker'); + }, runtimeOptionsCycle: () => ({ ok: true }), showMpvOsd: (text) => { osd.push(text); @@ -98,6 +102,14 @@ test('handleMpvCommandFromIpc dispatches special subtitle-delay shift command', assert.deepEqual(osd, []); }); +test('handleMpvCommandFromIpc dispatches special youtube picker open command', () => { + const { options, calls, sentCommands, osd } = createOptions(); + handleMpvCommandFromIpc(['__youtube-picker-open'], options); + assert.deepEqual(calls, ['youtube-picker']); + assert.deepEqual(sentCommands, []); + assert.deepEqual(osd, []); +}); + test('handleMpvCommandFromIpc does not forward commands while disconnected', () => { const { options, sentCommands, osd } = createOptions({ isMpvConnected: () => false, diff --git a/src/core/services/ipc-command.ts b/src/core/services/ipc-command.ts index f5342bb..166ac68 100644 --- a/src/core/services/ipc-command.ts +++ b/src/core/services/ipc-command.ts @@ -14,9 +14,11 @@ export interface HandleMpvCommandFromIpcOptions { PLAY_NEXT_SUBTITLE: string; SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string; SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string; + YOUTUBE_PICKER_OPEN: string; }; triggerSubsyncFromConfig: () => void; openRuntimeOptionsPalette: () => void; + openYoutubeTrackPicker: () => void | Promise; runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; showMpvOsd: (text: string) => void; mpvReplaySubtitle: () => void; @@ -90,6 +92,11 @@ export function handleMpvCommandFromIpc( return; } + if (first === options.specialCommands.YOUTUBE_PICKER_OPEN) { + void options.openYoutubeTrackPicker(); + return; + } + if ( first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START || first === options.specialCommands.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index de8595f..5cb4a7c 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -144,6 +144,7 @@ function createRegisterIpcDeps(overrides: Partial = {}): IpcServ getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), immersionTracker: null, ...overrides, }; @@ -236,6 +237,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { return { ok: true, message: 'done' }; }, appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }), + onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }); assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' }); @@ -305,6 +307,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () = getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }, registrar, ); @@ -611,6 +614,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }, registrar, ); @@ -677,6 +681,7 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }, registrar, ); @@ -746,6 +751,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }, registrar, ); diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index a8a4612..331451a 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -10,6 +10,8 @@ import type { SubtitlePosition, SubsyncManualRunRequest, SubsyncResult, + YoutubePickerResolveRequest, + YoutubePickerResolveResult, } from '../../types'; import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts'; import { @@ -23,6 +25,7 @@ import { parseRuntimeOptionValue, parseSubtitlePosition, parseSubsyncManualRunRequest, + parseYoutubePickerResolveRequest, } from '../../shared/ipc/validators'; const { BrowserWindow, ipcMain } = electron; @@ -61,6 +64,7 @@ export interface IpcServiceDeps { getCurrentSecondarySub: () => string; focusMainWindow: () => void; runSubsyncManual: (request: SubsyncManualRunRequest) => Promise; + onYoutubePickerResolve: (request: YoutubePickerResolveRequest) => Promise; getAnkiConnectStatus: () => boolean; getRuntimeOptions: () => unknown; setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown; @@ -163,6 +167,7 @@ export interface IpcDepsRuntimeOptions { getMpvClient: () => MpvClientLike | null; focusMainWindow: () => void; runSubsyncManual: (request: SubsyncManualRunRequest) => Promise; + onYoutubePickerResolve: (request: YoutubePickerResolveRequest) => Promise; getAnkiConnectStatus: () => boolean; getRuntimeOptions: () => unknown; setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown; @@ -225,6 +230,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService mainWindow.focus(); }, runSubsyncManual: options.runSubsyncManual, + onYoutubePickerResolve: options.onYoutubePickerResolve, getAnkiConnectStatus: options.getAnkiConnectStatus, getRuntimeOptions: options.getRuntimeOptions, setRuntimeOption: options.setRuntimeOption, @@ -285,6 +291,14 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar 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, () => { deps.openYomitanSettings(); }); diff --git a/src/core/services/overlay-runtime-init.test.ts b/src/core/services/overlay-runtime-init.test.ts index b9f8354..9fd9ad3 100644 --- a/src/core/services/overlay-runtime-init.test.ts +++ b/src/core/services/overlay-runtime-init.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; 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', () => { let createdIntegrations = 0; @@ -109,6 +109,136 @@ test('initializeOverlayRuntime starts Anki integration when ankiConnect.enabled 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', () => { let createdIntegrations = 0; let startedIntegrations = 0; diff --git a/src/core/services/overlay-runtime-init.ts b/src/core/services/overlay-runtime-init.ts index bbe8405..85513fe 100644 --- a/src/core/services/overlay-runtime-init.ts +++ b/src/core/services/overlay-runtime-init.ts @@ -47,6 +47,24 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte } 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; + getKnownWordCacheStatePath: () => string; + shouldStartAnkiIntegration?: () => boolean; + createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike; backendOverride: string | null; createMainWindow: () => void; registerGlobalShortcuts: () => void; @@ -60,23 +78,6 @@ export function initializeOverlayRuntime(options: { override?: string | null, targetMpvSocketPath?: string | 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; - getKnownWordCacheStatePath: () => string; - shouldStartAnkiIntegration?: () => boolean; - createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike; }): void { options.createMainWindow(); options.registerGlobalShortcuts(); @@ -112,35 +113,64 @@ export function initializeOverlayRuntime(options: { 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; + getKnownWordCacheStatePath: () => string; + shouldStartAnkiIntegration?: () => boolean; + createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike; +}): boolean { + if (options.getAnkiIntegration?.()) { + return false; + } + const config = options.getResolvedConfig(); const subtitleTimingTracker = options.getSubtitleTimingTracker(); const mpvClient = options.getMpvClient(); const runtimeOptionsManager = options.getRuntimeOptionsManager(); if ( - config.ankiConnect?.enabled === true && - subtitleTimingTracker && - mpvClient && - runtimeOptionsManager + config.ankiConnect?.enabled !== true || + !subtitleTimingTracker || + !mpvClient || + !runtimeOptionsManager ) { - const effectiveAnkiConfig = runtimeOptionsManager.getEffectiveAnkiConnectConfig( - config.ankiConnect, - ); - const createAnkiIntegration = options.createAnkiIntegration ?? createDefaultAnkiIntegration; - const integration = createAnkiIntegration({ - config: effectiveAnkiConfig, - aiConfig: mergeAiConfig(config.ai, config.ankiConnect?.ai), - subtitleTimingTracker, - mpvClient, - showDesktopNotification: options.showDesktopNotification, - createFieldGroupingCallback: options.createFieldGroupingCallback, - knownWordCacheStatePath: options.getKnownWordCacheStatePath(), - }); - if (options.shouldStartAnkiIntegration?.() !== false) { - integration.start(); - } - options.setAnkiIntegration(integration); + return false; } - options.updateVisibleOverlayVisibility(); + const effectiveAnkiConfig = runtimeOptionsManager.getEffectiveAnkiConnectConfig( + config.ankiConnect, + ); + const createAnkiIntegration = options.createAnkiIntegration ?? createDefaultAnkiIntegration; + const integration = createAnkiIntegration({ + config: effectiveAnkiConfig, + aiConfig: mergeAiConfig(config.ai, config.ankiConnect?.ai), + subtitleTimingTracker, + mpvClient, + showDesktopNotification: options.showDesktopNotification, + createFieldGroupingCallback: options.createFieldGroupingCallback, + knownWordCacheStatePath: options.getKnownWordCacheStatePath(), + }); + if (options.shouldStartAnkiIntegration?.() !== false) { + integration.start(); + } + options.setAnkiIntegration(integration); + return true; } diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts index 91b937f..a992828 100644 --- a/src/core/services/overlay-visibility.test.ts +++ b/src/core/services/overlay-visibility.test.ts @@ -200,6 +200,44 @@ test('Windows visible overlay stays click-through and does not steal focus while assert.ok(!calls.includes('focus')); }); +test('visible overlay stays hidden while a modal window is active', () => { + const { window, calls } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + modalActive: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: true, + isWindowsPlatform: false, + } as never); + + assert.ok(calls.includes('hide')); + assert.ok(!calls.includes('show')); + assert.ok(!calls.includes('update-bounds')); +}); + test('macOS tracked visible overlay stays visible without passively stealing focus', () => { const { window, calls } = createMainWindowRecorder(); const tracker: WindowTrackerStub = { diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts index c2bfb47..080b232 100644 --- a/src/core/services/overlay-visibility.ts +++ b/src/core/services/overlay-visibility.ts @@ -4,6 +4,7 @@ import { WindowGeometry } from '../../types'; export function updateVisibleOverlayVisibility(args: { visibleOverlayVisible: boolean; + modalActive?: boolean; forceMousePassthrough?: boolean; mainWindow: BrowserWindow | null; windowTracker: BaseWindowTracker | null; @@ -28,6 +29,12 @@ export function updateVisibleOverlayVisibility(args: { const mainWindow = args.mainWindow; + if (args.modalActive) { + mainWindow.hide(); + args.syncOverlayShortcuts(); + return; + } + const showPassiveVisibleOverlay = (): void => { const forceMousePassthrough = args.forceMousePassthrough === true; if (args.isWindowsPlatform || forceMousePassthrough) { diff --git a/src/core/services/startup.test.ts b/src/core/services/startup.test.ts index 246972d..156c994 100644 --- a/src/core/services/startup.test.ts +++ b/src/core/services/startup.test.ts @@ -194,3 +194,167 @@ test('runAppReadyRuntime headless refresh bootstraps Anki runtime without UI sta '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')); +}); diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts index 206647d..722a064 100644 --- a/src/core/services/startup.ts +++ b/src/core/services/startup.ts @@ -194,6 +194,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise { + return await downloadYoutubeSubtitleTrack(input); +} + +export async function acquireYoutubeSubtitleTracks(input: { + targetUrl: string; + outputDir: string; + tracks: YoutubeTrackOption[]; +}): Promise> { + return await downloadYoutubeSubtitleTracks(input); +} diff --git a/src/core/services/youtube/kinds.ts b/src/core/services/youtube/kinds.ts new file mode 100644 index 0000000..01dba27 --- /dev/null +++ b/src/core/services/youtube/kinds.ts @@ -0,0 +1 @@ +export type YoutubeTrackKind = 'manual' | 'auto'; diff --git a/src/core/services/youtube/labels.ts b/src/core/services/youtube/labels.ts new file mode 100644 index 0000000..9e623f0 --- /dev/null +++ b/src/core/services/youtube/labels.ts @@ -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})`; +} diff --git a/src/core/services/youtube/metadata-probe.test.ts b/src/core/services/youtube/metadata-probe.test.ts new file mode 100644 index 0000000..5db5743 --- /dev/null +++ b/src/core/services/youtube/metadata-probe.test.ts @@ -0,0 +1,89 @@ +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(fn: (dir: string) => Promise): Promise { + 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'); +} + +function makeHangingFakeYtDlpScript(dir: string): void { + const scriptPath = path.join(dir, 'yt-dlp'); + const script = `#!/usr/bin/env node +setInterval(() => {}, 1000); +`; + 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(payload: string, fn: () => Promise): Promise { + 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; + } + }); +} + +async function withHangingFakeYtDlp(fn: () => Promise): Promise { + return await withTempDir(async (root) => { + const binDir = path.join(root, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + makeHangingFakeYtDlpScript(binDir); + 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); + }); +}); + +test( + 'probeYoutubeVideoMetadata times out when yt-dlp hangs', + { timeout: 20_000 }, + async () => { + await withHangingFakeYtDlp(async () => { + await assert.rejects( + probeYoutubeVideoMetadata('https://www.youtube.com/watch?v=abc123'), + /timed out after 15000ms/, + ); + }); + }, +); diff --git a/src/core/services/youtube/metadata-probe.ts b/src/core/services/youtube/metadata-probe.ts new file mode 100644 index 0000000..2714fcc --- /dev/null +++ b/src/core/services/youtube/metadata-probe.ts @@ -0,0 +1,122 @@ +import { spawn } from 'node:child_process'; +import type { YoutubeVideoMetadata } from '../immersion-tracker/types'; + +const YOUTUBE_METADATA_PROBE_TIMEOUT_MS = 15_000; + +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[], + timeoutMs = YOUTUBE_METADATA_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 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 { + 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), + }; +} diff --git a/src/core/services/youtube/retime.test.ts b/src/core/services/youtube/retime.test.ts new file mode 100644 index 0000000..fe6485c --- /dev/null +++ b/src/core/services/youtube/retime.test.ts @@ -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 }); + } +}); diff --git a/src/core/services/youtube/retime.ts b/src/core/services/youtube/retime.ts new file mode 100644 index 0000000..a1dd29c --- /dev/null +++ b/src/core/services/youtube/retime.ts @@ -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)' : ''}`, + }; +} diff --git a/src/core/services/youtube/timedtext.test.ts b/src/core/services/youtube/timedtext.test.ts new file mode 100644 index 0000000..1f543cd --- /dev/null +++ b/src/core/services/youtube/timedtext.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { convertYoutubeTimedTextToVtt, normalizeYoutubeAutoVtt } from './timedtext'; + +test('convertYoutubeTimedTextToVtt leaves malformed numeric entities literal', () => { + const result = convertYoutubeTimedTextToVtt( + '

� � A

', + ); + + 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( + [ + '', + '

今日は

', + '

今日はいい天気ですね

', + '

今日はいい天気ですね

', + '
', + ].join(''), + ); + + assert.equal( + result, + [ + 'WEBVTT', + '', + '00:00:00.000 --> 00:00:00.999', + '今日は', + '', + '00:00:01.000 --> 00:00:03.000', + 'いい天気ですね', + '', + ].join('\n'), + ); +}); + +test('normalizeYoutubeAutoVtt strips cumulative rolling-caption prefixes', () => { + const result = normalizeYoutubeAutoVtt( + [ + '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'), + ); + + assert.equal( + result, + [ + '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'), + ); +}); diff --git a/src/core/services/youtube/timedtext.ts b/src/core/services/youtube/timedtext.ts new file mode 100644 index 0000000..e7d2231 --- /dev/null +++ b/src/core/services/youtube/timedtext.ts @@ -0,0 +1,166 @@ +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 { + const attrs = new Map(); + 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(/]*)>([\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(//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`; +} + +function normalizeRollingCaptionText(text: string, previousText: string): string { + if (!previousText || !text.startsWith(previousText)) { + return text; + } + return text.slice(previousText.length).trimStart(); +} + +export function normalizeYoutubeAutoVtt(content: string): string { + const normalizedContent = content.replace(/\r\n?/g, '\n'); + const blocks = normalizedContent.split(/\n{2,}/); + if (blocks.length === 0) { + return content; + } + + let previousText = ''; + let changed = false; + const normalizedBlocks = blocks.map((block) => { + if (!block.includes('-->')) { + return block; + } + + const lines = block.split('\n'); + const timingLineIndex = lines.findIndex((line) => line.includes('-->')); + if (timingLineIndex < 0 || timingLineIndex === lines.length - 1) { + return block; + } + + const textLines = lines.slice(timingLineIndex + 1); + const originalText = textLines.join('\n').trim(); + if (!originalText) { + return block; + } + + const normalizedText = normalizeRollingCaptionText(originalText, previousText); + previousText = originalText; + if (!normalizedText || normalizedText === originalText) { + return block; + } + + changed = true; + return [...lines.slice(0, timingLineIndex + 1), normalizedText].join('\n'); + }); + + if (!changed) { + return content; + } + return `${normalizedBlocks.join('\n\n')}\n`; +} diff --git a/src/core/services/youtube/track-download.test.ts b/src/core/services/youtube/track-download.test.ts new file mode 100644 index 0000000..7832e2e --- /dev/null +++ b/src/core/services/youtube/track-download.test.ts @@ -0,0 +1,570 @@ +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(fn: (dir: string) => Promise): Promise { + 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( + mode: 'both' | 'webp-only' | 'multi' | 'multi-primary-only-fail' | 'rolling-auto', + fn: (dir: string, binDir: string) => Promise, +): Promise { + 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( + expectations: Partial>, + fn: () => Promise, +): Promise { + 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( + handler: (url: string) => Promise | Response, + fn: () => Promise, +): Promise { + 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)', + }, + }); + + 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)', + }, + }), + /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)', + }, + }); + + 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)', + }, + }); + + assert.equal(path.extname(result.path), '.vtt'); + }, + ); + }); +}); + +test('downloadYoutubeSubtitleTrack normalizes rolling auto-caption vtt output from yt-dlp', async () => { + if (process.platform === 'win32') { + return; + } + + await withFakeYtDlp('rolling-auto', 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)', + }, + }); + + assert.equal( + fs.readFileSync(result.path, 'utf8'), + [ + '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'), + ); + }); +}); + +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', + }, + }); + + 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', + }, + }); + + 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( + [ + '', + '

今日は

', + '

今日はいい天気ですね

', + '

今日はいい天気ですね本当に

', + '
', + ].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', + }, + }); + + 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)', + }, + ], + }); + + 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)', + }, + ], + }); + + 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', + }, + ], + }); + + 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', + }, + ], + }); + + 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')); + }, + ); + }); +}); diff --git a/src/core/services/youtube/track-download.ts b/src/core/services/youtube/track-download.ts new file mode 100644 index 0000000..62c4bbe --- /dev/null +++ b/src/core/services/youtube/track-download.ts @@ -0,0 +1,315 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import type { YoutubeTrackOption } from './track-probe'; +import { + convertYoutubeTimedTextToVtt, + isYoutubeTimedTextExtension, + normalizeYoutubeAutoVtt, +} 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) + : input.track.kind === 'auto' && safeExt === 'vtt' + ? normalizeYoutubeAutoVtt(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}`); +} + +function normalizeDownloadedAutoSubtitle(pathname: string, track: YoutubeTrackOption): void { + if (track.kind !== 'auto' || path.extname(pathname).toLowerCase() !== '.vtt') { + return; + } + const content = fs.readFileSync(pathname, 'utf8'); + const normalized = normalizeYoutubeAutoVtt(content); + if (normalized !== content) { + fs.writeFileSync(pathname, normalized, 'utf8'); + } +} + +export async function downloadYoutubeSubtitleTrack(input: { + targetUrl: string; + outputDir: string; + track: YoutubeTrackOption; +}): 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.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}`); + } + normalizeDownloadedAutoSubtitle(subtitlePath, input.track); + return { path: subtitlePath }; +} + +export async function downloadYoutubeSubtitleTracks(input: { + targetUrl: string; + outputDir: string; + tracks: YoutubeTrackOption[]; +}): Promise> { + 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(); + 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.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(); + for (const track of input.tracks) { + const subtitlePath = pickLatestSubtitleFileForLanguage( + input.outputDir, + YOUTUBE_BATCH_PREFIX, + track.sourceLanguage, + ); + if (subtitlePath) { + normalizeDownloadedAutoSubtitle(subtitlePath, track); + 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(',')}`, + ); +} diff --git a/src/core/services/youtube/track-probe.test.ts b/src/core/services/youtube/track-probe.test.ts new file mode 100644 index 0000000..998aeaa --- /dev/null +++ b/src/core/services/youtube/track-probe.test.ts @@ -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(fn: (dir: string) => Promise): Promise { + 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( + payload: unknown, + fn: () => Promise, + options: { rawScript?: boolean } = {}, +): Promise { + 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/, + ); + }); +}); diff --git a/src/core/services/youtube/track-probe.ts b/src/core/services/youtube/track-probe.ts new file mode 100644 index 0000000..16d4304 --- /dev/null +++ b/src/core/services/youtube/track-probe.ts @@ -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; + automatic_captions?: Record; +}; + +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 | 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 { + 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, + }; +} diff --git a/src/core/services/youtube/track-selection.ts b/src/core/services/youtube/track-selection.ts new file mode 100644 index 0000000..141b100 --- /dev/null +++ b/src/core/services/youtube/track-selection.ts @@ -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; +} + diff --git a/src/logger.test.ts b/src/logger.test.ts index 58eacf5..57eee62 100644 --- a/src/logger.test.ts +++ b/src/logger.test.ts @@ -17,7 +17,7 @@ test('resolveDefaultLogFilePath uses APPDATA on windows', () => { 'C:\\Users\\tester\\AppData\\Roaming', 'SubMiner', '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', 'SubMiner', 'logs', - `SubMiner-${new Date().toISOString().slice(0, 10)}.log`, + `app-${new Date().toISOString().slice(0, 10)}.log`, ), ); }); diff --git a/src/logger.ts b/src/logger.ts index 7e5a98f..64e69d3 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,6 +1,4 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; +import { appendLogLine, resolveDefaultLogFilePath as resolveSharedDefaultLogFilePath } from './shared/log-files'; export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; export type LogLevelSource = 'cli' | 'config'; @@ -112,15 +110,11 @@ function safeStringify(value: unknown): string { } function resolveLogFilePath(): string { - const envPath = process.env.SUBMINER_MPV_LOG?.trim(); + const envPath = process.env.SUBMINER_APP_LOG?.trim(); if (envPath) { return envPath; } - return resolveDefaultLogFilePath({ - platform: process.platform, - homeDir: os.homedir(), - appDataDir: process.env.APPDATA, - }); + return resolveDefaultLogFilePath(); } export function resolveDefaultLogFilePath(options?: { @@ -128,27 +122,11 @@ export function resolveDefaultLogFilePath(options?: { homeDir?: string; appDataDir?: string; }): string { - const date = new Date().toISOString().slice(0, 10); - 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`); + return resolveSharedDefaultLogFilePath('app', options); } function appendToLogFile(line: string): void { - try { - 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 - } + appendLogLine(resolveLogFilePath(), line); } function emit(level: LogLevel, scope: string, message: string, meta: unknown[]): void { diff --git a/src/main.ts b/src/main.ts index be30496..157b855 100644 --- a/src/main.ts +++ b/src/main.ts @@ -113,6 +113,7 @@ import { } from './cli/args'; import type { CliArgs, CliCommandSource } from './cli/args'; import { printHelp } from './cli/help'; +import { IPC_CHANNELS } from './shared/ipc/contracts'; import { buildConfigParseErrorDetails, buildConfigWarningDialogDetails, @@ -279,6 +280,7 @@ import { handleMultiCopyDigit as handleMultiCopyDigitCore, hasMpvWebsocketPlugin, importYomitanDictionaryFromZip, + initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore, initializeOverlayRuntime as initializeOverlayRuntimeCore, jellyfinTicksToSecondsRuntime, listJellyfinItemsRuntime, @@ -309,12 +311,23 @@ import { upsertYomitanDictionarySettings, updateLastCardFromClipboard as updateLastCardFromClipboardCore, } 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 { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js'; import { createFirstRunSetupService, shouldAutoOpenFirstRunSetup, } from './main/runtime/first-run-setup-service'; +import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow'; +import { + clearYoutubePrimarySubtitleNotificationTimer, + createYoutubePrimarySubtitleNotificationRuntime, +} from './main/runtime/youtube-primary-subtitle-notification'; import { resolveAutoplayReadyMaxReleaseAttempts } from './main/runtime/startup-autoplay-release-policy'; import { buildFirstRunSetupHtml, @@ -332,6 +345,7 @@ import { detectWindowsMpvShortcuts, resolveWindowsMpvShortcutPaths, } from './main/runtime/windows-mpv-shortcuts'; +import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch'; import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup'; import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps'; import { @@ -384,6 +398,7 @@ import { registerIpcRuntimeServices } from './main/ipc-runtime'; import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies'; import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime'; import { createOverlayModalRuntimeService } from './main/overlay-runtime'; +import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open'; import type { OverlayHostedModal } from './shared/ipc/contracts'; import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime'; import { @@ -402,6 +417,7 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications'; import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate'; import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer'; +import { isYoutubePlaybackActive } from './main/runtime/youtube-playback'; import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy'; import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log'; import { @@ -442,7 +458,7 @@ import { resolveSubtitleSourcePath, } from './main/runtime/subtitle-prefetch-source'; import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init'; -import { codecToExtension } from './subsync/utils'; +import { codecToExtension, getSubsyncConfig } from './subsync/utils'; if (process.platform === 'linux') { app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal'); @@ -743,6 +759,7 @@ process.on('SIGTERM', () => { const overlayManager = createOverlayManager(); let overlayModalInputExclusive = false; let syncOverlayShortcutsForModal: (isActive: boolean) => void = () => {}; +let syncOverlayVisibilityForModal: () => void = () => {}; const handleModalInputStateChange = (isActive: boolean): void => { if (overlayModalInputExclusive === isActive) return; @@ -759,6 +776,7 @@ const handleModalInputStateChange = (isActive: boolean): void => { } } syncOverlayShortcutsForModal(isActive); + syncOverlayVisibilityForModal(); }; const buildOverlayContentMeasurementStoreMainDepsHandler = @@ -787,6 +805,181 @@ const appState = createAppState({ mpvSocketPath: getDefaultSocketPath(), texthookerPort: DEFAULT_TEXTHOOKER_PORT, }); +const startBackgroundWarmupsIfAllowed = (): void => { + 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) => { + return await openYoutubeTrackPicker( + { + sendToActiveOverlayWindow: (channel, nextPayload, runtimeOptions) => + overlayModalRuntime.sendToActiveOverlayWindow(channel, nextPayload, runtimeOptions), + waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs), + logWarn: (message) => logger.warn(message), + }, + payload, + ); + }, + 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); + }, + refreshSubtitleSidebarSource: async (sourcePath: string) => { + await refreshSubtitleSidebarFromSource(sourcePath); + }, + 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((_, 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; + let stableGeometry: WindowGeometry | null = null; + let stableSinceMs = 0; + while (Date.now() < deadline) { + const tracker = appState.windowTracker; + const trackerGeometry = tracker?.getGeometry() ?? null; + const mediaPath = + appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || ''; + const trackerFocused = tracker?.isTargetWindowFocused() ?? false; + if (tracker && tracker.isTracking() && trackerGeometry && trackerFocused && mediaPath) { + if (!geometryMatches(stableGeometry, trackerGeometry)) { + stableGeometry = trackerGeometry; + stableSinceMs = Date.now(); + } else if (Date.now() - stableSinceMs >= 200) { + return; + } + } else { + stableGeometry = null; + stableSinceMs = 0; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + logger.warn( + 'Timed out waiting for tracked playback window focus/media readiness 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), + reportSubtitleFailure: (message: string) => reportYoutubeSubtitleFailure(message), + 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: NonNullable; + source: CliCommandSource; +}): Promise { + youtubePrimarySubtitleNotificationRuntime.setAppOwnedFlowInFlight(true); + try { + 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 youtubeFlowRuntime.runYoutubePlaybackFlow({ + url: request.url, + mode: request.mode, + }); + logger.info(`YouTube playback flow completed from ${request.source}.`); + } finally { + youtubePrimarySubtitleNotificationRuntime.setAppOwnedFlowInFlight(false); + } +} + let firstRunSetupMessage: string | null = null; const resolveWindowsMpvShortcutRuntimePaths = () => resolveWindowsMpvShortcutPaths({ @@ -1040,6 +1233,49 @@ const currentMediaTokenizationGate = createCurrentMediaTokenizationGate(); const startupOsdSequencer = createStartupOsdSequencer({ showOsd: (message) => showMpvOsd(message), }); +const youtubePrimarySubtitleNotificationRuntime = createYoutubePrimarySubtitleNotificationRuntime({ + getPrimarySubtitleLanguages: () => getResolvedConfig().youtubeSubgen.primarySubLanguages, + notifyFailure: (message) => reportYoutubeSubtitleFailure(message), + schedule: (fn, delayMs) => setTimeout(fn, delayMs), + clearSchedule: clearYoutubePrimarySubtitleNotificationTimer, +}); + +function isYoutubePlaybackActiveNow(): boolean { + return isYoutubePlaybackActive( + appState.currentMediaPath, + appState.mpvClient?.currentVideoPath ?? null, + ); +} + +function reportYoutubeSubtitleFailure(message: string): void { + const type = getResolvedConfig().ankiConnect.behavior.notificationType; + if (type === 'osd' || type === 'both') { + showMpvOsd(message); + } + if (type === 'system' || type === 'both') { + try { + showDesktopNotification('SubMiner', { body: message }); + } catch { + logger.warn(`Unable to show desktop notification: ${message}`); + } + } +} + +async function openYoutubeTrackPickerFromPlayback(): Promise { + if (youtubeFlowRuntime.hasActiveSession()) { + showMpvOsd('YouTube subtitle flow already in progress.'); + return; + } + const currentMediaPath = + appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || ''; + if (!isYoutubePlaybackActiveNow() || !currentMediaPath) { + showMpvOsd('YouTube subtitle picker is only available during YouTube playback.'); + return; + } + await youtubeFlowRuntime.openManualPicker({ + url: currentMediaPath, + }); +} function maybeSignalPluginAutoplayReady( payload: SubtitleData, @@ -1215,6 +1451,18 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({ }, }); +async function refreshSubtitleSidebarFromSource(sourcePath: string): Promise { + const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim()); + if (!normalizedSourcePath) { + return; + } + await subtitlePrefetchInitController.initSubtitlePrefetch( + normalizedSourcePath, + lastObservedTimePos, + normalizedSourcePath, + ); +} + async function refreshSubtitlePrefetchFromActiveTrack(): Promise { const client = appState.mpvClient; if (!client?.connected) { @@ -1548,7 +1796,10 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt getConfig: () => { const config = getResolvedConfig().anilist.characterDictionary; return { - enabled: config.enabled && yomitanProfilePolicy.isCharacterDictionaryEnabled(), + enabled: + config.enabled && + yomitanProfilePolicy.isCharacterDictionaryEnabled() && + !isYoutubePlaybackActiveNow(), maxLoaded: config.maxLoaded, profileScope: config.profileScope, }; @@ -1656,6 +1907,7 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( createBuildOverlayVisibilityRuntimeMainDepsHandler({ getMainWindow: () => overlayManager.getMainWindow(), + getModalActive: () => overlayModalInputExclusive, getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getForceMousePassthrough: () => appState.statsOverlayVisible, getWindowTracker: () => appState.windowTracker, @@ -1717,6 +1969,9 @@ const buildRestorePreviousSecondarySubVisibilityMainDepsHandler = createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler({ getMpvClient: () => appState.mpvClient, }); +syncOverlayVisibilityForModal = () => { + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); +}; const restorePreviousSecondarySubVisibilityMainDeps = buildRestorePreviousSecondarySubVisibilityMainDepsHandler(); const restorePreviousSecondarySubVisibilityHandler = @@ -3064,7 +3319,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ await prewarmSubtitleDictionaries(); }, startBackgroundWarmups: () => { - startBackgroundWarmups(); + startBackgroundWarmupsIfAllowed(); }, texthookerOnlyMode: appState.texthookerOnlyMode, shouldAutoInitializeOverlayRuntimeFromConfig: () => @@ -3173,39 +3428,7 @@ void initializeDiscordPresenceService(); const handleCliCommand = createCliCommandRuntimeHandler({ handleTexthookerOnlyModeTransitionMainDeps: { isTexthookerOnlyMode: () => appState.texthookerOnlyMode, - ensureOverlayStartupPrereqs: () => { - if (appState.subtitlePosition === null) { - loadSubtitlePosition(); - } - if (appState.keybindings.length === 0) { - appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); - } - if (!appState.mpvClient) { - appState.mpvClient = createMpvClientRuntimeService(); - } - if (!appState.runtimeOptionsManager) { - appState.runtimeOptionsManager = new RuntimeOptionsManager( - () => configService.getConfig().ankiConnect, - { - applyAnkiPatch: (patch) => { - if (appState.ankiIntegration) { - appState.ankiIntegration.applyRuntimeConfigPatch(patch); - } - }, - getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle, - onOptionsChanged: () => { - subtitleProcessingController.invalidateTokenizationCache(); - subtitlePrefetchService?.onSeek(lastObservedTimePos); - broadcastRuntimeOptionsChanged(); - refreshOverlayShortcuts(); - }, - }, - ); - } - if (!appState.subtitleTimingTracker) { - appState.subtitleTimingTracker = new SubtitleTimingTracker(); - } - }, + ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), setTexthookerOnlyMode: (enabled) => { appState.texthookerOnlyMode = enabled; }, @@ -3218,6 +3441,40 @@ const handleCliCommand = createCliCommandRuntimeHandler({ handleCliCommandRuntimeServiceWithContext(args, source, cliContext), }); +function ensureOverlayStartupPrereqs(): void { + if (appState.subtitlePosition === null) { + loadSubtitlePosition(); + } + if (appState.keybindings.length === 0) { + appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); + } + if (!appState.mpvClient) { + appState.mpvClient = createMpvClientRuntimeService(); + } + if (!appState.runtimeOptionsManager) { + appState.runtimeOptionsManager = new RuntimeOptionsManager( + () => configService.getConfig().ankiConnect, + { + applyAnkiPatch: (patch) => { + if (appState.ankiIntegration) { + appState.ankiIntegration.applyRuntimeConfigPatch(patch); + } + }, + getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle, + onOptionsChanged: () => { + subtitleProcessingController.invalidateTokenizationCache(); + subtitlePrefetchService?.onSeek(lastObservedTimePos); + broadcastRuntimeOptionsChanged(); + refreshOverlayShortcuts(); + }, + }, + ); + } + if (!appState.subtitleTimingTracker) { + appState.subtitleTimingTracker = new SubtitleTimingTracker(); + } +} + const handleInitialArgsRuntimeHandler = createInitialArgsRuntimeHandler({ getInitialArgs: () => appState.initialArgs, isBackgroundMode: () => appState.backgroundMode, @@ -3227,6 +3484,10 @@ const handleInitialArgsRuntimeHandler = createInitialArgsRuntimeHandler({ isTexthookerOnlyMode: () => appState.texthookerOnlyMode, hasImmersionTracker: () => Boolean(appState.immersionTracker), getMpvClient: () => appState.mpvClient, + commandNeedsOverlayRuntime: (args) => commandNeedsOverlayRuntime(args), + ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + initializeOverlayRuntime: () => initializeOverlayRuntime(), logInfo: (message) => logger.info(message), handleCliCommand: (args, source) => handleCliCommand(args, source), }); @@ -3242,6 +3503,7 @@ const { createMecabTokenizerAndCheck, prewarmSubtitleDictionaries, startBackgroundWarmups, + startTokenizationWarmups, isTokenizationWarmupReady, } = composeMpvRuntimeHandlers< MpvIpcClient, @@ -3285,6 +3547,7 @@ const { startupOsdSequencer.reset(); clearScheduledSubtitlePrefetchRefresh(); subtitlePrefetchInitController.cancelPendingInit(); + youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path); if (path) { ensureImmersionTrackerStarted(); // Delay slightly to allow MPV's track-list to be populated. @@ -3321,7 +3584,7 @@ const { ); }, scheduleCharacterDictionarySync: () => { - if (!yomitanProfilePolicy.isCharacterDictionaryEnabled()) { + if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) { return; } characterDictionaryAutoSyncRuntime.scheduleSync(); @@ -3342,11 +3605,13 @@ const { } lastObservedTimePos = time; }, - onSubtitleTrackChange: () => { + onSubtitleTrackChange: (sid) => { scheduleSubtitlePrefetchRefresh(); + youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid); }, - onSubtitleTrackListChange: () => { + onSubtitleTrackListChange: (trackList) => { scheduleSubtitlePrefetchRefresh(); + youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList); }, updateSubtitleRenderMetrics: (patch) => { updateMpvSubtitleRenderMetrics(patch as Partial); @@ -3416,7 +3681,8 @@ const { ), getCharacterDictionaryEnabled: () => getResolvedConfig().anilist.characterDictionary.enabled && - yomitanProfilePolicy.isCharacterDictionaryEnabled(), + yomitanProfilePolicy.isCharacterDictionaryEnabled() && + !isYoutubePlaybackActiveNow(), getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled, getFrequencyDictionaryEnabled: () => getRuntimeBooleanOption( @@ -3513,7 +3779,19 @@ const { tokenizeSubtitleDeferred = tokenizeSubtitle; 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 { @@ -3546,6 +3824,11 @@ function getCurrentOverlayGeometry(): WindowGeometry { 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 { lastOverlayWindowGeometry = geometry; overlayManager.setOverlayWindowBounds(geometry); @@ -4146,6 +4429,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ mpvCommandMainDeps: { triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), + openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(), cycleRuntimeOption: (id, direction) => { if (!appState.runtimeOptionsManager) { return { ok: false, error: 'Runtime options manager unavailable' }; @@ -4189,6 +4473,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ onOverlayModalOpened: (modal) => { overlayModalRuntime.notifyOverlayModalOpened(modal); }, + onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request), openYomitanSettings: () => openYomitanSettings(), quitApp: () => requestAppQuit(), toggleVisibleOverlay: () => toggleVisibleOverlay(), @@ -4403,6 +4688,7 @@ const createCliCommandContextHandler = createCliCommandContextFactory({ runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand), runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) => runStatsCliCommand(argsFromCommand, source), + runYoutubePlaybackFlow: (request) => runYoutubePlaybackFlowMain(request), openYomitanSettings: () => openYomitanSettings(), cycleSecondarySubMode: () => handleCycleSecondarySubMode(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts index 7d5c7af..b0f07a8 100644 --- a/src/main/cli-runtime.ts +++ b/src/main/cli-runtime.ts @@ -38,6 +38,7 @@ export interface CliCommandRuntimeServiceContext { openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup']; runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand']; runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand']; + runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow']; openYomitanSettings: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; @@ -105,6 +106,11 @@ function createCliCommandDepsFromContext( runStatsCommand: context.runStatsCommand, runCommand: context.runJellyfinCommand, }, + app: { + stop: context.stopApp, + hasMainWindow: context.hasMainWindow, + runYoutubePlaybackFlow: context.runYoutubePlaybackFlow, + }, ui: { openFirstRunSetup: context.openFirstRunSetup, openYomitanSettings: context.openYomitanSettings, @@ -112,10 +118,6 @@ function createCliCommandDepsFromContext( openRuntimeOptionsPalette: context.openRuntimeOptionsPalette, printHelp: context.printHelp, }, - app: { - stop: context.stopApp, - hasMainWindow: context.hasMainWindow, - }, getMultiCopyTimeoutMs: context.getMultiCopyTimeoutMs, schedule: context.schedule, log: context.log, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index debb8b9..b9a2a2e 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -57,6 +57,7 @@ export interface MainIpcRuntimeServiceDepsParams { getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility']; onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed']; onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened']; + onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve']; openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings']; quitApp: IpcDepsRuntimeOptions['quitApp']; toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay']; @@ -166,6 +167,11 @@ export interface CliCommandRuntimeServiceDepsParams { runStatsCommand: CliCommandDepsRuntimeOptions['jellyfin']['runStatsCommand']; runCommand: CliCommandDepsRuntimeOptions['jellyfin']['runCommand']; }; + app: { + stop: CliCommandDepsRuntimeOptions['app']['stop']; + hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow']; + runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow']; + }; ui: { openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup']; openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings']; @@ -173,10 +179,6 @@ export interface CliCommandRuntimeServiceDepsParams { openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette']; printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp']; }; - app: { - stop: CliCommandDepsRuntimeOptions['app']['stop']; - hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow']; - }; getMultiCopyTimeoutMs: CliCommandDepsRuntimeOptions['getMultiCopyTimeoutMs']; schedule: CliCommandDepsRuntimeOptions['schedule']; log: CliCommandDepsRuntimeOptions['log']; @@ -189,6 +191,7 @@ export interface MpvCommandRuntimeServiceDepsParams { runtimeOptionsCycle: HandleMpvCommandFromIpcOptions['runtimeOptionsCycle']; triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig']; openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette']; + openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker']; showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd']; mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle']; mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle']; @@ -207,6 +210,7 @@ export function createMainIpcRuntimeServiceDeps( getVisibleOverlayVisibility: params.getVisibleOverlayVisibility, onOverlayModalClosed: params.onOverlayModalClosed, onOverlayModalOpened: params.onOverlayModalOpened, + onYoutubePickerResolve: params.onYoutubePickerResolve, openYomitanSettings: params.openYomitanSettings, quitApp: params.quitApp, toggleVisibleOverlay: params.toggleVisibleOverlay, @@ -324,6 +328,11 @@ export function createCliCommandRuntimeServiceDeps( runStatsCommand: params.jellyfin.runStatsCommand, runCommand: params.jellyfin.runCommand, }, + app: { + stop: params.app.stop, + hasMainWindow: params.app.hasMainWindow, + runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow, + }, ui: { openFirstRunSetup: params.ui.openFirstRunSetup, openYomitanSettings: params.ui.openYomitanSettings, @@ -331,10 +340,6 @@ export function createCliCommandRuntimeServiceDeps( openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette, printHelp: params.ui.printHelp, }, - app: { - stop: params.app.stop, - hasMainWindow: params.app.hasMainWindow, - }, getMultiCopyTimeoutMs: params.getMultiCopyTimeoutMs, schedule: params.schedule, log: params.log, @@ -350,6 +355,7 @@ export function createMpvCommandRuntimeServiceDeps( specialCommands: params.specialCommands, triggerSubsyncFromConfig: params.triggerSubsyncFromConfig, openRuntimeOptionsPalette: params.openRuntimeOptionsPalette, + openYoutubeTrackPicker: params.openYoutubeTrackPicker, runtimeOptionsCycle: params.runtimeOptionsCycle, showMpvOsd: params.showMpvOsd, mpvReplaySubtitle: params.mpvReplaySubtitle, diff --git a/src/main/ipc-mpv-command.ts b/src/main/ipc-mpv-command.ts index 588aa2a..aefea49 100644 --- a/src/main/ipc-mpv-command.ts +++ b/src/main/ipc-mpv-command.ts @@ -12,6 +12,7 @@ type MpvPropertyClientLike = { export interface MpvCommandFromIpcRuntimeDeps { triggerSubsyncFromConfig: () => void; openRuntimeOptionsPalette: () => void; + openYoutubeTrackPicker: () => void | Promise; cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; showMpvOsd: (text: string) => void; replayCurrentSubtitle: () => void; @@ -33,6 +34,7 @@ export function handleMpvCommandFromIpcRuntime( specialCommands: SPECIAL_COMMANDS, triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, + openYoutubeTrackPicker: deps.openYoutubeTrackPicker, runtimeOptionsCycle: deps.cycleRuntimeOption, showMpvOsd: deps.showMpvOsd, mpvReplaySubtitle: deps.replayCurrentSubtitle, diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts index b3cf8f2..a5d67a8 100644 --- a/src/main/overlay-runtime.test.ts +++ b/src/main/overlay-runtime.test.ts @@ -275,6 +275,82 @@ test('sendToActiveOverlayWindow prefers visible main overlay window for modal op 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', () => { const window = createMockWindow(); const state: boolean[] = []; @@ -430,3 +506,33 @@ test('modal fallback reveal keeps mouse events ignored until modal confirms open runtime.notifyOverlayModalOpened('jimaku'); 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); +}); diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index 366862b..f6334b7 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -16,11 +16,15 @@ export interface OverlayModalRuntime { sendToActiveOverlayWindow: ( channel: string, payload?: unknown, - runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal }, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, ) => boolean; openRuntimeOptionsPalette: () => void; handleOverlayModalClosed: (modal: OverlayHostedModal) => void; notifyOverlayModalOpened: (modal: OverlayHostedModal) => void; + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; getRestoreVisibleOverlayOnModalClose: () => Set; } @@ -33,7 +37,10 @@ export function createOverlayModalRuntimeService( options: OverlayModalRuntimeOptions = {}, ): OverlayModalRuntime { const restoreVisibleOverlayOnModalClose = new Set(); + const modalOpenWaiters = new Map void>>(); let modalActive = false; + let mainWindowMousePassthroughForcedByModal = false; + let mainWindowHiddenByModal = false; let pendingModalWindowReveal: BrowserWindow | null = null; let pendingModalWindowRevealTimeout: ReturnType | null = null; @@ -163,6 +170,54 @@ export function createOverlayModalRuntimeService( 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 => { pendingModalWindowReveal = window; if (pendingModalWindowRevealTimeout !== null) { @@ -182,9 +237,13 @@ export function createOverlayModalRuntimeService( const sendToActiveOverlayWindow = ( channel: string, payload?: unknown, - runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal }, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, ): boolean => { const restoreOnModalClose = runtimeOptions?.restoreOnModalClose; + const preferModalWindow = runtimeOptions?.preferModalWindow === true; const sendNow = (window: BrowserWindow): void => { ensureModalWindowInteractive(window); @@ -198,7 +257,7 @@ export function createOverlayModalRuntimeService( if (restoreOnModalClose) { restoreVisibleOverlayOnModalClose.add(restoreOnModalClose); const mainWindow = getTargetOverlayWindow(); - if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) { + if (!preferModalWindow && mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) { sendOrQueueForWindow(mainWindow, (window) => { if (payload === undefined) { window.webContents.send(channel); @@ -255,6 +314,8 @@ export function createOverlayModalRuntimeService( if (restoreVisibleOverlayOnModalClose.size === 0) { clearPendingModalWindowReveal(); notifyModalStateChange(false); + setMainWindowMousePassthroughForModal(false); + setMainWindowVisibilityForModal(false); if (modalWindow && !modalWindow.isDestroyed()) { modalWindow.hide(); } @@ -263,6 +324,11 @@ export function createOverlayModalRuntimeService( const notifyOverlayModalOpened = (modal: OverlayHostedModal): void => { if (!restoreVisibleOverlayOnModalClose.has(modal)) return; + const waiters = modalOpenWaiters.get(modal) ?? []; + modalOpenWaiters.delete(modal); + for (const resolve of waiters) { + resolve(true); + } notifyModalStateChange(true); const targetWindow = getActiveOverlayWindowForModalInput(); clearPendingModalWindowReveal(); @@ -270,6 +336,12 @@ export function createOverlayModalRuntimeService( return; } + const modalWindow = deps.getModalWindow(); + if (modalWindow && !modalWindow.isDestroyed() && targetWindow === modalWindow) { + setMainWindowMousePassthroughForModal(true); + setMainWindowVisibilityForModal(true); + } + if (targetWindow.isVisible()) { targetWindow.setIgnoreMouseEvents(false); elevateModalWindow(targetWindow); @@ -285,11 +357,34 @@ export function createOverlayModalRuntimeService( showModalWindow(targetWindow); }; + const waitForModalOpen = async ( + modal: OverlayHostedModal, + timeoutMs: number, + ): Promise => + await new Promise((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 { sendToActiveOverlayWindow, openRuntimeOptionsPalette, handleOverlayModalClosed, notifyOverlayModalOpened, + waitForModalOpen, getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose, }; } diff --git a/src/main/overlay-visibility-runtime.ts b/src/main/overlay-visibility-runtime.ts index 3060b06..45b59d8 100644 --- a/src/main/overlay-visibility-runtime.ts +++ b/src/main/overlay-visibility-runtime.ts @@ -8,6 +8,7 @@ const OVERLAY_LOADING_OSD_COOLDOWN_MS = 30_000; export interface OverlayVisibilityRuntimeDeps { getMainWindow: () => BrowserWindow | null; + getModalActive: () => boolean; getVisibleOverlayVisible: () => boolean; getForceMousePassthrough: () => boolean; getWindowTracker: () => BaseWindowTracker | null; @@ -37,6 +38,7 @@ export function createOverlayVisibilityRuntimeService( updateVisibleOverlayVisibility(): void { updateVisibleOverlayVisibility({ visibleOverlayVisible: deps.getVisibleOverlayVisible(), + modalActive: deps.getModalActive(), forceMousePassthrough: deps.getForceMousePassthrough(), mainWindow: deps.getMainWindow(), windowTracker: deps.getWindowTracker(), diff --git a/src/main/runtime/anilist-media-guess.test.ts b/src/main/runtime/anilist-media-guess.test.ts index f76d7c8..77e01d8 100644 --- a/src/main/runtime/anilist-media-guess.test.ts +++ b/src/main/runtime/anilist-media-guess.test.ts @@ -68,3 +68,32 @@ test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => { }); assert.equal(state.mediaGuessPromise, null); }); + +test('ensureAnilistMediaGuess skips youtube playback urls', async () => { + let state: AnilistMediaGuessRuntimeState = { + mediaKey: 'https://www.youtube.com/watch?v=abc123', + mediaDurationSec: null, + mediaGuess: null, + mediaGuessPromise: null, + lastDurationProbeAtMs: 0, + }; + let calls = 0; + const ensureGuess = createEnsureAnilistMediaGuessHandler({ + getState: () => state, + setState: (next) => { + state = next; + }, + resolveMediaPathForJimaku: (value) => value, + getCurrentMediaPath: () => 'https://www.youtube.com/watch?v=abc123', + getCurrentMediaTitle: () => 'Video', + guessAnilistMediaInfo: async () => { + calls += 1; + return { title: 'Show', season: null, episode: 1, source: 'guessit' }; + }, + }); + + const guess = await ensureGuess('https://www.youtube.com/watch?v=abc123'); + assert.equal(guess, null); + assert.equal(calls, 0); + assert.equal(state.mediaGuess, null); +}); diff --git a/src/main/runtime/anilist-media-guess.ts b/src/main/runtime/anilist-media-guess.ts index 7a0a799..aed73d4 100644 --- a/src/main/runtime/anilist-media-guess.ts +++ b/src/main/runtime/anilist-media-guess.ts @@ -1,4 +1,5 @@ import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater'; +import { isYoutubeMediaPath } from './youtube-playback'; export type AnilistMediaGuessRuntimeState = { mediaKey: string | null; @@ -26,6 +27,9 @@ export function createMaybeProbeAnilistDurationHandler(deps: { if (state.mediaKey !== mediaKey) { return null; } + if (isYoutubeMediaPath(mediaKey)) { + return null; + } if (typeof state.mediaDurationSec === 'number' && state.mediaDurationSec > 0) { return state.mediaDurationSec; } @@ -73,6 +77,9 @@ export function createEnsureAnilistMediaGuessHandler(deps: { if (state.mediaKey !== mediaKey) { return null; } + if (isYoutubeMediaPath(mediaKey)) { + return null; + } if (state.mediaGuess) { return state.mediaGuess; } diff --git a/src/main/runtime/anilist-media-state.test.ts b/src/main/runtime/anilist-media-state.test.ts index 8720ccd..26b58aa 100644 --- a/src/main/runtime/anilist-media-state.test.ts +++ b/src/main/runtime/anilist-media-state.test.ts @@ -20,6 +20,18 @@ test('get current anilist media key trims and normalizes empty path', () => { assert.equal(getEmptyKey(), null); }); +test('get current anilist media key skips youtube playback urls', () => { + const getYoutubeKey = createGetCurrentAnilistMediaKeyHandler({ + getCurrentMediaPath: () => ' https://www.youtube.com/watch?v=abc123 ', + }); + const getShortYoutubeKey = createGetCurrentAnilistMediaKeyHandler({ + getCurrentMediaPath: () => 'https://youtu.be/abc123', + }); + + assert.equal(getYoutubeKey(), null); + assert.equal(getShortYoutubeKey(), null); +}); + test('reset anilist media tracking clears duration/guess/probe state', () => { let mediaKey: string | null = 'old'; let mediaDurationSec: number | null = 123; diff --git a/src/main/runtime/anilist-media-state.ts b/src/main/runtime/anilist-media-state.ts index 433967d..1660b67 100644 --- a/src/main/runtime/anilist-media-state.ts +++ b/src/main/runtime/anilist-media-state.ts @@ -1,11 +1,15 @@ import type { AnilistMediaGuessRuntimeState } from './anilist-media-guess'; +import { isYoutubeMediaPath } from './youtube-playback'; export function createGetCurrentAnilistMediaKeyHandler(deps: { getCurrentMediaPath: () => string | null; }) { return (): string | null => { const mediaPath = deps.getCurrentMediaPath()?.trim(); - return mediaPath && mediaPath.length > 0 ? mediaPath : null; + if (!mediaPath || mediaPath.length === 0 || isYoutubeMediaPath(mediaPath)) { + return null; + } + return mediaPath; }; } diff --git a/src/main/runtime/anilist-post-watch.test.ts b/src/main/runtime/anilist-post-watch.test.ts index 4deac3a..b4461b5 100644 --- a/src/main/runtime/anilist-post-watch.test.ts +++ b/src/main/runtime/anilist-post-watch.test.ts @@ -76,3 +76,52 @@ test('createMaybeRunAnilistPostWatchUpdateHandler queues when token missing', as assert.ok(calls.includes('inflight:true')); assert.ok(calls.includes('inflight:false')); }); + +test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirely', async () => { + const calls: string[] = []; + const handler = createMaybeRunAnilistPostWatchUpdateHandler({ + getInFlight: () => false, + setInFlight: (value) => calls.push(`inflight:${value}`), + getResolvedConfig: () => ({}), + isAnilistTrackingEnabled: () => true, + getCurrentMediaKey: () => 'https://www.youtube.com/watch?v=abc123', + hasMpvClient: () => true, + getTrackedMediaKey: () => 'https://www.youtube.com/watch?v=abc123', + resetTrackedMedia: () => calls.push('reset'), + getWatchedSeconds: () => 1000, + maybeProbeAnilistDuration: async () => { + calls.push('probe'); + return 1000; + }, + ensureAnilistMediaGuess: async () => { + calls.push('guess'); + return { title: 'Show', season: null, episode: 1 }; + }, + hasAttemptedUpdateKey: () => false, + processNextAnilistRetryUpdate: async () => { + calls.push('process-retry'); + return { ok: true, message: 'noop' }; + }, + refreshAnilistClientSecretState: async () => { + calls.push('refresh-token'); + return 'token'; + }, + enqueueRetry: () => calls.push('enqueue'), + markRetryFailure: () => calls.push('mark-failure'), + markRetrySuccess: () => calls.push('mark-success'), + refreshRetryQueueState: () => calls.push('refresh'), + updateAnilistPostWatchProgress: async () => { + calls.push('update'); + return { status: 'updated', message: 'ok' }; + }, + rememberAttemptedUpdateKey: () => calls.push('remember'), + showMpvOsd: (message) => calls.push(`osd:${message}`), + logInfo: (message) => calls.push(`info:${message}`), + logWarn: (message) => calls.push(`warn:${message}`), + minWatchSeconds: 600, + minWatchRatio: 0.85, + }); + + await handler(); + assert.deepEqual(calls, []); +}); diff --git a/src/main/runtime/anilist-post-watch.ts b/src/main/runtime/anilist-post-watch.ts index 27e535b..74fa849 100644 --- a/src/main/runtime/anilist-post-watch.ts +++ b/src/main/runtime/anilist-post-watch.ts @@ -1,3 +1,5 @@ +import { isYoutubeMediaPath } from './youtube-playback'; + type AnilistGuess = { title: string; episode: number | null; @@ -130,6 +132,9 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: { if (!mediaKey || !deps.hasMpvClient()) { return; } + if (isYoutubeMediaPath(mediaKey)) { + return; + } if (deps.getTrackedMediaKey() !== mediaKey) { deps.resetTrackedMedia(mediaKey); } diff --git a/src/main/runtime/cli-command-context-deps.test.ts b/src/main/runtime/cli-command-context-deps.test.ts index 73e3809..fe242d8 100644 --- a/src/main/runtime/cli-command-context-deps.test.ts +++ b/src/main/runtime/cli-command-context-deps.test.ts @@ -60,6 +60,9 @@ test('build cli command context deps maps handlers and values', () => { runJellyfinCommand: async () => { calls.push('run-jellyfin'); }, + runYoutubePlaybackFlow: async () => { + calls.push('run-youtube-playback'); + }, openYomitanSettings: () => calls.push('yomitan'), cycleSecondarySubMode: () => calls.push('cycle-secondary'), openRuntimeOptionsPalette: () => calls.push('runtime-options'), diff --git a/src/main/runtime/cli-command-context-deps.ts b/src/main/runtime/cli-command-context-deps.ts index c8b10cd..1a8b2a9 100644 --- a/src/main/runtime/cli-command-context-deps.ts +++ b/src/main/runtime/cli-command-context-deps.ts @@ -36,6 +36,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary']; runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand']; runJellyfinCommand: (args: CliArgs) => Promise; + runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow']; openYomitanSettings: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; @@ -83,6 +84,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { generateCharacterDictionary: deps.generateCharacterDictionary, runStatsCommand: deps.runStatsCommand, runJellyfinCommand: deps.runJellyfinCommand, + runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow, openYomitanSettings: deps.openYomitanSettings, cycleSecondarySubMode: deps.cycleSecondarySubMode, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, diff --git a/src/main/runtime/cli-command-context-factory.test.ts b/src/main/runtime/cli-command-context-factory.test.ts index 3d329de..c7b32da 100644 --- a/src/main/runtime/cli-command-context-factory.test.ts +++ b/src/main/runtime/cli-command-context-factory.test.ts @@ -63,6 +63,7 @@ test('cli command context factory composes main deps and context handlers', () = }), runStatsCommand: async () => {}, runJellyfinCommand: async () => {}, + runYoutubePlaybackFlow: async () => {}, openYomitanSettings: () => {}, cycleSecondarySubMode: () => {}, openRuntimeOptionsPalette: () => {}, diff --git a/src/main/runtime/cli-command-context-main-deps.test.ts b/src/main/runtime/cli-command-context-main-deps.test.ts index 3c48ef2..ee4a402 100644 --- a/src/main/runtime/cli-command-context-main-deps.test.ts +++ b/src/main/runtime/cli-command-context-main-deps.test.ts @@ -84,7 +84,9 @@ test('cli command context main deps builder maps state and callbacks', async () runJellyfinCommand: async () => { calls.push('run-jellyfin'); }, - + runYoutubePlaybackFlow: async () => { + calls.push('run-youtube-playback'); + }, openYomitanSettings: () => calls.push('open-yomitan'), cycleSecondarySubMode: () => calls.push('cycle-secondary'), openRuntimeOptionsPalette: () => calls.push('open-runtime-options'), diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts index 9e6dfe7..ed39ce5 100644 --- a/src/main/runtime/cli-command-context-main-deps.ts +++ b/src/main/runtime/cli-command-context-main-deps.ts @@ -41,6 +41,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary']; runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand']; runJellyfinCommand: (args: CliArgs) => Promise; + runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow']; openYomitanSettings: () => void; cycleSecondarySubMode: () => void; @@ -95,6 +96,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { deps.generateCharacterDictionary(targetPath), runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source), runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args), + runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request), openYomitanSettings: () => deps.openYomitanSettings(), cycleSecondarySubMode: () => deps.cycleSecondarySubMode(), openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), diff --git a/src/main/runtime/cli-command-context.test.ts b/src/main/runtime/cli-command-context.test.ts index 1eeb660..2953687 100644 --- a/src/main/runtime/cli-command-context.test.ts +++ b/src/main/runtime/cli-command-context.test.ts @@ -50,6 +50,7 @@ function createDeps() { }), runStatsCommand: async () => {}, runJellyfinCommand: async () => {}, + runYoutubePlaybackFlow: async () => {}, openYomitanSettings: () => {}, cycleSecondarySubMode: () => {}, openRuntimeOptionsPalette: () => {}, diff --git a/src/main/runtime/cli-command-context.ts b/src/main/runtime/cli-command-context.ts index de9d630..f159d86 100644 --- a/src/main/runtime/cli-command-context.ts +++ b/src/main/runtime/cli-command-context.ts @@ -41,6 +41,7 @@ export type CliCommandContextFactoryDeps = { generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary']; runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand']; runJellyfinCommand: (args: CliArgs) => Promise; + runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow']; openYomitanSettings: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; @@ -95,6 +96,7 @@ export function createCliCommandContext( generateCharacterDictionary: deps.generateCharacterDictionary, runStatsCommand: deps.runStatsCommand, runJellyfinCommand: deps.runJellyfinCommand, + runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow, openYomitanSettings: deps.openYomitanSettings, cycleSecondarySubMode: deps.cycleSecondarySubMode, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, diff --git a/src/main/runtime/cli-command-runtime-handler.test.ts b/src/main/runtime/cli-command-runtime-handler.test.ts index 281b2ab..6b51e41 100644 --- a/src/main/runtime/cli-command-runtime-handler.test.ts +++ b/src/main/runtime/cli-command-runtime-handler.test.ts @@ -33,3 +33,28 @@ test('cli command runtime handler applies precheck and forwards command with con 'cli:initial:ctx', ]); }); + +test('cli command runtime handler prepares overlay prerequisites before overlay runtime commands', () => { + const calls: string[] = []; + const handler = createCliCommandRuntimeHandler({ + handleTexthookerOnlyModeTransitionMainDeps: { + isTexthookerOnlyMode: () => false, + setTexthookerOnlyMode: () => calls.push('set-mode'), + commandNeedsOverlayRuntime: () => true, + ensureOverlayStartupPrereqs: () => calls.push('prereqs'), + startBackgroundWarmups: () => calls.push('warmups'), + logInfo: (message) => calls.push(`log:${message}`), + }, + createCliCommandContext: () => { + calls.push('context'); + return { id: 'ctx' }; + }, + handleCliCommandRuntimeServiceWithContext: (_args, source, context) => { + calls.push(`cli:${source}:${context.id}`); + }, + }); + + handler({ settings: true } as never); + + assert.deepEqual(calls, ['prereqs', 'context', 'cli:initial:ctx']); +}); diff --git a/src/main/runtime/cli-command-runtime-handler.ts b/src/main/runtime/cli-command-runtime-handler.ts index e1eb199..e57c3a4 100644 --- a/src/main/runtime/cli-command-runtime-handler.ts +++ b/src/main/runtime/cli-command-runtime-handler.ts @@ -23,6 +23,12 @@ export function createCliCommandRuntimeHandler(deps: { return (args: CliArgs, source: CliCommandSource = 'initial'): void => { handleTexthookerOnlyModeTransitionHandler(args); + if ( + !deps.handleTexthookerOnlyModeTransitionMainDeps.isTexthookerOnlyMode() && + deps.handleTexthookerOnlyModeTransitionMainDeps.commandNeedsOverlayRuntime(args) + ) { + deps.handleTexthookerOnlyModeTransitionMainDeps.ensureOverlayStartupPrereqs(); + } const cliContext = deps.createCliCommandContext(); deps.handleCliCommandRuntimeServiceWithContext(args, source, cliContext); }; diff --git a/src/main/runtime/composers/ipc-runtime-composer.test.ts b/src/main/runtime/composers/ipc-runtime-composer.test.ts index 878a738..6f404b2 100644 --- a/src/main/runtime/composers/ipc-runtime-composer.test.ts +++ b/src/main/runtime/composers/ipc-runtime-composer.test.ts @@ -10,6 +10,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b mpvCommandMainDeps: { triggerSubsyncFromConfig: async () => {}, openRuntimeOptionsPalette: () => {}, + openYoutubeTrackPicker: () => {}, cycleRuntimeOption: () => ({ ok: true }), showMpvOsd: () => {}, replayCurrentSubtitle: () => {}, @@ -67,6 +68,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b getAnilistQueueStatus: () => ({}) as never, retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }, ankiJimakuDeps: { patchAnkiConnectEnabled: () => {}, diff --git a/src/main/runtime/immersion-startup.test.ts b/src/main/runtime/immersion-startup.test.ts index 575a23a..e7b5fd3 100644 --- a/src/main/runtime/immersion-startup.test.ts +++ b/src/main/runtime/immersion-startup.test.ts @@ -56,6 +56,57 @@ test('createImmersionTrackerStartupHandler skips when disabled', () => { assert.equal(tracker, 'unchanged'); }); +test('createImmersionTrackerStartupHandler skips when env disables session tracking', () => { + const calls: string[] = []; + const originalEnv = process.env.SUBMINER_DISABLE_IMMERSION_TRACKING; + process.env.SUBMINER_DISABLE_IMMERSION_TRACKING = '1'; + + try { + let tracker: unknown = 'unchanged'; + const handler = createImmersionTrackerStartupHandler({ + getResolvedConfig: () => { + calls.push('getResolvedConfig'); + return makeConfig(); + }, + getConfiguredDbPath: () => { + calls.push('getConfiguredDbPath'); + return '/tmp/subminer.db'; + }, + createTrackerService: () => { + calls.push('createTrackerService'); + return {}; + }, + setTracker: (nextTracker) => { + tracker = nextTracker; + }, + getMpvClient: () => null, + seedTrackerFromCurrentMedia: () => calls.push('seedTracker'), + logInfo: (message) => calls.push(`info:${message}`), + logDebug: (message) => calls.push(`debug:${message}`), + logWarn: (message) => calls.push(`warn:${message}`), + }); + + handler(); + + assert.equal(calls.includes('getResolvedConfig'), false); + assert.equal(calls.includes('getConfiguredDbPath'), false); + assert.equal(calls.includes('createTrackerService'), false); + assert.equal(calls.includes('seedTracker'), false); + assert.equal(tracker, 'unchanged'); + assert.ok( + calls.includes( + 'info:Immersion tracking disabled for this session by SUBMINER_DISABLE_IMMERSION_TRACKING=1.', + ), + ); + } finally { + if (originalEnv === undefined) { + delete process.env.SUBMINER_DISABLE_IMMERSION_TRACKING; + } else { + process.env.SUBMINER_DISABLE_IMMERSION_TRACKING = originalEnv; + } + } +}); + test('createImmersionTrackerStartupHandler creates tracker and auto-connects mpv', () => { const calls: string[] = []; const trackerInstance = { kind: 'tracker' }; diff --git a/src/main/runtime/immersion-startup.ts b/src/main/runtime/immersion-startup.ts index 20c720b..719f641 100644 --- a/src/main/runtime/immersion-startup.ts +++ b/src/main/runtime/immersion-startup.ts @@ -23,6 +23,8 @@ type ImmersionTrackingConfig = { type ImmersionTrackerPolicy = Omit; +const DISABLE_IMMERSION_TRACKING_SESSION_ENV = 'SUBMINER_DISABLE_IMMERSION_TRACKING'; + type ImmersionTrackerServiceParams = { dbPath: string; policy: ImmersionTrackerPolicy; @@ -49,7 +51,16 @@ export type ImmersionTrackerStartupDeps = { export function createImmersionTrackerStartupHandler( deps: ImmersionTrackerStartupDeps, ): () => void { + const isSessionTrackingDisabled = process.env[DISABLE_IMMERSION_TRACKING_SESSION_ENV] === '1'; + return () => { + if (isSessionTrackingDisabled) { + deps.logInfo( + `Immersion tracking disabled for this session by ${DISABLE_IMMERSION_TRACKING_SESSION_ENV}=1.`, + ); + return; + } + const config = deps.getResolvedConfig(); if (config.immersionTracking?.enabled === false) { deps.logInfo('Immersion tracking disabled in config'); diff --git a/src/main/runtime/initial-args-handler.test.ts b/src/main/runtime/initial-args-handler.test.ts index 50062a3..b9da6bc 100644 --- a/src/main/runtime/initial-args-handler.test.ts +++ b/src/main/runtime/initial-args-handler.test.ts @@ -13,6 +13,10 @@ test('initial args handler no-ops without initial args', () => { isTexthookerOnlyMode: () => false, hasImmersionTracker: () => false, getMpvClient: () => null, + commandNeedsOverlayRuntime: () => false, + ensureOverlayStartupPrereqs: () => {}, + isOverlayRuntimeInitialized: () => false, + initializeOverlayRuntime: () => {}, logInfo: () => {}, handleCliCommand: () => { handled = true; @@ -36,6 +40,10 @@ test('initial args handler ensures tray in background mode', () => { isTexthookerOnlyMode: () => true, hasImmersionTracker: () => false, getMpvClient: () => null, + commandNeedsOverlayRuntime: () => false, + ensureOverlayStartupPrereqs: () => {}, + isOverlayRuntimeInitialized: () => false, + initializeOverlayRuntime: () => {}, logInfo: () => {}, handleCliCommand: () => {}, }); @@ -61,6 +69,10 @@ test('initial args handler auto-connects mpv when needed', () => { connectCalls += 1; }, }), + commandNeedsOverlayRuntime: () => false, + ensureOverlayStartupPrereqs: () => {}, + isOverlayRuntimeInitialized: () => false, + initializeOverlayRuntime: () => {}, logInfo: () => { logged = true; }, @@ -83,6 +95,14 @@ test('initial args handler forwards args to cli handler', () => { isTexthookerOnlyMode: () => false, hasImmersionTracker: () => false, getMpvClient: () => null, + commandNeedsOverlayRuntime: () => false, + ensureOverlayStartupPrereqs: () => { + seenSources.push('prereqs'); + }, + isOverlayRuntimeInitialized: () => false, + initializeOverlayRuntime: () => { + seenSources.push('init-overlay'); + }, logInfo: () => {}, handleCliCommand: (_args, source) => { seenSources.push(source); @@ -93,6 +113,37 @@ test('initial args handler forwards args to cli handler', () => { assert.deepEqual(seenSources, ['initial']); }); +test('initial args handler bootstraps overlay before initial overlay-runtime commands', () => { + const calls: string[] = []; + const args = { settings: true } as never; + const handleInitialArgs = createHandleInitialArgsHandler({ + getInitialArgs: () => args, + isBackgroundMode: () => false, + shouldEnsureTrayOnStartup: () => false, + shouldRunHeadlessInitialCommand: () => false, + ensureTray: () => {}, + isTexthookerOnlyMode: () => false, + hasImmersionTracker: () => false, + getMpvClient: () => null, + commandNeedsOverlayRuntime: (inputArgs) => inputArgs === args, + ensureOverlayStartupPrereqs: () => { + calls.push('prereqs'); + }, + isOverlayRuntimeInitialized: () => false, + initializeOverlayRuntime: () => { + calls.push('init-overlay'); + }, + logInfo: () => {}, + handleCliCommand: (_args, source) => { + calls.push(`cli:${source}`); + }, + }); + + handleInitialArgs(); + + assert.deepEqual(calls, ['prereqs', 'init-overlay', 'cli:initial']); +}); + test('initial args handler can ensure tray outside background mode when requested', () => { let ensuredTray = false; const handleInitialArgs = createHandleInitialArgsHandler({ @@ -106,6 +157,10 @@ test('initial args handler can ensure tray outside background mode when requeste isTexthookerOnlyMode: () => true, hasImmersionTracker: () => false, getMpvClient: () => null, + commandNeedsOverlayRuntime: () => false, + ensureOverlayStartupPrereqs: () => {}, + isOverlayRuntimeInitialized: () => false, + initializeOverlayRuntime: () => {}, logInfo: () => {}, handleCliCommand: () => {}, }); @@ -133,6 +188,10 @@ test('initial args handler skips tray and mpv auto-connect for headless refresh' connectCalls += 1; }, }), + commandNeedsOverlayRuntime: () => true, + ensureOverlayStartupPrereqs: () => {}, + isOverlayRuntimeInitialized: () => false, + initializeOverlayRuntime: () => {}, logInfo: () => {}, handleCliCommand: () => {}, }); diff --git a/src/main/runtime/initial-args-handler.ts b/src/main/runtime/initial-args-handler.ts index 119f8da..acfe021 100644 --- a/src/main/runtime/initial-args-handler.ts +++ b/src/main/runtime/initial-args-handler.ts @@ -14,6 +14,10 @@ export function createHandleInitialArgsHandler(deps: { isTexthookerOnlyMode: () => boolean; hasImmersionTracker: () => boolean; getMpvClient: () => MpvClientLike | null; + commandNeedsOverlayRuntime: (args: CliArgs) => boolean; + ensureOverlayStartupPrereqs: () => void; + isOverlayRuntimeInitialized: () => boolean; + initializeOverlayRuntime: () => void; logInfo: (message: string) => void; handleCliCommand: (args: CliArgs, source: 'initial') => void; }) { @@ -39,6 +43,13 @@ export function createHandleInitialArgsHandler(deps: { mpvClient.connect(); } + if (!runHeadless && deps.commandNeedsOverlayRuntime(initialArgs)) { + deps.ensureOverlayStartupPrereqs(); + if (!deps.isOverlayRuntimeInitialized()) { + deps.initializeOverlayRuntime(); + } + } + deps.handleCliCommand(initialArgs, 'initial'); }; } diff --git a/src/main/runtime/initial-args-main-deps.test.ts b/src/main/runtime/initial-args-main-deps.test.ts index d4b3675..bf5eee5 100644 --- a/src/main/runtime/initial-args-main-deps.test.ts +++ b/src/main/runtime/initial-args-main-deps.test.ts @@ -15,6 +15,10 @@ test('initial args main deps builder maps runtime callbacks and state readers', isTexthookerOnlyMode: () => false, hasImmersionTracker: () => true, getMpvClient: () => mpvClient, + commandNeedsOverlayRuntime: () => true, + ensureOverlayStartupPrereqs: () => calls.push('prereqs'), + isOverlayRuntimeInitialized: () => false, + initializeOverlayRuntime: () => calls.push('init-overlay'), logInfo: (message) => calls.push(`info:${message}`), handleCliCommand: (_args, source) => calls.push(`cli:${source}`), })(); @@ -26,9 +30,13 @@ test('initial args main deps builder maps runtime callbacks and state readers', assert.equal(deps.isTexthookerOnlyMode(), false); assert.equal(deps.hasImmersionTracker(), true); assert.equal(deps.getMpvClient(), mpvClient); + assert.equal(deps.commandNeedsOverlayRuntime(args), true); + assert.equal(deps.isOverlayRuntimeInitialized(), false); deps.ensureTray(); + deps.ensureOverlayStartupPrereqs(); + deps.initializeOverlayRuntime(); deps.logInfo('x'); deps.handleCliCommand(args, 'initial'); - assert.deepEqual(calls, ['ensure-tray', 'info:x', 'cli:initial']); + assert.deepEqual(calls, ['ensure-tray', 'prereqs', 'init-overlay', 'info:x', 'cli:initial']); }); diff --git a/src/main/runtime/initial-args-main-deps.ts b/src/main/runtime/initial-args-main-deps.ts index c25acab..bdae28f 100644 --- a/src/main/runtime/initial-args-main-deps.ts +++ b/src/main/runtime/initial-args-main-deps.ts @@ -9,6 +9,10 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: { isTexthookerOnlyMode: () => boolean; hasImmersionTracker: () => boolean; getMpvClient: () => { connected: boolean; connect: () => void } | null; + commandNeedsOverlayRuntime: (args: CliArgs) => boolean; + ensureOverlayStartupPrereqs: () => void; + isOverlayRuntimeInitialized: () => boolean; + initializeOverlayRuntime: () => void; logInfo: (message: string) => void; handleCliCommand: (args: CliArgs, source: 'initial') => void; }) { @@ -21,6 +25,10 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: { isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(), hasImmersionTracker: () => deps.hasImmersionTracker(), getMpvClient: () => deps.getMpvClient(), + commandNeedsOverlayRuntime: (args: CliArgs) => deps.commandNeedsOverlayRuntime(args), + ensureOverlayStartupPrereqs: () => deps.ensureOverlayStartupPrereqs(), + isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(), + initializeOverlayRuntime: () => deps.initializeOverlayRuntime(), logInfo: (message: string) => deps.logInfo(message), handleCliCommand: (args: CliArgs, source: 'initial') => deps.handleCliCommand(args, source), }); diff --git a/src/main/runtime/initial-args-runtime-handler.test.ts b/src/main/runtime/initial-args-runtime-handler.test.ts index 16aa6c6..ac0fade 100644 --- a/src/main/runtime/initial-args-runtime-handler.test.ts +++ b/src/main/runtime/initial-args-runtime-handler.test.ts @@ -16,6 +16,10 @@ test('initial args runtime handler composes main deps and runs initial command f connected: false, connect: () => calls.push('connect'), }), + commandNeedsOverlayRuntime: () => false, + ensureOverlayStartupPrereqs: () => calls.push('prereqs'), + isOverlayRuntimeInitialized: () => false, + initializeOverlayRuntime: () => calls.push('init-overlay'), logInfo: (message) => calls.push(`log:${message}`), handleCliCommand: (_args, source) => calls.push(`cli:${source}`), }); @@ -44,6 +48,10 @@ test('initial args runtime handler skips mpv auto-connect for stats mode', () => connected: false, connect: () => calls.push('connect'), }), + commandNeedsOverlayRuntime: () => false, + ensureOverlayStartupPrereqs: () => calls.push('prereqs'), + isOverlayRuntimeInitialized: () => false, + initializeOverlayRuntime: () => calls.push('init-overlay'), logInfo: (message) => calls.push(`log:${message}`), handleCliCommand: (_args, source) => calls.push(`cli:${source}`), }); @@ -67,6 +75,10 @@ test('initial args runtime handler skips tray and mpv auto-connect for headless connected: false, connect: () => calls.push('connect'), }), + commandNeedsOverlayRuntime: () => true, + ensureOverlayStartupPrereqs: () => calls.push('prereqs'), + isOverlayRuntimeInitialized: () => false, + initializeOverlayRuntime: () => calls.push('init-overlay'), logInfo: (message) => calls.push(`log:${message}`), handleCliCommand: (_args, source) => calls.push(`cli:${source}`), }); diff --git a/src/main/runtime/ipc-bridge-actions-main-deps.test.ts b/src/main/runtime/ipc-bridge-actions-main-deps.test.ts index a5b6be2..7dd5665 100644 --- a/src/main/runtime/ipc-bridge-actions-main-deps.test.ts +++ b/src/main/runtime/ipc-bridge-actions-main-deps.test.ts @@ -13,6 +13,7 @@ test('ipc bridge action main deps builders map callbacks', async () => { buildMpvCommandDeps: () => ({ triggerSubsyncFromConfig: async () => {}, openRuntimeOptionsPalette: () => {}, + openYoutubeTrackPicker: () => {}, cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), showMpvOsd: () => {}, replayCurrentSubtitle: () => {}, diff --git a/src/main/runtime/ipc-bridge-actions.test.ts b/src/main/runtime/ipc-bridge-actions.test.ts index 7276a47..d4e142a 100644 --- a/src/main/runtime/ipc-bridge-actions.test.ts +++ b/src/main/runtime/ipc-bridge-actions.test.ts @@ -10,6 +10,7 @@ test('handle mpv command handler forwards command and built deps', () => { const deps = { triggerSubsyncFromConfig: () => {}, openRuntimeOptionsPalette: () => {}, + openYoutubeTrackPicker: () => {}, cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), showMpvOsd: () => {}, replayCurrentSubtitle: () => {}, diff --git a/src/main/runtime/ipc-mpv-command-main-deps.test.ts b/src/main/runtime/ipc-mpv-command-main-deps.test.ts index 7ce97ae..edb6186 100644 --- a/src/main/runtime/ipc-mpv-command-main-deps.test.ts +++ b/src/main/runtime/ipc-mpv-command-main-deps.test.ts @@ -7,6 +7,9 @@ test('ipc mpv command main deps builder maps callbacks', () => { const deps = createBuildMpvCommandFromIpcRuntimeMainDepsHandler({ triggerSubsyncFromConfig: () => calls.push('subsync'), openRuntimeOptionsPalette: () => calls.push('palette'), + openYoutubeTrackPicker: () => { + calls.push('youtube-picker'); + }, cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), showMpvOsd: (text) => calls.push(`osd:${text}`), replayCurrentSubtitle: () => calls.push('replay'), @@ -22,6 +25,7 @@ test('ipc mpv command main deps builder maps callbacks', () => { deps.triggerSubsyncFromConfig(); deps.openRuntimeOptionsPalette(); + void deps.openYoutubeTrackPicker(); assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' }); deps.showMpvOsd('hello'); deps.replayCurrentSubtitle(); @@ -34,6 +38,7 @@ test('ipc mpv command main deps builder maps callbacks', () => { assert.deepEqual(calls, [ 'subsync', 'palette', + 'youtube-picker', 'osd:hello', 'replay', 'next', diff --git a/src/main/runtime/ipc-mpv-command-main-deps.ts b/src/main/runtime/ipc-mpv-command-main-deps.ts index 99776e6..fafca8d 100644 --- a/src/main/runtime/ipc-mpv-command-main-deps.ts +++ b/src/main/runtime/ipc-mpv-command-main-deps.ts @@ -6,6 +6,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler( return (): MpvCommandFromIpcRuntimeDeps => ({ triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), + openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(), cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction), showMpvOsd: (text: string) => deps.showMpvOsd(text), replayCurrentSubtitle: () => deps.replayCurrentSubtitle(), diff --git a/src/main/runtime/mpv-main-event-bindings.test.ts b/src/main/runtime/mpv-main-event-bindings.test.ts index a3910e9..e7bcd4a 100644 --- a/src/main/runtime/mpv-main-event-bindings.test.ts +++ b/src/main/runtime/mpv-main-event-bindings.test.ts @@ -26,7 +26,6 @@ test('main mpv event binder wires callbacks through to runtime deps', () => { calls.push('post-watch'); }, logSubtitleTimingError: () => calls.push('subtitle-error'), - setCurrentSubText: (text) => calls.push(`set-sub:${text}`), broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`), onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`), diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index 705d398..845b2fd 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -116,3 +116,45 @@ test('mpv main event main deps map app state updates and delegate callbacks', as assert.ok(calls.includes('restore-mpv-sub')); assert.ok(calls.includes('reset-sidebar-layout')); }); + +test('mpv main event main deps wire subtitle callbacks without suppression gate', () => { + const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({ + appState: { + initialArgs: null, + overlayRuntimeInitialized: true, + mpvClient: null, + immersionTracker: null, + subtitleTimingTracker: null, + currentSubText: '', + currentSubAssText: '', + playbackPaused: null, + previousSecondarySubVisibility: false, + }, + getQuitOnDisconnectArmed: () => false, + scheduleQuitCheck: () => {}, + quitApp: () => {}, + reportJellyfinRemoteStopped: () => {}, + syncOverlayMpvSubtitleSuppression: () => {}, + maybeRunAnilistPostWatchUpdate: async () => {}, + logSubtitleTimingError: () => {}, + broadcastToOverlayWindows: () => {}, + onSubtitleChange: () => {}, + ensureImmersionTrackerInitialized: () => {}, + updateCurrentMediaPath: () => {}, + restoreMpvSubVisibility: () => {}, + resetSubtitleSidebarEmbeddedLayout: () => {}, + getCurrentAnilistMediaKey: () => null, + resetAnilistMediaTracking: () => {}, + maybeProbeAnilistDuration: () => {}, + ensureAnilistMediaGuess: () => {}, + syncImmersionMediaState: () => {}, + updateCurrentMediaTitle: () => {}, + resetAnilistMediaGuessState: () => {}, + reportJellyfinRemoteProgress: () => {}, + updateSubtitleRenderMetrics: () => {}, + refreshDiscordPresence: () => {}, + })(); + + deps.setCurrentSubText('sub'); + assert.equal(typeof deps.setCurrentSubText, 'function'); +}); diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts index ec73751..e281691 100644 --- a/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts @@ -12,6 +12,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb const deps = createBuildOverlayVisibilityRuntimeMainDepsHandler({ getMainWindow: () => mainWindow, + getModalActive: () => true, getVisibleOverlayVisible: () => true, getForceMousePassthrough: () => true, getWindowTracker: () => tracker, @@ -32,6 +33,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb })(); assert.equal(deps.getMainWindow(), mainWindow); + assert.equal(deps.getModalActive(), true); assert.equal(deps.getVisibleOverlayVisible(), true); assert.equal(deps.getForceMousePassthrough(), true); assert.equal(deps.getTrackerNotReadyWarningShown(), false); diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.ts index c9cf6a8..2d3063d 100644 --- a/src/main/runtime/overlay-visibility-runtime-main-deps.ts +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.ts @@ -7,6 +7,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler( ) { return (): OverlayVisibilityRuntimeDeps => ({ getMainWindow: () => deps.getMainWindow(), + getModalActive: () => deps.getModalActive(), getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(), getForceMousePassthrough: () => deps.getForceMousePassthrough(), getWindowTracker: () => deps.getWindowTracker(), diff --git a/src/main/runtime/windows-mpv-launch.ts b/src/main/runtime/windows-mpv-launch.ts index 8e3555a..7501565 100644 --- a/src/main/runtime/windows-mpv-launch.ts +++ b/src/main/runtime/windows-mpv-launch.ts @@ -33,13 +33,17 @@ export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string { return ''; } -export function buildWindowsMpvLaunchArgs(targets: string[]): string[] { - return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...targets]; +export function buildWindowsMpvLaunchArgs( + targets: string[], + extraArgs: string[] = [], +): string[] { + return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...extraArgs, ...targets]; } export function launchWindowsMpv( targets: string[], deps: WindowsMpvLaunchDeps, + extraArgs: string[] = [], ): { ok: boolean; mpvPath: string } { const mpvPath = resolveWindowsMpvPath(deps); if (!mpvPath) { @@ -51,7 +55,7 @@ export function launchWindowsMpv( } try { - deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets)); + deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets, extraArgs)); return { ok: true, mpvPath }; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/main/runtime/youtube-flow.test.ts b/src/main/runtime/youtube-flow.test.ts new file mode 100644 index 0000000..ccc6fe1 --- /dev/null +++ b/src/main/runtime/youtube-flow.test.ts @@ -0,0 +1,1145 @@ +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 can open a manual picker session and load the selected subtitles', async () => { + const commands: Array> = []; + const focusOverlayCalls: string[] = []; + const osdMessages: string[] = []; + const openedPayloads: YoutubePickerOpenPayload[] = []; + const waits: number[] = []; + const refreshedSidebarSources: string[] = []; + + 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([ + [primaryTrack.id, '/tmp/auto-ja-orig.vtt'], + [secondaryTrack.id, '/tmp/manual-en.vtt'], + ]); + }, + acquireYoutubeSubtitleTrack: async ({ track }) => ({ + path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`, + }), + retimeYoutubePrimaryTrack: async ({ primaryPath }) => `${primaryPath}.retimed`, + openPicker: async (payload) => { + openedPayloads.push(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 '字幕です'; + } + return [ + { + type: 'sub', + id: 5, + lang: 'ja-orig', + title: 'primary', + external: true, + 'external-filename': '/tmp/auto-ja-orig.vtt.retimed', + }, + { + type: 'sub', + id: 6, + lang: 'en', + title: 'secondary', + external: true, + 'external-filename': '/tmp/manual-en.vtt', + }, + ]; + }, + refreshCurrentSubtitle: () => {}, + refreshSubtitleSidebarSource: async (sourcePath: string) => { + refreshedSidebarSources.push(sourcePath); + }, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => {}, + waitForAnkiReady: async () => {}, + wait: async (ms) => { + waits.push(ms); + }, + waitForPlaybackWindowReady: async () => { + waits.push(1); + }, + waitForOverlayGeometryReady: async () => { + waits.push(2); + }, + focusOverlayWindow: () => { + focusOverlayCalls.push('focus-overlay'); + }, + showMpvOsd: (text) => { + osdMessages.push(text); + }, + reportSubtitleFailure: () => { + throw new Error('manual picker success should not report failure'); + }, + warn: (message) => { + throw new Error(message); + }, + log: () => {}, + getYoutubeOutputDir: () => '/tmp', + }); + + await runtime.openManualPicker({ url: 'https://example.com' }); + + assert.equal(openedPayloads.length, 1); + assert.equal(openedPayloads[0]?.defaultPrimaryTrackId, primaryTrack.id); + assert.equal(openedPayloads[0]?.defaultSecondaryTrackId, secondaryTrack.id); + assert.ok(waits.includes(150)); + assert.deepEqual(osdMessages, [ + 'Getting subtitles...', + 'Downloading subtitles...', + 'Loading subtitles...', + 'Primary and secondary subtitles loaded.', + ]); + assert.ok( + commands.some( + (command) => + command[0] === 'sub-add' && + command[1] === '/tmp/auto-ja-orig.vtt.retimed' && + command[2] === 'select', + ), + ); + assert.ok( + commands.some( + (command) => + command[0] === 'set_property' && + command[1] === 'sub-visibility' && + command[2] === 'yes', + ), + ); + assert.ok( + commands.every( + (command) => + !( + command[0] === 'set_property' && + command[1] === 'secondary-sub-visibility' && + command[2] === 'yes' + ), + ), + ); + assert.deepEqual(refreshedSidebarSources, ['/tmp/auto-ja-orig.vtt.retimed']); + assert.deepEqual(focusOverlayCalls, ['focus-overlay']); +}); + +test('youtube flow retries secondary after partial batch subtitle failure', async () => { + const acquireSingleCalls: string[] = []; + const commands: Array> = []; + const waits: number[] = []; + let secondaryTrackAdded = false; + + const runtime = createYoutubeFlowRuntime({ + probeYoutubeTracks: async () => ({ + videoId: 'video123', + title: 'Video 123', + tracks: [primaryTrack, secondaryTrack], + }), + acquireYoutubeSubtitleTracks: async () => + new Map([[primaryTrack.id, '/tmp/auto-ja-orig.vtt']]), + acquireYoutubeSubtitleTrack: async ({ track }) => { + acquireSingleCalls.push(track.id); + return { path: `/tmp/${track.id}.vtt` }; + }, + retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath, + 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); + if ( + command[0] === 'sub-add' && + command[1] === '/tmp/manual:en.vtt' && + command[2] === 'cached' + ) { + secondaryTrackAdded = true; + } + }, + requestMpvProperty: async (name) => { + if (name === 'sub-text') { + return '字幕です'; + } + return secondaryTrackAdded + ? [ + { + 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', + }, + ] + : [ + { + type: 'sub', + id: 5, + lang: 'ja-orig', + title: 'primary', + external: true, + 'external-filename': '/tmp/auto-ja-orig.vtt', + }, + ]; + }, + refreshCurrentSubtitle: () => {}, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => {}, + waitForAnkiReady: async () => {}, + wait: async (ms) => { + waits.push(ms); + }, + waitForPlaybackWindowReady: async () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => {}, + showMpvOsd: () => {}, + reportSubtitleFailure: () => { + throw new Error('secondary retry should not report primary failure'); + }, + warn: (message) => { + throw new Error(message); + }, + log: () => {}, + getYoutubeOutputDir: () => '/tmp', + }); + + await runtime.openManualPicker({ url: 'https://example.com' }); + + assert.deepEqual(acquireSingleCalls, [secondaryTrack.id]); + assert.ok(waits.includes(350)); + assert.ok( + commands.some( + (command) => + command[0] === 'sub-add' && + command[1] === '/tmp/manual:en.vtt' && + command[2] === 'cached', + ), + ); +}); + +test('youtube flow reports probe failure through the configured reporter in manual mode', async () => { + const failures: string[] = []; + + const runtime = createYoutubeFlowRuntime({ + probeYoutubeTracks: async () => { + throw new Error('probe failed'); + }, + acquireYoutubeSubtitleTracks: async () => new Map(), + acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/unused.vtt' }), + retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath, + openPicker: async () => true, + pauseMpv: () => {}, + resumeMpv: () => {}, + sendMpvCommand: () => {}, + requestMpvProperty: async () => null, + refreshCurrentSubtitle: () => {}, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => {}, + waitForAnkiReady: async () => {}, + wait: async () => {}, + waitForPlaybackWindowReady: async () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => {}, + showMpvOsd: () => {}, + reportSubtitleFailure: (message) => { + failures.push(message); + }, + warn: () => {}, + log: () => {}, + getYoutubeOutputDir: () => '/tmp', + }); + + await runtime.openManualPicker({ url: 'https://example.com' }); + + assert.deepEqual(failures, [ + 'Primary subtitles failed to load. Use the YouTube subtitle picker to try manually.', + ]); +}); + +test('youtube flow does not report failure when subtitle track binds before cue text appears', async () => { + const failures: string[] = []; + + const runtime = createYoutubeFlowRuntime({ + probeYoutubeTracks: async () => ({ + videoId: 'video123', + title: 'Video 123', + tracks: [primaryTrack], + }), + acquireYoutubeSubtitleTracks: async () => new Map(), + acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }), + retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath, + openPicker: async (payload) => { + queueMicrotask(() => { + void runtime.resolveActivePicker({ + sessionId: payload.sessionId, + action: 'use-selected', + primaryTrackId: primaryTrack.id, + secondaryTrackId: null, + }); + }); + return true; + }, + pauseMpv: () => {}, + resumeMpv: () => {}, + sendMpvCommand: () => {}, + 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: () => { + throw new Error('should not refresh empty subtitle text'); + }, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => {}, + waitForAnkiReady: async () => {}, + wait: async () => {}, + waitForPlaybackWindowReady: async () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => {}, + showMpvOsd: () => {}, + reportSubtitleFailure: (message) => { + failures.push(message); + }, + warn: (message) => { + throw new Error(message); + }, + log: () => {}, + getYoutubeOutputDir: () => '/tmp', + }); + + await runtime.openManualPicker({ url: 'https://example.com' }); + + assert.deepEqual(failures, []); +}); + +test('youtube flow retries secondary subtitle selection until mpv reports the expected secondary sid', async () => { + const commands: Array> = []; + const waits: number[] = []; + let secondarySidReads = 0; + + const runtime = createYoutubeFlowRuntime({ + probeYoutubeTracks: async () => ({ + videoId: 'video123', + title: 'Video 123', + tracks: [primaryTrack, secondaryTrack], + }), + acquireYoutubeSubtitleTracks: async () => + new Map([ + [primaryTrack.id, '/tmp/auto-ja-orig.vtt'], + [secondaryTrack.id, '/tmp/manual-en.vtt'], + ]), + acquireYoutubeSubtitleTrack: async ({ track }) => ({ + path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`, + }), + retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath, + 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 '字幕です'; + } + if (name === 'secondary-sid') { + secondarySidReads += 1; + return secondarySidReads >= 2 ? 6 : null; + } + 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: 'manual-en.vtt', + external: true, + 'external-filename': null, + }, + ]; + }, + refreshCurrentSubtitle: () => {}, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => {}, + waitForAnkiReady: async () => {}, + wait: async (ms) => { + waits.push(ms); + }, + waitForPlaybackWindowReady: async () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => {}, + showMpvOsd: () => {}, + reportSubtitleFailure: () => { + throw new Error('secondary selection retry should not report failure'); + }, + warn: (message) => { + throw new Error(message); + }, + log: () => {}, + getYoutubeOutputDir: () => '/tmp', + }); + + await runtime.openManualPicker({ url: 'https://example.com' }); + + assert.equal( + commands.filter( + (command) => + command[0] === 'set_property' && command[1] === 'secondary-sid' && command[2] === 6, + ).length, + 2, + ); + assert.ok(waits.includes(100)); +}); + +test('youtube flow reuses the matching existing manual secondary track instead of a loose language match', async () => { + const commands: Array> = []; + let selectedSecondarySid: number | null = null; + + const runtime = createYoutubeFlowRuntime({ + probeYoutubeTracks: async () => ({ + videoId: 'video123', + title: 'Video 123', + tracks: [ + primaryTrack, + { + ...secondaryTrack, + id: 'manual:en', + sourceLanguage: 'en', + kind: 'manual', + title: 'manual-en.vtt', + }, + ], + }), + acquireYoutubeSubtitleTracks: async () => + new Map([ + [primaryTrack.id, '/tmp/auto-ja-orig.vtt'], + [secondaryTrack.id, '/tmp/manual-en.vtt'], + ]), + acquireYoutubeSubtitleTrack: async ({ track }) => ({ + path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`, + }), + retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath, + openPicker: async (payload) => { + queueMicrotask(() => { + void runtime.resolveActivePicker({ + sessionId: payload.sessionId, + action: 'use-selected', + primaryTrackId: primaryTrack.id, + secondaryTrackId: 'manual:en', + }); + }); + return true; + }, + pauseMpv: () => {}, + resumeMpv: () => {}, + sendMpvCommand: (command) => { + commands.push(command); + if ( + command[0] === 'set_property' && + command[1] === 'secondary-sid' && + typeof command[2] === 'number' + ) { + selectedSecondarySid = command[2]; + } + }, + requestMpvProperty: async (name) => { + if (name === 'sub-text') { + return '字幕です'; + } + if (name === 'sid') { + return 5; + } + if (name === 'secondary-sid') { + return selectedSecondarySid; + } + return [ + { + type: 'sub', + id: 5, + lang: 'ja-orig', + title: 'auto-ja-orig.vtt', + external: true, + 'external-filename': '/tmp/auto-ja-orig.vtt', + }, + { + type: 'sub', + id: 6, + lang: 'en', + title: 'English', + external: true, + 'external-filename': null, + }, + { + type: 'sub', + id: 8, + lang: 'en', + title: 'manual-en.vtt', + external: true, + 'external-filename': null, + }, + ]; + }, + refreshCurrentSubtitle: () => {}, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => {}, + waitForAnkiReady: async () => {}, + wait: async () => {}, + waitForPlaybackWindowReady: async () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => {}, + showMpvOsd: () => {}, + reportSubtitleFailure: () => { + throw new Error('authoritative secondary bind should not report failure'); + }, + warn: (message) => { + throw new Error(message); + }, + log: () => {}, + getYoutubeOutputDir: () => '/tmp', + }); + + await runtime.openManualPicker({ url: 'https://example.com' }); + + assert.equal(selectedSecondarySid, 8); + assert.ok( + commands.some( + (command) => + command[0] === 'set_property' && command[1] === 'secondary-sid' && command[2] === 8, + ), + ); +}); + +test('youtube flow leaves non-authoritative youtube subtitle tracks untouched after authoritative tracks bind', async () => { + const commands: Array> = []; + let selectedPrimarySid: number | null = null; + let selectedSecondarySid: number | null = null; + + const runtime = createYoutubeFlowRuntime({ + probeYoutubeTracks: async () => ({ + videoId: 'video123', + title: 'Video 123', + tracks: [primaryTrack, secondaryTrack], + }), + acquireYoutubeSubtitleTracks: async () => + new Map([ + [primaryTrack.id, '/tmp/manual-ja.ja.srt'], + [secondaryTrack.id, '/tmp/manual-en.en.srt'], + ]), + acquireYoutubeSubtitleTrack: async ({ track }) => ({ + path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`, + }), + retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath, + 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); + if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') { + selectedPrimarySid = command[2]; + } + if ( + command[0] === 'set_property' && + command[1] === 'secondary-sid' && + typeof command[2] === 'number' + ) { + selectedSecondarySid = command[2]; + } + }, + requestMpvProperty: async (name) => { + if (name === 'sub-text') { + return '字幕です'; + } + if (name === 'sid') { + return selectedPrimarySid; + } + if (name === 'secondary-sid') { + return selectedSecondarySid; + } + return [ + { type: 'sub', id: 1, lang: 'en', title: 'English', external: true, 'external-filename': null }, + { type: 'sub', id: 2, lang: 'ja', title: 'Japanese', external: true, 'external-filename': null }, + { type: 'sub', id: 3, lang: 'ja-en', title: 'Japanese from English', external: true, 'external-filename': null }, + { type: 'sub', id: 4, lang: 'ja-ja', title: 'Japanese from Japanese', external: true, 'external-filename': null }, + { type: 'sub', id: 5, lang: 'ja-orig', title: 'auto-ja-orig.vtt', external: true, 'external-filename': '/tmp/auto-ja-orig.vtt' }, + { type: 'sub', id: 6, lang: 'en', title: 'manual-en.en.srt', external: true, 'external-filename': '/tmp/manual-en.en.srt' }, + ]; + }, + refreshCurrentSubtitle: () => {}, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => {}, + waitForAnkiReady: async () => {}, + wait: async () => {}, + waitForPlaybackWindowReady: async () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => {}, + showMpvOsd: () => {}, + reportSubtitleFailure: () => { + throw new Error('authoritative bind should not report failure'); + }, + warn: (message) => { + throw new Error(message); + }, + log: () => {}, + getYoutubeOutputDir: () => '/tmp', + }); + + await runtime.openManualPicker({ url: 'https://example.com' }); + + assert.equal(commands.some((command) => command[0] === 'sub-remove'), false); +}); + +test('youtube flow reuses existing manual youtube subtitle tracks when both requested languages already exist', async () => { + const commands: Array> = []; + let selectedPrimarySid: number | null = null; + let selectedSecondarySid: number | null = null; + const refreshedSidebarSources: string[] = []; + + const runtime = createYoutubeFlowRuntime({ + probeYoutubeTracks: async () => ({ + videoId: 'video123', + title: 'Video 123', + tracks: [ + { ...primaryTrack, id: 'manual:ja', sourceLanguage: 'ja', kind: 'manual', title: 'Japanese' }, + { ...secondaryTrack, id: 'manual:en', sourceLanguage: 'en', kind: 'manual', title: 'English' }, + ], + }), + acquireYoutubeSubtitleTracks: async () => { + throw new Error('should not batch download when both manual tracks already exist in mpv'); + }, + acquireYoutubeSubtitleTrack: async ({ track }) => { + if (track.language === 'ja') { + return { path: '/tmp/manual-ja.ja.srt' }; + } + throw new Error('should not download secondary track when manual english already exists'); + }, + retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath, + openPicker: async (payload) => { + queueMicrotask(() => { + void runtime.resolveActivePicker({ + sessionId: payload.sessionId, + action: 'use-selected', + primaryTrackId: 'manual:ja', + secondaryTrackId: 'manual:en', + }); + }); + return true; + }, + pauseMpv: () => {}, + resumeMpv: () => {}, + sendMpvCommand: (command) => { + commands.push(command); + if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') { + selectedPrimarySid = command[2]; + } + if ( + command[0] === 'set_property' && + command[1] === 'secondary-sid' && + typeof command[2] === 'number' + ) { + selectedSecondarySid = command[2]; + } + }, + requestMpvProperty: async (name) => { + if (name === 'sub-text') { + return '字幕です'; + } + if (name === 'sid') { + return selectedPrimarySid; + } + if (name === 'secondary-sid') { + return selectedSecondarySid; + } + return [ + { type: 'sub', id: 1, lang: 'en', title: 'English', external: true, 'external-filename': null }, + { type: 'sub', id: 2, lang: 'ja', title: 'Japanese', external: true, 'external-filename': null }, + { type: 'sub', id: 3, lang: 'ja-en', title: 'Japanese from English', external: true, 'external-filename': null }, + { type: 'sub', id: 4, lang: 'ja-ja', title: 'Japanese from Japanese', external: true, 'external-filename': null }, + ]; + }, + refreshCurrentSubtitle: () => {}, + refreshSubtitleSidebarSource: async (sourcePath) => { + refreshedSidebarSources.push(sourcePath); + }, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => {}, + waitForAnkiReady: async () => {}, + wait: async () => {}, + waitForPlaybackWindowReady: async () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => {}, + showMpvOsd: () => {}, + reportSubtitleFailure: () => { + throw new Error('existing manual tracks should not report failure'); + }, + warn: (message) => { + throw new Error(message); + }, + log: () => {}, + getYoutubeOutputDir: () => '/tmp', + }); + + await runtime.openManualPicker({ url: 'https://example.com' }); + + assert.equal(selectedPrimarySid, 2); + assert.equal(selectedSecondarySid, 1); + assert.equal(commands.some((command) => command[0] === 'sub-add'), false); + assert.deepEqual(refreshedSidebarSources, ['/tmp/manual-ja.ja.srt']); + assert.equal(commands.some((command) => command[0] === 'sub-remove'), false); +}); + +test('youtube flow waits for manual youtube tracks to appear before falling back to injected copies', async () => { + const commands: Array> = []; + let selectedPrimarySid: number | null = null; + let selectedSecondarySid: number | null = null; + let trackListReads = 0; + + const runtime = createYoutubeFlowRuntime({ + probeYoutubeTracks: async () => ({ + videoId: 'video123', + title: 'Video 123', + tracks: [ + { ...primaryTrack, id: 'manual:ja', sourceLanguage: 'ja', kind: 'manual', title: 'Japanese' }, + { ...secondaryTrack, id: 'manual:en', sourceLanguage: 'en', kind: 'manual', title: 'English' }, + ], + }), + acquireYoutubeSubtitleTracks: async () => { + throw new Error('should not batch download when manual tracks appear after startup'); + }, + acquireYoutubeSubtitleTrack: async ({ track }) => { + if (track.language === 'ja') { + return { path: '/tmp/manual-ja.ja.srt' }; + } + throw new Error('should not download secondary track when manual english appears in mpv'); + }, + retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath, + openPicker: async (payload) => { + queueMicrotask(() => { + void runtime.resolveActivePicker({ + sessionId: payload.sessionId, + action: 'use-selected', + primaryTrackId: 'manual:ja', + secondaryTrackId: 'manual:en', + }); + }); + return true; + }, + pauseMpv: () => {}, + resumeMpv: () => {}, + sendMpvCommand: (command) => { + commands.push(command); + if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') { + selectedPrimarySid = command[2]; + } + if ( + command[0] === 'set_property' && + command[1] === 'secondary-sid' && + typeof command[2] === 'number' + ) { + selectedSecondarySid = command[2]; + } + }, + requestMpvProperty: async (name) => { + if (name === 'sub-text') { + return '字幕です'; + } + if (name === 'sid') { + return selectedPrimarySid; + } + if (name === 'secondary-sid') { + return selectedSecondarySid; + } + trackListReads += 1; + if (trackListReads === 1) { + return []; + } + return [ + { type: 'sub', id: 1, lang: 'en', title: 'English', external: true, 'external-filename': null }, + { type: 'sub', id: 2, lang: 'ja', title: 'Japanese', external: true, 'external-filename': null }, + { type: 'sub', id: 3, lang: 'ja-en', title: 'Japanese from English', external: true, 'external-filename': null }, + { type: 'sub', id: 4, lang: 'ja-ja', title: 'Japanese from Japanese', external: true, 'external-filename': null }, + ]; + }, + refreshCurrentSubtitle: () => {}, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => {}, + waitForAnkiReady: async () => {}, + wait: async () => {}, + waitForPlaybackWindowReady: async () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => {}, + showMpvOsd: () => {}, + reportSubtitleFailure: () => { + throw new Error('delayed manual tracks should not report failure'); + }, + warn: (message) => { + throw new Error(message); + }, + log: () => {}, + getYoutubeOutputDir: () => '/tmp', + }); + + await runtime.openManualPicker({ url: 'https://example.com' }); + + assert.equal(selectedPrimarySid, 2); + assert.equal(selectedSecondarySid, 1); + assert.equal(commands.some((command) => command[0] === 'sub-add'), false); +}); + +test('youtube flow reuses manual youtube tracks even when mpv exposes external filenames', async () => { + const commands: Array> = []; + let selectedPrimarySid: number | null = null; + let selectedSecondarySid: number | null = null; + + const runtime = createYoutubeFlowRuntime({ + probeYoutubeTracks: async () => ({ + videoId: 'video123', + title: 'Video 123', + tracks: [ + { + id: 'manual:ja', + language: 'ja', + sourceLanguage: 'ja', + kind: 'manual', + title: 'Japanese', + label: 'Japanese', + }, + { + id: 'manual:en', + language: 'en', + sourceLanguage: 'en', + kind: 'manual', + title: 'English', + label: 'English', + }, + ], + }), + acquireYoutubeSubtitleTracks: async () => { + throw new Error('should not batch-download when existing manual tracks are reusable'); + }, + acquireYoutubeSubtitleTrack: async ({ track }) => { + if (track.id === 'manual:ja') { + return { path: '/tmp/manual-ja.ja.srt' }; + } + throw new Error('should not download secondary track when existing manual english track is reusable'); + }, + retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath, + openPicker: async () => false, + pauseMpv: () => {}, + resumeMpv: () => {}, + sendMpvCommand: (command) => { + commands.push(command); + if (command[0] === 'set_property' && command[1] === 'sid') { + selectedPrimarySid = Number(command[2]); + } + if (command[0] === 'set_property' && command[1] === 'secondary-sid') { + selectedSecondarySid = Number(command[2]); + } + }, + requestMpvProperty: async (name) => { + if (name === 'track-list') { + return [ + { + type: 'sub', + id: 1, + lang: 'en', + title: 'English', + external: true, + 'external-filename': '/tmp/mpv-ytdl-track-en.vtt', + }, + { + type: 'sub', + id: 2, + lang: 'ja', + title: 'Japanese', + external: true, + 'external-filename': '/tmp/mpv-ytdl-track-ja.vtt', + }, + { + type: 'sub', + id: 3, + lang: 'ja-en', + title: 'Japanese from English', + external: true, + 'external-filename': '/tmp/mpv-ytdl-track-ja-en.vtt', + }, + ]; + } + if (name === 'sid') { + return selectedPrimarySid; + } + if (name === 'secondary-sid') { + return selectedSecondarySid; + } + if (name === 'sub-text') { + return ''; + } + return null; + }, + refreshCurrentSubtitle: () => {}, + refreshSubtitleSidebarSource: async () => {}, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => {}, + waitForAnkiReady: async () => {}, + wait: async () => {}, + waitForPlaybackWindowReady: async () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => {}, + showMpvOsd: () => {}, + reportSubtitleFailure: (message) => { + throw new Error(message); + }, + warn: (message) => { + throw new Error(message); + }, + log: () => {}, + getYoutubeOutputDir: () => '/tmp', + }); + + await runtime.runYoutubePlaybackFlow({ + url: 'https://example.com/watch?v=video123', + mode: 'download', + }); + + assert.equal(selectedPrimarySid, 2); + assert.equal(selectedSecondarySid, 1); + assert.equal(commands.some((command) => command[0] === 'sub-add'), false); +}); + +test('youtube flow falls back to existing auto secondary track when auto secondary download fails', async () => { + const commands: Array> = []; + let selectedPrimarySid: number | null = null; + let selectedSecondarySid: number | null = null; + let primaryTrackAdded = false; + + const runtime = createYoutubeFlowRuntime({ + probeYoutubeTracks: async () => ({ + videoId: 'video123', + title: 'Video 123', + tracks: [ + { + id: 'auto:ja-orig', + language: 'ja-orig', + sourceLanguage: 'ja-orig', + kind: 'auto', + title: 'Japanese (Original)', + label: 'Japanese (Original) (auto)', + }, + { + id: 'auto:en', + language: 'en', + sourceLanguage: 'en', + kind: 'auto', + title: 'English', + label: 'English (auto)', + }, + ], + }), + acquireYoutubeSubtitleTracks: async () => + new Map([['auto:ja-orig', '/tmp/auto-ja-orig.ja-orig.vtt']]), + acquireYoutubeSubtitleTrack: async ({ track }) => { + if (track.id === 'auto:en') { + throw new Error('HTTP 429 while downloading en'); + } + return { path: '/tmp/auto-ja-orig.ja-orig.vtt' }; + }, + retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath, + openPicker: async () => false, + pauseMpv: () => {}, + resumeMpv: () => {}, + sendMpvCommand: (command) => { + commands.push(command); + if ( + command[0] === 'sub-add' && + command[1] === '/tmp/auto-ja-orig.ja-orig.vtt' && + command[2] === 'select' + ) { + primaryTrackAdded = true; + } + if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') { + selectedPrimarySid = command[2]; + } + if ( + command[0] === 'set_property' && + command[1] === 'secondary-sid' && + typeof command[2] === 'number' + ) { + selectedSecondarySid = command[2]; + } + }, + requestMpvProperty: async (name) => { + if (name === 'sub-text') { + return ''; + } + if (name === 'sid') { + return selectedPrimarySid; + } + if (name === 'secondary-sid') { + return selectedSecondarySid; + } + return primaryTrackAdded + ? [ + { + type: 'sub', + id: 1, + lang: 'en', + title: 'English', + external: true, + 'external-filename': '/tmp/mpv-auto-en.vtt', + }, + { + type: 'sub', + id: 3, + lang: 'ja-orig', + title: 'Japanese (Original)', + external: true, + 'external-filename': '/tmp/mpv-auto-ja-orig.vtt', + }, + { + type: 'sub', + id: 4, + lang: 'ja-orig', + title: 'auto-ja-orig.ja-orig.vtt', + external: true, + 'external-filename': '/tmp/auto-ja-orig.ja-orig.vtt', + }, + ] + : [ + { + type: 'sub', + id: 1, + lang: 'en', + title: 'English', + external: true, + 'external-filename': '/tmp/mpv-auto-en.vtt', + }, + { + type: 'sub', + id: 3, + lang: 'ja-orig', + title: 'Japanese (Original)', + external: true, + 'external-filename': '/tmp/mpv-auto-ja-orig.vtt', + }, + ]; + }, + refreshCurrentSubtitle: () => {}, + refreshSubtitleSidebarSource: async () => {}, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => {}, + waitForAnkiReady: async () => {}, + wait: async () => {}, + waitForPlaybackWindowReady: async () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => {}, + showMpvOsd: () => {}, + reportSubtitleFailure: (message) => { + throw new Error(message); + }, + warn: (message) => { + throw new Error(message); + }, + log: () => {}, + getYoutubeOutputDir: () => '/tmp', + }); + + await runtime.runYoutubePlaybackFlow({ + url: 'https://example.com/watch?v=video123', + mode: 'download', + }); + + assert.equal(selectedPrimarySid, 4); + assert.equal(selectedSecondarySid, 1); +}); diff --git a/src/main/runtime/youtube-flow.ts b/src/main/runtime/youtube-flow.ts new file mode 100644 index 0000000..ac0ea80 --- /dev/null +++ b/src/main/runtime/youtube-flow.ts @@ -0,0 +1,896 @@ +import os from 'node:os'; +import path from 'node:path'; +import type { + 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; +type YoutubeFlowMode = 'download' | 'generate'; + +type YoutubeFlowDeps = { + probeYoutubeTracks: (url: string) => Promise; + acquireYoutubeSubtitleTrack: typeof acquireYoutubeSubtitleTrack; + acquireYoutubeSubtitleTracks: typeof acquireYoutubeSubtitleTracks; + retimeYoutubePrimaryTrack: (input: { + targetUrl: string; + primaryTrack: YoutubeTrackOption; + primaryPath: string; + secondaryTrack: YoutubeTrackOption | null; + secondaryPath: string | null; + }) => Promise; + openPicker: YoutubeFlowOpenPicker; + pauseMpv: () => void; + resumeMpv: () => void; + sendMpvCommand: (command: Array) => void; + requestMpvProperty: (name: string) => Promise; + refreshCurrentSubtitle: (text: string) => void; + refreshSubtitleSidebarSource?: (sourcePath: string) => Promise; + startTokenizationWarmups: () => Promise; + waitForTokenizationReady: () => Promise; + waitForAnkiReady: () => Promise; + wait: (ms: number) => Promise; + waitForPlaybackWindowReady: () => Promise; + waitForOverlayGeometryReady: () => Promise; + focusOverlayWindow: () => void; + showMpvOsd: (text: string) => void; + reportSubtitleFailure: (message: 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 | 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 suppressYoutubeSubtitleState(deps: YoutubeFlowDeps): void { + deps.sendMpvCommand(['set_property', 'sub-auto', 'no']); + deps.sendMpvCommand(['set_property', 'sid', 'no']); + deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']); + deps.sendMpvCommand(['set_property', 'sub-visibility', 'no']); + deps.sendMpvCommand(['set_property', 'secondary-sub-visibility', 'no']); +} + +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; +} + +async function ensureSubtitleTrackSelection(input: { + deps: YoutubeFlowDeps; + property: 'sid' | 'secondary-sid'; + targetId: number; +}): Promise { + input.deps.sendMpvCommand(['set_property', input.property, input.targetId]); + for (let attempt = 0; attempt < 4; attempt += 1) { + const currentId = parseTrackId(await input.deps.requestMpvProperty(input.property)); + if (currentId === input.targetId) { + return; + } + await input.deps.wait(100); + input.deps.sendMpvCommand(['set_property', input.property, input.targetId]); + } +} + +function normalizeTrackListEntry(track: Record): { + 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 matchesTitleBasename(title: string, basename: string): boolean { + const normalizedTitle = title.trim(); + return normalizedTitle.length > 0 && path.basename(normalizedTitle) === basename; +} + +function isLikelyTranslatedYoutubeTrack(entry: { lang: string; title: string }): boolean { + const normalizedTitle = entry.title.trim().toLowerCase(); + if (normalizedTitle.includes(' from ')) { + return true; + } + return /-[a-z]{2,}(?:-[a-z0-9]+)?$/i.test(entry.lang.trim()); +} + +function matchExistingManualYoutubeTrackId( + trackListRaw: unknown, + trackOption: YoutubeTrackOption, + excludeId: number | null = null, +): number | null { + if (!Array.isArray(trackListRaw)) { + return null; + } + + const expectedTitle = trackOption.title?.trim().toLowerCase() || ''; + const expectedLanguages = new Set( + [trackOption.language, trackOption.sourceLanguage] + .map((value) => value.trim().toLowerCase()) + .filter((value) => value.length > 0), + ); + const tracks = trackListRaw + .filter( + (track): track is Record => Boolean(track) && typeof track === 'object', + ) + .filter((track) => track.type === 'sub') + .map(normalizeTrackListEntry) + .filter((track) => track.external && track.id !== null && track.id !== excludeId) + .filter((track) => !isLikelyTranslatedYoutubeTrack(track)); + + const exactTitleMatch = tracks.find( + (track) => + expectedTitle.length > 0 && + track.title.trim().toLowerCase() === expectedTitle && + expectedLanguages.has(track.lang.trim().toLowerCase()), + ); + if (exactTitleMatch?.id !== null && exactTitleMatch?.id !== undefined) { + return exactTitleMatch.id; + } + + if (expectedTitle.length === 0) { + const languageOnlyMatch = tracks.find((track) => + expectedLanguages.has(track.lang.trim().toLowerCase()), + ); + if (languageOnlyMatch?.id !== null && languageOnlyMatch?.id !== undefined) { + return languageOnlyMatch.id; + } + } + + return null; +} + +function matchExistingYoutubeTrackId( + trackListRaw: unknown, + trackOption: YoutubeTrackOption, + excludeId: number | null = null, +): number | null { + if (!Array.isArray(trackListRaw)) { + return null; + } + + const expectedTitle = trackOption.title?.trim().toLowerCase() || ''; + const expectedLanguages = new Set( + [trackOption.language, trackOption.sourceLanguage] + .map((value) => value.trim().toLowerCase()) + .filter((value) => value.length > 0), + ); + const tracks = trackListRaw + .filter( + (track): track is Record => Boolean(track) && typeof track === 'object', + ) + .filter((track) => track.type === 'sub') + .map(normalizeTrackListEntry) + .filter((track) => track.external && track.id !== null && track.id !== excludeId); + + const exactTitleMatch = tracks.find( + (track) => + expectedTitle.length > 0 && + track.title.trim().toLowerCase() === expectedTitle && + expectedLanguages.has(track.lang.trim().toLowerCase()), + ); + if (exactTitleMatch?.id !== null && exactTitleMatch?.id !== undefined) { + return exactTitleMatch.id; + } + + if (expectedTitle.length === 0) { + const languageOnlyMatch = tracks.find((track) => + expectedLanguages.has(track.lang.trim().toLowerCase()), + ); + if (languageOnlyMatch?.id !== null && languageOnlyMatch?.id !== undefined) { + return languageOnlyMatch.id; + } + } + + return null; +} + +function matchExternalTrackId( + trackListRaw: unknown, + filePath: string, + 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 => 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 titleMatch = externalTracks.find((track) => matchesTitleBasename(track.title, basename)); + if (titleMatch?.id !== null && titleMatch?.id !== undefined) { + return titleMatch.id; + } + + return null; +} + +async function injectDownloadedSubtitles( + deps: YoutubeFlowDeps, + primarySelection: { + track: YoutubeTrackOption; + existingTrackId: number | null; + injectedPath: string | null; + }, + secondaryTrack: YoutubeTrackOption | null, + secondarySelection: { + existingTrackId: number | null; + injectedPath: string | null; + } | null, +): Promise { + deps.sendMpvCommand(['set_property', 'sub-delay', 0]); + deps.sendMpvCommand(['set_property', 'sid', 'no']); + deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']); + if (primarySelection.injectedPath) { + deps.sendMpvCommand([ + 'sub-add', + primarySelection.injectedPath, + 'select', + path.basename(primarySelection.injectedPath), + primarySelection.track.sourceLanguage, + ]); + } + if (secondarySelection?.injectedPath && secondaryTrack) { + deps.sendMpvCommand([ + 'sub-add', + secondarySelection.injectedPath, + 'cached', + path.basename(secondarySelection.injectedPath), + secondaryTrack.sourceLanguage, + ]); + } + + let trackListRaw: unknown = await deps.requestMpvProperty('track-list'); + let primaryTrackId: number | null = primarySelection.existingTrackId; + let secondaryTrackId: number | null = secondarySelection?.existingTrackId ?? null; + for (let attempt = 0; attempt < 12; attempt += 1) { + if (attempt > 0 || primarySelection.injectedPath || secondarySelection?.injectedPath) { + await deps.wait(attempt === 0 ? 150 : 100); + trackListRaw = await deps.requestMpvProperty('track-list'); + } + if (primaryTrackId === null && primarySelection.injectedPath) { + primaryTrackId = matchExternalTrackId(trackListRaw, primarySelection.injectedPath); + } + if (secondarySelection?.injectedPath && secondaryTrack && secondaryTrackId === null) { + secondaryTrackId = matchExternalTrackId( + trackListRaw, + secondarySelection.injectedPath, + primaryTrackId, + ); + } + if ( + primaryTrackId !== null && + (!secondaryTrack || secondarySelection === null || secondaryTrackId !== null) + ) { + break; + } + } + + if (primaryTrackId !== null) { + await ensureSubtitleTrackSelection({ + deps, + property: 'sid', + targetId: primaryTrackId, + }); + deps.sendMpvCommand(['set_property', 'sub-visibility', 'yes']); + } else { + deps.warn( + `Unable to bind downloaded primary subtitle track in mpv: ${ + primarySelection.injectedPath ? path.basename(primarySelection.injectedPath) : primarySelection.track.label + }`, + ); + } + if (secondaryTrack && secondarySelection) { + if (secondaryTrackId !== null) { + await ensureSubtitleTrackSelection({ + deps, + property: 'secondary-sid', + targetId: secondaryTrackId, + }); + } else { + deps.warn( + `Unable to bind downloaded secondary subtitle track in mpv: ${ + secondarySelection.injectedPath + ? path.basename(secondarySelection.injectedPath) + : secondaryTrack.label + }`, + ); + } + } + + if (primaryTrackId === null) { + return false; + } + + const currentSubText = await deps.requestMpvProperty('sub-text'); + if (typeof currentSubText === 'string' && currentSubText.trim().length > 0) { + deps.refreshCurrentSubtitle(currentSubText); + } + + deps.showMpvOsd( + secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.', + ); + return true; +} + +export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { + let activeSession: YoutubeFlowSession | null = null; + + const acquireSelectedTracks = async (input: { + targetUrl: string; + outputDir: string; + primaryTrack: YoutubeTrackOption; + secondaryTrack: YoutubeTrackOption | null; + 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, + }) + ).path; + return { primaryPath, secondaryPath: null }; + } + + try { + const batchResult = await deps.acquireYoutubeSubtitleTracks({ + targetUrl: input.targetUrl, + outputDir: input.outputDir, + tracks: [input.primaryTrack, input.secondaryTrack], + }); + 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, + }) + ).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, + }) + ).path; + return { primaryPath, secondaryPath: null }; + } catch (error) { + throw error; + } + }; + + const resolveActivePicker = async ( + request: YoutubePickerResolveRequest, + ): Promise => { + 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 => + new Promise((resolve, reject) => { + activeSession = { sessionId, resolve, reject }; + }).finally(() => { + activeSession = null; + }); + + const reportPrimarySubtitleFailure = (): void => { + deps.reportSubtitleFailure( + 'Primary subtitles failed to load. Use the YouTube subtitle picker to try manually.', + ); + }; + + const buildOpenPayload = ( + input: { + url: string; + }, + probe: YoutubeTrackProbeResult, + ): YoutubePickerOpenPayload => { + const defaults = chooseDefaultYoutubeTrackIds(probe.tracks); + return { + sessionId: createSessionId(), + url: input.url, + tracks: probe.tracks, + defaultPrimaryTrackId: defaults.primaryTrackId, + defaultSecondaryTrackId: defaults.secondaryTrackId, + hasTracks: probe.tracks.length > 0, + }; + }; + + const loadTracksIntoMpv = async (input: { + url: string; + mode: YoutubeFlowMode; + outputDir: string; + primaryTrack: YoutubeTrackOption; + secondaryTrack: YoutubeTrackOption | null; + secondaryFailureLabel: string; + tokenizationWarmupPromise?: Promise; + showDownloadProgress: boolean; + }): Promise => { + const osdProgress = input.showDownloadProgress + ? createYoutubeFlowOsdProgress(deps.showMpvOsd) + : null; + if (osdProgress) { + osdProgress.setMessage('Downloading subtitles...'); + } + try { + let initialTrackListRaw: unknown = null; + let existingPrimaryTrackId: number | null = null; + let existingSecondaryTrackId: number | null = null; + for (let attempt = 0; attempt < 8; attempt += 1) { + if (attempt > 0) { + await deps.wait(attempt === 1 ? 150 : 100); + } + initialTrackListRaw = await deps.requestMpvProperty('track-list'); + existingPrimaryTrackId = + input.primaryTrack.kind === 'manual' + ? matchExistingManualYoutubeTrackId(initialTrackListRaw, input.primaryTrack) + : null; + existingSecondaryTrackId = + input.secondaryTrack?.kind === 'manual' + ? matchExistingManualYoutubeTrackId( + initialTrackListRaw, + input.secondaryTrack, + existingPrimaryTrackId, + ) + : null; + const primaryReady = input.primaryTrack.kind !== 'manual' || existingPrimaryTrackId !== null; + const secondaryReady = + !input.secondaryTrack || + input.secondaryTrack.kind !== 'manual' || + existingSecondaryTrackId !== null; + if (primaryReady && secondaryReady) { + break; + } + } + + let primarySidebarPath: string; + let primaryInjectedPath: string | null = null; + let secondaryInjectedPath: string | null = null; + + if (existingPrimaryTrackId !== null) { + primarySidebarPath = ( + await deps.acquireYoutubeSubtitleTrack({ + targetUrl: input.url, + outputDir: input.outputDir, + track: input.primaryTrack, + }) + ).path; + } else if (existingSecondaryTrackId !== null || !input.secondaryTrack) { + primaryInjectedPath = ( + await deps.acquireYoutubeSubtitleTrack({ + targetUrl: input.url, + outputDir: input.outputDir, + track: input.primaryTrack, + }) + ).path; + primarySidebarPath = await deps.retimeYoutubePrimaryTrack({ + targetUrl: input.url, + primaryTrack: input.primaryTrack, + primaryPath: primaryInjectedPath, + secondaryTrack: input.secondaryTrack, + secondaryPath: null, + }); + primaryInjectedPath = primarySidebarPath; + } else { + const acquired = await acquireSelectedTracks({ + targetUrl: input.url, + outputDir: input.outputDir, + primaryTrack: input.primaryTrack, + secondaryTrack: existingSecondaryTrackId === null ? input.secondaryTrack : null, + secondaryFailureLabel: input.secondaryFailureLabel, + }); + primarySidebarPath = await deps.retimeYoutubePrimaryTrack({ + targetUrl: input.url, + primaryTrack: input.primaryTrack, + primaryPath: acquired.primaryPath, + secondaryTrack: input.secondaryTrack, + secondaryPath: acquired.secondaryPath, + }); + primaryInjectedPath = primarySidebarPath; + secondaryInjectedPath = acquired.secondaryPath; + } + + if (input.secondaryTrack && existingSecondaryTrackId === null && secondaryInjectedPath === null) { + try { + secondaryInjectedPath = ( + await deps.acquireYoutubeSubtitleTrack({ + targetUrl: input.url, + outputDir: input.outputDir, + track: input.secondaryTrack, + }) + ).path; + } catch (error) { + const fallbackExistingSecondaryTrackId = + input.secondaryTrack.kind === 'auto' + ? matchExistingYoutubeTrackId( + initialTrackListRaw, + input.secondaryTrack, + existingPrimaryTrackId, + ) + : null; + if (fallbackExistingSecondaryTrackId !== null) { + existingSecondaryTrackId = fallbackExistingSecondaryTrackId; + } else { + deps.warn( + `${input.secondaryFailureLabel}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } + + deps.showMpvOsd('Loading subtitles...'); + const refreshedActiveSubtitle = await injectDownloadedSubtitles( + deps, + { + track: input.primaryTrack, + existingTrackId: existingPrimaryTrackId, + injectedPath: primaryInjectedPath, + }, + input.secondaryTrack, + input.secondaryTrack + ? { + existingTrackId: existingSecondaryTrackId, + injectedPath: secondaryInjectedPath, + } + : null, + ); + if (!refreshedActiveSubtitle) { + return false; + } + try { + await deps.refreshSubtitleSidebarSource?.(primarySidebarPath); + } catch (error) { + deps.warn( + `Failed to refresh parsed subtitle cues for sidebar: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + if (input.tokenizationWarmupPromise) { + await input.tokenizationWarmupPromise; + } + await deps.waitForTokenizationReady(); + await deps.waitForAnkiReady(); + return true; + } finally { + osdProgress?.stop(); + } + }; + + const openManualPicker = async (input: { + url: string; + mode?: YoutubeFlowMode; + }): Promise => { + 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) + }`, + ); + reportPrimarySubtitleFailure(); + restoreOverlayInputFocus(deps); + return; + } + + const openPayload = buildOpenPayload(input, probe); + await deps.waitForPlaybackWindowReady(); + await deps.waitForOverlayGeometryReady(); + await deps.wait(YOUTUBE_PICKER_SETTLE_DELAY_MS); + const pickerSelection = createPickerSelectionPromise(openPayload.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) + }`, + ); + 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.'); + restoreOverlayInputFocus(deps); + return; + } + + const request = await pickerSelection; + if (request.action === 'continue-without-subtitles') { + restoreOverlayInputFocus(deps); + return; + } + + const primaryTrack = getTrackById(probe.tracks, request.primaryTrackId); + if (!primaryTrack) { + deps.warn('No primary YouTube subtitle track selected.'); + restoreOverlayInputFocus(deps); + return; + } + + const selected = normalizeYoutubeTrackSelection({ + primaryTrackId: primaryTrack.id, + secondaryTrackId: request.secondaryTrackId, + }); + const secondaryTrack = getTrackById(probe.tracks, selected.secondaryTrackId); + + try { + deps.showMpvOsd('Getting subtitles...'); + const loaded = await loadTracksIntoMpv({ + url: input.url, + mode: input.mode ?? 'download', + outputDir: normalizeOutputPath(deps.getYoutubeOutputDir()), + primaryTrack, + secondaryTrack, + secondaryFailureLabel: 'Failed to download secondary YouTube subtitle track', + showDownloadProgress: true, + }); + if (!loaded) { + reportPrimarySubtitleFailure(); + } + } catch (error) { + deps.warn( + `Failed to download primary YouTube subtitle track: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + reportPrimarySubtitleFailure(); + } finally { + restoreOverlayInputFocus(deps); + } + }; + + async function runYoutubePlaybackFlow(input: { + url: string; + mode: YoutubeFlowMode; + }): Promise { + 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(); + suppressYoutubeSubtitleState(deps); + 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) + }`, + ); + reportPrimarySubtitleFailure(); + releasePlaybackGate(deps); + restoreOverlayInputFocus(deps); + return; + } + + const defaults = chooseDefaultYoutubeTrackIds(probe.tracks); + const primaryTrack = getTrackById(probe.tracks, defaults.primaryTrackId); + const secondaryTrack = getTrackById(probe.tracks, defaults.secondaryTrackId); + if (!primaryTrack) { + reportPrimarySubtitleFailure(); + releasePlaybackGate(deps); + restoreOverlayInputFocus(deps); + return; + } + + try { + deps.showMpvOsd('Getting subtitles...'); + const loaded = await loadTracksIntoMpv({ + url: input.url, + mode: input.mode, + outputDir, + primaryTrack, + secondaryTrack, + secondaryFailureLabel: + input.mode === 'generate' + ? 'Failed to generate secondary YouTube subtitle track' + : 'Failed to download secondary YouTube subtitle track', + tokenizationWarmupPromise, + showDownloadProgress: false, + }); + if (!loaded) { + reportPrimarySubtitleFailure(); + } + } catch (error) { + deps.warn( + `Failed to ${ + input.mode === 'generate' ? 'generate' : 'download' + } primary YouTube subtitle track: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + reportPrimarySubtitleFailure(); + } finally { + releasePlaybackGate(deps); + restoreOverlayInputFocus(deps); + } + } + + return { + runYoutubePlaybackFlow, + openManualPicker, + resolveActivePicker, + cancelActivePicker, + hasActiveSession: () => Boolean(activeSession), + }; +} diff --git a/src/main/runtime/youtube-picker-open.test.ts b/src/main/runtime/youtube-picker-open.test.ts new file mode 100644 index 0000000..2e41b7c --- /dev/null +++ b/src/main/runtime/youtube-picker-open.test.ts @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { openYoutubeTrackPicker } from './youtube-picker-open'; +import type { YoutubePickerOpenPayload } from '../../types'; + +const payload: YoutubePickerOpenPayload = { + sessionId: 'yt-1', + url: 'https://example.com/watch?v=abc', + tracks: [], + defaultPrimaryTrackId: null, + defaultSecondaryTrackId: null, + hasTracks: false, +}; + +test('youtube picker open prefers dedicated modal window on first attempt', async () => { + const sends: Array<{ + channel: string; + payload: YoutubePickerOpenPayload; + options: { + restoreOnModalClose: 'youtube-track-picker'; + preferModalWindow: boolean; + }; + }> = []; + + const opened = await openYoutubeTrackPicker( + { + sendToActiveOverlayWindow: (channel, nextPayload, options) => { + sends.push({ + channel, + payload: nextPayload as YoutubePickerOpenPayload, + options: options as { + restoreOnModalClose: 'youtube-track-picker'; + preferModalWindow: boolean; + }, + }); + return true; + }, + waitForModalOpen: async () => true, + logWarn: () => {}, + }, + payload, + ); + + assert.equal(opened, true); + assert.deepEqual(sends, [ + { + channel: 'youtube:picker-open', + payload, + options: { + restoreOnModalClose: 'youtube-track-picker', + preferModalWindow: true, + }, + }, + ]); +}); + +test('youtube picker open retries on the dedicated modal window after open timeout', async () => { + const preferModalWindowValues: boolean[] = []; + const warns: string[] = []; + let waitCalls = 0; + + const opened = await openYoutubeTrackPicker( + { + sendToActiveOverlayWindow: (_channel, _payload, options) => { + preferModalWindowValues.push(Boolean(options?.preferModalWindow)); + return true; + }, + waitForModalOpen: async () => { + waitCalls += 1; + return waitCalls === 2; + }, + logWarn: (message) => { + warns.push(message); + }, + }, + payload, + ); + + assert.equal(opened, true); + assert.deepEqual(preferModalWindowValues, [true, true]); + assert.equal( + warns.includes( + 'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying dedicated modal window.', + ), + true, + ); +}); + +test('youtube picker open fails when the dedicated modal window cannot be targeted', async () => { + const opened = await openYoutubeTrackPicker( + { + sendToActiveOverlayWindow: () => false, + waitForModalOpen: async () => true, + logWarn: () => {}, + }, + payload, + ); + + assert.equal(opened, false); +}); diff --git a/src/main/runtime/youtube-picker-open.ts b/src/main/runtime/youtube-picker-open.ts new file mode 100644 index 0000000..fe231fe --- /dev/null +++ b/src/main/runtime/youtube-picker-open.ts @@ -0,0 +1,42 @@ +import type { YoutubePickerOpenPayload } from '../../types'; +import type { OverlayHostedModal } from '../../shared/ipc/contracts'; + +const YOUTUBE_PICKER_MODAL: OverlayHostedModal = 'youtube-track-picker'; +const YOUTUBE_PICKER_OPEN_TIMEOUT_MS = 1500; + +export async function openYoutubeTrackPicker( + deps: { + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => boolean; + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + logWarn: (message: string) => void; + }, + payload: YoutubePickerOpenPayload, +): Promise { + const sendPickerOpen = (): boolean => + deps.sendToActiveOverlayWindow('youtube:picker-open', payload, { + restoreOnModalClose: YOUTUBE_PICKER_MODAL, + preferModalWindow: true, + }); + + if (!sendPickerOpen()) { + return false; + } + if (await deps.waitForModalOpen(YOUTUBE_PICKER_MODAL, YOUTUBE_PICKER_OPEN_TIMEOUT_MS)) { + return true; + } + + deps.logWarn( + 'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying dedicated modal window.', + ); + if (!sendPickerOpen()) { + return false; + } + return await deps.waitForModalOpen(YOUTUBE_PICKER_MODAL, YOUTUBE_PICKER_OPEN_TIMEOUT_MS); +} diff --git a/src/main/runtime/youtube-playback.test.ts b/src/main/runtime/youtube-playback.test.ts new file mode 100644 index 0000000..e8b4fec --- /dev/null +++ b/src/main/runtime/youtube-playback.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { isYoutubeMediaPath, isYoutubePlaybackActive } from './youtube-playback'; + +test('isYoutubeMediaPath detects youtube watch and short urls', () => { + assert.equal(isYoutubeMediaPath('https://www.youtube.com/watch?v=abc123'), true); + assert.equal(isYoutubeMediaPath('https://m.youtube.com/watch?v=abc123'), true); + assert.equal(isYoutubeMediaPath('https://youtu.be/abc123'), true); + assert.equal(isYoutubeMediaPath('https://www.youtube-nocookie.com/embed/abc123'), true); +}); + +test('isYoutubeMediaPath ignores local files and non-youtube urls', () => { + assert.equal(isYoutubeMediaPath('/tmp/video.mkv'), false); + assert.equal(isYoutubeMediaPath('https://example.com/watch?v=abc123'), false); + assert.equal(isYoutubeMediaPath('https://notyoutube.com/watch?v=abc123'), false); + assert.equal(isYoutubeMediaPath(' '), false); + assert.equal(isYoutubeMediaPath(null), false); +}); + +test('isYoutubePlaybackActive checks both current media and mpv video paths', () => { + assert.equal(isYoutubePlaybackActive('/tmp/video.mkv', 'https://youtu.be/abc123'), true); + assert.equal(isYoutubePlaybackActive('https://www.youtube.com/watch?v=abc123', null), true); + assert.equal(isYoutubePlaybackActive('/tmp/video.mkv', '/tmp/video.mkv'), false); +}); diff --git a/src/main/runtime/youtube-playback.ts b/src/main/runtime/youtube-playback.ts new file mode 100644 index 0000000..7e21ba5 --- /dev/null +++ b/src/main/runtime/youtube-playback.ts @@ -0,0 +1,39 @@ +function trimToNull(value: string | null | undefined): string | null { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function matchesYoutubeHost(hostname: string, expectedHost: string): boolean { + return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`); +} + +export function isYoutubeMediaPath(mediaPath: string | null | undefined): boolean { + const normalized = trimToNull(mediaPath); + if (!normalized) { + return false; + } + + let parsed: URL; + try { + parsed = new URL(normalized); + } catch { + return false; + } + + const host = parsed.hostname.toLowerCase(); + return ( + matchesYoutubeHost(host, 'youtu.be') || + matchesYoutubeHost(host, 'youtube.com') || + matchesYoutubeHost(host, 'youtube-nocookie.com') + ); +} + +export function isYoutubePlaybackActive( + currentMediaPath: string | null | undefined, + currentVideoPath: string | null | undefined, +): boolean { + return isYoutubeMediaPath(currentMediaPath) || isYoutubeMediaPath(currentVideoPath); +} diff --git a/src/main/runtime/youtube-primary-subtitle-notification.test.ts b/src/main/runtime/youtube-primary-subtitle-notification.test.ts new file mode 100644 index 0000000..51a7d60 --- /dev/null +++ b/src/main/runtime/youtube-primary-subtitle-notification.test.ts @@ -0,0 +1,197 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createYoutubePrimarySubtitleNotificationRuntime, + type YoutubePrimarySubtitleNotificationTimer, +} from './youtube-primary-subtitle-notification'; + +function createTimerHarness() { + let nextId = 1; + const timers = new Map void>(); + return { + schedule: (fn: () => void): YoutubePrimarySubtitleNotificationTimer => { + const id = nextId++; + timers.set(id, fn); + return { id }; + }, + clear: (timer: YoutubePrimarySubtitleNotificationTimer | null) => { + if (!timer) { + return; + } + if (typeof timer === 'object' && 'id' in timer) { + timers.delete(timer.id); + } + }, + runAll: () => { + const pending = [...timers.values()]; + timers.clear(); + for (const fn of pending) { + fn(); + } + }, + size: () => timers.size, + }; +} + +test('notifier reports missing preferred primary subtitle once for youtube media', () => { + const notifications: string[] = []; + const timers = createTimerHarness(); + const runtime = createYoutubePrimarySubtitleNotificationRuntime({ + getPrimarySubtitleLanguages: () => ['ja', 'jpn'], + notifyFailure: (message) => { + notifications.push(message); + }, + schedule: (fn) => timers.schedule(fn), + clearSchedule: (timer) => timers.clear(timer), + }); + + runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc'); + runtime.handleSubtitleTrackChange(null); + runtime.handleSubtitleTrackListChange([ + { type: 'sub', id: 2, lang: 'en', title: 'English', external: true }, + ]); + + assert.equal(timers.size(), 1); + timers.runAll(); + timers.runAll(); + + assert.deepEqual(notifications, [ + 'Primary subtitle failed to download or load. Try again from the subtitle modal.', + ]); +}); + +test('notifier suppresses failure when preferred primary subtitle is selected', () => { + const notifications: string[] = []; + const timers = createTimerHarness(); + const runtime = createYoutubePrimarySubtitleNotificationRuntime({ + getPrimarySubtitleLanguages: () => ['ja', 'jpn'], + notifyFailure: (message) => { + notifications.push(message); + }, + schedule: (fn) => timers.schedule(fn), + clearSchedule: (timer) => timers.clear(timer), + }); + + runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc'); + runtime.handleSubtitleTrackListChange([ + { type: 'sub', id: 5, lang: 'ja', title: 'Japanese', external: true }, + ]); + runtime.handleSubtitleTrackChange(5); + timers.runAll(); + + assert.deepEqual(notifications, []); +}); + +test('notifier suppresses failure when selected track is marked active before sid arrives', () => { + const notifications: string[] = []; + const timers = createTimerHarness(); + const runtime = createYoutubePrimarySubtitleNotificationRuntime({ + getPrimarySubtitleLanguages: () => ['ja', 'jpn'], + notifyFailure: (message) => { + notifications.push(message); + }, + schedule: (fn) => timers.schedule(fn), + clearSchedule: (timer) => timers.clear(timer), + }); + + runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc'); + runtime.handleSubtitleTrackChange(null); + runtime.handleSubtitleTrackListChange([ + { type: 'sub', id: 5, lang: 'ja', title: 'Japanese', external: false, selected: true }, + ]); + timers.runAll(); + + assert.deepEqual(notifications, []); +}); + +test('notifier suppresses failure when any external subtitle track is selected', () => { + const notifications: string[] = []; + const timers = createTimerHarness(); + const runtime = createYoutubePrimarySubtitleNotificationRuntime({ + getPrimarySubtitleLanguages: () => ['ja', 'jpn'], + notifyFailure: (message) => { + notifications.push(message); + }, + schedule: (fn) => timers.schedule(fn), + clearSchedule: (timer) => timers.clear(timer), + }); + + runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc'); + runtime.handleSubtitleTrackListChange([ + { type: 'sub', id: 5, lang: '', title: 'auto-ja-orig.ja-orig.vtt', external: true }, + ]); + runtime.handleSubtitleTrackChange(5); + timers.runAll(); + + assert.deepEqual(notifications, []); +}); + +test('notifier resets when media changes away from youtube', () => { + const notifications: string[] = []; + const timers = createTimerHarness(); + const runtime = createYoutubePrimarySubtitleNotificationRuntime({ + getPrimarySubtitleLanguages: () => ['ja'], + notifyFailure: (message) => { + notifications.push(message); + }, + schedule: (fn) => timers.schedule(fn), + clearSchedule: (timer) => timers.clear(timer), + }); + + runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc'); + runtime.handleMediaPathChange('/tmp/video.mkv'); + timers.runAll(); + + assert.deepEqual(notifications, []); +}); + +test('notifier ignores empty and null media paths and waits for track list before reporting', () => { + const notifications: string[] = []; + const timers = createTimerHarness(); + const runtime = createYoutubePrimarySubtitleNotificationRuntime({ + getPrimarySubtitleLanguages: () => ['ja'], + notifyFailure: (message) => { + notifications.push(message); + }, + schedule: (fn) => timers.schedule(fn), + clearSchedule: (timer) => timers.clear(timer), + }); + + runtime.handleMediaPathChange(null); + runtime.handleMediaPathChange(''); + assert.equal(timers.size(), 0); + + runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc'); + runtime.handleSubtitleTrackChange(7); + runtime.handleSubtitleTrackListChange([ + { type: 'sub', id: 7, lang: 'ja', title: 'Japanese', external: true }, + ]); + timers.runAll(); + assert.deepEqual(notifications, []); +}); + +test('notifier suppresses timer while app-owned youtube flow is still settling', () => { + const notifications: string[] = []; + const timers = createTimerHarness(); + const runtime = createYoutubePrimarySubtitleNotificationRuntime({ + getPrimarySubtitleLanguages: () => ['ja'], + notifyFailure: (message) => { + notifications.push(message); + }, + schedule: (fn) => timers.schedule(fn), + clearSchedule: (timer) => timers.clear(timer), + }); + + runtime.setAppOwnedFlowInFlight(true); + runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc'); + + assert.equal(timers.size(), 0); + + runtime.setAppOwnedFlowInFlight(false); + assert.equal(timers.size(), 1); + + timers.runAll(); + assert.deepEqual(notifications, [ + 'Primary subtitle failed to download or load. Try again from the subtitle modal.', + ]); +}); diff --git a/src/main/runtime/youtube-primary-subtitle-notification.ts b/src/main/runtime/youtube-primary-subtitle-notification.ts new file mode 100644 index 0000000..99ff8ba --- /dev/null +++ b/src/main/runtime/youtube-primary-subtitle-notification.ts @@ -0,0 +1,185 @@ +import { isYoutubeMediaPath } from './youtube-playback'; +import { normalizeYoutubeLangCode } from '../../core/services/youtube/labels'; + +export type YoutubePrimarySubtitleNotificationTimer = ReturnType | { id: number }; + +type SubtitleTrackEntry = { + id: number | null; + type: string; + lang: string; + external: boolean; + selected: boolean; +}; + +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 normalizeTrack(entry: unknown): SubtitleTrackEntry | null { + if (!entry || typeof entry !== 'object') { + return null; + } + const track = entry as Record; + return { + id: parseTrackId(track.id), + type: String(track.type || '').trim(), + lang: String(track.lang || '').trim(), + external: track.external === true, + selected: track.selected === true, + }; +} + +export function clearYoutubePrimarySubtitleNotificationTimer( + timer: YoutubePrimarySubtitleNotificationTimer | null, +): void { + if (!timer) { + return; + } + if (typeof timer === 'object' && timer !== null && 'id' in timer) { + clearTimeout((timer as { id: number }).id); + return; + } + clearTimeout(timer); +} + +function buildPreferredLanguageSet(values: string[]): Set { + const normalized = values + .map((value) => normalizeYoutubeLangCode(value)) + .filter((value) => value.length > 0); + return new Set(normalized); +} + +function matchesPreferredLanguage(language: string, preferred: Set): boolean { + if (preferred.size === 0) { + return false; + } + const normalized = normalizeYoutubeLangCode(language); + if (!normalized) { + return false; + } + if (preferred.has(normalized)) { + return true; + } + const base = normalized.split('-')[0] || normalized; + return preferred.has(base); +} + +function hasSelectedPrimarySubtitle( + sid: number | null, + trackList: unknown[] | null, + preferredLanguages: Set, +): boolean { + if (!Array.isArray(trackList)) { + return false; + } + + const tracks = trackList.map(normalizeTrack); + const activeTrack = + (sid === null ? null : tracks.find((track) => track?.type === 'sub' && track.id === sid) ?? null) ?? + tracks.find((track) => track?.type === 'sub' && track.selected) ?? + null; + if (!activeTrack) { + return false; + } + if (activeTrack.external) { + return true; + } + return matchesPreferredLanguage(activeTrack.lang, preferredLanguages); +} + +export function createYoutubePrimarySubtitleNotificationRuntime(deps: { + getPrimarySubtitleLanguages: () => string[]; + notifyFailure: (message: string) => void; + schedule: (fn: () => void, delayMs: number) => YoutubePrimarySubtitleNotificationTimer; + clearSchedule: (timer: YoutubePrimarySubtitleNotificationTimer | null) => void; + delayMs?: number; +}) { + const delayMs = deps.delayMs ?? 5000; + let currentMediaPath: string | null = null; + let currentSid: number | null = null; + let currentTrackList: unknown[] | null = null; + let pendingTimer: YoutubePrimarySubtitleNotificationTimer | null = null; + let lastReportedMediaPath: string | null = null; + let appOwnedFlowInFlight = false; + + const clearPendingTimer = (): void => { + deps.clearSchedule(pendingTimer); + pendingTimer = null; + }; + + const maybeReportFailure = (): void => { + const mediaPath = currentMediaPath?.trim() || ''; + if (!mediaPath || !isYoutubeMediaPath(mediaPath)) { + return; + } + if (lastReportedMediaPath === mediaPath) { + return; + } + const preferredLanguages = buildPreferredLanguageSet(deps.getPrimarySubtitleLanguages()); + if (preferredLanguages.size === 0) { + return; + } + if (hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages)) { + return; + } + lastReportedMediaPath = mediaPath; + deps.notifyFailure('Primary subtitle failed to download or load. Try again from the subtitle modal.'); + }; + + const schedulePendingCheck = (): void => { + clearPendingTimer(); + if (appOwnedFlowInFlight) { + return; + } + const mediaPath = currentMediaPath?.trim() || ''; + if (!mediaPath || !isYoutubeMediaPath(mediaPath)) { + return; + } + pendingTimer = deps.schedule(() => { + pendingTimer = null; + maybeReportFailure(); + }, delayMs); + }; + + return { + handleMediaPathChange: (path: string | null): void => { + const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : null; + if (currentMediaPath !== normalizedPath) { + lastReportedMediaPath = null; + } + currentMediaPath = normalizedPath; + currentSid = null; + currentTrackList = null; + schedulePendingCheck(); + }, + handleSubtitleTrackChange: (sid: number | null): void => { + currentSid = sid; + const preferredLanguages = buildPreferredLanguageSet(deps.getPrimarySubtitleLanguages()); + if (hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages)) { + clearPendingTimer(); + } + }, + handleSubtitleTrackListChange: (trackList: unknown[] | null): void => { + currentTrackList = trackList; + const preferredLanguages = buildPreferredLanguageSet(deps.getPrimarySubtitleLanguages()); + if (hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages)) { + clearPendingTimer(); + } + }, + setAppOwnedFlowInFlight: (inFlight: boolean): void => { + appOwnedFlowInFlight = inFlight; + if (inFlight) { + clearPendingTimer(); + return; + } + schedulePendingCheck(); + }, + }; +} diff --git a/src/main/state.test.ts b/src/main/state.test.ts index aaa9f44..6ddf7f3 100644 --- a/src/main/state.test.ts +++ b/src/main/state.test.ts @@ -1,7 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { parseArgs } from '../cli/args'; import { + applyStartupState, + createAppState, createInitialAnilistMediaGuessRuntimeState, createInitialAnilistUpdateInFlightState, transitionAnilistClientSecretState, @@ -91,3 +94,22 @@ test('transitionAnilistUpdateInFlightState updates inFlight only', () => { assert.deepEqual(transitioned, { inFlight: true }); assert.notEqual(transitioned, current); }); + +test('applyStartupState preserves cleared startup-only runtime flags', () => { + const appState = createAppState({ + mpvSocketPath: '/tmp/mpv.sock', + texthookerPort: 4000, + }); + + applyStartupState(appState, { + initialArgs: parseArgs(['--settings']), + mpvSocketPath: '/tmp/mpv.sock', + texthookerPort: 4000, + backendOverride: null, + autoStartOverlay: false, + texthookerOnlyMode: false, + backgroundMode: false, + }); + + assert.equal(appState.initialArgs?.settings, true); +}); diff --git a/src/preload-args.test.ts b/src/preload-args.test.ts new file mode 100644 index 0000000..ec6dd07 --- /dev/null +++ b/src/preload-args.test.ts @@ -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); +}); diff --git a/src/preload-args.ts b/src/preload-args.ts new file mode 100644 index 0000000..a0d6ad3 --- /dev/null +++ b/src/preload-args.ts @@ -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; +} diff --git a/src/preload.ts b/src/preload.ts index ba063ab..8d0299d 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -17,6 +17,7 @@ */ import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'; +import { resolveOverlayLayerFromArgv } from './preload-args'; import type { SubtitleData, SubtitlePosition, @@ -51,13 +52,13 @@ import type { ControllerConfigUpdate, ControllerPreferenceUpdate, ResolvedControllerConfig, + YoutubePickerOpenPayload, + YoutubePickerResolveRequest, + YoutubePickerResolveResult, } from './types'; import { IPC_CHANNELS } from './shared/ipc/contracts'; -const overlayLayerArg = process.argv.find((arg) => arg.startsWith('--overlay-layer=')); -const overlayLayerFromArg = overlayLayerArg?.slice('--overlay-layer='.length); -const overlayLayer = - overlayLayerFromArg === 'visible' || overlayLayerFromArg === 'modal' ? overlayLayerFromArg : null; +const overlayLayer = resolveOverlayLayerFromArgv(process.argv); type EmptyListener = () => void; type PayloadedListener = (payload: T) => void; @@ -121,6 +122,13 @@ function createQueuedIpcListenerWithPayload( const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen); const onOpenJimakuEvent = createQueuedIpcListener(IPC_CHANNELS.event.jimakuOpen); +const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload( + IPC_CHANNELS.event.youtubePickerOpen, + (payload) => payload as YoutubePickerOpenPayload, +); +const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener( + IPC_CHANNELS.event.youtubePickerCancel, +); const onKeyboardModeToggleRequestedEvent = createQueuedIpcListener( IPC_CHANNELS.event.keyboardModeToggleRequested, ); @@ -313,10 +321,16 @@ const electronAPI: ElectronAPI = { }, onOpenRuntimeOptions: onOpenRuntimeOptionsEvent, onOpenJimaku: onOpenJimakuEvent, + onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent, + onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent, onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent, onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent, appendClipboardVideoToQueue: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue), + youtubePickerResolve: ( + request: YoutubePickerResolveRequest, + ): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.youtubePickerResolve, request), notifyOverlayModalClosed: (modal) => { ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal); }, diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 2104eef..c4ba4c8 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -332,6 +332,7 @@ function createKeyboardHandlerHarness() { return true; }, handleControllerDebugKeydown: () => false, + handleYoutubePickerKeydown: () => false, handleSessionHelpKeydown: () => false, openSessionHelpModal: () => {}, appendClipboardVideoToQueue: () => {}, @@ -489,6 +490,34 @@ test('keyboard mode: repeated popup navigation keys are forwarded while popup is } }); +test('popup-visible mpv keybindings still fire for bound keys', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.updateKeybindings([ + { + key: 'Space', + command: ['cycle', 'pause'], + }, + { + key: 'KeyQ', + command: ['quit'], + }, + ] as never); + + ctx.state.yomitanPopupVisible = true; + testGlobals.setPopupVisible(true); + + testGlobals.dispatchKeydown({ key: ' ', code: 'Space' }); + testGlobals.dispatchKeydown({ key: 'q', code: 'KeyQ' }); + + assert.deepEqual(testGlobals.mpvCommands.slice(-2), [['cycle', 'pause'], ['quit']]); + } finally { + testGlobals.restore(); + } +}); + test('keyboard mode: controller helpers dispatch popup audio play/cycle and scroll bridge commands', async () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); @@ -590,6 +619,33 @@ test('keyboard mode: configured stats toggle works even while popup is open', as } }); +test('youtube picker: unhandled keys still dispatch mpv keybindings', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.updateKeybindings([ + { + key: 'Space', + command: ['cycle', 'pause'], + }, + { + key: 'KeyQ', + command: ['quit'], + }, + ] as never); + + ctx.state.youtubePickerModalOpen = true; + + testGlobals.dispatchKeydown({ key: ' ', code: 'Space' }); + testGlobals.dispatchKeydown({ key: 'q', code: 'KeyQ' }); + + assert.deepEqual(testGlobals.mpvCommands.slice(-2), [['cycle', 'pause'], ['quit']]); + } finally { + testGlobals.restore(); + } +}); + test('keyboard mode: h moves left when popup is closed', async () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); @@ -817,6 +873,22 @@ test('keyboard mode: closing lookup clears yomitan active text source so same to } }); +test('subtitle refresh outside keyboard mode clears yomitan active text source', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + handlers.handleSubtitleContentUpdated(); + await wait(0); + + const clearCommands = testGlobals.commandEvents.filter( + (event) => event.type === 'clearActiveTextSource', + ); + assert.deepEqual(clearCommands, [{ type: 'clearActiveTextSource' }]); + } finally { + testGlobals.restore(); + } +}); + test('keyboard mode: lookup toggle closes popup when DOM visibility is the source of truth', async () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 0afb5ae..01c423e 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -15,6 +15,7 @@ export function createKeyboardHandlers( handleSubsyncKeydown: (e: KeyboardEvent) => boolean; handleKikuKeydown: (e: KeyboardEvent) => boolean; handleJimakuKeydown: (e: KeyboardEvent) => boolean; + handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean; handleControllerSelectKeydown: (e: KeyboardEvent) => boolean; handleControllerDebugKeydown: (e: KeyboardEvent) => boolean; handleSessionHelpKeydown: (e: KeyboardEvent) => boolean; @@ -479,6 +480,8 @@ export function createKeyboardHandlers( function handleSubtitleContentUpdated(): void { if (!ctx.state.keyboardDrivenModeEnabled) { + dispatchYomitanFrontendClearActiveTextSource(); + clearNativeSubtitleSelection(); return; } if (pendingSelectionAnchorAfterSubtitleSeek) { @@ -678,6 +681,11 @@ export function createKeyboardHandlers( ]); if (modifierOnlyCodes.has(e.code)) return false; + const keyString = keyEventToString(e); + if (ctx.state.keybindingsMap.has(keyString)) { + return false; + } + if (!e.ctrlKey && !e.metaKey && !e.altKey && e.code === 'KeyM') { if (e.repeat) return false; dispatchYomitanPopupMineSelected(); @@ -834,6 +842,11 @@ export function createKeyboardHandlers( options.handleJimakuKeydown(e); return; } + if (ctx.state.youtubePickerModalOpen) { + if (options.handleYoutubePickerKeydown(e)) { + return; + } + } if (ctx.state.controllerSelectModalOpen) { options.handleControllerSelectKeydown(e); return; @@ -871,8 +884,8 @@ export function createKeyboardHandlers( ) { if (handleYomitanPopupKeybind(e)) { e.preventDefault(); + return; } - return; } if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) { diff --git a/src/renderer/handlers/mouse.test.ts b/src/renderer/handlers/mouse.test.ts index 1966a08..f297200 100644 --- a/src/renderer/handlers/mouse.test.ts +++ b/src/renderer/handlers/mouse.test.ts @@ -3,7 +3,10 @@ import test from 'node:test'; import type { SubtitleSidebarConfig } from '../../types'; import { createMouseHandlers } from './mouse.js'; -import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js'; +import { + YOMITAN_POPUP_HIDDEN_EVENT, + YOMITAN_POPUP_SHOWN_EVENT, +} from '../yomitan-popup.js'; function createClassList() { const classes = new Set(); @@ -18,6 +21,22 @@ function createClassList() { classes.delete(token); } }, + toggle: (token: string, force?: boolean) => { + if (force === undefined) { + if (classes.has(token)) { + classes.delete(token); + return false; + } + classes.add(token); + return true; + } + if (force) { + classes.add(token); + return true; + } + classes.delete(token); + return false; + }, contains: (token: string) => classes.has(token), }; } @@ -314,6 +333,74 @@ test('subtitle leave restores passthrough while embedded sidebar is open but not } }); +test('restorePointerInteractionState reapplies the secondary hover class from pointer location', async () => { + const ctx = createMouseTestContext(); + ctx.platform.shouldToggleMouseIgnore = true; + + const documentListeners = new Map void>>(); + const originalDocument = (globalThis as { document?: unknown }).document; + const originalWindow = (globalThis as { window?: unknown }).window; + + const secondarySubContainer = ctx.dom.secondarySubContainer as unknown as object; + const overlay = ctx.dom.overlay as unknown as { classList: ReturnType }; + + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + addEventListener: (type: string, listener: (event: MouseEvent | PointerEvent) => void) => { + const listeners = documentListeners.get(type) ?? []; + listeners.push(listener); + documentListeners.set(type, listeners); + }, + elementFromPoint: () => secondarySubContainer, + }, + }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + setIgnoreMouseEvents: () => {}, + }, + innerHeight: 1000, + getSelection: () => ({ rangeCount: 0, isCollapsed: true }), + }, + }); + + try { + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => false, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: () => {}, + }); + + handlers.setupPointerTracking(); + await handlers.handleSecondaryMouseEnter({ + clientX: 10, + clientY: 20, + } as unknown as MouseEvent); + handlers.restorePointerInteractionState(); + + overlay.classList.add('interactive'); + const mousemove = documentListeners.get('mousemove')?.[0]; + assert.ok(mousemove); + mousemove?.({ clientX: 10, clientY: 20 } as MouseEvent); + + assert.equal(ctx.state.isOverSubtitle, true); + assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), true); + } finally { + Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument }); + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + } +}); + test('pending hover pause check is ignored when mouse leaves before pause state resolves', async () => { const ctx = createMouseTestContext(); const mpvCommands: Array<(string | number)[]> = []; @@ -612,3 +699,153 @@ test('popup open pauses and popup close resumes when yomitan popup auto-pause is Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode }); } }); + +test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => { + const ctx = createMouseTestContext(); + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; + const documentListeners = new Map void>>(); + ctx.platform.shouldToggleMouseIgnore = true; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + ignoreCalls.push({ ignore, forward: options?.forward }); + }, + }, + getComputedStyle: () => ({ + visibility: 'hidden', + display: 'none', + opacity: '0', + }), + focus: () => {}, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + addEventListener: (type: string, listener: (event: unknown) => void) => { + const bucket = documentListeners.get(type) ?? []; + bucket.push(listener); + documentListeners.set(type, bucket); + }, + elementFromPoint: () => ctx.dom.subtitleContainer, + querySelectorAll: () => [], + body: {}, + }, + }); + + try { + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => false, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: () => {}, + }); + + handlers.setupPointerTracking(); + for (const listener of documentListeners.get('mousemove') ?? []) { + listener({ clientX: 120, clientY: 240 }); + } + handlers.restorePointerInteractionState(); + + assert.equal(ctx.state.isOverSubtitle, true); + assert.equal(ctx.dom.overlay.classList.contains('interactive'), true); + assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument }); + } +}); + +test('restorePointerInteractionState keeps overlay interactive until first real pointer move can resync hover', () => { + const ctx = createMouseTestContext(); + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; + const documentListeners = new Map void>>(); + let hoveredElement: unknown = null; + ctx.platform.shouldToggleMouseIgnore = true; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + ignoreCalls.push({ ignore, forward: options?.forward }); + }, + }, + getComputedStyle: () => ({ + visibility: 'hidden', + display: 'none', + opacity: '0', + }), + focus: () => {}, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + addEventListener: (type: string, listener: (event: unknown) => void) => { + const bucket = documentListeners.get(type) ?? []; + bucket.push(listener); + documentListeners.set(type, bucket); + }, + elementFromPoint: () => hoveredElement, + querySelectorAll: () => [], + body: {}, + }, + }); + + try { + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => false, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: () => {}, + }); + + handlers.setupPointerTracking(); + handlers.restorePointerInteractionState(); + + assert.equal(ctx.state.isOverSubtitle, false); + assert.equal(ctx.dom.overlay.classList.contains('interactive'), true); + assert.deepEqual(ignoreCalls, [ + { ignore: true, forward: true }, + { ignore: false, forward: undefined }, + ]); + + hoveredElement = null; + for (const listener of documentListeners.get('mousemove') ?? []) { + listener({ clientX: 24, clientY: 48 }); + } + + assert.equal(ctx.state.isOverSubtitle, false); + assert.equal(ctx.dom.overlay.classList.contains('interactive'), false); + assert.deepEqual(ignoreCalls, [ + { ignore: true, forward: true }, + { ignore: false, forward: undefined }, + { ignore: true, forward: true }, + ]); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument }); + } +}); diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index 43462bf..b26ec33 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -25,6 +25,74 @@ export function createMouseHandlers( let popupPauseRequestId = 0; let pausedBySubtitleHover = false; let pausedByYomitanPopup = false; + let lastPointerPosition: { clientX: number; clientY: number } | null = null; + let pendingPointerResync = false; + + function isElementWithinContainer(element: Element | null, container: HTMLElement): boolean { + if (!element) { + return false; + } + if (element === container) { + return true; + } + return typeof container.contains === 'function' ? container.contains(element) : false; + } + + function updatePointerPosition(event: MouseEvent | PointerEvent): void { + lastPointerPosition = { + clientX: event.clientX, + clientY: event.clientY, + }; + } + + function syncHoverStateFromPoint(clientX: number, clientY: number): boolean { + const hoveredElement = + typeof document.elementFromPoint === 'function' + ? document.elementFromPoint(clientX, clientY) + : null; + const overPrimarySubtitle = isElementWithinContainer(hoveredElement, ctx.dom.subtitleContainer); + const overSecondarySubtitle = isElementWithinContainer( + hoveredElement, + ctx.dom.secondarySubContainer, + ); + + ctx.state.isOverSubtitle = overPrimarySubtitle || overSecondarySubtitle; + ctx.dom.secondarySubContainer.classList.toggle( + 'secondary-sub-hover-active', + overSecondarySubtitle, + ); + + return ctx.state.isOverSubtitle; + } + + function restorePointerInteractionState(): void { + const pointerPosition = lastPointerPosition; + pendingPointerResync = false; + if (pointerPosition) { + syncHoverStateFromPoint(pointerPosition.clientX, pointerPosition.clientY); + } else { + ctx.state.isOverSubtitle = false; + ctx.dom.secondarySubContainer.classList.remove('secondary-sub-hover-active'); + } + syncOverlayMouseIgnoreState(ctx); + + if (!ctx.platform.shouldToggleMouseIgnore || ctx.state.isOverSubtitle) { + return; + } + + pendingPointerResync = true; + ctx.dom.overlay.classList.add('interactive'); + window.electronAPI.setIgnoreMouseEvents(false); + } + + function maybeResyncPointerHoverState(event: MouseEvent | PointerEvent): void { + if (!pendingPointerResync) { + return; + } + pendingPointerResync = false; + syncHoverStateFromPoint(event.clientX, event.clientY); + syncOverlayMouseIgnoreState(ctx); + } function isWithinOtherSubtitleContainer( relatedTarget: EventTarget | null, @@ -222,6 +290,17 @@ export function createMouseHandlers( }); } + function setupPointerTracking(): void { + document.addEventListener('mousemove', (event: MouseEvent) => { + updatePointerPosition(event); + maybeResyncPointerHoverState(event); + }); + document.addEventListener('pointermove', (event: PointerEvent) => { + updatePointerPosition(event); + maybeResyncPointerHoverState(event); + }); + } + function setupSelectionObserver(): void { document.addEventListener('selectionchange', () => { const selection = window.getSelection(); @@ -283,7 +362,9 @@ export function createMouseHandlers( handleSecondaryMouseLeave: (event?: MouseEvent) => handleMouseLeave(event, true), handleMouseEnter, handleMouseLeave, + restorePointerInteractionState, setupDragging, + setupPointerTracking, setupResizeHandler, setupSelectionObserver, setupYomitanObserver, diff --git a/src/renderer/index.html b/src/renderer/index.html index 142512f..c16b8ed 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -85,6 +85,36 @@ +