Compare commits

..

25 Commits

Author SHA1 Message Date
b7e0026d48 docs: update youtube release notes and docs 2026-03-23 01:20:13 -07:00
207151dba3 fix: address latest CodeRabbit review items 2026-03-23 01:01:53 -07:00
c6e6aeebbe fix(ci): format stats media library hook 2026-03-23 00:40:41 -07:00
e9fc6bf8ec feat(stats): improve YouTube media metadata and picker key handling 2026-03-23 00:36:23 -07:00
2e43d95396 fix(immersion): special-case youtube media paths in runtime and tracking 2026-03-23 00:36:19 -07:00
3e7615b3bd docs(backlog): update task tracking notes 2026-03-23 00:36:17 -07:00
1f48ff000c fix(launcher): preserve user YouTube ytdl raw options 2026-03-23 00:34:39 -07:00
ba9bae63e4 test: address latest review feedback 2026-03-22 20:28:45 -07:00
415c758840 test: force x11 backend in launcher ci harness 2026-03-22 20:19:06 -07:00
ff72976bae test: stub launcher youtube deps in failing case 2026-03-22 20:17:07 -07:00
0def04b09c test: isolate launcher youtube flow deps 2026-03-22 20:14:53 -07:00
6bf148514e test: stub launcher youtube deps in CI 2026-03-22 20:12:59 -07:00
07b91f8704 style: format stats library files 2026-03-22 20:10:23 -07:00
d8a7ae77b0 fix: address latest review feedback 2026-03-22 20:09:16 -07:00
809b57af44 style: format stats library tab 2026-03-22 19:40:28 -07:00
ef716b82c7 fix: persist canonical title from youtube metadata 2026-03-22 19:40:12 -07:00
d65575c80d fix: address CodeRabbit review feedback 2026-03-22 19:37:49 -07:00
8da3a26855 fix(ci): add changelog fragment for immersion changes 2026-03-22 19:07:07 -07:00
8928bfdf7e chore: add shared log-file source for diagnostics 2026-03-22 18:38:58 -07:00
16f7b2507b feat: update subtitle sidebar overlay behavior 2026-03-22 18:38:56 -07:00
7d8d2ae7a7 refactor: unify cli and runtime wiring for startup and youtube flow 2026-03-22 18:38:54 -07:00
3fb33af116 docs: update docs for youtube subtitle and mining flow 2026-03-22 18:38:51 -07:00
8ddace5536 fix: unwrap mpv youtube streams for anki media mining 2026-03-22 18:34:38 -07:00
e7242d006f fix: align youtube playback with shared overlay startup 2026-03-22 18:34:25 -07:00
7666a094f4 fix: harden preload argv parsing for popup windows 2026-03-22 18:34:16 -07:00
131 changed files with 1291 additions and 5963 deletions

View File

@@ -1,49 +1,14 @@
# Changelog
## v0.9.2 (2026-03-25)
### Fixed
- Overlay: Fixed overlay pointer tracking so Windows click-through toggles immediately when the cursor enters or leaves subtitle regions, without waiting for a later hover resync.
- Overlay: Fixed Windows overlay window tracking on scaled displays by converting native tracked window bounds to Electron DIP coordinates before applying overlay bounds.
- Launcher: Fixed Windows direct `--youtube-play` startup so MPV boots reliably, stays paused until the app-owned subtitle flow is ready, and reuses an already-running SubMiner instance when available.
- Launcher: Fixed standalone Windows `--youtube-play` sessions so closing MPV fully exits SubMiner instead of leaving hidden overlay windows or a background process behind.
- Overlay: Fixed `subminer <youtube-url>` on Linux so the YouTube playback flow waits for Yomitan to load before creating the overlay window, avoiding the broken lookup popup state that previously required a manual overlay refresh.
## v0.9.1 (2026-03-24)
## v0.9.0 (2026-03-22)
### Changed
- Release: Reduced packaged release size by excluding duplicate `extraResources` payload and pruning docs, tests, sourcemaps, and other source-only files from Electron bundles.
### Fixed
- Overlay: Restored controller navigation and lookup/mining controls while the subtitle sidebar is open, while keeping true modal dialogs blocking controller actions.
- Tokenizer: Fixed subtitle annotation clearing so explanatory contrast endings like `んですけど` are excluded consistently across the shared tokenizer filter and annotation stage.
## 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.
- Subtitle Sidebar: Added subtitle sidebar state and behavior updates, including startup-auto-open controls and resume positioning improvements.
- Subtitle Sidebar: Fixed subtitle prefetch and embedded overlay passthrough sync between sidebar and overlay subtitle rendering.
- Launcher: Added an app-owned YouTube subtitle picker flow that boots mpv paused, opens an overlay track picker, and downloads selected subtitles into external files.
- Launcher: Added explicit `download` and `generate` YouTube subtitle modes with `download` as the default path.
- Launcher: Disabled mpv native YouTube subtitle auto-loading for the app-owned flow so external subtitle files stay authoritative.
- Launcher: Added OSD status messages for YouTube playback startup, subtitle acquisition, and subtitle loading so the flow stays visible before and during the picker.
- 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)

View File

@@ -68,7 +68,7 @@ Local stats dashboard — watch time, anime library, vocabulary growth, mining t
<table>
<tr>
<td><b>YouTube</b></td>
<td>Auto-loaded yt-dlp subtitle tracks at startup with a manual overlay picker on demand (<code>Ctrl+Alt+C</code>)</td>
<td>App-owned subtitle picker with downloaded/native track selection and local fallback generation</td>
</tr>
<tr>
<td><b>AniList</b></td>
@@ -222,7 +222,6 @@ 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
```
---

View File

@@ -1,26 +1,21 @@
---
id: TASK-218
title: Delete zero-session media from stats library and trends
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'
updated_date: '2026-03-22 21:10'
labels:
- stats
- immersion-tracker
priority: medium
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
- /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
---
## Description

View File

@@ -1,26 +1,22 @@
---
id: TASK-219
title: Restore streamed video progress in anime episodes
status: Done
title: 'Restore streamed video progress in anime episodes'
status: In Progress
assignee:
- codex
created_date: '2026-03-22 21:25'
updated_date: '2026-03-24 06:44'
updated_date: '2026-03-22 21:25'
labels:
- stats
- immersion-tracker
- youtube
priority: medium
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
- /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
---
## Description
@@ -31,13 +27,7 @@ Episode progress for streamed media can stay at `0%` because some remote session
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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
- [ ] #1 Anime episode progress ignores zero-valued session checkpoints and falls back to subtitle/event timing
- [ ] #2 New streamed sessions persist meaningful progress even when playback-position updates are missing or sparse
- [ ] #3 Regression tests cover the zero-checkpoint remote-session case
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,58 +0,0 @@
---
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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
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.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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`.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,56 +0,0 @@
---
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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
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.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,109 +0,0 @@
---
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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
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.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,61 +0,0 @@
---
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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
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.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,41 +0,0 @@
---
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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,42 +0,0 @@
---
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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,65 +0,0 @@
---
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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
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.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,64 +0,0 @@
---
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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
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.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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 pickers 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.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,55 +0,0 @@
---
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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
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.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Latest CodeRabbit comment targets youtube-track-picker.test.ts harness cleanup and correct restoration of global properties.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,57 +0,0 @@
---
id: TASK-231
title: Restore controller input while subtitle sidebar is open
status: Done
assignee:
- '@codex'
created_date: '2026-03-24 00:15'
updated_date: '2026-03-24 00:15'
labels:
- bug
- controller
- subtitle-sidebar
- overlay
dependencies: []
references:
- /home/sudacode/projects/japanese/SubMiner/src/renderer/renderer.ts
- /home/sudacode/projects/japanese/SubMiner/src/renderer/controller-interaction-blocking.ts
- /home/sudacode/projects/japanese/SubMiner/src/renderer/controller-interaction-blocking.test.ts
priority: high
ordinal: 54900
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
When keyboard-only mode is active, opening the subtitle sidebar should not disable controller navigation and lookup/mining controls. Restore controller input while the sidebar is open, while keeping true modal dialogs blocking controller actions.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Opening the subtitle sidebar does not block controller input for keyboard-only mode actions.
- [x] #2 Controller-select/debug and other true modal dialogs still block controller actions while open.
- [x] #3 Focused regression coverage exists for the sidebar-open controller gating rule.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Root cause: renderer gamepad polling used the broad `isAnyModalOpen()` check as its interaction gate, and that list includes `subtitleSidebarModalOpen`. The subtitle sidebar is non-modal for controller usage, so gamepad input was being suppressed whenever the sidebar was visible.
Fixed by extracting a dedicated controller-interaction blocking helper that excludes the subtitle sidebar but keeps the existing blocking behavior for true modal dialogs.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Restored controller input while the subtitle sidebar is open by switching gamepad polling to a dedicated modal-blocking rule that leaves the sidebar controller-passive. Added a regression test covering the sidebar-open exception and preserving hard blocks for actual modal dialogs.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,66 +0,0 @@
---
id: TASK-232
title: Trim release package size by pruning duplicate and source-only assets
status: Done
assignee:
- '@codex'
created_date: '2026-03-24 12:05'
updated_date: '2026-03-24 12:30'
labels:
- release
- packaging
priority: medium
ordinal: 54700
dependencies: []
references:
- /home/sudacode/projects/japanese/SubMiner/package.json
- /home/sudacode/projects/japanese/SubMiner/src/release-workflow.test.ts
- /home/sudacode/projects/japanese/SubMiner/src/core/services/texthooker.ts
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Reduce packaged release artifact size without changing user-visible functionality by pruning files that are duplicated between `app.asar` and `extraResources`, excluding source/test/doc-only trees from Electron packaging, and trimming obviously non-runtime vendored payload.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Electron packaging excludes repo content that is source-only, test-only, docs-only, or duplicated by `extraResources`.
- [x] #2 Release packaging tests cover the new exclusion rules.
- [x] #3 Verification includes at least targeted release-packaging tests and one packaging-oriented validation step.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Completed scope:
- Exclude `assets`, `plugin`, and `vendor/yomitan-jlpt-vocab` from `files` because they are already staged via `extraResources`.
- Exclude `dist` sourcemaps/tests, repo docs/tests/packaging metadata, and stats source leftovers from `files`.
- Exclude non-runtime `vendor/texthooker-ui` payload such as `public/`, `.vscode/`, and package metadata.
- Exclude Linux musl libsql binary from packaged app payload for AppImage-focused savings.
Verification:
- `bun test src/release-workflow.test.ts`
- `bun run build`
- `node_modules/.bin/electron-builder --linux dir --publish never`
- `node_modules/.bin/electron-builder --linux AppImage --publish never`
Observed result:
- `release/linux-unpacked/resources/app.asar` dropped from about `100 MB` to `29 MB`.
- `release/SubMiner-0.9.0.AppImage` dropped from about `256 MB` to `194 MB`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Trimmed Electron packaging so release artifacts no longer bundle duplicated `extraResources`, source/test/doc-only repo content, non-runtime `texthooker-ui` files, or the Linux musl libsql binary. Added release-packaging regression coverage and verified the Linux package shrink with fresh local builds.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,69 +0,0 @@
---
id: TASK-233
title: Cut patch release v0.9.1 for package size pruning
status: Done
assignee:
- '@codex'
created_date: '2026-03-24 12:40'
updated_date: '2026-03-24 12:55'
labels:
- release
- patch
dependencies:
- TASK-232
references:
- /home/sudacode/projects/japanese/SubMiner/package.json
- /home/sudacode/projects/japanese/SubMiner/CHANGELOG.md
- /home/sudacode/projects/japanese/SubMiner/release/release-notes.md
- /home/sudacode/projects/japanese/SubMiner/docs-site/changelog.md
priority: high
ordinal: 54800
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Publish a patch release for the packaging-size cleanup by bumping the app version to `0.9.1`, generating committed release metadata, and keeping release-facing docs/changelog surfaces aligned.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Repository version metadata is updated to `0.9.1`.
- [x] #2 `CHANGELOG.md`, `release/release-notes.md`, and `docs-site/changelog.md` contain the committed `v0.9.1` release line and the consumed fragment is removed.
- [x] #3 Release-readiness verification passes for changelog, docs, tests, and build lanes.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Completed:
- Bumped `package.json` to `0.9.1`.
- Ran `bun run changelog:build --version 0.9.1 --date 2026-03-24`, which generated `CHANGELOG.md` + `release/release-notes.md` and consumed both pending release fragments.
- Synced `docs-site/changelog.md` with the generated `v0.9.1` release line.
- Confirmed no additional README/docs wording changes were needed beyond changelog surfaces.
Verification:
- `bun run changelog:lint`
- `bun run changelog:check --version 0.9.1`
- `bun run verify:config-example`
- `bun run typecheck`
- `bun run test:fast`
- `bun run test:env`
- `bun run build`
- `bun run docs:test`
- `bun run docs:build`
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Prepared patch release `v0.9.1` locally. Version metadata, committed changelog artifacts, release notes, and docs-site changelog are aligned, and the release gate is green. Pending manual release actions are the release-prep commit, `git tag v0.9.1`, and push/tag publication.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,68 +0,0 @@
---
id: TASK-234
title: 'Address PR #35 latest CodeRabbit review round'
status: Done
assignee:
- codex
created_date: '2026-03-26 03:59'
updated_date: '2026-03-26 04:01'
labels:
- review-comments
- coderabbit
dependencies: []
references:
- /Users/sudacode/projects/japanese/SubMiner/src/main.ts
- /Users/sudacode/projects/japanese/SubMiner/src/cli/args.test.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/main/runtime/cli-command-prechecks.test.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/main/runtime/youtube-playback-launch.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Assess and implement the latest actionable CodeRabbit feedback on PR #35 for the Windows YouTube playback flow. Scope includes fixing the overlapping youtubePlay cleanup race in main runtime state and any low-risk follow-up test/clarity comments from the same review round.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Overlapping youtubePlay requests no longer let an older flow clear active quit-on-disconnect/app-owned-flow state for a newer flow.
- [x] #2 Latest low-risk CodeRabbit test and clarity follow-ups for this PR round are addressed or intentionally rejected based on code verification.
- [x] #3 Relevant tests covering the touched areas pass locally.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Update runYoutubePlaybackFlowMain in src/main.ts to use a per-request generation guard around shared YouTube flow state so overlapping requests cannot clear the active timer, armed flag, or app-owned-flow marker for a newer request.
2. Address verified low-risk latest-round follow-ups: add direct startup-prereq assertions in src/cli/args.test.ts, extend side-effect assertions in src/main/runtime/cli-command-prechecks.test.ts, and rename the youtube-playback-launch polling variable for clarity.
3. Run targeted Bun tests for the touched areas and record results in the task notes/final summary.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented per-request youtubePlaybackFlowGeneration guard in src/main.ts so superseded youtubePlay flows cannot clear the active arm timer, armed flag, or app-owned-flow state for a newer request.
Added explicit startup-prereq assertions in src/cli/args.test.ts and stronger warmup/log side-effect assertions in src/main/runtime/cli-command-prechecks.test.ts for the latest CodeRabbit follow-ups.
Renamed youtube-playback-launch polling variable from pathChanged to pathDiffersFromInitial for accuracy without behavior change.
Verification: bun test src/cli/args.test.ts; bun test src/main/runtime/cli-command-prechecks.test.ts; bun test src/main/runtime/youtube-playback-launch.test.ts; bun run typecheck.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Addressed the latest PR #35 CodeRabbit round by making YouTube playback flow cleanup generation-safe in src/main.ts. Overlapping youtubePlay requests now isolate timer/armed/app-owned-flow cleanup to the currently active request so an older flow cannot clear state for its replacement.
Also folded in the latest low-risk follow-ups: args tests now assert that youtube playback requires overlay startup prerequisites, cli-command precheck tests now assert warmup/log side effects for the youtube transition, and youtube-playback-launch.ts uses a clearer variable name for the initial-path comparison.
Verification:
- bun test src/cli/args.test.ts
- bun test src/main/runtime/cli-command-prechecks.test.ts
- bun test src/main/runtime/youtube-playback-launch.test.ts
- bun run typecheck
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,56 +0,0 @@
---
id: TASK-235
title: 'Address PR #35 autoplay retry CodeRabbit follow-up'
status: Done
assignee:
- codex
created_date: '2026-03-26 04:30'
updated_date: '2026-03-26 04:31'
labels:
- review-comments
- coderabbit
dependencies: []
references:
- /Users/sudacode/projects/japanese/SubMiner/src/main.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Assess and implement the latest CodeRabbit follow-up on PR #35 concerning stale autoplay-ready fallback retries interfering with a new app-owned YouTube playback flow in main.ts.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Starting a new app-owned YouTube playback flow invalidates any pending autoplay-ready fallback retries from older playback state before mpv prep begins.
- [x] #2 Relevant verification for the touched main.ts autoplay retry logic passes locally.
- [x] #3 Task notes/final summary capture the fix and verification.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a helper in src/main.ts that invalidates pending autoplay-ready fallback retry state by clearing the tracked media path and advancing the autoplay generation counter.
2. Invoke that helper at the start of runYoutubePlaybackFlowMain before app-owned YouTube playback takes over so stale retries cannot unpause reused playback.
3. Run relevant verification for the touched main.ts path and record results in the task notes/final summary.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Added invalidatePendingAutoplayReadyFallbacks() in src/main.ts to clear the tracked autoplay-ready media path and advance the autoplay generation before a new app-owned YouTube flow claims playback. This invalidates stale fallback retry closures even when the reused playback path is the same.
Verification: bun test src/main/runtime/mpv-main-event-actions.test.ts; bun test src/main/runtime/startup-autoplay-release-policy.test.ts; bun run typecheck.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Addressed the latest PR #35 CodeRabbit follow-up by invalidating pending autoplay-ready fallback retries before a new app-owned YouTube playback flow takes over in src/main.ts. The new helper clears the tracked autoplay media path and advances the autoplay generation counter, so retry closures from older playback state cannot later unpause the newly prepared flow when reusing the same media path.
Verification:
- bun test src/main/runtime/mpv-main-event-actions.test.ts
- bun test src/main/runtime/startup-autoplay-release-policy.test.ts
- bun run typecheck
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,5 @@
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.

View File

@@ -0,0 +1,7 @@
type: changed
area: launcher
- Added an app-owned YouTube subtitle flow that pauses mpv, lets the overlay picker choose tracks, and injects downloaded subtitle files before playback resumes.
- Added absPlayer-style YouTube timedtext parsing/conversion so downloaded subtitle tracks load as parsed cues for the sidebar, tokenization, and mining flows.
- Added yt-dlp metadata probing so YouTube playback and immersion tracking keep canonical video and channel metadata.
- Hardened the YouTube picker against duplicate submissions and tightened YouTube URL detection so follow-up runtime flows only treat real YouTube hosts as YouTube playback.

View File

@@ -0,0 +1,5 @@
type: changed
area: launcher
- Stopped forcing `--ytdl-raw-options=` before user-provided MPV options during YouTube playback so existing YouTube cookie integrations in user configs are no longer clobbered.
- Reordered launcher argument application so user `--args` are appended after SubMiners internal YouTube defaults, preserving explicit runtime overrides for `--ytdl-raw-options-*`.

View File

@@ -187,7 +187,7 @@
// ==========================================
// Secondary Subtitles
// Dual subtitle track options.
// Used by the YouTube subtitle loading flow as secondary language preferences.
// Used by subminer YouTube subtitle generation as secondary language preferences.
// Hot-reload: defaultMode updates live while SubMiner is running.
// ==========================================
"secondarySub": {
@@ -414,24 +414,24 @@
}, // Jimaku API configuration and defaults.
// ==========================================
// YouTube Playback Settings
// Defaults for SubMiner YouTube subtitle loading and languages.
// YouTube Subtitle Generation
// Defaults for SubMiner YouTube subtitle generation.
// ==========================================
"youtubeSubgen": {
"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
"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
"ai": {
"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.
"model": "", // Optional model override for YouTube subtitle AI post-processing.
"systemPrompt": "" // Optional system prompt override for YouTube subtitle AI post-processing.
}, // Ai setting.
"primarySubLanguages": [
"ja",
"jpn"
] // Comma-separated primary subtitle language priority used by the launcher.
}, // Defaults for SubMiner YouTube subtitle loading and languages.
}, // Defaults for SubMiner YouTube subtitle generation.
// ==========================================
// Anilist

View File

@@ -1,23 +1,11 @@
# Changelog
## v0.9.1 (2026-03-24)
- Reduced packaged release size by excluding duplicate `extraResources` payload and pruning docs, tests, sourcemaps, and other source-only files from Electron bundles.
- Restored controller navigation and lookup/mining controls while the subtitle sidebar is open, while keeping true modal dialogs blocking controller actions.
- Fixed subtitle annotation clearing so explanatory contrast endings like `んですけど` are excluded consistently across the shared tokenizer filter and annotation stage.
## 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.
## v0.9.0 (2026-03-22)
- Added an app-owned YouTube subtitle picker flow that boots mpv paused, opens an overlay picker, and downloads selected subtitles into external files before playback resumes.
- Added explicit launcher/app YouTube subtitle modes `download` and `generate`, with `download` as the default path.
- Disabled mpv native YouTube subtitle auto-loading for the app-owned flow so injected external subtitle files stay authoritative.
- Added OSD status updates covering YouTube playback startup, subtitle acquisition, and subtitle loading.
- 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.
- Improved sidebar startup/resume behavior and overlay/sidebar subtitle synchronization.
## 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.

View File

@@ -127,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 Playback Settings**](#youtube-playback-settings) - Defaults for YouTube subtitle loading
- [**YouTube Subtitle Generation**](#youtube-subtitle-generation) - Launcher defaults for yt-dlp + local whisper fallback
## Core Settings
@@ -469,7 +469,6 @@ 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 |
@@ -741,7 +740,7 @@ Palette controls:
### Shared AI Provider
Shared OpenAI-compatible transport settings live at the top level under `ai`.
Anki reads this provider directly. Legacy subtitle fallback keeps the same provider shape for compatibility, then applies feature-local overrides where supported.
Anki and YouTube subtitle cleanup both read this provider, then apply feature-local overrides where supported.
```json
{
@@ -765,10 +764,10 @@ Anki reads this provider directly. Legacy subtitle fallback keeps the same provi
| `systemPrompt` | string | Optional system prompt override for shared provider workflows |
| `requestTimeoutMs` | integer milliseconds | Shared request timeout (default: `15000`) |
SubMiner uses the shared provider for:
SubMiner uses the shared provider in two places:
- Anki translation/enrichment when `ankiConnect.ai.enabled` is `true`
- Legacy subtitle fallback compatibility when `youtubeSubgen.fixWithAi` is `true`
- YouTube whisper subtitle post-processing when `youtubeSubgen.fixWithAi` is `true`
### AnkiConnect
@@ -1326,13 +1325,22 @@ 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 Playback Settings
### YouTube Subtitle Generation
Set defaults used by the `subminer` launcher for YouTube subtitle loading:
Set defaults used by the `subminer` launcher for YouTube subtitle generation:
```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"]
}
}
@@ -1340,22 +1348,27 @@ Set defaults used by the `subminer` launcher for YouTube subtitle loading:
| Option | Values | Description |
| --------------------- | -------------------- | ---------------------------------------------------------------------------------------------- |
| `primarySubLanguages` | string[] | Primary subtitle language priority for YouTube auto-loading (default `["ja", "jpn"]`) |
| `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"]`) |
Current launcher behavior:
Launcher behavior:
- 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.
- 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.
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)
- Tracks are resolved and loaded before mpv starts; the older launcher mode switch has been removed.
- Subtitle files are generated or downloaded 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.

View File

@@ -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 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_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_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 |

View File

@@ -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('during startup while mpv is paused');
expect(usageContents).toContain('before mpv starts');
expect(installationContents).toContain('bun run build:appimage');
expect(installationContents).toContain('bun run build:win');

View File

@@ -51,8 +51,8 @@ features:
- icon:
src: /assets/video.svg
alt: Video playback icon
title: YouTube Playback
details: Play YouTube URLs or ytsearch targets directly — SubMiner automatically selects and loads subtitles for the video.
title: YouTube & Whisper
details: Play YouTube URLs or searches with native subtitles, or generate them with whisper.cpp and optional AI cleanup.
link: /usage#youtube-playback
linkText: YouTube playback
- icon:
@@ -72,10 +72,10 @@ features:
- icon:
src: /assets/tokenization.svg
alt: Tracking chart icon
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.
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.
link: /immersion-tracking
linkText: Dashboard & tracking
linkText: Stats details
- icon:
src: /assets/cross-platform.svg
alt: Cross-platform icon
@@ -120,7 +120,7 @@ const demoAssetVersion = '20260223-2';
<div class="workflow-step" style="animation-delay: 240ms">
<div class="step-number">05</div>
<div class="step-title">Track</div>
<div class="step-desc">Open the stats dashboard to review sessions, vocabulary trends, and mine cards from past viewing history.</div>
<div class="step-desc">Review immersion history and repeat high-value patterns over time.</div>
</div>
</div>
</section>

View File

@@ -58,30 +58,24 @@ 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 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 <path>` | 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 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 <path>` | Generate character dictionary ZIP from file/dir target |
| `subminer texthooker` | Launch texthooker-only mode |
| `subminer app` | Pass arguments directly to SubMiner binary |
Use `subminer <subcommand> -h` for command-specific help.

View File

@@ -187,7 +187,7 @@
// ==========================================
// Secondary Subtitles
// Dual subtitle track options.
// Used by the YouTube subtitle loading flow as secondary language preferences.
// Used by subminer YouTube subtitle generation as secondary language preferences.
// Hot-reload: defaultMode updates live while SubMiner is running.
// ==========================================
"secondarySub": {
@@ -414,24 +414,24 @@
}, // Jimaku API configuration and defaults.
// ==========================================
// YouTube Playback Settings
// Defaults for SubMiner YouTube subtitle loading and languages.
// YouTube Subtitle Generation
// Defaults for SubMiner YouTube subtitle generation.
// ==========================================
"youtubeSubgen": {
"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
"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
"ai": {
"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.
"model": "", // Optional model override for YouTube subtitle AI post-processing.
"systemPrompt": "" // Optional system prompt override for YouTube subtitle AI post-processing.
}, // Ai setting.
"primarySubLanguages": [
"ja",
"jpn"
] // Comma-separated primary subtitle language priority used by the launcher.
}, // Defaults for SubMiner YouTube subtitle loading and languages.
}, // Defaults for SubMiner YouTube subtitle generation.
// ==========================================
// Anilist

View File

@@ -67,7 +67,6 @@ 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` |

View File

@@ -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`)
- media sync and subtitle tooling (`alass`, `ffsubsync`, `whisper` fallback path)
- `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)
- favor the default lightweight YouTube subtitle startup settings on low-resource systems
- prefer YouTube `--mode automatic` over `preprocess` on low-resource systems
### Practical low-impact profile

View File

@@ -78,6 +78,8 @@ 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)
@@ -135,13 +137,14 @@ 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 <path>`: 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`).
- Subcommand help pages are available (for example `subminer jellyfin -h`, `subminer yt -h`).
### First-Run Setup
@@ -171,8 +174,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 workflows (`subminer jellyfin ...`).
Top-level launcher flags like `--jellyfin-*` are intentionally rejected.
Use subcommands for Jellyfin/YouTube command families (`subminer jellyfin ...`, `subminer yt ...`).
Top-level launcher flags like `--jellyfin-*` and `--yt-subgen-*` are intentionally rejected.
### MPV Profile Example (mpv.conf)
@@ -225,18 +228,27 @@ 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 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.
For YouTube playback, SubMiner now resolves subtitle tracks before mpv starts playback: it pauses at startup, opens an overlay subtitle picker, resolves the selected tracks, then resumes with the downloaded subtitle files attached.
Notes:
- Install `yt-dlp` so mpv can resolve YouTube streams and subtitle tracks reliably.
- 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.
- For YouTube URLs, the overlay picker lets you choose the primary and optional secondary subtitle tracks before playback resumes.
- For YouTube URLs, `subminer` generates only the missing tracks after probing YouTube's native/manual subtitle inventory.
- It probes manual/native YouTube subtitle tracks first, then falls back to local `whisper.cpp` only for missing tracks.
- Primary subtitle target languages come from `youtubeSubgen.primarySubLanguages` (defaults to `["ja","jpn"]`).
- Secondary target languages come from `secondarySub.secondarySubLanguages` (defaults to English if unset).
- Configure defaults in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`) under `youtubeSubgen` and `secondarySub`.
- 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`
## Controller Support

View File

@@ -51,7 +51,7 @@ export function runDoctorCommand(
ok: deps.commandExists('ffmpeg'),
detail: deps.commandExists('ffmpeg')
? 'found'
: 'missing (optional unless legacy subtitle fallback is enabled)',
: 'missing (optional unless subtitle generation)',
},
{
label: 'fzf',

View File

@@ -82,32 +82,16 @@ function createContext(): LauncherCommandContext {
};
}
test('youtube playback launches overlay with app-owned youtube flow args', async () => {
test('youtube playback launches overlay with youtube-play args in the primary app start', async () => {
const calls: string[] = [];
const context = createContext();
context.pluginRuntimeConfig = {
...context.pluginRuntimeConfig,
autoStart: false,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
};
let receivedStartMpvOptions: Record<string, unknown> | 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;
startMpv: async () => {
calls.push('startMpv');
},
waitForUnixSocketReady: async () => true,
@@ -126,8 +110,4 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
'startMpv',
'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw --youtube-mode download',
]);
assert.deepEqual(receivedStartMpvOptions, {
startPaused: true,
disableYoutubeSubtitleAutoLoad: true,
});
});

View File

@@ -10,6 +10,7 @@ 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/);

View File

@@ -111,6 +111,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
youtubeSubgenAudioFormat: process.env.SUBMINER_YT_SUBGEN_AUDIO_FORMAT || 'm4a',
youtubeSubgenKeepTemp: process.env.SUBMINER_YT_SUBGEN_KEEP_TEMP === '1',
youtubeFixWithAi: launcherConfig.fixWithAi === true,
youtubeMode: undefined,
jimakuApiKey: process.env.SUBMINER_JIMAKU_API_KEY || '',
jimakuApiKeyCommand: process.env.SUBMINER_JIMAKU_API_KEY_COMMAND || '',
jimakuApiBaseUrl: process.env.SUBMINER_JIMAKU_API_BASE_URL || DEFAULT_JIMAKU_API_BASE_URL,
@@ -249,6 +250,29 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
parsed.jellyfinLogout = Boolean(modeFlags.logout);
}
if (invocations.ytInvocation) {
if (invocations.ytInvocation.mode) {
parsed.youtubeMode = invocations.ytInvocation.mode;
}
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);
}

View File

@@ -14,6 +14,19 @@ export interface JellyfinInvocation {
logLevel?: string;
}
export interface YtInvocation {
target?: string;
mode?: 'download' | 'generate';
outDir?: string;
keepTemp?: boolean;
whisperBin?: string;
whisperModel?: string;
whisperVadModel?: string;
whisperThreads?: number;
ytSubgenAudioFormat?: string;
logLevel?: string;
}
export interface CommandActionInvocation {
action: string;
logLevel?: string;
@@ -21,6 +34,7 @@ export interface CommandActionInvocation {
export interface CliInvocations {
jellyfinInvocation: JellyfinInvocation | null;
ytInvocation: YtInvocation | null;
configInvocation: CommandActionInvocation | null;
mpvInvocation: CommandActionInvocation | null;
appInvocation: { appArgs: string[] } | null;
@@ -76,6 +90,8 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
const commandNames = new Set([
'jellyfin',
'jf',
'yt',
'youtube',
'doctor',
'config',
'mpv',
@@ -127,6 +143,7 @@ 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;
@@ -201,6 +218,43 @@ export function parseCliPrograms(
};
});
commandProgram
.command('yt')
.alias('youtube')
.description('YouTube workflows')
.argument('[target]', 'YouTube URL or ytsearch: query')
.option('--mode <mode>', 'YouTube subtitle acquisition mode')
.option('-o, --out-dir <dir>', 'Subtitle output dir')
.option('--keep-temp', 'Keep temp files')
.option('--whisper-bin <path>', 'whisper.cpp CLI path')
.option('--whisper-model <path>', 'whisper model path')
.option('--whisper-vad-model <path>', 'whisper.cpp VAD model path')
.option('--whisper-threads <n>', 'whisper.cpp thread count')
.option('--yt-subgen-audio-format <format>', 'Audio extraction format')
.option('--log-level <level>', 'Log level')
.action((target: string | undefined, options: Record<string, unknown>) => {
ytInvocation = {
target,
mode:
typeof options.mode === 'string' && (options.mode === 'download' || options.mode === 'generate')
? options.mode
: undefined,
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')
@@ -334,6 +388,7 @@ export function parseCliPrograms(
rootTarget: rootProgram.processedArgs[0],
invocations: {
jellyfinInvocation,
ytInvocation,
configInvocation,
mpvInvocation,
appInvocation,

View File

@@ -362,7 +362,7 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
});
});
test('launcher routes youtube urls through regular playback startup', { timeout: 15000 }, () => {
test('launcher disables plugin startup pause gate for app-owned youtube flow', { timeout: 15000 }, () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
@@ -430,16 +430,13 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
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);
const result = runLauncher(['yt', 'https://www.youtube.com/watch?v=abc123'], env);
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
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);
assert.match(
fs.readFileSync(mpvArgsPath, 'utf8'),
/--script-opts=.*subminer-auto_start_pause_until_ready=no/,
);
});
});

View File

@@ -560,25 +560,28 @@ export async function startMpv(
if (targetKind === 'url' && isYoutubeTarget(target)) {
log('info', args.logLevel, 'Applying URL playback options');
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}`,
'--ytdl-raw-options-append=write-subs=',
'--ytdl-raw-options-append=sub-format=vtt/best',
`--ytdl-raw-options-append=sub-langs=${subtitleLangs}`,
);
} else {
mpvArgs.push('--sub-auto=no');
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}`);
if (options?.disableYoutubeSubtitleAutoLoad !== true) {
mpvArgs.push(
'--sub-auto=fuzzy',
`--slang=${subtitleLangs}`,
'--ytdl-raw-options-append=write-subs=',
'--ytdl-raw-options-append=sub-format=vtt/best',
`--ytdl-raw-options-append=sub-langs=${subtitleLangs}`,
);
} else {
mpvArgs.push('--sub-auto=no');
}
}
}
if (args.mpvArgs) {

View File

@@ -85,6 +85,13 @@ test('parseArgs maps mpv idle action', () => {
assert.equal(parsed.mpvStatus, false);
});
test('parseArgs captures youtube mode forwarding', () => {
const parsed = parseArgs(['youtube', 'https://example.com', '--mode', 'generate'], 'subminer', {});
assert.equal(parsed.target, 'https://example.com');
assert.equal(parsed.youtubeMode, 'generate');
});
test('parseArgs maps dictionary command and log-level override', () => {
const parsed = parseArgs(['dictionary', '.', '--log-level', 'debug'], 'subminer', {});

View File

@@ -1,6 +1,6 @@
{
"name": "subminer",
"version": "0.9.2",
"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",
@@ -166,44 +166,20 @@
},
"files": [
"**/*",
"!assets{,/**/*}",
"!src{,/**/*}",
"!launcher{,/**/*}",
"!docs{,/**/*}",
"!tests{,/**/*}",
"!packaging{,/**/*}",
"!README.md",
"!CHANGELOG.md",
"!AGENTS.md",
"!CLAUDE.md",
"!stats/src{,/**/*}",
"!stats/index.html",
"!stats/public{,/**/*}",
"!stats/package.json",
"!stats/tsconfig.json",
"!stats/vite.config.ts",
"!docs-site{,/**/*}",
"!changes{,/**/*}",
"!backlog{,/**/*}",
"!.tmp{,/**/*}",
"!release-*{,/**/*}",
"!dist/**/*.map",
"!dist/**/*.test.*",
"!dist/**/__tests__{,/**/*}",
"!scripts/**/*.test.*",
"!plugin{,/**/*}",
"!vendor/subminer-yomitan{,/**/*}",
"!vendor/yomitan-jlpt-vocab{,/**/*}",
"!vendor/texthooker-ui/src{,/**/*}",
"!vendor/texthooker-ui/node_modules{,/**/*}",
"!vendor/texthooker-ui/.svelte-kit{,/**/*}",
"!vendor/texthooker-ui/.vscode{,/**/*}",
"!vendor/texthooker-ui/public{,/**/*}",
"!vendor/texthooker-ui/README.md",
"!vendor/texthooker-ui/package.json",
"!vendor/texthooker-ui/package-lock.json",
"!vendor/texthooker-ui/tsconfig*.json",
"!node_modules/@libsql/linux-x64-musl{,/**/*}"
"!vendor/texthooker-ui/package-lock.json"
],
"extraResources": [
{

View File

@@ -1,8 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
commandNeedsOverlayStartupPrereqs,
commandNeedsOverlayRuntime,
hasExplicitCommand,
isHeadlessInitialCommand,
parseArgs,
@@ -58,13 +56,8 @@ 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',
]);
test('parseArgs captures youtube playback commands and mode', () => {
const args = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc', '--youtube-mode', 'generate']);
assert.equal(args.youtubePlay, 'https://youtube.com/watch?v=abc');
assert.equal(args.youtubeMode, 'generate');
@@ -72,13 +65,6 @@ test('parseArgs captures youtube startup forwarding flags', () => {
assert.equal(shouldStartApp(args), true);
});
test('youtube playback does not use generic overlay-runtime bootstrap classification', () => {
const args = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']);
assert.equal(commandNeedsOverlayRuntime(args), false);
assert.equal(commandNeedsOverlayStartupPrereqs(args), true);
});
test('parseArgs handles jellyfin item listing controls', () => {
const args = parseArgs([
'--jellyfin-items',
@@ -150,9 +136,6 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(shouldStartApp(help), false);
assert.equal(shouldRunSettingsOnlyStartup(help), false);
const youtubePlay = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']);
assert.equal(commandNeedsOverlayStartupPrereqs(youtubePlay), true);
const anilistStatus = parseArgs(['--anilist-status']);
assert.equal(anilistStatus.anilistStatus, true);
assert.equal(hasExplicitCommand(anilistStatus), true);

View File

@@ -500,9 +500,6 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.triggerSubsync ||
args.markAudioCard ||
args.openRuntimeOptions
|| Boolean(args.youtubePlay)
);
}
export function commandNeedsOverlayStartupPrereqs(args: CliArgs): boolean {
return commandNeedsOverlayRuntime(args) || Boolean(args.youtubePlay);
}

View File

@@ -13,6 +13,8 @@ ${B}Session${R}
--background Start in tray/background mode
--start Connect to mpv and launch overlay
--launch-mpv ${D}[targets...]${R} Launch mpv with the SubMiner mpv profile and exit
--youtube-play ${D}URL${R} Open YouTube subtitle picker flow for a URL
--youtube-mode ${D}download|generate${R} Subtitle acquisition mode for YouTube flow
--stop Stop the running instance
--stats Open the stats dashboard in your browser
--texthooker Start texthooker server only ${D}(no overlay)${R}

View File

@@ -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 subtitles', () => {
test('accepts per-feature ai overrides for anki and youtube subtitle generation', () => {
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,? \/\/ Legacy subtitle fallback post-processing switch kept for compatibility; use is currently disabled by default\. Values: true \| false/,
/"fixWithAi": false,? \/\/ Use shared AI provider to post-process whisper-generated YouTube subtitles\. Values: true \| false/,
);
assert.match(
output,
/"systemPrompt": "",? \/\/ Optional system prompt override for legacy subtitle fallback post-processing; not used by default\./,
/"systemPrompt": "",? \/\/ Optional system prompt override for YouTube subtitle AI post-processing\./,
);
assert.doesNotMatch(output, /"mode": "automatic"/);
assert.match(
output,
/"whisperThreads": 4,? \/\/ Legacy thread tuning for subtitle fallback tooling; not used by default\./,
/"whisperThreads": 4,? \/\/ Thread count passed to whisper\.cpp subtitle generation runs\./,
);
assert.match(
output,

View File

@@ -77,7 +77,6 @@ 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', () => {

View File

@@ -369,47 +369,43 @@ export function buildIntegrationConfigOptionRegistry(
path: 'youtubeSubgen.whisperBin',
kind: 'string',
defaultValue: defaultConfig.youtubeSubgen.whisperBin,
description: 'Legacy compatibility path kept for external subtitle fallback tools; not used by default.',
description: 'Path to whisper.cpp CLI used as fallback transcription engine.',
},
{
path: 'youtubeSubgen.whisperModel',
kind: 'string',
defaultValue: defaultConfig.youtubeSubgen.whisperModel,
description: 'Legacy compatibility model path kept for external subtitle fallback tooling; not used by default.',
description: 'Path to whisper model used for fallback transcription.',
},
{
path: 'youtubeSubgen.whisperVadModel',
kind: 'string',
defaultValue: defaultConfig.youtubeSubgen.whisperVadModel,
description:
'Legacy compatibility VAD path kept for external subtitle fallback tooling; not used by default.',
description: 'Path to optional whisper VAD model used for subtitle generation.',
},
{
path: 'youtubeSubgen.whisperThreads',
kind: 'number',
defaultValue: defaultConfig.youtubeSubgen.whisperThreads,
description: 'Legacy thread tuning for subtitle fallback tooling; not used by default.',
description: 'Thread count passed to whisper.cpp subtitle generation runs.',
},
{
path: 'youtubeSubgen.fixWithAi',
kind: 'boolean',
defaultValue: defaultConfig.youtubeSubgen.fixWithAi,
description:
'Legacy subtitle fallback post-processing switch kept for compatibility; use is currently disabled by default.',
description: 'Use shared AI provider to post-process whisper-generated YouTube subtitles.',
},
{
path: 'youtubeSubgen.ai.model',
kind: 'string',
defaultValue: defaultConfig.youtubeSubgen.ai.model,
description:
'Optional model override for legacy subtitle fallback post-processing; not used by default.',
description: 'Optional model override for YouTube subtitle AI post-processing.',
},
{
path: 'youtubeSubgen.ai.systemPrompt',
kind: 'string',
defaultValue: defaultConfig.youtubeSubgen.ai.systemPrompt,
description:
'Optional system prompt override for legacy subtitle fallback post-processing; not used by default.',
description: 'Optional system prompt override for YouTube subtitle AI post-processing.',
},
{
path: 'youtubeSubgen.primarySubLanguages',

View File

@@ -46,7 +46,6 @@ 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<ResolvedConfig['keybindings']> = [
@@ -65,7 +64,6 @@ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
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'] },

View File

@@ -74,7 +74,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
title: 'Secondary Subtitles',
description: [
'Dual subtitle track options.',
'Used by the YouTube subtitle loading flow as secondary language preferences.',
'Used by subminer YouTube subtitle generation 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 Playback Settings',
description: ['Defaults for SubMiner YouTube subtitle loading and languages.'],
title: 'YouTube Subtitle Generation',
description: ['Defaults for SubMiner YouTube subtitle generation.'],
key: 'youtubeSubgen',
},
{

View File

@@ -186,8 +186,8 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
runJellyfinCommand: async () => {
calls.push('runJellyfinCommand');
},
runYoutubePlaybackFlow: async (request) => {
calls.push(`runYoutubePlaybackFlow:${request.url}:${request.mode}:${request.source}`);
runYoutubePlaybackFlow: async ({ url, mode }) => {
calls.push(`runYoutubePlaybackFlow:${url}:${mode}`);
},
printHelp: () => {
calls.push('printHelp');
@@ -212,69 +212,6 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
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, ['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, ['youtube:https://youtube.com/watch?v=abc:download']);
});
test('handleCliCommand reuses initialized overlay runtime for second-instance youtube playback', () => {
const { deps, calls } = createDeps({
isOverlayRuntimeInitialized: () => true,
runYoutubePlaybackFlow: async (request) => {
calls.push(`youtube:${request.url}:${request.mode}:${request.source}`);
},
});
handleCliCommand(
makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'download' }),
'second-instance',
deps,
);
assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:download:second-instance']);
});
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,
@@ -294,6 +231,40 @@ test('handleCliCommand reconnects MPV for second-instance --start when overlay r
);
});
test('handleCliCommand starts youtube playback flow on initial launch', () => {
const { deps, calls } = createDeps({
runYoutubePlaybackFlow: async (request) => {
calls.push(`youtube:${request.url}:${request.mode}`);
},
});
handleCliCommand(
makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'generate' }),
'initial',
deps,
);
assert.deepEqual(calls, [
'initializeOverlayRuntime',
'youtube:https://youtube.com/watch?v=abc:generate',
]);
});
test('handleCliCommand defaults youtube mode to download when omitted', () => {
const { deps, calls } = createDeps({
runYoutubePlaybackFlow: async (request) => {
calls.push(`youtube:${request.url}:${request.mode}`);
},
});
handleCliCommand(makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc' }), 'initial', deps);
assert.deepEqual(calls, [
'initializeOverlayRuntime',
'youtube:https://youtube.com/watch?v=abc:download',
]);
});
test('handleCliCommand processes --start for second-instance when overlay runtime is not initialized', () => {
const { deps, calls } = createDeps();
const args = makeArgs({ start: true });

View File

@@ -15,7 +15,6 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
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',
},
triggerSubsyncFromConfig: () => {
calls.push('subsync');
@@ -23,9 +22,6 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
openRuntimeOptionsPalette: () => {
calls.push('runtime-options');
},
openYoutubeTrackPicker: () => {
calls.push('youtube-picker');
},
runtimeOptionsCycle: () => ({ ok: true }),
showMpvOsd: (text) => {
osd.push(text);
@@ -102,14 +98,6 @@ 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,

View File

@@ -14,11 +14,9 @@ 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<void>;
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void;
mpvReplaySubtitle: () => void;
@@ -92,11 +90,6 @@ 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

View File

@@ -200,44 +200,6 @@ 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 = {

View File

@@ -4,7 +4,6 @@ import { WindowGeometry } from '../../types';
export function updateVisibleOverlayVisibility(args: {
visibleOverlayVisible: boolean;
modalActive?: boolean;
forceMousePassthrough?: boolean;
mainWindow: BrowserWindow | null;
windowTracker: BaseWindowTracker | null;
@@ -29,12 +28,6 @@ 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) {

View File

@@ -1,41 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
test('normalizeOverlayWindowBoundsForPlatform returns original geometry outside Windows', () => {
const geometry = { x: 150, y: 90, width: 1200, height: 675 };
assert.deepEqual(normalizeOverlayWindowBoundsForPlatform(geometry, 'linux', null), geometry);
});
test('normalizeOverlayWindowBoundsForPlatform returns original geometry on Windows when screen is unavailable', () => {
const geometry = { x: 150, y: 90, width: 1200, height: 675 };
assert.deepEqual(normalizeOverlayWindowBoundsForPlatform(geometry, 'win32', null), geometry);
});
test('normalizeOverlayWindowBoundsForPlatform converts Windows physical pixels to DIP', () => {
assert.deepEqual(
normalizeOverlayWindowBoundsForPlatform(
{
x: 150,
y: 75,
width: 1920,
height: 1080,
},
'win32',
{
screenToDipRect: (_window, rect) => ({
x: Math.round(rect.x / 1.5),
y: Math.round(rect.y / 1.5),
width: Math.round(rect.width / 1.5),
height: Math.round(rect.height / 1.5),
}),
},
),
{
x: 100,
y: 50,
width: 1280,
height: 720,
},
);
});

View File

@@ -1,25 +0,0 @@
import type { WindowGeometry } from '../../types';
type ScreenDipConverter = {
screenToDipRect: (
window: Electron.BrowserWindow | null,
rect: Electron.Rectangle,
) => Electron.Rectangle;
};
export function normalizeOverlayWindowBoundsForPlatform(
geometry: WindowGeometry,
platform: NodeJS.Platform,
screen: ScreenDipConverter | null,
): WindowGeometry {
if (platform !== 'win32' || !screen) {
return geometry;
}
return screen.screenToDipRect(null, {
x: geometry.x,
y: geometry.y,
width: geometry.width,
height: geometry.height,
});
}

View File

@@ -1,4 +1,4 @@
import { BrowserWindow, screen, type Session } from 'electron';
import { BrowserWindow, type Session } from 'electron';
import * as path from 'path';
import { WindowGeometry } from '../../types';
import { createLogger } from '../../logger';
@@ -8,7 +8,6 @@ import {
type OverlayWindowKind,
} from './overlay-window-input';
import { buildOverlayWindowOptions } from './overlay-window-options';
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
const logger = createLogger('main:overlay-window');
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
@@ -34,7 +33,12 @@ export function updateOverlayWindowBounds(
window: BrowserWindow | null,
): void {
if (!geometry || !window || window.isDestroyed()) return;
window.setBounds(normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen));
window.setBounds({
x: geometry.x,
y: geometry.y,
width: geometry.width,
height: geometry.height,
});
}
export function ensureOverlayWindowLevel(window: BrowserWindow): void {

View File

@@ -3893,172 +3893,6 @@ test('tokenizeSubtitle keeps frequency for content-led merged token with trailin
assert.equal(result.tokens?.[0]?.frequencyRank, 5468);
});
test('tokenizeSubtitle clears all annotations for explanatory contrast endings', async () => {
const result = await tokenizeSubtitle(
'最近辛いものが続いとるんですけど',
makeDepsFromYomitanTokens(
[
{ surface: '最近', reading: 'さいきん', headword: '最近' },
{ surface: '辛い', reading: 'つらい', headword: '辛い' },
{ surface: 'もの', reading: 'もの', headword: 'もの' },
{ surface: 'が', reading: 'が', headword: 'が' },
{ surface: '続いとる', reading: 'つづいとる', headword: '続く' },
{ surface: 'んですけど', reading: 'んですけど', headword: 'ん' },
],
{
getFrequencyDictionaryEnabled: () => true,
getFrequencyRank: (text) =>
text === '最近' ? 120 : text === '辛い' ? 800 : text === '続く' ? 240 : 77,
getJlptLevel: (text) =>
text === '最近' ? 'N4' : text === '辛い' ? 'N2' : text === '続く' ? 'N4' : null,
isKnownWord: (text) => text === '最近',
getMinSentenceWordsForNPlusOne: () => 1,
tokenizeWithMecab: async () => [
{
headword: '最近',
surface: '最近',
reading: 'サイキン',
startPos: 0,
endPos: 2,
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞',
pos2: '副詞可能',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: '辛い',
surface: '辛い',
reading: 'ツライ',
startPos: 2,
endPos: 4,
partOfSpeech: PartOfSpeech.i_adjective,
pos1: '形容詞',
pos2: '自立',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: 'もの',
surface: 'もの',
reading: 'モノ',
startPos: 4,
endPos: 6,
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞',
pos2: '一般',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: 'が',
surface: 'が',
reading: 'ガ',
startPos: 6,
endPos: 7,
partOfSpeech: PartOfSpeech.particle,
pos1: '助詞',
pos2: '格助詞',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: '続く',
surface: '続いとる',
reading: 'ツヅイトル',
startPos: 7,
endPos: 11,
partOfSpeech: PartOfSpeech.verb,
pos1: '動詞',
pos2: '自立',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: 'ん',
surface: 'んですけど',
reading: 'ンデスケド',
startPos: 11,
endPos: 16,
partOfSpeech: PartOfSpeech.other,
pos1: '名詞|助動詞|助詞',
pos2: '非自立',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
],
},
),
);
assert.deepEqual(
result.tokens?.map((token) => ({
surface: token.surface,
headword: token.headword,
isKnown: token.isKnown,
isNPlusOneTarget: token.isNPlusOneTarget,
frequencyRank: token.frequencyRank,
jlptLevel: token.jlptLevel,
})),
[
{
surface: '最近',
headword: '最近',
isKnown: true,
isNPlusOneTarget: false,
frequencyRank: 120,
jlptLevel: 'N4',
},
{
surface: '辛い',
headword: '辛い',
isKnown: false,
isNPlusOneTarget: false,
frequencyRank: 800,
jlptLevel: 'N2',
},
{
surface: 'もの',
headword: 'もの',
isKnown: false,
isNPlusOneTarget: false,
frequencyRank: 77,
jlptLevel: undefined,
},
{
surface: 'が',
headword: 'が',
isKnown: false,
isNPlusOneTarget: false,
frequencyRank: undefined,
jlptLevel: undefined,
},
{
surface: '続いとる',
headword: '続く',
isKnown: false,
isNPlusOneTarget: false,
frequencyRank: 240,
jlptLevel: 'N4',
},
{
surface: 'んですけど',
headword: 'ん',
isKnown: false,
isNPlusOneTarget: false,
frequencyRank: undefined,
jlptLevel: undefined,
},
],
);
});
test('tokenizeSubtitle excludes default non-independent pos2 from N+1 when JLPT/frequency are disabled', async () => {
let mecabCalls = 0;
const result = await tokenizeSubtitle(

View File

@@ -246,18 +246,6 @@ test('shouldExcludeTokenFromSubtitleAnnotations excludes explanatory pondering e
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
});
test('shouldExcludeTokenFromSubtitleAnnotations excludes explanatory contrast endings', () => {
const token = makeToken({
surface: 'んですけど',
headword: 'ん',
reading: 'ンデスケド',
pos1: '名詞|助動詞|助詞',
pos2: '非自立',
});
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
});
test('shouldExcludeTokenFromSubtitleAnnotations excludes auxiliary-stem そうだ grammar tails', () => {
const token = makeToken({
surface: 'そうだ',

View File

@@ -46,7 +46,6 @@ const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_TRAILING_PARTICLES = [
'ね',
'よ',
'な',
'けど',
'よね',
'かな',
'かね',

View File

@@ -41,7 +41,6 @@ const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_TRAILING_PARTICLES = [
'ね',
'よ',
'な',
'けど',
'よね',
'かな',
'かね',

View File

@@ -1,10 +1,16 @@
import type { YoutubeFlowMode } from '../../../types';
import type { YoutubeTrackOption } from './track-probe';
import { downloadYoutubeSubtitleTrack, downloadYoutubeSubtitleTracks } from './track-download';
export function isYoutubeGenerationMode(mode: YoutubeFlowMode): boolean {
return mode === 'generate';
}
export async function acquireYoutubeSubtitleTrack(input: {
targetUrl: string;
outputDir: string;
track: YoutubeTrackOption;
mode: YoutubeFlowMode;
}): Promise<{ path: string }> {
return await downloadYoutubeSubtitleTrack(input);
}
@@ -13,6 +19,7 @@ export async function acquireYoutubeSubtitleTracks(input: {
targetUrl: string;
outputDir: string;
tracks: YoutubeTrackOption[];
mode: YoutubeFlowMode;
}): Promise<Map<string, string>> {
return await downloadYoutubeSubtitleTracks(input);
}

View File

@@ -26,18 +26,6 @@ process.stdout.write(${JSON.stringify(payload)});
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<T>(payload: string, fn: () => Promise<T>): Promise<T> {
return await withTempDir(async (root) => {
const binDir = path.join(root, 'bin');
@@ -53,37 +41,9 @@ async function withFakeYtDlp<T>(payload: string, fn: () => Promise<T>): Promise<
});
}
async function withHangingFakeYtDlp<T>(fn: () => Promise<T>): Promise<T> {
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/,
);
});
},
);

View File

@@ -1,8 +1,6 @@
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;
@@ -23,19 +21,11 @@ type YtDlpYoutubeMetadata = {
description?: string;
};
function runCapture(
command: string,
args: string[],
timeoutMs = YOUTUBE_METADATA_PROBE_TIMEOUT_MS,
): Promise<{ stdout: string; stderr: string }> {
function runCapture(command: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
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) => {
@@ -44,12 +34,8 @@ function runCapture(
proc.stderr.on('data', (chunk) => {
stderr += String(chunk);
});
proc.once('error', (error) => {
clearTimeout(timer);
reject(error);
});
proc.once('error', reject);
proc.once('close', (code) => {
clearTimeout(timer);
if (code === 0) {
resolve({ stdout, stderr });
return;

View File

@@ -1,76 +0,0 @@
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 { resolveYoutubePlaybackUrl } from './playback-resolve';
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-playback-resolve-'));
try {
return await fn(dir);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
function makeFakeYtDlpScript(dir: string, payload: string): void {
const scriptPath = path.join(dir, 'yt-dlp');
const script = `#!/usr/bin/env node
process.stdout.write(${JSON.stringify(payload)});
`;
fs.writeFileSync(scriptPath, script, 'utf8');
if (process.platform !== 'win32') {
fs.chmodSync(scriptPath, 0o755);
}
fs.writeFileSync(scriptPath + '.cmd', `@echo off\r\nnode "${scriptPath}"\r\n`, 'utf8');
}
async function withFakeYtDlp<T>(payload: string, fn: () => Promise<T>): Promise<T> {
return await withTempDir(async (root) => {
const binDir = path.join(root, 'bin');
fs.mkdirSync(binDir, { recursive: true });
makeFakeYtDlpScript(binDir, payload);
const fakeCommandPath =
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
process.env.SUBMINER_YTDLP_BIN = fakeCommandPath;
try {
return await fn();
} finally {
if (originalCommand === undefined) {
delete process.env.SUBMINER_YTDLP_BIN;
} else {
process.env.SUBMINER_YTDLP_BIN = originalCommand;
}
}
});
}
test('resolveYoutubePlaybackUrl returns the first playable URL line', async () => {
await withFakeYtDlp(
'\nhttps://manifest.googlevideo.com/api/manifest/hls_playlist/test\nhttps://ignored.example/video\n',
async () => {
const result = await resolveYoutubePlaybackUrl('https://www.youtube.com/watch?v=abc123');
assert.equal(result, 'https://manifest.googlevideo.com/api/manifest/hls_playlist/test');
},
);
});
test('resolveYoutubePlaybackUrl rejects when yt-dlp returns no URL', async () => {
await withFakeYtDlp('\n', async () => {
await assert.rejects(
resolveYoutubePlaybackUrl('https://www.youtube.com/watch?v=abc123'),
/returned empty output/,
);
});
});
test('resolveYoutubePlaybackUrl rejects when yt-dlp output exceeds capture limit', async () => {
await withFakeYtDlp(`${'x'.repeat(1024 * 1024 + 1)}\n`, async () => {
await assert.rejects(
resolveYoutubePlaybackUrl('https://www.youtube.com/watch?v=abc123'),
/exceeded 1048576 bytes/,
);
});
});

View File

@@ -1,108 +0,0 @@
import { spawn } from 'node:child_process';
const YOUTUBE_PLAYBACK_RESOLVE_TIMEOUT_MS = 15_000;
const DEFAULT_PLAYBACK_FORMAT = 'b';
const MAX_CAPTURE_BYTES = 1024 * 1024;
function terminateCaptureProcess(proc: ReturnType<typeof spawn>): void {
if (proc.killed) {
return;
}
try {
proc.kill('SIGKILL');
} catch {
proc.kill();
}
}
function runCapture(
command: string,
args: string[],
timeoutMs = YOUTUBE_PLAYBACK_RESOLVE_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 = '';
let settled = false;
const cleanup = (): void => {
clearTimeout(timer);
proc.stdout.removeAllListeners('data');
proc.stderr.removeAllListeners('data');
proc.removeAllListeners('error');
proc.removeAllListeners('close');
};
const rejectOnce = (error: Error): void => {
if (settled) return;
settled = true;
cleanup();
reject(error);
};
const resolveOnce = (result: { stdout: string; stderr: string }): void => {
if (settled) return;
settled = true;
cleanup();
resolve(result);
};
const appendChunk = (
current: string,
chunk: unknown,
streamName: 'stdout' | 'stderr',
): string => {
const next = current + String(chunk);
if (Buffer.byteLength(next, 'utf8') > MAX_CAPTURE_BYTES) {
terminateCaptureProcess(proc);
rejectOnce(new Error(`yt-dlp ${streamName} exceeded ${MAX_CAPTURE_BYTES} bytes`));
}
return next;
};
const timer = setTimeout(() => {
terminateCaptureProcess(proc);
rejectOnce(new Error(`yt-dlp timed out after ${timeoutMs}ms`));
}, timeoutMs);
proc.stdout.setEncoding('utf8');
proc.stderr.setEncoding('utf8');
proc.stdout.on('data', (chunk) => {
stdout = appendChunk(stdout, chunk, 'stdout');
});
proc.stderr.on('data', (chunk) => {
stderr = appendChunk(stderr, chunk, 'stderr');
});
proc.once('error', (error) => {
rejectOnce(error);
});
proc.once('close', (code) => {
if (settled) {
return;
}
if (code === 0) {
resolveOnce({ stdout, stderr });
return;
}
rejectOnce(new Error(stderr.trim() || `yt-dlp exited with status ${code ?? 'unknown'}`));
});
});
}
export async function resolveYoutubePlaybackUrl(
targetUrl: string,
format = DEFAULT_PLAYBACK_FORMAT,
): Promise<string> {
const ytDlpCommand = process.env.SUBMINER_YTDLP_BIN?.trim() || 'yt-dlp';
const { stdout } = await runCapture(ytDlpCommand, [
'--get-url',
'--no-warnings',
'-f',
format,
targetUrl,
]);
const playbackUrl =
stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0) ?? '';
if (!playbackUrl) {
throw new Error('yt-dlp returned empty output while resolving YouTube playback URL');
}
return playbackUrl;
}

View File

@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { convertYoutubeTimedTextToVtt, normalizeYoutubeAutoVtt } from './timedtext';
import { convertYoutubeTimedTextToVtt } from './timedtext';
test('convertYoutubeTimedTextToVtt leaves malformed numeric entities literal', () => {
const result = convertYoutubeTimedTextToVtt(
@@ -38,38 +38,3 @@ test('convertYoutubeTimedTextToVtt does not swallow text after zero-length overl
].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'),
);
});

View File

@@ -115,52 +115,3 @@ export function convertYoutubeTimedTextToVtt(xml: string): string {
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`;
}

View File

@@ -174,6 +174,7 @@ test('downloadYoutubeSubtitleTrack prefers subtitle files over later webp artifa
kind: 'auto',
label: 'Japanese (auto)',
},
mode: 'download',
});
assert.equal(path.extname(result.path), '.vtt');
@@ -203,6 +204,7 @@ test('downloadYoutubeSubtitleTrack ignores stale subtitle files from prior runs'
kind: 'auto',
label: 'Japanese (auto)',
},
mode: 'download',
}),
/No subtitle file was downloaded/,
);
@@ -231,6 +233,7 @@ test('downloadYoutubeSubtitleTrack uses auto subtitle flags and raw source langu
kind: 'auto',
label: 'Japanese (auto)',
},
mode: 'download',
});
assert.equal(path.extname(result.path), '.vtt');
@@ -261,6 +264,7 @@ test('downloadYoutubeSubtitleTrack keeps manual subtitle flag for manual tracks'
kind: 'manual',
label: 'Japanese (manual)',
},
mode: 'download',
});
assert.equal(path.extname(result.path), '.vtt');
@@ -269,43 +273,6 @@ test('downloadYoutubeSubtitleTrack keeps manual subtitle flag for manual tracks'
});
});
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(
@@ -326,6 +293,7 @@ test('downloadYoutubeSubtitleTrack prefers direct download URL when available',
downloadUrl: 'https://example.com/subs/ja.vtt',
fileExtension: 'vtt',
},
mode: 'download',
});
assert.equal(path.basename(result.path), 'auto-ja-orig.ja-orig.vtt');
@@ -352,6 +320,7 @@ test('downloadYoutubeSubtitleTrack sanitizes metadata source language in filenam
downloadUrl: 'https://example.com/subs/ja.vtt',
fileExtension: 'vtt',
},
mode: 'download',
});
assert.equal(path.dirname(result.path), path.join(root, 'out'));
@@ -390,6 +359,7 @@ test('downloadYoutubeSubtitleTrack converts srv3 auto subtitles into regular vtt
downloadUrl: 'https://example.com/subs/ja.srv3',
fileExtension: 'srv3',
},
mode: 'download',
});
assert.equal(path.basename(result.path), 'auto-ja-orig.ja-orig.vtt');
@@ -440,6 +410,7 @@ test('downloadYoutubeSubtitleTracks downloads primary and secondary in one invoc
label: 'English (auto)',
},
],
mode: 'download',
});
assert.match(path.basename(result.get('auto:ja-orig') ?? ''), /\.ja-orig\.vtt$/);
@@ -473,6 +444,7 @@ test('downloadYoutubeSubtitleTracks preserves successfully downloaded primary fi
label: 'English (auto)',
},
],
mode: 'download',
});
assert.match(path.basename(result.get('auto:ja-orig') ?? ''), /\.ja-orig\.vtt$/);
@@ -512,6 +484,7 @@ test('downloadYoutubeSubtitleTracks prefers direct download URLs when available'
fileExtension: 'vtt',
},
],
mode: 'download',
});
assert.deepEqual(seen, [
@@ -557,6 +530,7 @@ test('downloadYoutubeSubtitleTracks keeps duplicate source-language direct downl
fileExtension: 'vtt',
},
],
mode: 'download',
});
assert.deepEqual(seen, [

View File

@@ -1,12 +1,9 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import type { YoutubeFlowMode } from '../../../types';
import type { YoutubeTrackOption } from './track-probe';
import {
convertYoutubeTimedTextToVtt,
isYoutubeTimedTextExtension,
normalizeYoutubeAutoVtt,
} from './timedtext';
import { convertYoutubeTimedTextToVtt, isYoutubeTimedTextExtension } from './timedtext';
const YOUTUBE_SUBTITLE_EXTENSIONS = new Set(['.srt', '.vtt', '.ass']);
const YOUTUBE_BATCH_PREFIX = 'youtube-batch';
@@ -174,11 +171,7 @@ async function downloadSubtitleFromUrl(input: {
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;
const normalizedBody = isYoutubeTimedTextExtension(ext) ? convertYoutubeTimedTextToVtt(body) : body;
fs.writeFileSync(targetPath, normalizedBody, 'utf8');
return { path: targetPath };
}
@@ -192,21 +185,11 @@ function canDownloadSubtitleFromUrl(track: YoutubeTrackOption): boolean {
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;
mode: YoutubeFlowMode;
}): Promise<{ path: string }> {
fs.mkdirSync(input.outputDir, { recursive: true });
const prefix = input.track.id.replace(/[^a-z0-9_-]+/gi, '-');
@@ -232,7 +215,7 @@ export async function downloadYoutubeSubtitleTrack(input: {
targetUrl: input.targetUrl,
outputTemplate,
sourceLanguages: [input.track.sourceLanguage],
includeAutoSubs: input.track.kind === 'auto',
includeAutoSubs: input.mode === 'generate' || input.track.kind === 'auto',
includeManualSubs: input.track.kind === 'manual',
}),
];
@@ -242,7 +225,6 @@ export async function downloadYoutubeSubtitleTrack(input: {
if (!subtitlePath) {
throw new Error(`No subtitle file was downloaded for ${input.track.sourceLanguage}`);
}
normalizeDownloadedAutoSubtitle(subtitlePath, input.track);
return { path: subtitlePath };
}
@@ -250,6 +232,7 @@ export async function downloadYoutubeSubtitleTracks(input: {
targetUrl: string;
outputDir: string;
tracks: YoutubeTrackOption[];
mode: YoutubeFlowMode;
}): Promise<Map<string, string>> {
fs.mkdirSync(input.outputDir, { recursive: true });
const hasDuplicateSourceLanguages =
@@ -277,7 +260,8 @@ export async function downloadYoutubeSubtitleTracks(input: {
}
const outputTemplate = path.join(input.outputDir, `${YOUTUBE_BATCH_PREFIX}.%(ext)s`);
const includeAutoSubs = input.tracks.some((track) => track.kind === 'auto');
const includeAutoSubs =
input.mode === 'generate' || input.tracks.some((track) => track.kind === 'auto');
const includeManualSubs = input.tracks.some((track) => track.kind === 'manual');
const result = await runCaptureDetailed(
@@ -299,7 +283,6 @@ export async function downloadYoutubeSubtitleTracks(input: {
track.sourceLanguage,
);
if (subtitlePath) {
normalizeDownloadedAutoSubtitle(subtitlePath, track);
results.set(track.id, subtitlePath);
}
}

View File

@@ -105,7 +105,6 @@ import { createLogger, setLogLevel, type LogLevelSource } from './logger';
import { resolveDefaultLogFilePath } from './logger';
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
import {
commandNeedsOverlayStartupPrereqs,
commandNeedsOverlayRuntime,
isHeadlessInitialCommand,
parseArgs,
@@ -316,7 +315,6 @@ import {
acquireYoutubeSubtitleTrack,
acquireYoutubeSubtitleTracks,
} from './core/services/youtube/generate';
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
import { retimeYoutubeSubtitle } from './core/services/youtube/retime';
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
import { startStatsServer } from './core/services/stats-server';
@@ -326,10 +324,6 @@ import {
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,
@@ -348,9 +342,6 @@ import {
resolveWindowsMpvShortcutPaths,
} from './main/runtime/windows-mpv-shortcuts';
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch';
import { shouldEnsureTrayOnStartupForInitialArgs } from './main/runtime/startup-tray-policy';
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
import {
@@ -403,7 +394,6 @@ 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 {
@@ -501,19 +491,12 @@ let anilistUpdateInFlightState = createInitialAnilistUpdateInFlightState();
const anilistAttemptedUpdateKeys = new Set<string>();
let anilistCachedAccessToken: string | null = null;
let jellyfinPlayQuitOnDisconnectArmed = false;
let youtubePlayQuitOnDisconnectArmed = false;
let youtubePlayQuitOnDisconnectArmTimer: ReturnType<typeof setTimeout> | null = null;
let youtubePlaybackFlowGeneration = 0;
const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US';
const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000;
const DISCORD_PRESENCE_APP_ID = '1475264834730856619';
const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000;
const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000;
const YOUTUBE_MPV_CONNECT_TIMEOUT_MS = 3000;
const YOUTUBE_MPV_AUTO_LAUNCH_TIMEOUT_MS = 10000;
const YOUTUBE_MPV_YTDL_FORMAT = 'bestvideo*+bestaudio/best';
const YOUTUBE_DIRECT_PLAYBACK_FORMAT = 'b';
const MPV_JELLYFIN_DEFAULT_ARGS = [
'--sub-auto=fuzzy',
'--sub-file-paths=.;subs;subtitles',
@@ -771,7 +754,6 @@ 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;
@@ -788,7 +770,6 @@ const handleModalInputStateChange = (isActive: boolean): void => {
}
}
syncOverlayShortcutsForModal(isActive);
syncOverlayVisibilityForModal();
};
const buildOverlayContentMeasurementStoreMainDepsHandler =
@@ -818,6 +799,9 @@ const appState = createAppState({
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
});
const startBackgroundWarmupsIfAllowed = (): void => {
if (appState.youtubePlaybackFlowPending) {
return;
}
startBackgroundWarmups();
};
const youtubeFlowRuntime = createYoutubeFlowRuntime({
@@ -836,15 +820,27 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
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,
const preferDedicatedModalWindow = false;
const sendPickerOpen = (preferModalWindow: boolean): boolean =>
overlayModalRuntime.sendToActiveOverlayWindow('youtube:picker-open', payload, {
restoreOnModalClose: 'youtube-track-picker',
preferModalWindow,
});
if (!sendPickerOpen(preferDedicatedModalWindow)) {
return false;
}
if (await overlayModalRuntime.waitForModalOpen('youtube-track-picker', 1500)) {
return true;
}
logger.warn(
'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying on visible overlay.',
);
if (!sendPickerOpen(!preferDedicatedModalWindow)) {
return false;
}
return await overlayModalRuntime.waitForModalOpen('youtube-track-picker', 1500);
},
pauseMpv: () => {
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'yes']);
@@ -863,9 +859,6 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
refreshCurrentSubtitle: (text: string) => {
subtitleProcessingController.refreshCurrentSubtitle(text);
},
refreshSubtitleSidebarSource: async (sourcePath: string) => {
await refreshSubtitleSidebarFromSource(sourcePath);
},
startTokenizationWarmups: async () => {
await startTokenizationWarmups();
},
@@ -896,30 +889,14 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
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;
if (tracker && tracker.isTracking() && tracker.getGeometry()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
logger.warn(
'Timed out waiting for tracked playback window focus/media readiness before opening YouTube subtitle picker.',
);
logger.warn('Timed out waiting for tracked playback window before opening YouTube subtitle picker.');
},
waitForOverlayGeometryReady: async () => {
const deadline = Date.now() + 4000;
@@ -947,142 +924,60 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
}
},
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'),
});
const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({
requestPath: async () => {
const client = appState.mpvClient;
if (!client) return null;
const value = await client.requestProperty('path').catch(() => null);
return typeof value === 'string' ? value : null;
},
requestProperty: async (name) => {
const client = appState.mpvClient;
if (!client) return null;
return await client.requestProperty(name);
},
sendMpvCommand: (command) => {
sendMpvCommandRuntime(appState.mpvClient, command);
},
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
});
const waitForYoutubeMpvConnected = createWaitForMpvConnectedHandler({
getMpvClient: () => appState.mpvClient,
now: () => Date.now(),
sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
});
function clearYoutubePlayQuitOnDisconnectArmTimer(): void {
if (youtubePlayQuitOnDisconnectArmTimer) {
clearTimeout(youtubePlayQuitOnDisconnectArmTimer);
youtubePlayQuitOnDisconnectArmTimer = null;
}
}
function invalidatePendingAutoplayReadyFallbacks(): void {
autoPlayReadySignalMediaPath = null;
autoPlayReadySignalGeneration += 1;
}
async function runYoutubePlaybackFlowMain(request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
mode: 'download' | 'generate';
source: CliCommandSource;
}): Promise<void> {
const flowGeneration = ++youtubePlaybackFlowGeneration;
invalidatePendingAutoplayReadyFallbacks();
youtubePrimarySubtitleNotificationRuntime.setAppOwnedFlowInFlight(true);
let flowCompleted = false;
try {
clearYoutubePlayQuitOnDisconnectArmTimer();
youtubePlayQuitOnDisconnectArmed = false;
await ensureYoutubePlaybackRuntimeReady();
let playbackUrl = request.url;
let launchedWindowsMpv = false;
if (process.platform === 'win32') {
try {
playbackUrl = await resolveYoutubePlaybackUrl(request.url, YOUTUBE_DIRECT_PLAYBACK_FORMAT);
logger.info('Resolved direct YouTube playback URL for Windows MPV startup.');
} catch (error) {
logger.warn(
`Failed to resolve direct YouTube playback URL; falling back to page URL: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
if (process.platform === 'win32' && !appState.mpvClient?.connected) {
const launchResult = launchWindowsMpv(
[playbackUrl],
createWindowsMpvLaunchDeps({
showError: (title, content) => dialog.showErrorBox(title, content),
}),
[
'--pause=yes',
'--ytdl=yes',
`--ytdl-format=${YOUTUBE_MPV_YTDL_FORMAT}`,
'--sub-auto=no',
'--sub-file-paths=.;subs;subtitles',
'--sid=auto',
'--secondary-sid=auto',
'--secondary-sub-visibility=no',
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
`--log-file=${DEFAULT_MPV_LOG_PATH}`,
`--input-ipc-server=${appState.mpvSocketPath}`,
],
);
launchedWindowsMpv = launchResult.ok;
if (launchResult.ok) {
logger.info(`Bootstrapping Windows mpv for YouTube playback via ${launchResult.mpvPath}`);
}
if (!launchResult.ok) {
logger.warn('Unable to bootstrap Windows mpv for YouTube playback.');
}
}
const connected = await waitForYoutubeMpvConnected(
launchedWindowsMpv ? YOUTUBE_MPV_AUTO_LAUNCH_TIMEOUT_MS : YOUTUBE_MPV_CONNECT_TIMEOUT_MS,
const wasYoutubePlaybackFlowPending = appState.youtubePlaybackFlowPending;
appState.youtubePlaybackFlowPending = true;
if (process.platform === 'win32' && !appState.mpvClient?.connected) {
const launchResult = launchWindowsMpv(
[request.url],
createWindowsMpvLaunchDeps({
showError: (title, content) => dialog.showErrorBox(title, content),
}),
[
'--pause=yes',
'--sub-auto=no',
'--sid=no',
'--secondary-sid=no',
'--script-opts=subminer-auto_start_pause_until_ready=no',
`--input-ipc-server=${appState.mpvSocketPath}`,
],
);
if (!connected) {
throw new Error(
launchedWindowsMpv
? 'MPV not connected after auto-launch. Ensure mpv is installed and can open the requested YouTube URL.'
: 'MPV not connected. Start mpv with the SubMiner profile or retry after mpv finishes starting.',
);
}
if (request.source === 'initial') {
youtubePlayQuitOnDisconnectArmTimer = setTimeout(() => {
if (youtubePlaybackFlowGeneration !== flowGeneration) {
return;
}
youtubePlayQuitOnDisconnectArmed = true;
youtubePlayQuitOnDisconnectArmTimer = null;
}, 3000);
}
const mediaReady = await prepareYoutubePlaybackInMpv({ url: playbackUrl });
if (!mediaReady) {
throw new Error('Timed out waiting for mpv to load the requested YouTube URL.');
if (!launchResult.ok) {
logger.warn('Unable to bootstrap Windows mpv for YouTube playback.');
}
}
if (!appState.mpvClient?.connected) {
appState.mpvClient?.connect();
}
await ensureOverlayRuntimeReady();
try {
await youtubeFlowRuntime.runYoutubePlaybackFlow({
url: request.url,
mode: request.mode,
});
flowCompleted = true;
logger.info(`YouTube playback flow completed from ${request.source}.`);
} finally {
if (youtubePlaybackFlowGeneration === flowGeneration) {
if (!flowCompleted) {
clearYoutubePlayQuitOnDisconnectArmTimer();
youtubePlayQuitOnDisconnectArmed = false;
}
youtubePrimarySubtitleNotificationRuntime.setAppOwnedFlowInFlight(false);
appState.youtubePlaybackFlowPending = wasYoutubePlaybackFlowPending;
if (!wasYoutubePlaybackFlowPending) {
startBackgroundWarmupsIfAllowed();
}
}
}
async function ensureOverlayRuntimeReady(): Promise<void> {
await ensureYomitanExtensionLoaded();
initializeOverlayRuntime();
}
let firstRunSetupMessage: string | null = null;
const resolveWindowsMpvShortcutRuntimePaths = () =>
resolveWindowsMpvShortcutPaths({
@@ -1336,12 +1231,6 @@ 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(
@@ -1350,42 +1239,11 @@ function isYoutubePlaybackActiveNow(): boolean {
);
}
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<void> {
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,
options?: { forceWhilePaused?: boolean },
): void {
if (youtubePrimarySubtitleNotificationRuntime.isAppOwnedFlowInFlight()) {
logger.debug('[autoplay-ready] suppressed while app-owned YouTube flow is active');
if (appState.youtubePlaybackFlowPending) {
return;
}
if (!payload.text.trim()) {
@@ -1558,18 +1416,6 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
},
});
async function refreshSubtitleSidebarFromSource(sourcePath: string): Promise<void> {
const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim());
if (!normalizedSourcePath) {
return;
}
await subtitlePrefetchInitController.initSubtitlePrefetch(
normalizedSourcePath,
lastObservedTimePos,
normalizedSourcePath,
);
}
async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> {
const client = appState.mpvClient;
if (!client?.connected) {
@@ -2014,7 +1860,6 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
createBuildOverlayVisibilityRuntimeMainDepsHandler({
getMainWindow: () => overlayManager.getMainWindow(),
getModalActive: () => overlayModalInputExclusive,
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getForceMousePassthrough: () => appState.statsOverlayVisible,
getWindowTracker: () => appState.windowTracker,
@@ -2076,9 +1921,6 @@ const buildRestorePreviousSecondarySubVisibilityMainDepsHandler =
createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler({
getMpvClient: () => appState.mpvClient,
});
syncOverlayVisibilityForModal = () => {
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
};
const restorePreviousSecondarySubVisibilityMainDeps =
buildRestorePreviousSecondarySubVisibilityMainDepsHandler();
const restorePreviousSecondarySubVisibilityHandler =
@@ -2936,10 +2778,6 @@ const {
annotationSubtitleWsService.stop();
},
stopTexthookerService: () => texthookerService.stop(),
getMainOverlayWindow: () => overlayManager.getMainWindow(),
clearMainOverlayWindow: () => overlayManager.setMainWindow(null),
getModalOverlayWindow: () => overlayManager.getModalWindow(),
clearModalOverlayWindow: () => overlayManager.setModalWindow(null),
getYomitanParserWindow: () => appState.yomitanParserWindow,
clearYomitanParserState: () => {
appState.yomitanParserWindow = null;
@@ -3539,11 +3377,43 @@ void initializeDiscordPresenceService();
const handleCliCommand = createCliCommandRuntimeHandler({
handleTexthookerOnlyModeTransitionMainDeps: {
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
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();
}
},
setTexthookerOnlyMode: (enabled) => {
appState.texthookerOnlyMode = enabled;
},
commandNeedsOverlayStartupPrereqs: (inputArgs) => commandNeedsOverlayStartupPrereqs(inputArgs),
commandNeedsOverlayRuntime: (inputArgs) => commandNeedsOverlayRuntime(inputArgs),
startBackgroundWarmups: () => startBackgroundWarmups(),
logInfo: (message: string) => logger.info(message),
},
@@ -3552,65 +3422,15 @@ 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();
}
}
async function ensureYoutubePlaybackRuntimeReady(): Promise<void> {
ensureOverlayStartupPrereqs();
await ensureYomitanExtensionLoaded();
if (!appState.overlayRuntimeInitialized) {
initializeOverlayRuntime();
return;
}
ensureOverlayWindowsReadyForVisibilityActions();
}
const handleInitialArgsRuntimeHandler = createInitialArgsRuntimeHandler({
getInitialArgs: () => appState.initialArgs,
isBackgroundMode: () => appState.backgroundMode,
shouldEnsureTrayOnStartup: () =>
shouldEnsureTrayOnStartupForInitialArgs(process.platform, appState.initialArgs),
shouldEnsureTrayOnStartup: () => process.platform === 'win32',
shouldRunHeadlessInitialCommand: (args) => isHeadlessInitialCommand(args),
ensureTray: () => ensureTray(),
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
hasImmersionTracker: () => Boolean(appState.immersionTracker),
getMpvClient: () => appState.mpvClient,
commandNeedsOverlayStartupPrereqs: (args) => commandNeedsOverlayStartupPrereqs(args),
commandNeedsOverlayRuntime: (args) => commandNeedsOverlayRuntime(args),
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
initializeOverlayRuntime: () => initializeOverlayRuntime(),
logInfo: (message) => logger.info(message),
handleCliCommand: (args, source) => handleCliCommand(args, source),
});
@@ -3635,8 +3455,7 @@ const {
>({
bindMpvMainEventHandlersMainDeps: {
appState,
getQuitOnDisconnectArmed: () =>
jellyfinPlayQuitOnDisconnectArmed || youtubePlayQuitOnDisconnectArmed,
getQuitOnDisconnectArmed: () => jellyfinPlayQuitOnDisconnectArmed,
scheduleQuitCheck: (callback) => {
setTimeout(callback, 500);
},
@@ -3671,7 +3490,6 @@ const {
startupOsdSequencer.reset();
clearScheduledSubtitlePrefetchRefresh();
subtitlePrefetchInitController.cancelPendingInit();
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
if (path) {
ensureImmersionTrackerStarted();
// Delay slightly to allow MPV's track-list to be populated.
@@ -3699,6 +3517,9 @@ const {
immersionMediaRuntime.syncFromCurrentMediaState();
},
signalAutoplayReadyIfWarm: () => {
if (appState.youtubePlaybackFlowPending) {
return;
}
if (!isTokenizationWarmupReady()) {
return;
}
@@ -3729,13 +3550,11 @@ const {
}
lastObservedTimePos = time;
},
onSubtitleTrackChange: (sid) => {
onSubtitleTrackChange: () => {
scheduleSubtitlePrefetchRefresh();
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid);
},
onSubtitleTrackListChange: (trackList) => {
onSubtitleTrackListChange: () => {
scheduleSubtitlePrefetchRefresh();
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList);
},
updateSubtitleRenderMetrics: (patch) => {
updateMpvSubtitleRenderMetrics(patch as Partial<MpvSubtitleRenderMetrics>);
@@ -4097,6 +3916,21 @@ function destroyTray(): void {
function initializeOverlayRuntime(): void {
initializeOverlayRuntimeHandler();
initializeOverlayAnkiIntegrationCore({
getResolvedConfig: () => getResolvedConfig(),
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
getMpvClient: () => appState.mpvClient,
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
getAnkiIntegration: () => appState.ankiIntegration,
setAnkiIntegration: (integration) => {
appState.ankiIntegration = integration as AnkiIntegration | null;
},
showDesktopNotification,
createFieldGroupingCallback: () => createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
shouldStartAnkiIntegration: () =>
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
});
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
syncOverlayMpvSubtitleSuppression();
}
@@ -4553,7 +4387,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
mpvCommandMainDeps: {
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
cycleRuntimeOption: (id, direction) => {
if (!appState.runtimeOptionsManager) {
return { ok: false, error: 'Runtime options manager unavailable' };
@@ -4979,7 +4812,10 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
appState.overlayRuntimeInitialized = initialized;
},
startBackgroundWarmups: () => {
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
if (
(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) ||
appState.youtubePlaybackFlowPending
) {
return;
}
startBackgroundWarmups();

View File

@@ -1,5 +1,6 @@
import { handleCliCommand, createCliCommandDepsRuntime } from '../core/services';
import type { CliArgs, CliCommandSource } from '../cli/args';
import type { YoutubeFlowMode } from '../types';
import {
createCliCommandRuntimeServiceDeps,
CliCommandRuntimeServiceDepsParams,
@@ -38,7 +39,11 @@ export interface CliCommandRuntimeServiceContext {
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand'];
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
runYoutubePlaybackFlow: (request: {
url: string;
mode: YoutubeFlowMode;
source: CliCommandSource;
}) => Promise<void>;
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;

View File

@@ -191,7 +191,6 @@ export interface MpvCommandRuntimeServiceDepsParams {
runtimeOptionsCycle: HandleMpvCommandFromIpcOptions['runtimeOptionsCycle'];
triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig'];
openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette'];
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
@@ -355,7 +354,6 @@ export function createMpvCommandRuntimeServiceDeps(
specialCommands: params.specialCommands,
triggerSubsyncFromConfig: params.triggerSubsyncFromConfig,
openRuntimeOptionsPalette: params.openRuntimeOptionsPalette,
openYoutubeTrackPicker: params.openYoutubeTrackPicker,
runtimeOptionsCycle: params.runtimeOptionsCycle,
showMpvOsd: params.showMpvOsd,
mpvReplaySubtitle: params.mpvReplaySubtitle,

View File

@@ -12,7 +12,6 @@ type MpvPropertyClientLike = {
export interface MpvCommandFromIpcRuntimeDeps {
triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void;
openYoutubeTrackPicker: () => void | Promise<void>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void;
replayCurrentSubtitle: () => void;
@@ -34,7 +33,6 @@ export function handleMpvCommandFromIpcRuntime(
specialCommands: SPECIAL_COMMANDS,
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
openYoutubeTrackPicker: deps.openYoutubeTrackPicker,
runtimeOptionsCycle: deps.cycleRuntimeOption,
showMpvOsd: deps.showMpvOsd,
mpvReplaySubtitle: deps.replayCurrentSubtitle,

View File

@@ -8,7 +8,6 @@ const OVERLAY_LOADING_OSD_COOLDOWN_MS = 30_000;
export interface OverlayVisibilityRuntimeDeps {
getMainWindow: () => BrowserWindow | null;
getModalActive: () => boolean;
getVisibleOverlayVisible: () => boolean;
getForceMousePassthrough: () => boolean;
getWindowTracker: () => BaseWindowTracker | null;
@@ -38,7 +37,6 @@ export function createOverlayVisibilityRuntimeService(
updateVisibleOverlayVisibility(): void {
updateVisibleOverlayVisibility({
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
modalActive: deps.getModalActive(),
forceMousePassthrough: deps.getForceMousePassthrough(),
mainWindow: deps.getMainWindow(),
windowTracker: deps.getWindowTracker(),

View File

@@ -16,8 +16,6 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
destroyMainOverlayWindow: () => calls.push('destroy-main-overlay-window'),
destroyModalOverlayWindow: () => calls.push('destroy-modal-overlay-window'),
destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'),
clearYomitanParserState: () => calls.push('clear-yomitan-state'),
stopWindowTracker: () => calls.push('stop-tracker'),
@@ -40,7 +38,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
});
cleanup();
assert.equal(calls.length, 28);
assert.equal(calls.length, 26);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));

View File

@@ -6,8 +6,6 @@ export function createOnWillQuitCleanupHandler(deps: {
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
destroyMainOverlayWindow: () => void;
destroyModalOverlayWindow: () => void;
destroyYomitanParserWindow: () => void;
clearYomitanParserState: () => void;
stopWindowTracker: () => void;
@@ -36,8 +34,6 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket();
deps.stopTexthookerService();
deps.destroyMainOverlayWindow();
deps.destroyModalOverlayWindow();
deps.destroyYomitanParserWindow();
deps.clearYomitanParserState();
deps.stopWindowTracker();

View File

@@ -18,16 +18,6 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'),
getMainOverlayWindow: () => ({
isDestroyed: () => false,
destroy: () => calls.push('destroy-main-overlay-window'),
}),
clearMainOverlayWindow: () => calls.push('clear-main-overlay-window'),
getModalOverlayWindow: () => ({
isDestroyed: () => false,
destroy: () => calls.push('destroy-modal-overlay-window'),
}),
clearModalOverlayWindow: () => calls.push('clear-modal-overlay-window'),
getYomitanParserWindow: () => ({
isDestroyed: () => false,
@@ -71,10 +61,6 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
cleanup();
assert.ok(calls.includes('destroy-tray'));
assert.ok(calls.includes('destroy-main-overlay-window'));
assert.ok(calls.includes('clear-main-overlay-window'));
assert.ok(calls.includes('destroy-modal-overlay-window'));
assert.ok(calls.includes('clear-modal-overlay-window'));
assert.ok(calls.includes('destroy-yomitan-window'));
assert.ok(calls.includes('flush-mpv-log'));
assert.ok(calls.includes('destroy-socket'));
@@ -99,16 +85,6 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
getMainOverlayWindow: () => ({
isDestroyed: () => true,
destroy: () => calls.push('destroy-main-overlay-window'),
}),
clearMainOverlayWindow: () => calls.push('clear-main-overlay-window'),
getModalOverlayWindow: () => ({
isDestroyed: () => true,
destroy: () => calls.push('destroy-modal-overlay-window'),
}),
clearModalOverlayWindow: () => calls.push('clear-modal-overlay-window'),
getYomitanParserWindow: () => ({
isDestroyed: () => true,
destroy: () => calls.push('destroy-yomitan-window'),

View File

@@ -25,10 +25,6 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
getMainOverlayWindow: () => DestroyableWindow | null;
clearMainOverlayWindow: () => void;
getModalOverlayWindow: () => DestroyableWindow | null;
clearModalOverlayWindow: () => void;
getYomitanParserWindow: () => DestroyableWindow | null;
clearYomitanParserState: () => void;
@@ -64,20 +60,6 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
stopTexthookerService: () => deps.stopTexthookerService(),
destroyMainOverlayWindow: () => {
const window = deps.getMainOverlayWindow();
if (!window) return;
if (window.isDestroyed()) return;
window.destroy();
deps.clearMainOverlayWindow();
},
destroyModalOverlayWindow: () => {
const window = deps.getModalOverlayWindow();
if (!window) return;
if (window.isDestroyed()) return;
window.destroy();
deps.clearModalOverlayWindow();
},
destroyYomitanParserWindow: () => {
const window = deps.getYomitanParserWindow();
if (!window) return;

View File

@@ -61,7 +61,7 @@ test('build cli command context deps maps handlers and values', () => {
calls.push('run-jellyfin');
},
runYoutubePlaybackFlow: async () => {
calls.push('run-youtube-playback');
calls.push('run-youtube');
},
openYomitanSettings: () => calls.push('yomitan'),
cycleSecondarySubMode: () => calls.push('cycle-secondary'),

View File

@@ -9,6 +9,7 @@ test('cli command context factory composes main deps and context handlers', () =
mpvClient: null,
texthookerPort: 5174,
overlayRuntimeInitialized: false,
youtubePlaybackFlowPending: false,
};
const createContext = createCliCommandContextFactory({

View File

@@ -9,6 +9,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
mpvClient: null,
texthookerPort: 5174,
overlayRuntimeInitialized: false,
youtubePlaybackFlowPending: false,
};
const build = createBuildCliCommandContextMainDepsHandler({
@@ -85,8 +86,9 @@ test('cli command context main deps builder maps state and callbacks', async ()
calls.push('run-jellyfin');
},
runYoutubePlaybackFlow: async () => {
calls.push('run-youtube-playback');
calls.push('run-youtube');
},
openYomitanSettings: () => calls.push('open-yomitan'),
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),

View File

@@ -1,4 +1,5 @@
import type { CliArgs } from '../../cli/args';
import type { YoutubeFlowMode } from '../../types';
import type { CliCommandContextFactoryDeps } from './cli-command-context';
type CliCommandContextMainState = {
@@ -41,7 +42,11 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
runYoutubePlaybackFlow: (request: {
url: string;
mode: YoutubeFlowMode;
source: 'initial' | 'second-instance';
}) => Promise<void>;
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;

View File

@@ -1,4 +1,5 @@
import type { CliArgs } from '../../cli/args';
import type { YoutubeFlowMode } from '../../types';
import type {
CliCommandRuntimeServiceContext,
CliCommandRuntimeServiceContextHandlers,
@@ -41,7 +42,11 @@ export type CliCommandContextFactoryDeps = {
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
runYoutubePlaybackFlow: (request: {
url: string;
mode: YoutubeFlowMode;
source: 'initial' | 'second-instance';
}) => Promise<void>;
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;

View File

@@ -7,14 +7,14 @@ test('cli prechecks main deps builder maps transition handlers', () => {
const deps = createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler({
isTexthookerOnlyMode: () => true,
setTexthookerOnlyMode: (enabled) => calls.push(`set:${enabled}`),
commandNeedsOverlayStartupPrereqs: () => true,
commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
startBackgroundWarmups: () => calls.push('warmups'),
logInfo: (message) => calls.push(`info:${message}`),
})();
assert.equal(deps.isTexthookerOnlyMode(), true);
assert.equal(deps.commandNeedsOverlayStartupPrereqs({} as never), true);
assert.equal(deps.commandNeedsOverlayRuntime({} as never), true);
deps.setTexthookerOnlyMode(false);
deps.ensureOverlayStartupPrereqs();
deps.startBackgroundWarmups();

View File

@@ -3,7 +3,7 @@ import type { CliArgs } from '../../cli/args';
export function createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler(deps: {
isTexthookerOnlyMode: () => boolean;
setTexthookerOnlyMode: (enabled: boolean) => void;
commandNeedsOverlayStartupPrereqs: (args: CliArgs) => boolean;
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
ensureOverlayStartupPrereqs: () => void;
startBackgroundWarmups: () => void;
logInfo: (message: string) => void;
@@ -11,8 +11,7 @@ export function createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler(dep
return () => ({
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
setTexthookerOnlyMode: (enabled: boolean) => deps.setTexthookerOnlyMode(enabled),
commandNeedsOverlayStartupPrereqs: (args: CliArgs) =>
deps.commandNeedsOverlayStartupPrereqs(args),
commandNeedsOverlayRuntime: (args: CliArgs) => deps.commandNeedsOverlayRuntime(args),
ensureOverlayStartupPrereqs: () => deps.ensureOverlayStartupPrereqs(),
startBackgroundWarmups: () => deps.startBackgroundWarmups(),
logInfo: (message: string) => deps.logInfo(message),

View File

@@ -7,7 +7,7 @@ test('texthooker precheck no-ops when mode is disabled', () => {
const handlePrecheck = createHandleTexthookerOnlyModeTransitionHandler({
isTexthookerOnlyMode: () => false,
setTexthookerOnlyMode: () => {},
commandNeedsOverlayStartupPrereqs: () => true,
commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => {},
startBackgroundWarmups: () => {
warmups += 1;
@@ -29,7 +29,7 @@ test('texthooker precheck disables mode and warms up on start command', () => {
setTexthookerOnlyMode: (enabled) => {
mode = enabled;
},
commandNeedsOverlayStartupPrereqs: () => false,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {
prereqs += 1;
},
@@ -55,7 +55,7 @@ test('texthooker precheck no-ops for texthooker command', () => {
setTexthookerOnlyMode: (enabled) => {
mode = enabled;
},
commandNeedsOverlayStartupPrereqs: () => true,
commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => {},
startBackgroundWarmups: () => {},
logInfo: () => {},
@@ -64,33 +64,3 @@ test('texthooker precheck no-ops for texthooker command', () => {
handlePrecheck({ start: true, texthooker: true } as never);
assert.equal(mode, true);
});
test('texthooker precheck transitions for youtube playback startup prereqs', () => {
let mode = true;
let prereqs = 0;
let warmups = 0;
let logs = 0;
const handlePrecheck = createHandleTexthookerOnlyModeTransitionHandler({
isTexthookerOnlyMode: () => mode,
setTexthookerOnlyMode: (enabled) => {
mode = enabled;
},
commandNeedsOverlayStartupPrereqs: () => true,
ensureOverlayStartupPrereqs: () => {
prereqs += 1;
},
startBackgroundWarmups: () => {
warmups += 1;
},
logInfo: () => {
logs += 1;
},
});
handlePrecheck({ youtubePlay: 'https://youtube.com/watch?v=abc', texthooker: false } as never);
assert.equal(mode, false);
assert.equal(prereqs, 1);
assert.equal(warmups, 1);
assert.equal(logs, 1);
});

View File

@@ -3,7 +3,7 @@ import type { CliArgs } from '../../cli/args';
export function createHandleTexthookerOnlyModeTransitionHandler(deps: {
isTexthookerOnlyMode: () => boolean;
setTexthookerOnlyMode: (enabled: boolean) => void;
commandNeedsOverlayStartupPrereqs: (args: CliArgs) => boolean;
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
ensureOverlayStartupPrereqs: () => void;
startBackgroundWarmups: () => void;
logInfo: (message: string) => void;
@@ -12,7 +12,7 @@ export function createHandleTexthookerOnlyModeTransitionHandler(deps: {
if (
deps.isTexthookerOnlyMode() &&
!args.texthooker &&
(args.start || deps.commandNeedsOverlayStartupPrereqs(args))
(args.start || deps.commandNeedsOverlayRuntime(args))
) {
deps.ensureOverlayStartupPrereqs();
deps.setTexthookerOnlyMode(false);

View File

@@ -8,7 +8,7 @@ test('cli command runtime handler applies precheck and forwards command with con
handleTexthookerOnlyModeTransitionMainDeps: {
isTexthookerOnlyMode: () => true,
setTexthookerOnlyMode: () => calls.push('set-mode'),
commandNeedsOverlayStartupPrereqs: () => true,
commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
startBackgroundWarmups: () => calls.push('warmups'),
logInfo: (message) => calls.push(`log:${message}`),
@@ -33,53 +33,3 @@ 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'),
commandNeedsOverlayStartupPrereqs: () => 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']);
});
test('cli command runtime handler skips generic overlay prerequisites for youtube playback', () => {
const calls: string[] = [];
const handler = createCliCommandRuntimeHandler({
handleTexthookerOnlyModeTransitionMainDeps: {
isTexthookerOnlyMode: () => false,
setTexthookerOnlyMode: () => calls.push('set-mode'),
commandNeedsOverlayStartupPrereqs: () => false,
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({ youtubePlay: 'https://youtube.com/watch?v=abc' } as never);
assert.deepEqual(calls, ['context', 'cli:initial:ctx']);
});

View File

@@ -23,12 +23,6 @@ export function createCliCommandRuntimeHandler<TCliContext>(deps: {
return (args: CliArgs, source: CliCommandSource = 'initial'): void => {
handleTexthookerOnlyModeTransitionHandler(args);
if (
!deps.handleTexthookerOnlyModeTransitionMainDeps.isTexthookerOnlyMode() &&
deps.handleTexthookerOnlyModeTransitionMainDeps.commandNeedsOverlayStartupPrereqs(args)
) {
deps.handleTexthookerOnlyModeTransitionMainDeps.ensureOverlayStartupPrereqs();
}
const cliContext = deps.createCliCommandContext();
deps.handleCliCommandRuntimeServiceWithContext(args, source, cliContext);
};

View File

@@ -10,7 +10,6 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
mpvCommandMainDeps: {
triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {},
cycleRuntimeOption: () => ({ ok: true }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},

View File

@@ -72,6 +72,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
youtubePlaybackFlowPending: false,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
@@ -280,6 +281,7 @@ test('composeMpvRuntimeHandlers skips MeCab warmup when all POS-dependent annota
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
youtubePlaybackFlowPending: false,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
@@ -411,6 +413,7 @@ test('composeMpvRuntimeHandlers runs tokenization warmup once across sequential
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
youtubePlaybackFlowPending: false,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
@@ -550,6 +553,7 @@ test('composeMpvRuntimeHandlers does not block first tokenization on dictionary
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
youtubePlaybackFlowPending: false,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
@@ -683,6 +687,7 @@ test('composeMpvRuntimeHandlers shows annotation loading OSD after tokenization-
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
youtubePlaybackFlowPending: false,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
@@ -830,6 +835,7 @@ test('composeMpvRuntimeHandlers reuses completed background tokenization warmups
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
youtubePlaybackFlowPending: false,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},

View File

@@ -20,10 +20,6 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
getMainOverlayWindow: () => null,
clearMainOverlayWindow: () => {},
getModalOverlayWindow: () => null,
clearModalOverlayWindow: () => {},
getYomitanParserWindow: () => null,
clearYomitanParserState: () => {},
getWindowTracker: () => null,

View File

@@ -13,11 +13,6 @@ test('initial args handler no-ops without initial args', () => {
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayStartupPrereqs: () => false,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {},
handleCliCommand: () => {
handled = true;
@@ -41,11 +36,6 @@ test('initial args handler ensures tray in background mode', () => {
isTexthookerOnlyMode: () => true,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayStartupPrereqs: () => false,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {},
handleCliCommand: () => {},
});
@@ -71,11 +61,6 @@ test('initial args handler auto-connects mpv when needed', () => {
connectCalls += 1;
},
}),
commandNeedsOverlayStartupPrereqs: () => false,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {
logged = true;
},
@@ -98,15 +83,6 @@ test('initial args handler forwards args to cli handler', () => {
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayStartupPrereqs: () => false,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {
seenSources.push('prereqs');
},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {
seenSources.push('init-overlay');
},
logInfo: () => {},
handleCliCommand: (_args, source) => {
seenSources.push(source);
@@ -117,70 +93,6 @@ 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,
commandNeedsOverlayStartupPrereqs: (inputArgs) => inputArgs === args,
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 prepares prereqs but skips eager overlay bootstrap for youtube playback', () => {
const calls: string[] = [];
const args = { youtubePlay: 'https://youtube.com/watch?v=abc' } as never;
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => args,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => {},
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayStartupPrereqs: () => true,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {
calls.push('prereqs');
},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {
calls.push('init-overlay');
},
logInfo: () => {},
handleCliCommand: (_args, source) => {
calls.push(`cli:${source}`);
},
});
handleInitialArgs();
assert.deepEqual(calls, ['prereqs', 'cli:initial']);
});
test('initial args handler can ensure tray outside background mode when requested', () => {
let ensuredTray = false;
const handleInitialArgs = createHandleInitialArgsHandler({
@@ -194,11 +106,6 @@ test('initial args handler can ensure tray outside background mode when requeste
isTexthookerOnlyMode: () => true,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayStartupPrereqs: () => false,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {},
handleCliCommand: () => {},
});
@@ -226,11 +133,6 @@ test('initial args handler skips tray and mpv auto-connect for headless refresh'
connectCalls += 1;
},
}),
commandNeedsOverlayStartupPrereqs: () => true,
commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {},
handleCliCommand: () => {},
});

View File

@@ -14,11 +14,6 @@ export function createHandleInitialArgsHandler(deps: {
isTexthookerOnlyMode: () => boolean;
hasImmersionTracker: () => boolean;
getMpvClient: () => MpvClientLike | null;
commandNeedsOverlayStartupPrereqs: (args: CliArgs) => boolean;
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
ensureOverlayStartupPrereqs: () => void;
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntime: () => void;
logInfo: (message: string) => void;
handleCliCommand: (args: CliArgs, source: 'initial') => void;
}) {
@@ -44,15 +39,6 @@ export function createHandleInitialArgsHandler(deps: {
mpvClient.connect();
}
if (!runHeadless && deps.commandNeedsOverlayStartupPrereqs(initialArgs)) {
deps.ensureOverlayStartupPrereqs();
}
if (!runHeadless && deps.commandNeedsOverlayRuntime(initialArgs)) {
if (!deps.isOverlayRuntimeInitialized()) {
deps.initializeOverlayRuntime();
}
}
deps.handleCliCommand(initialArgs, 'initial');
};
}

View File

@@ -15,11 +15,6 @@ test('initial args main deps builder maps runtime callbacks and state readers',
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,
getMpvClient: () => mpvClient,
commandNeedsOverlayStartupPrereqs: () => true,
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}`),
})();
@@ -31,14 +26,9 @@ 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.commandNeedsOverlayStartupPrereqs(args), true);
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', 'prereqs', 'init-overlay', 'info:x', 'cli:initial']);
assert.deepEqual(calls, ['ensure-tray', 'info:x', 'cli:initial']);
});

View File

@@ -9,11 +9,6 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
isTexthookerOnlyMode: () => boolean;
hasImmersionTracker: () => boolean;
getMpvClient: () => { connected: boolean; connect: () => void } | null;
commandNeedsOverlayStartupPrereqs: (args: CliArgs) => boolean;
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
ensureOverlayStartupPrereqs: () => void;
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntime: () => void;
logInfo: (message: string) => void;
handleCliCommand: (args: CliArgs, source: 'initial') => void;
}) {
@@ -26,12 +21,6 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
hasImmersionTracker: () => deps.hasImmersionTracker(),
getMpvClient: () => deps.getMpvClient(),
commandNeedsOverlayStartupPrereqs: (args: CliArgs) =>
deps.commandNeedsOverlayStartupPrereqs(args),
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),
});

View File

@@ -16,11 +16,6 @@ test('initial args runtime handler composes main deps and runs initial command f
connected: false,
connect: () => calls.push('connect'),
}),
commandNeedsOverlayStartupPrereqs: () => false,
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}`),
});
@@ -49,11 +44,6 @@ test('initial args runtime handler skips mpv auto-connect for stats mode', () =>
connected: false,
connect: () => calls.push('connect'),
}),
commandNeedsOverlayStartupPrereqs: () => false,
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}`),
});
@@ -77,11 +67,6 @@ test('initial args runtime handler skips tray and mpv auto-connect for headless
connected: false,
connect: () => calls.push('connect'),
}),
commandNeedsOverlayStartupPrereqs: () => true,
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}`),
});

View File

@@ -13,7 +13,6 @@ test('ipc bridge action main deps builders map callbacks', async () => {
buildMpvCommandDeps: () => ({
triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},

View File

@@ -10,7 +10,6 @@ test('handle mpv command handler forwards command and built deps', () => {
const deps = {
triggerSubsyncFromConfig: () => {},
openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},

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