mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-05 00:12:06 -07:00
[codex] Make Windows mpv shortcut self-contained (#40)
This commit is contained in:
46
CHANGELOG.md
46
CHANGELOG.md
@@ -1,9 +1,51 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## Unreleased
|
## v0.11.0 (2026-04-03)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Overlay: Added a playlist browser overlay modal for browsing sibling video files and the live mpv queue during playback.
|
||||||
|
- Overlay: Added the default `Ctrl+Alt+P` keybinding to open the playlist browser and manage queue order without leaving playback.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Setup: Made mpv plugin installation mandatory in the first-run setup flow, removed the skip path, and kept Finish disabled until the plugin is installed.
|
||||||
|
- Setup: Clarified that the mpv plugin requirement applies to setup on every platform, while the optional `SubMiner mpv` shortcut remains the recommended Windows playback entry point.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- AniList: Stopped post-watch tracking from sending a second progress update when the current episode was already satisfied by a ready retry item in the same watch-completion pass.
|
- Main: Resolve the YouTube playback socket path lazily so startup honors CLI and config overrides.
|
||||||
|
- Main: Add regression coverage for the lazy socket-path lookup during Windows mpv startup.
|
||||||
|
- Main: Keep integrated `--start --texthooker` launches on the full app-ready startup path so the texthooker page and websocket servers start together during normal playback startup.
|
||||||
|
- Main: Stop the mpv/plugin auto-start flow from spawning a separate standalone texthooker helper during normal `subminer <video>` launches.
|
||||||
|
- Overlay: Keep tracked macOS visible overlays click-through by default so subtitle sidebar passthrough works immediately without requiring a subtitle hover cycle first.
|
||||||
|
- Overlay: Add regression coverage for the macOS visible-overlay passthrough default.
|
||||||
|
- Anilist: Stop AniList post-watch from sending a second progress update when the current episode was already satisfied by a ready retry item in the same watch-completion pass.
|
||||||
|
- Anilist: Add regression coverage for the retry-queue plus live-update duplicate path.
|
||||||
|
- Overlay: Fixed Kiku duplicate grouping to reuse duplicate note IDs from both generic sentence-card creation and Yomitan popup mining instead of running extra duplicate scans after add.
|
||||||
|
- Overlay: Fixed the Yomitan popup mining flow to add cards in the background while keeping the stock popup progress feedback, then pause playback and close the lookup popup before the Kiku merge modal opens.
|
||||||
|
- Overlay: Fixed configured subtitle-jump keybindings so backward and forward subtitle seeks keep playback paused when invoked from a paused state.
|
||||||
|
- Launcher: Fixed the Windows `SubMiner mpv` shortcut and `SubMiner.exe --launch-mpv` flow to launch mpv with SubMiner's required default args directly instead of requiring an `mpv.conf` profile named `subminer`.
|
||||||
|
- Launcher: Clarified the Windows install and usage docs so the shortcut path is documented as self-contained, while the optional `subminer` mpv profile remains available for manual mpv launches.
|
||||||
|
- Launcher: Hardened the first-run setup blocker copy and stale custom-scheme handling so setup messages stay aligned with config, plugin, and dictionary readiness.
|
||||||
|
- Launcher: Fixed the Windows `SubMiner mpv` shortcut idle launch so loading a video after opening the shortcut keeps mpv in the expected SubMiner-managed session, auto-starts the overlay, and re-arms subtitle auto-selection for the newly opened file.
|
||||||
|
- Launcher: Removed the redundant `.` subtitle search path from the Windows shortcut launch args and deduped repeated subtitle source tracks in the manual sync picker so duplicate external subtitle entries no longer appear from the shortcut path.
|
||||||
|
- Playback: Fixed managed local playback so duplicate startup-ready retries no longer unpause media after a later manual pause on the same file.
|
||||||
|
- Playback: Fixed managed local subtitle auto-selection so local files reuse configured primary and secondary subtitle language priorities instead of staying on mpv's initial `sid=auto` guess.
|
||||||
|
- Launcher: Added a blank-by-default `mpv.executablePath` override for Windows playback so users can point SubMiner at `mpv.exe` when it is not on `PATH`.
|
||||||
|
- Launcher: Kept the Windows shortcut and `--launch-mpv` flow simple by preserving PATH auto-discovery as the default and exposing the override in first-run setup.
|
||||||
|
- Launcher: Added `windows` as a recognized launcher backend option and auto-detection target on Windows.
|
||||||
|
- Launcher: Honored `SUBMINER_YTDLP_BIN` consistently across YouTube playback URL resolution, track probing, subtitle downloads, and metadata probing.
|
||||||
|
- Launcher: Kept the first-run setup window from navigating away on unexpected URLs.
|
||||||
|
- Launcher: Made Windows mpv honor an explicitly configured executable path instead of silently falling back to PATH.
|
||||||
|
- Launcher: Hardened `--launch-mpv` parsing and Windows binary resolution so valueless flags do not swallow media targets and symlinked launcher installs do not short-circuit PATH lookup.
|
||||||
|
- Playback: Prevented stale async playlist-browser subtitle rearm callbacks from overriding newer subtitle selections during rapid file changes.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- Docs Site: Added a dedicated Subtitle Sidebar guide and linked it from the homepage and configuration docs.
|
||||||
|
- Docs Site: Linked Jimaku integration from the homepage to its dedicated docs page.
|
||||||
|
- Docs Site: Refreshed docs-site theme tokens and hover/selection styling for the updated pages.
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
- Release: Retried AUR clone and push operations in the tagged release workflow.
|
||||||
|
- Release: Kept GitHub Releases green when AUR publish flakes and needs manual follow-up.
|
||||||
|
|
||||||
## v0.10.0 (2026-03-29)
|
## v0.10.0 (2026-03-29)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ Look up words with Yomitan, export to Anki in one key, track your immersion —
|
|||||||
|
|
||||||
SubMiner runs as an invisible Electron overlay on top of mpv. Subtitles render as an interactive layer. Move your cursor over any word and trigger a [Yomitan](https://github.com/yomidevs/yomitan) lookup. Press one key to snapshot the sentence, audio, and screenshot into Anki via AnkiConnect.
|
SubMiner runs as an invisible Electron overlay on top of mpv. Subtitles render as an interactive layer. Move your cursor over any word and trigger a [Yomitan](https://github.com/yomidevs/yomitan) lookup. Press one key to snapshot the sentence, audio, and screenshot into Anki via AnkiConnect.
|
||||||
|
|
||||||
|
First-run setup requires the mpv plugin before it can finish. On Windows, the optional `SubMiner mpv` shortcut created during setup is the recommended playback entry point because it launches `mpv` with SubMiner's defaults directly, so you do not need an `mpv.conf` profile just to use it.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Dictionary Lookups
|
### Dictionary Lookups
|
||||||
@@ -67,6 +69,8 @@ Local stats dashboard — watch time, anime library, vocabulary growth, mining t
|
|||||||
|
|
||||||
Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback.
|
Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback.
|
||||||
|
|
||||||
|
Managed local playback now reapplies your configured subtitle language priorities after mpv loads track metadata, so mixed subtitle sets can settle onto the expected primary and secondary tracks instead of staying on mpv's initial `sid=auto` guess.
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
### Integrations
|
### Integrations
|
||||||
@@ -74,7 +78,7 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
|||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>YouTube</b></td>
|
<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>Auto-loaded yt-dlp subtitle tracks at startup with config-driven primary/secondary language priorities and a manual overlay picker on demand (<code>Ctrl+Alt+C</code>)</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>AniList</b></td>
|
<td><b>AniList</b></td>
|
||||||
@@ -224,7 +228,7 @@ See the [build-from-source guide](https://docs.subminer.moe/installation#from-so
|
|||||||
|
|
||||||
### 2. First Launch
|
### 2. First Launch
|
||||||
|
|
||||||
Run the app. On first launch SubMiner starts in the system tray, creates a default config, and opens a setup popup to install the mpv plugin and configure Yomitan dictionaries.
|
Run the app. On first launch SubMiner starts in the system tray, creates a default config, and opens a setup popup to finish config, install the mpv plugin, and configure Yomitan dictionaries.
|
||||||
|
|
||||||
### 3. Mine
|
### 3. Mine
|
||||||
|
|
||||||
|
|||||||
BIN
assets/SubMiner-square.png
Normal file
BIN
assets/SubMiner-square.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
BIN
assets/SubMiner.ico
Normal file
BIN
assets/SubMiner.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
id: TASK-272
|
||||||
|
title: 'Assess and address PR #40 CodeRabbit review follow-ups'
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-04-03 07:52'
|
||||||
|
updated_date: '2026-04-03 08:04'
|
||||||
|
labels:
|
||||||
|
- coderabbit
|
||||||
|
- review
|
||||||
|
- launcher
|
||||||
|
milestone: 'PR #40'
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- 'https://github.com/ksyasuda/SubMiner/pull/40'
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Implement the valid CodeRabbit findings on PR #40 and keep the Windows mpv shortcut / first-run setup flow consistent end to end.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [ ] #1 Windows binary resolution does not return install directories as executable candidates
|
||||||
|
- [ ] #2 Launch-mpv arg parsing preserves space-separated mpv option values and target separation
|
||||||
|
- [ ] #3 Windows mpv launch args keep the final input-ipc-server and script-opts socket path in sync when custom values are supplied
|
||||||
|
- [ ] #4 First-run setup navigation swallows stale or invalid custom-scheme actions without navigating away
|
||||||
|
- [ ] #5 Setup messaging and footer copy reflect configReady, plugin, and dictionary gates consistently
|
||||||
|
- [ ] #6 Regression tests cover the fixed behaviors
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Addressed CodeRabbit follow-ups for PR #40. Hardened launcher binary discovery on Windows and PATH resolution, fixed launch-mpv argument parsing for value-bearing flags, synced custom Windows mpv IPC socket values into script opts, and tightened first-run setup messaging/navigation to handle stale actions and blocker copy. Verified with `bun test src/main-entry-runtime.test.ts src/main/runtime/windows-mpv-launch.test.ts src/main/runtime/first-run-setup-window.test.ts launcher/mpv.test.ts`.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
id: TASK-270
|
||||||
|
title: Make Windows mpv shortcut self-contained
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-04-02 07:13'
|
||||||
|
updated_date: '2026-04-02 07:19'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Remove the Windows mpv shortcut's dependency on a pre-existing mpv profile so the installer-created `SubMiner mpv` flow works out of the box without requiring the user to edit `mpv.conf`. Keep docs aligned with the new behavior and preserve the optional profile guidance for manual mpv usage.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 `SubMiner.exe --launch-mpv` launches mpv with SubMiner's required default args without requiring an mpv profile named `subminer`.
|
||||||
|
- [x] #2 Windows shortcut/help/docs no longer describe `--launch-mpv` as depending on the SubMiner mpv profile.
|
||||||
|
- [x] #3 Automated tests cover the Windows launch args behavior and pass after the change.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Updated the Windows `--launch-mpv` path to pass SubMiner's default mpv args directly instead of requiring `--profile=subminer`. Adjusted Windows shortcut/help text to describe the self-contained defaults-based launch, and updated Windows docs to state that `mpv.conf` is not required for the shortcut path while preserving the optional profile guidance for manual mpv launches.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
id: TASK-271
|
||||||
|
title: Fix local playback subtitle auto-selection and startup pause release
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- codex
|
||||||
|
created_date: '2026-04-03 07:47'
|
||||||
|
updated_date: '2026-04-03 08:03'
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
- playback
|
||||||
|
- subtitles
|
||||||
|
dependencies: []
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Investigate local-video startup on desktop playback where managed subtitle defaults can bind the wrong primary subtitle track and startup readiness retries can force playback to resume after the user manually pauses. Scope includes fixing the pause/unpause loop and making local subtitle auto-selection prefer the intended primary/secondary tracks for sentence mining sessions.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Desktop local playback no longer forces `pause=no` after the user manually pauses during or after startup readiness handling.
|
||||||
|
- [x] #2 Managed local subtitle startup selects the expected primary track before secondary track selection for mixed-language subtitle sets like Japanese primary plus English secondary.
|
||||||
|
- [x] #3 Regression tests cover the pause-release bug and the local subtitle auto-selection behavior.
|
||||||
|
- [x] #4 Internal docs are updated if runtime behavior or operator expectations change.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Add regression coverage for startup autoplay release so duplicate ready handling cannot unpause playback after the user manually pauses the same media.
|
||||||
|
2. Add regression coverage for managed local subtitle startup selection using configured primary/secondary subtitle language preferences, with JA/EN remaining the default fallback behavior.
|
||||||
|
3. Extract or add reusable subtitle track ranking logic that prefers configured primary and secondary subtitle languages, with stable scoring for external tracks and non-SDH labels.
|
||||||
|
4. Update local playback startup/runtime wiring so managed subtitle defaults use explicit ranked selection instead of raw sid=auto / secondary-sid=auto while preserving config-driven language preference ordering.
|
||||||
|
5. Run focused subtitle/playback tests, then update task notes/final summary with any behavior or docs impact.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Implemented config-aware managed local subtitle selection runtime for local media path changes and playlist-browser local playback rearm, using `youtube.primarySubLanguages` for primary preference and `secondarySub.secondarySubLanguages` for secondary preference with JA/EN fallback defaults.
|
||||||
|
|
||||||
|
Updated autoplay ready gate to ignore duplicate readiness signals for the same media so later manual pauses are not overridden by repeated tokenization-ready events.
|
||||||
|
|
||||||
|
Updated config/template wording to document that the existing subtitle language preferences now drive managed subtitle auto-selection beyond YouTube-only flows.
|
||||||
|
|
||||||
|
Verification: `bun test src/config/config.test.ts src/main/runtime/autoplay-ready-gate.test.ts src/main/runtime/local-subtitle-selection.test.ts src/main/runtime/playlist-browser-runtime.test.ts`; `bun run typecheck`.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Stopped startup readiness retries from re-unpausing the same media after a later manual pause, and added config-aware managed local subtitle selection so local playback prefers the configured primary/secondary subtitle languages instead of relying on raw mpv `sid=auto` behavior. Added regression coverage for autoplay-ready gating, local subtitle selection, playlist-browser local playback rearm, and config template wording updates.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
id: TASK-273
|
||||||
|
title: >-
|
||||||
|
Fix first-run setup false positive when canonical mpv plugin is already
|
||||||
|
installed
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- Kyle Yasuda
|
||||||
|
created_date: '2026-04-03 23:26'
|
||||||
|
updated_date: '2026-04-04 00:31'
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
- macos
|
||||||
|
- first-run-setup
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Investigate and fix launcher/app first-run setup gating so playback does not block when the SubMiner mpv plugin is already installed at the canonical mpv config path on macOS. Align mpv path resolution with the actual install location, keep plugin detection scoped to the canonical plugin entrypoint, and make launcher setup gating resilient to stale cancelled setup state.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [ ] #1 `resolveDefaultMpvInstallPaths` resolves the canonical macOS mpv config path used by existing installs.
|
||||||
|
- [ ] #2 Playback launcher bypasses first-run setup when the canonical `scripts/subminer/main.lua` plugin entrypoint already exists, even if `setup-state.json` is stale.
|
||||||
|
- [ ] #3 Regression tests cover canonical plugin detection and launcher handling of stale cancelled setup state.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Root cause ended up split across path resolution and launcher gating. No automated test command was executed in this pass by request.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Updated macOS mpv install path resolution to use the canonical `~/.config/mpv` location so first-run plugin detection matches the actual installed plugin path.
|
||||||
|
|
||||||
|
Restricted plugin detection to the canonical `scripts/subminer/main.lua` entrypoint instead of config presence or legacy loader files.
|
||||||
|
|
||||||
|
Updated the launcher setup gate to bypass stale `setup-state.json` when the mpv plugin is already installed, and to ignore an initially stale `cancelled` state after spawning setup.
|
||||||
|
|
||||||
|
Added regression coverage for canonical macOS detection and launcher setup-gate bypass behavior. No automated test command was executed in this pass by request.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
<!-- DOD:BEGIN -->
|
||||||
|
- [ ] #1 Manual verification with scenario: existing plugin installed in custom mpv config path does not open first-run setup.
|
||||||
|
<!-- DOD:END -->
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
type: docs
|
|
||||||
area: docs-site
|
|
||||||
|
|
||||||
- Added a dedicated Subtitle Sidebar guide and linked it from the homepage and configuration docs.
|
|
||||||
- Linked Jimaku integration from the homepage to its dedicated docs page.
|
|
||||||
- Refreshed docs-site theme tokens and hover/selection styling for the updated pages.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: main
|
|
||||||
|
|
||||||
- Resolve the YouTube playback socket path lazily so startup honors CLI and config overrides.
|
|
||||||
- Add regression coverage for the lazy socket-path lookup during Windows mpv startup.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: release
|
|
||||||
|
|
||||||
- Retried AUR clone and push operations in the tagged release workflow.
|
|
||||||
- Kept GitHub Releases green when AUR publish flakes and needs manual follow-up.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: main
|
|
||||||
|
|
||||||
- Keep integrated `--start --texthooker` launches on the full app-ready startup path so the texthooker page and websocket servers start together during normal playback startup.
|
|
||||||
- Stop the mpv/plugin auto-start flow from spawning a separate standalone texthooker helper during normal `subminer <video>` launches.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Added a playlist browser overlay modal for browsing sibling video files and the live mpv queue during playback.
|
|
||||||
- Added the default `Ctrl+Alt+P` keybinding to open the playlist browser and manage queue order without leaving playback.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Keep tracked macOS visible overlays click-through by default so subtitle sidebar passthrough works immediately without requiring a subtitle hover cycle first.
|
|
||||||
- Add regression coverage for the macOS visible-overlay passthrough default.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: anilist
|
|
||||||
|
|
||||||
- Stop AniList post-watch from sending a second progress update when the current episode was already satisfied by a ready retry item in the same watch-completion pass.
|
|
||||||
- Add regression coverage for the retry-queue plus live-update duplicate path.
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Fixed Kiku duplicate grouping to reuse duplicate note IDs from both generic sentence-card creation and Yomitan popup mining instead of running extra duplicate scans after add.
|
|
||||||
- Fixed the Yomitan popup mining flow to add cards in the background while keeping the stock popup progress feedback, then pause playback and close the lookup popup before the Kiku merge modal opens.
|
|
||||||
- Fixed configured subtitle-jump keybindings so backward and forward subtitle seeks keep playback paused when invoked from a paused state.
|
|
||||||
5
changes/fix-first-run-setup-plugin-detection.md
Normal file
5
changes/fix-first-run-setup-plugin-detection.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: launcher
|
||||||
|
|
||||||
|
Fixed first-run setup blocking playback on macOS when the SubMiner mpv plugin was already installed at the canonical `~/.config/mpv` path.
|
||||||
|
Fixed launcher setup gating so stale cancelled setup state no longer prevents playback when the canonical mpv plugin entrypoint already exists.
|
||||||
@@ -187,7 +187,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// Secondary Subtitles
|
// Secondary Subtitles
|
||||||
// Dual subtitle track options.
|
// Dual subtitle track options.
|
||||||
// Used by the YouTube subtitle loading flow as secondary language preferences.
|
// Used by managed subtitle loading as secondary language preferences for local and YouTube playback.
|
||||||
// Hot-reload: defaultMode updates live while SubMiner is running.
|
// Hot-reload: defaultMode updates live while SubMiner is running.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"secondarySub": {
|
"secondarySub": {
|
||||||
@@ -415,14 +415,14 @@
|
|||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// YouTube Playback Settings
|
// YouTube Playback Settings
|
||||||
// Defaults for SubMiner YouTube subtitle loading and languages.
|
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"youtube": {
|
"youtube": {
|
||||||
"primarySubLanguages": [
|
"primarySubLanguages": [
|
||||||
"ja",
|
"ja",
|
||||||
"jpn"
|
"jpn"
|
||||||
] // Comma-separated primary subtitle language priority for YouTube auto-loading.
|
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||||
}, // Defaults for SubMiner YouTube subtitle loading and languages.
|
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Anilist
|
// Anilist
|
||||||
@@ -458,6 +458,15 @@
|
|||||||
"externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay
|
"externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay
|
||||||
}, // Optional external Yomitan profile integration.
|
}, // Optional external Yomitan profile integration.
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// MPV Launcher
|
||||||
|
// Optional mpv.exe override for Windows playback entry points.
|
||||||
|
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
||||||
|
// ==========================================
|
||||||
|
"mpv": {
|
||||||
|
"executablePath": "" // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
||||||
|
}, // Optional mpv.exe override for Windows playback entry points.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Jellyfin
|
// Jellyfin
|
||||||
// Optional Jellyfin integration for auth, browsing, and playback launch.
|
// Optional Jellyfin integration for auth, browsing, and playback launch.
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ src/
|
|||||||
handlers/ # Keyboard/mouse interaction modules
|
handlers/ # Keyboard/mouse interaction modules
|
||||||
modals/ # Jimaku/Kiku/subsync/runtime-options/session-help modals
|
modals/ # Jimaku/Kiku/subsync/runtime-options/session-help modals
|
||||||
positioning/ # Subtitle position controller (drag-to-reposition)
|
positioning/ # Subtitle position controller (drag-to-reposition)
|
||||||
window-trackers/ # Backend-specific tracker implementations (Hyprland, Sway, X11, macOS)
|
window-trackers/ # Backend-specific tracker implementations (Hyprland, Sway, X11, macOS, Windows)
|
||||||
jimaku/ # Jimaku API integration helpers
|
jimaku/ # Jimaku API integration helpers
|
||||||
subsync/ # Subtitle sync (alass/ffsubsync) helpers
|
subsync/ # Subtitle sync (alass/ffsubsync) helpers
|
||||||
subtitle/ # Subtitle processing utilities
|
subtitle/ # Subtitle processing utilities
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.11.0 (2026-04-03)
|
||||||
|
- Added a playlist browser overlay modal for browsing sibling video files and the live mpv queue during playback, with a default `Ctrl+Alt+P` keybinding.
|
||||||
|
- Made mpv plugin installation mandatory in first-run setup (removed skip path); Finish stays disabled until the plugin is installed.
|
||||||
|
- Fixed the Windows `SubMiner mpv` shortcut to launch mpv with required default args directly instead of requiring an `mpv.conf` profile named `subminer`.
|
||||||
|
- Fixed the Windows mpv idle launch so loading a video after opening the shortcut keeps mpv in the SubMiner-managed session and auto-starts the overlay.
|
||||||
|
- Added a blank-by-default `mpv.executablePath` config override for Windows playback when mpv is not on `PATH`, exposed in first-run setup.
|
||||||
|
- Fixed Kiku duplicate grouping to reuse duplicate note IDs from both sentence-card creation and Yomitan popup mining, with background card addition and proper merge-modal sequencing.
|
||||||
|
- Fixed configured subtitle-jump keybindings to keep playback paused when invoked from a paused state.
|
||||||
|
- Fixed managed local subtitle auto-selection to reuse configured language priorities instead of staying on mpv's initial `sid=auto` guess.
|
||||||
|
- Kept tracked macOS visible overlays click-through by default so subtitle sidebar passthrough works immediately.
|
||||||
|
- Stopped AniList post-watch from sending duplicate progress updates when already satisfied by a retry item.
|
||||||
|
- Kept integrated `--start --texthooker` launches on the full app-ready startup path.
|
||||||
|
- Honored `SUBMINER_YTDLP_BIN` consistently across all YouTube flows (playback URL resolution, track probing, subtitle downloads, metadata probing).
|
||||||
|
- Added `windows` as a recognized launcher backend option and auto-detection target.
|
||||||
|
- Added a dedicated Subtitle Sidebar guide to the docs site with links from homepage and configuration docs.
|
||||||
|
|
||||||
## v0.10.0 (2026-03-29)
|
## v0.10.0 (2026-03-29)
|
||||||
- Fixed stats startup so the immersion tracker can run when `Bun.serve` is unavailable.
|
- Fixed stats startup so the immersion tracker can run when `Bun.serve` is unavailable.
|
||||||
- Added a Node `http` fallback for Electron/runtime paths that do not expose Bun, so stats keeps working there too.
|
- Added a Node `http` fallback for Electron/runtime paths that do not expose Bun, so stats keeps working there too.
|
||||||
|
|||||||
@@ -448,6 +448,8 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
|
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
|
||||||
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
||||||
|
|
||||||
|
`secondarySub.secondarySubLanguages` also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback.
|
||||||
|
|
||||||
**Display modes:**
|
**Display modes:**
|
||||||
|
|
||||||
- **hidden** — Secondary subtitles not shown
|
- **hidden** — Secondary subtitles not shown
|
||||||
@@ -1342,7 +1344,7 @@ Usage notes:
|
|||||||
|
|
||||||
### YouTube Playback Settings
|
### YouTube Playback Settings
|
||||||
|
|
||||||
Set defaults used by the `subminer` launcher for YouTube subtitle loading:
|
Set defaults used by managed subtitle auto-selection and the `subminer` launcher YouTube flow:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -1354,7 +1356,7 @@ Set defaults used by the `subminer` launcher for YouTube subtitle loading:
|
|||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| --------------------- | -------------------- | ---------------------------------------------------------------------------------------------- |
|
| --------------------- | -------------------- | ---------------------------------------------------------------------------------------------- |
|
||||||
| `primarySubLanguages` | string[] | Primary subtitle language priority for YouTube auto-loading (default `["ja", "jpn"]`) |
|
| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) |
|
||||||
|
|
||||||
Current launcher behavior:
|
Current launcher behavior:
|
||||||
|
|
||||||
@@ -1370,6 +1372,7 @@ Language targets are derived from subtitle config:
|
|||||||
|
|
||||||
- primary track: `youtube.primarySubLanguages` (falls back to `["ja","jpn"]`)
|
- primary track: `youtube.primarySubLanguages` (falls back to `["ja","jpn"]`)
|
||||||
- secondary track: `secondarySub.secondarySubLanguages` (falls back to English when empty)
|
- secondary track: `secondarySub.secondarySubLanguages` (falls back to English when empty)
|
||||||
|
- Local playback uses the same priorities after mpv reports subtitle track metadata, so sidecar/internal mixed sets can override an incorrect initial `sid=auto` pick.
|
||||||
- Tracks are resolved and loaded before mpv starts; the older launcher mode switch has been removed.
|
- Tracks are resolved and loaded before mpv starts; the older launcher mode switch has been removed.
|
||||||
|
|
||||||
Precedence for launcher defaults is: CLI flag > environment variable > `config.jsonc` > built-in default.
|
Precedence for launcher defaults is: CLI flag > environment variable > `config.jsonc` > built-in default.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
**macOS** — macOS 10.13 or later. Accessibility permission required for window tracking.
|
**macOS** — macOS 10.13 or later. Accessibility permission required for window tracking.
|
||||||
|
|
||||||
**Windows** — Windows 10 or later. Install `mpv` and keep it available on `PATH`; SubMiner's packaged build handles window tracking directly.
|
**Windows** — Windows 10 or later. Install `mpv`; keep it on `PATH` for auto-discovery or set `mpv.executablePath` in config if `mpv.exe` lives elsewhere. SubMiner's packaged build handles window tracking directly.
|
||||||
|
|
||||||
### Optional Tools
|
### Optional Tools
|
||||||
|
|
||||||
@@ -171,7 +171,9 @@ Install `mpv` separately and ensure `mpv.exe` is on `PATH`. `ffmpeg` is still re
|
|||||||
|
|
||||||
### Windows Usage Notes
|
### Windows Usage Notes
|
||||||
|
|
||||||
- Launch `SubMiner.exe` once to let the first-run setup flow seed `%APPDATA%\\SubMiner\\config.jsonc`, offer mpv plugin installation, open bundled Yomitan settings, and optionally create `SubMiner mpv` Start Menu/Desktop shortcuts.
|
- Launch `SubMiner.exe` once to let the first-run setup flow seed `%APPDATA%\\SubMiner\\config.jsonc`, require mpv plugin installation, and open bundled Yomitan settings. The optional `SubMiner mpv` Start Menu/Desktop shortcut can also be created during setup, and on Windows it is the recommended way to launch mpv playback with SubMiner defaults.
|
||||||
|
- If `mpv.exe` is not on `PATH`, set `mpv.executablePath` in `config.jsonc` or use the first-run setup field to point at the executable. Leave it blank to keep PATH auto-discovery.
|
||||||
|
- `SubMiner.exe --launch-mpv` and the optional `SubMiner mpv` shortcut pass SubMiner's default mpv socket/subtitle args directly and do not require an `mpv.conf` profile named `subminer`.
|
||||||
- First-run mpv plugin installs pin `binary_path` to the current `SubMiner.exe` automatically. Manual plugin configs can leave `binary_path` empty unless SubMiner is installed in a non-standard location.
|
- First-run mpv plugin installs pin `binary_path` to the current `SubMiner.exe` automatically. Manual plugin configs can leave `binary_path` empty unless SubMiner is installed in a non-standard location.
|
||||||
- Windows plugin installs rewrite `socket_path` to `\\.\pipe\subminer-socket`; do not keep `/tmp/subminer-socket` on Windows.
|
- Windows plugin installs rewrite `socket_path` to `\\.\pipe\subminer-socket`; do not keep `/tmp/subminer-socket` on Windows.
|
||||||
- Native window tracking is built in on Windows; no `xdotool`, `xwininfo`, or compositor-specific helper is required.
|
- Native window tracking is built in on Windows; no `xdotool`, `xwininfo`, or compositor-specific helper is required.
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ Use `subminer <subcommand> -h` for command-specific help.
|
|||||||
| `-T, --no-texthooker` | Disable texthooker server |
|
| `-T, --no-texthooker` | Disable texthooker server |
|
||||||
| `-p, --profile` | mpv profile name (default: `subminer`) |
|
| `-p, --profile` | mpv profile name (default: `subminer`) |
|
||||||
| `-a, --args` | Pass additional mpv arguments as a quoted string |
|
| `-a, --args` | Pass additional mpv arguments as a quoted string |
|
||||||
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`) |
|
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) |
|
||||||
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
||||||
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
|
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ texthooker_enabled=yes
|
|||||||
# Port for the texthooker server.
|
# Port for the texthooker server.
|
||||||
texthooker_port=5174
|
texthooker_port=5174
|
||||||
|
|
||||||
# Window manager backend: auto, hyprland, sway, x11, macos.
|
# Window manager backend: auto, hyprland, sway, x11, macos, windows.
|
||||||
backend=auto
|
backend=auto
|
||||||
|
|
||||||
# Start the overlay automatically when a file is loaded.
|
# Start the overlay automatically when a file is loaded.
|
||||||
|
|||||||
@@ -187,7 +187,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// Secondary Subtitles
|
// Secondary Subtitles
|
||||||
// Dual subtitle track options.
|
// Dual subtitle track options.
|
||||||
// Used by the YouTube subtitle loading flow as secondary language preferences.
|
// Used by managed subtitle loading as secondary language preferences for local and YouTube playback.
|
||||||
// Hot-reload: defaultMode updates live while SubMiner is running.
|
// Hot-reload: defaultMode updates live while SubMiner is running.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"secondarySub": {
|
"secondarySub": {
|
||||||
@@ -415,14 +415,14 @@
|
|||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// YouTube Playback Settings
|
// YouTube Playback Settings
|
||||||
// Defaults for SubMiner YouTube subtitle loading and languages.
|
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"youtube": {
|
"youtube": {
|
||||||
"primarySubLanguages": [
|
"primarySubLanguages": [
|
||||||
"ja",
|
"ja",
|
||||||
"jpn"
|
"jpn"
|
||||||
] // Comma-separated primary subtitle language priority for YouTube auto-loading.
|
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||||
}, // Defaults for SubMiner YouTube subtitle loading and languages.
|
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Anilist
|
// Anilist
|
||||||
@@ -458,6 +458,15 @@
|
|||||||
"externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay
|
"externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay
|
||||||
}, // Optional external Yomitan profile integration.
|
}, // Optional external Yomitan profile integration.
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// MPV Launcher
|
||||||
|
// Optional mpv.exe override for Windows playback entry points.
|
||||||
|
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
||||||
|
// ==========================================
|
||||||
|
"mpv": {
|
||||||
|
"executablePath": "" // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
||||||
|
}, // Optional mpv.exe override for Windows playback entry points.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Jellyfin
|
// Jellyfin
|
||||||
// Optional Jellyfin integration for auth, browsing, and playback launch.
|
// Optional Jellyfin integration for auth, browsing, and playback launch.
|
||||||
|
|||||||
@@ -117,12 +117,15 @@ SubMiner.AppImage --help # Show all options
|
|||||||
|
|
||||||
### Windows mpv Shortcut
|
### Windows mpv Shortcut
|
||||||
|
|
||||||
If you enabled the optional Windows shortcut during install, SubMiner creates a `SubMiner mpv` shortcut in the Start menu and/or on the desktop. It runs `SubMiner.exe --launch-mpv`, which starts `mpv.exe` with SubMiner's `subminer` profile.
|
First-run setup creates the config file, then requires the mpv plugin and Yomitan dictionaries before it can finish.
|
||||||
|
|
||||||
|
If you enabled the optional Windows shortcut during install, SubMiner creates a `SubMiner mpv` shortcut in the Start menu and/or on the desktop. On Windows, that shortcut is the recommended way to launch local files with SubMiner because it starts `mpv.exe` with the right defaults directly.
|
||||||
|
After setup completes, the shortcut is the normal Windows playback entry point.
|
||||||
|
|
||||||
You can use it three ways:
|
You can use it three ways:
|
||||||
|
|
||||||
- Double-click `SubMiner mpv` to open `mpv` with the SubMiner profile.
|
- Double-click `SubMiner mpv` to open `mpv` with SubMiner's default socket/subtitle args.
|
||||||
- Drag a video file onto `SubMiner mpv` to launch that file with the same profile.
|
- Drag a video file onto `SubMiner mpv` to launch that file with the same defaults.
|
||||||
- Run it directly from Command Prompt or PowerShell with `--launch-mpv`.
|
- Run it directly from Command Prompt or PowerShell with `--launch-mpv`.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -130,7 +133,7 @@ You can use it three ways:
|
|||||||
& "C:\Program Files\SubMiner\SubMiner.exe" --launch-mpv "C:\Videos\episode 01.mkv"
|
& "C:\Program Files\SubMiner\SubMiner.exe" --launch-mpv "C:\Videos\episode 01.mkv"
|
||||||
```
|
```
|
||||||
|
|
||||||
This flow requires `mpv.exe` to be on `PATH`. If it is installed elsewhere, set `SUBMINER_MPV_PATH` to the full `mpv.exe` path before launching.
|
This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blank to auto-discover from `PATH`, or set it to the full `mpv.exe` path if mpv is installed elsewhere. `SUBMINER_MPV_PATH` is still honored as a fallback. On Windows, `--launch-mpv` does not require an `mpv.conf` profile named `subminer`.
|
||||||
|
|
||||||
### Launcher Subcommands
|
### Launcher Subcommands
|
||||||
|
|
||||||
@@ -157,12 +160,13 @@ SubMiner.AppImage --setup
|
|||||||
Setup flow:
|
Setup flow:
|
||||||
|
|
||||||
- config file: create the default config directory and prefer `config.jsonc`
|
- config file: create the default config directory and prefer `config.jsonc`
|
||||||
- plugin status: install or skip the bundled mpv plugin
|
- plugin status: install the bundled mpv plugin before finishing setup
|
||||||
- Yomitan shortcut: open bundled Yomitan settings directly from the setup window
|
- Yomitan shortcut: open bundled Yomitan settings directly from the setup window
|
||||||
- dictionary check: ensure at least one bundled Yomitan dictionary is available
|
- dictionary check: ensure at least one bundled Yomitan dictionary is available, unless an external Yomitan profile is configured
|
||||||
- Windows: optionally create or remove `SubMiner mpv` Start Menu/Desktop shortcuts (`SubMiner.exe --launch-mpv`)
|
- Windows: optionally create or remove `SubMiner mpv` Start Menu/Desktop shortcuts (`SubMiner.exe --launch-mpv`)
|
||||||
|
- Windows: optionally set `mpv.executablePath` if `mpv.exe` is not on `PATH`
|
||||||
- refresh: re-check plugin + dictionary state without restarting
|
- refresh: re-check plugin + dictionary state without restarting
|
||||||
- `Finish setup` stays disabled until dictionary availability is detected
|
- `Finish setup` stays disabled until the config, plugin, and dictionary gates are satisfied
|
||||||
- finish action writes setup completion state and suppresses future auto-open prompts
|
- finish action writes setup completion state and suppresses future auto-open prompts
|
||||||
|
|
||||||
AniList character dictionary auto-sync (optional):
|
AniList character dictionary auto-sync (optional):
|
||||||
@@ -189,7 +193,7 @@ Top-level launcher flags like `--jellyfin-*` are intentionally rejected.
|
|||||||
|
|
||||||
You can append additional MPV arguments with launcher `-a/--args`, for example `--args "--ao=alsa --volume=80"`.
|
You can append additional MPV arguments with launcher `-a/--args`, for example `--args "--ao=alsa --volume=80"`.
|
||||||
|
|
||||||
You can define a matching profile in `~/.config/mpv/mpv.conf` for consistency when launching `mpv` manually or from other tools. `subminer` launches with `--profile=subminer` by default (or override with `subminer -p <profile> ...`):
|
You can define a matching profile in `~/.config/mpv/mpv.conf` for consistency when launching `mpv` manually or from other tools. The Windows `SubMiner.exe --launch-mpv` shortcut path uses equivalent args directly, but skips the extra current-directory subtitle scan to avoid duplicate sidecar detection when you drag a video onto the shortcut; the optional profile remains useful for manual mpv launches and the `subminer` wrapper defaults to `--profile=subminer` (or override with `subminer -p <profile> ...`):
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[subminer]
|
[subminer]
|
||||||
@@ -210,10 +214,6 @@ secondary-sid=auto
|
|||||||
secondary-sub-visibility=no
|
secondary-sub-visibility=no
|
||||||
```
|
```
|
||||||
|
|
||||||
::: warning
|
|
||||||
`secondary-slang` is not a valid mpv option. Use `slang` with `sid=auto` / `secondary-sid=auto` to set subtitle language preferences.
|
|
||||||
:::
|
|
||||||
|
|
||||||
### Yomitan setup
|
### Yomitan setup
|
||||||
|
|
||||||
SubMiner includes a bundled Yomitan extension for overlay word lookup. This bundled extension is separate from any Yomitan browser extension you may have installed.
|
SubMiner includes a bundled Yomitan extension for overlay word lookup. This bundled extension is separate from any Yomitan browser extension you may have installed.
|
||||||
@@ -238,6 +238,8 @@ Notes:
|
|||||||
- Secondary target languages come from `secondarySub.secondarySubLanguages` (defaults to English if unset).
|
- 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 `youtube` and `secondarySub`.
|
- Configure defaults in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`) under `youtube` and `secondarySub`.
|
||||||
|
|
||||||
|
For local video files, SubMiner now uses those same config-driven language priorities after mpv finishes reporting subtitle tracks. That means mixed internal/external subtitle sets can correct an initial `sid=auto` guess and settle onto the expected primary and secondary tracks without manual cycling.
|
||||||
|
|
||||||
## Controller Support
|
## Controller Support
|
||||||
|
|
||||||
SubMiner supports gamepad/controller input for couch-friendly usage via the Chrome Gamepad API. Controller input drives the overlay while keyboard-only mode is enabled.
|
SubMiner supports gamepad/controller input for couch-friendly usage via the Chrome Gamepad API. Controller input drives the overlay while keyboard-only mode is enabled.
|
||||||
@@ -291,9 +293,7 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
|
|||||||
| `Alt+Shift+O` | Toggle visible overlay |
|
| `Alt+Shift+O` | Toggle visible overlay |
|
||||||
| `Alt+Shift+Y` | Open Yomitan settings |
|
| `Alt+Shift+Y` | Open Yomitan settings |
|
||||||
|
|
||||||
::: tip
|
|
||||||
`Alt+Shift+Y` is fixed and not configurable. All other shortcuts can be changed under `shortcuts` in your config.
|
`Alt+Shift+Y` is fixed and not configurable. All other shortcuts can be changed under `shortcuts` in your config.
|
||||||
:::
|
|
||||||
|
|
||||||
Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback.
|
Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback.
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ import {
|
|||||||
getDefaultConfigDir,
|
getDefaultConfigDir,
|
||||||
getSetupStatePath,
|
getSetupStatePath,
|
||||||
readSetupState,
|
readSetupState,
|
||||||
|
resolveDefaultMpvInstallPaths,
|
||||||
} from '../../src/shared/setup-state.js';
|
} from '../../src/shared/setup-state.js';
|
||||||
|
import { detectInstalledFirstRunPlugin } from '../../src/main/runtime/first-run-setup-plugin.js';
|
||||||
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
||||||
|
|
||||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
@@ -105,6 +107,14 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
|||||||
const ready = await ensureLauncherSetupReady({
|
const ready = await ensureLauncherSetupReady({
|
||||||
readSetupState: () => readSetupState(statePath),
|
readSetupState: () => readSetupState(statePath),
|
||||||
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
|
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
|
||||||
|
isPluginInstalled: () => {
|
||||||
|
const installPaths = resolveDefaultMpvInstallPaths(
|
||||||
|
process.platform,
|
||||||
|
os.homedir(),
|
||||||
|
process.env.XDG_CONFIG_HOME,
|
||||||
|
);
|
||||||
|
return detectInstalledFirstRunPlugin(installPaths);
|
||||||
|
},
|
||||||
launchSetupApp: () => {
|
launchSetupApp: () => {
|
||||||
const setupArgs = ['--background', '--setup'];
|
const setupArgs = ['--background', '--setup'];
|
||||||
if (args.logLevel) {
|
if (args.logLevel) {
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ test('createDefaultArgs normalizes configured language codes and env thread over
|
|||||||
assert.deepEqual(parsed.youtubeAudioLangs, ['ja', 'jpn', 'en', 'eng']);
|
assert.deepEqual(parsed.youtubeAudioLangs, ['ja', 'jpn', 'en', 'eng']);
|
||||||
assert.equal(parsed.whisperThreads, 7);
|
assert.equal(parsed.whisperThreads, 7);
|
||||||
assert.equal(parsed.youtubeWhisperSourceLanguage, 'ja');
|
assert.equal(parsed.youtubeWhisperSourceLanguage, 'ja');
|
||||||
|
assert.equal(parsed.profile, '');
|
||||||
} finally {
|
} finally {
|
||||||
if (originalThreads === undefined) {
|
if (originalThreads === undefined) {
|
||||||
delete process.env.SUBMINER_WHISPER_THREADS;
|
delete process.env.SUBMINER_WHISPER_THREADS;
|
||||||
|
|||||||
@@ -49,10 +49,17 @@ function parseLogLevel(value: string): LogLevel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseBackend(value: string): Backend {
|
function parseBackend(value: string): Backend {
|
||||||
if (value === 'auto' || value === 'hyprland' || value === 'x11' || value === 'macos') {
|
if (
|
||||||
|
value === 'auto' ||
|
||||||
|
value === 'hyprland' ||
|
||||||
|
value === 'sway' ||
|
||||||
|
value === 'x11' ||
|
||||||
|
value === 'macos' ||
|
||||||
|
value === 'windows'
|
||||||
|
) {
|
||||||
return value as Backend;
|
return value as Backend;
|
||||||
}
|
}
|
||||||
fail(`Invalid backend: ${value} (must be auto, hyprland, x11, or macos)`);
|
fail(`Invalid backend: ${value} (must be auto, hyprland, sway, x11, macos, or windows)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDictionaryTarget(value: string): string {
|
function parseDictionaryTarget(value: string): string {
|
||||||
@@ -97,7 +104,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
|
|||||||
backend: 'auto',
|
backend: 'auto',
|
||||||
directory: '.',
|
directory: '.',
|
||||||
recursive: false,
|
recursive: false,
|
||||||
profile: 'subminer',
|
profile: '',
|
||||||
startOverlay: false,
|
startOverlay: false,
|
||||||
whisperBin: process.env.SUBMINER_WHISPER_BIN || launcherConfig.whisperBin || '',
|
whisperBin: process.env.SUBMINER_WHISPER_BIN || launcherConfig.whisperBin || '',
|
||||||
whisperModel: process.env.SUBMINER_WHISPER_MODEL || launcherConfig.whisperModel || '',
|
whisperModel: process.env.SUBMINER_WHISPER_MODEL || launcherConfig.whisperModel || '',
|
||||||
|
|||||||
@@ -17,20 +17,20 @@ test('resolveTopLevelCommand respects the app alias after root options', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('parseCliPrograms keeps root options and target when no command is present', () => {
|
test('parseCliPrograms keeps root options and target when no command is present', () => {
|
||||||
const result = parseCliPrograms(['--backend', 'x11', '/tmp/movie.mkv'], 'subminer');
|
const result = parseCliPrograms(['--backend', 'windows', '/tmp/movie.mkv'], 'subminer');
|
||||||
|
|
||||||
assert.equal(result.options.backend, 'x11');
|
assert.equal(result.options.backend, 'windows');
|
||||||
assert.equal(result.rootTarget, '/tmp/movie.mkv');
|
assert.equal(result.rootTarget, '/tmp/movie.mkv');
|
||||||
assert.equal(result.invocations.appInvocation, null);
|
assert.equal(result.invocations.appInvocation, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseCliPrograms routes app alias arguments through passthrough mode', () => {
|
test('parseCliPrograms routes app alias arguments through passthrough mode', () => {
|
||||||
const result = parseCliPrograms(
|
const result = parseCliPrograms(
|
||||||
['--backend', 'macos', 'bin', '--anilist', '--log-level', 'debug'],
|
['--backend', 'windows', 'bin', '--anilist', '--log-level', 'debug'],
|
||||||
'subminer',
|
'subminer',
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(result.options.backend, 'macos');
|
assert.equal(result.options.backend, 'windows');
|
||||||
assert.deepEqual(result.invocations.appInvocation, {
|
assert.deepEqual(result.invocations.appInvocation, {
|
||||||
appArgs: ['--anilist', '--log-level', 'debug'],
|
appArgs: ['--anilist', '--log-level', 'debug'],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ export interface CliInvocations {
|
|||||||
|
|
||||||
function applyRootOptions(program: Command): void {
|
function applyRootOptions(program: Command): void {
|
||||||
program
|
program
|
||||||
.option('-b, --backend <backend>', 'Display backend')
|
.option(
|
||||||
|
'-b, --backend <backend>',
|
||||||
|
'Display backend (auto, hyprland, sway, x11, macos, windows)',
|
||||||
|
)
|
||||||
.option('-d, --directory <dir>', 'Directory to browse')
|
.option('-d, --directory <dir>', 'Directory to browse')
|
||||||
.option('-a, --args <args>', 'Pass arguments to MPV')
|
.option('-a, --args <args>', 'Pass arguments to MPV')
|
||||||
.option('-r, --recursive', 'Search directories recursively')
|
.option('-r, --recursive', 'Search directories recursively')
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { EventEmitter } from 'node:events';
|
|||||||
import type { Args } from './types';
|
import type { Args } from './types';
|
||||||
import {
|
import {
|
||||||
cleanupPlaybackSession,
|
cleanupPlaybackSession,
|
||||||
|
detectBackend,
|
||||||
findAppBinary,
|
findAppBinary,
|
||||||
launchAppCommandDetached,
|
launchAppCommandDetached,
|
||||||
launchTexthookerOnly,
|
launchTexthookerOnly,
|
||||||
@@ -56,6 +57,22 @@ function createTempSocketPath(): { dir: string; socketPath: string } {
|
|||||||
return { dir, socketPath: path.join(dir, 'mpv.sock') };
|
return { dir, socketPath: path.join(dir, 'mpv.sock') };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withPlatform<T>(platform: NodeJS.Platform, callback: () => T): T {
|
||||||
|
const originalDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||||
|
Object.defineProperty(process, 'platform', {
|
||||||
|
configurable: true,
|
||||||
|
value: platform,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return callback();
|
||||||
|
} finally {
|
||||||
|
if (originalDescriptor) {
|
||||||
|
Object.defineProperty(process, 'platform', originalDescriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test('mpv module exposes only canonical socket readiness helper', () => {
|
test('mpv module exposes only canonical socket readiness helper', () => {
|
||||||
assert.equal('waitForSocket' in mpvModule, false);
|
assert.equal('waitForSocket' in mpvModule, false);
|
||||||
});
|
});
|
||||||
@@ -102,6 +119,12 @@ test('parseMpvArgString preserves empty quoted tokens', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('detectBackend resolves windows on win32 auto mode', () => {
|
||||||
|
withPlatform('win32', () => {
|
||||||
|
assert.equal(detectBackend('auto'), 'windows');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
|
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
|
||||||
const error = withProcessExitIntercept(() => {
|
const error = withProcessExitIntercept(() => {
|
||||||
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
|
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
|
||||||
@@ -427,6 +450,21 @@ function withFindAppBinaryEnvSandbox(run: () => void): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withFindAppBinaryPlatformSandbox(
|
||||||
|
platform: NodeJS.Platform,
|
||||||
|
run: (pathModule: typeof path) => void,
|
||||||
|
): void {
|
||||||
|
const originalPlatform = process.platform;
|
||||||
|
try {
|
||||||
|
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
|
||||||
|
withFindAppBinaryEnvSandbox(() =>
|
||||||
|
run(platform === 'win32' ? (path.win32 as typeof path) : path),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function withAccessSyncStub(
|
function withAccessSyncStub(
|
||||||
isExecutablePath: (filePath: string) => boolean,
|
isExecutablePath: (filePath: string) => boolean,
|
||||||
run: () => void,
|
run: () => void,
|
||||||
@@ -447,7 +485,22 @@ function withAccessSyncStub(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test('findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists', () => {
|
function withRealpathSyncStub(resolvePath: (filePath: string) => string, run: () => void): void {
|
||||||
|
const originalRealpathSync = fs.realpathSync;
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(fs as any).realpathSync = (filePath: string): string => resolvePath(filePath);
|
||||||
|
run();
|
||||||
|
} finally {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(fs as any).realpathSync = originalRealpathSync;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
'findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists',
|
||||||
|
{ concurrency: false },
|
||||||
|
() => {
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
|
||||||
const originalHomedir = os.homedir;
|
const originalHomedir = os.homedir;
|
||||||
try {
|
try {
|
||||||
@@ -455,26 +508,30 @@ test('findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists', ()
|
|||||||
const appImage = path.join(baseDir, '.local/bin/SubMiner.AppImage');
|
const appImage = path.join(baseDir, '.local/bin/SubMiner.AppImage');
|
||||||
makeExecutable(appImage);
|
makeExecutable(appImage);
|
||||||
|
|
||||||
withFindAppBinaryEnvSandbox(() => {
|
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
|
||||||
const result = findAppBinary('/some/other/path/subminer');
|
const result = findAppBinary('/some/other/path/subminer', pathModule);
|
||||||
assert.equal(result, appImage);
|
assert.equal(result, appImage);
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
os.homedir = originalHomedir;
|
os.homedir = originalHomedir;
|
||||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin candidate does not exist', () => {
|
test(
|
||||||
|
'findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin candidate does not exist',
|
||||||
|
{ concurrency: false },
|
||||||
|
() => {
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
|
||||||
const originalHomedir = os.homedir;
|
const originalHomedir = os.homedir;
|
||||||
try {
|
try {
|
||||||
os.homedir = () => baseDir;
|
os.homedir = () => baseDir;
|
||||||
withFindAppBinaryEnvSandbox(() => {
|
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
|
||||||
withAccessSyncStub(
|
withAccessSyncStub(
|
||||||
(filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage',
|
(filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage',
|
||||||
() => {
|
() => {
|
||||||
const result = findAppBinary('/some/other/path/subminer');
|
const result = findAppBinary('/some/other/path/subminer', pathModule);
|
||||||
assert.equal(result, '/opt/SubMiner/SubMiner.AppImage');
|
assert.equal(result, '/opt/SubMiner/SubMiner.AppImage');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -483,9 +540,13 @@ test('findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin c
|
|||||||
os.homedir = originalHomedir;
|
os.homedir = originalHomedir;
|
||||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('findAppBinary finds subminer on PATH when AppImage candidates do not exist', () => {
|
test(
|
||||||
|
'findAppBinary finds subminer on PATH when AppImage candidates do not exist',
|
||||||
|
{ concurrency: false },
|
||||||
|
() => {
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-path-'));
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-path-'));
|
||||||
const originalHomedir = os.homedir;
|
const originalHomedir = os.homedir;
|
||||||
const originalPath = process.env.PATH;
|
const originalPath = process.env.PATH;
|
||||||
@@ -497,12 +558,124 @@ test('findAppBinary finds subminer on PATH when AppImage candidates do not exist
|
|||||||
makeExecutable(wrapperPath);
|
makeExecutable(wrapperPath);
|
||||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
|
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
|
||||||
|
|
||||||
withFindAppBinaryEnvSandbox(() => {
|
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
|
||||||
withAccessSyncStub(
|
withAccessSyncStub(
|
||||||
(filePath) => filePath === wrapperPath,
|
(filePath) => filePath === wrapperPath,
|
||||||
() => {
|
() => {
|
||||||
// selfPath must differ from wrapperPath so the self-check does not exclude it
|
// selfPath must differ from wrapperPath so the self-check does not exclude it
|
||||||
const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer'));
|
const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer'), pathModule);
|
||||||
|
assert.equal(result, wrapperPath);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
os.homedir = originalHomedir;
|
||||||
|
process.env.PATH = originalPath;
|
||||||
|
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'findAppBinary excludes PATH matches that canonicalize to the launcher path',
|
||||||
|
{ concurrency: false },
|
||||||
|
() => {
|
||||||
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-realpath-'));
|
||||||
|
const originalHomedir = os.homedir;
|
||||||
|
const originalPath = process.env.PATH;
|
||||||
|
try {
|
||||||
|
os.homedir = () => baseDir;
|
||||||
|
const binDir = path.join(baseDir, 'bin');
|
||||||
|
const wrapperPath = path.join(binDir, 'subminer');
|
||||||
|
const canonicalPath = path.join(baseDir, 'launch', 'subminer');
|
||||||
|
makeExecutable(wrapperPath);
|
||||||
|
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
|
||||||
|
|
||||||
|
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
|
||||||
|
withAccessSyncStub(
|
||||||
|
(filePath) => filePath === wrapperPath,
|
||||||
|
() => {
|
||||||
|
withRealpathSyncStub(
|
||||||
|
(filePath) => {
|
||||||
|
if (filePath === canonicalPath || filePath === wrapperPath) {
|
||||||
|
return canonicalPath;
|
||||||
|
}
|
||||||
|
return filePath;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
const result = findAppBinary(canonicalPath, pathModule);
|
||||||
|
assert.equal(result, null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
os.homedir = originalHomedir;
|
||||||
|
process.env.PATH = originalPath;
|
||||||
|
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('findAppBinary resolves Windows install paths when present', { concurrency: false }, () => {
|
||||||
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-'));
|
||||||
|
const originalHomedir = os.homedir;
|
||||||
|
const originalLocalAppData = process.env.LOCALAPPDATA;
|
||||||
|
try {
|
||||||
|
os.homedir = () => baseDir;
|
||||||
|
process.env.LOCALAPPDATA = path.win32.join(baseDir, 'AppData', 'Local');
|
||||||
|
const appExe = path.win32.join(
|
||||||
|
baseDir,
|
||||||
|
'AppData',
|
||||||
|
'Local',
|
||||||
|
'Programs',
|
||||||
|
'SubMiner',
|
||||||
|
'SubMiner.exe',
|
||||||
|
);
|
||||||
|
|
||||||
|
withFindAppBinaryPlatformSandbox('win32', (pathModule) => {
|
||||||
|
withAccessSyncStub(
|
||||||
|
(filePath) => filePath === appExe,
|
||||||
|
() => {
|
||||||
|
const result = findAppBinary(
|
||||||
|
pathModule.join(baseDir, 'launcher', 'SubMiner.exe'),
|
||||||
|
pathModule,
|
||||||
|
);
|
||||||
|
assert.equal(result, appExe);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
os.homedir = originalHomedir;
|
||||||
|
if (originalLocalAppData === undefined) {
|
||||||
|
delete process.env.LOCALAPPDATA;
|
||||||
|
} else {
|
||||||
|
process.env.LOCALAPPDATA = originalLocalAppData;
|
||||||
|
}
|
||||||
|
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findAppBinary resolves SubMiner.exe on PATH on Windows', { concurrency: false }, () => {
|
||||||
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-path-'));
|
||||||
|
const originalHomedir = os.homedir;
|
||||||
|
const originalPath = process.env.PATH;
|
||||||
|
try {
|
||||||
|
os.homedir = () => baseDir;
|
||||||
|
const binDir = path.win32.join(baseDir, 'bin');
|
||||||
|
const wrapperPath = path.win32.join(binDir, 'SubMiner.exe');
|
||||||
|
makeExecutable(wrapperPath);
|
||||||
|
process.env.PATH = `${binDir}${path.win32.delimiter}${originalPath ?? ''}`;
|
||||||
|
|
||||||
|
withFindAppBinaryPlatformSandbox('win32', (pathModule) => {
|
||||||
|
withAccessSyncStub(
|
||||||
|
(filePath) => filePath === wrapperPath,
|
||||||
|
() => {
|
||||||
|
const result = findAppBinary(
|
||||||
|
pathModule.join(baseDir, 'launcher', 'SubMiner.exe'),
|
||||||
|
pathModule,
|
||||||
|
);
|
||||||
assert.equal(result, wrapperPath);
|
assert.equal(result, wrapperPath);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -513,3 +686,42 @@ test('findAppBinary finds subminer on PATH when AppImage candidates do not exist
|
|||||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'findAppBinary resolves a Windows install directory to SubMiner.exe',
|
||||||
|
{ concurrency: false },
|
||||||
|
() => {
|
||||||
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-dir-'));
|
||||||
|
const originalHomedir = os.homedir;
|
||||||
|
const originalSubminerBinaryPath = process.env.SUBMINER_BINARY_PATH;
|
||||||
|
try {
|
||||||
|
os.homedir = () => baseDir;
|
||||||
|
const installDir = path.win32.join(baseDir, 'Programs', 'SubMiner');
|
||||||
|
const appExe = path.win32.join(installDir, 'SubMiner.exe');
|
||||||
|
process.env.SUBMINER_BINARY_PATH = installDir;
|
||||||
|
fs.mkdirSync(installDir, { recursive: true });
|
||||||
|
fs.writeFileSync(appExe, '#!/bin/sh\nexit 0\n');
|
||||||
|
fs.chmodSync(appExe, 0o755);
|
||||||
|
|
||||||
|
const originalPlatform = process.platform;
|
||||||
|
try {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||||
|
const result = findAppBinary(
|
||||||
|
path.win32.join(baseDir, 'launcher', 'SubMiner.exe'),
|
||||||
|
path.win32,
|
||||||
|
);
|
||||||
|
assert.equal(result, appExe);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
os.homedir = originalHomedir;
|
||||||
|
if (originalSubminerBinaryPath === undefined) {
|
||||||
|
delete process.env.SUBMINER_BINARY_PATH;
|
||||||
|
} else {
|
||||||
|
process.env.SUBMINER_BINARY_PATH = originalSubminerBinaryPath;
|
||||||
|
}
|
||||||
|
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
121
launcher/mpv.ts
121
launcher/mpv.ts
@@ -14,11 +14,11 @@ import {
|
|||||||
isExecutable,
|
isExecutable,
|
||||||
resolveBinaryPathCandidate,
|
resolveBinaryPathCandidate,
|
||||||
resolveCommandInvocation,
|
resolveCommandInvocation,
|
||||||
realpathMaybe,
|
|
||||||
isYoutubeTarget,
|
isYoutubeTarget,
|
||||||
uniqueNormalizedLangCodes,
|
uniqueNormalizedLangCodes,
|
||||||
sleep,
|
sleep,
|
||||||
normalizeLangCode,
|
normalizeLangCode,
|
||||||
|
realpathMaybe,
|
||||||
} from './util.js';
|
} from './util.js';
|
||||||
|
|
||||||
export const state = {
|
export const state = {
|
||||||
@@ -35,6 +35,8 @@ type SpawnTarget = {
|
|||||||
args: string[];
|
args: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PathModule = Pick<typeof path, 'join' | 'extname' | 'delimiter' | 'sep' | 'resolve'>;
|
||||||
|
|
||||||
const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid');
|
const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid');
|
||||||
const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900;
|
const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900;
|
||||||
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700;
|
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700;
|
||||||
@@ -225,6 +227,7 @@ export function makeTempDir(prefix: string): string {
|
|||||||
|
|
||||||
export function detectBackend(backend: Backend): Exclude<Backend, 'auto'> {
|
export function detectBackend(backend: Backend): Exclude<Backend, 'auto'> {
|
||||||
if (backend !== 'auto') return backend;
|
if (backend !== 'auto') return backend;
|
||||||
|
if (process.platform === 'win32') return 'windows';
|
||||||
if (process.platform === 'darwin') return 'macos';
|
if (process.platform === 'darwin') return 'macos';
|
||||||
const xdgCurrentDesktop = (process.env.XDG_CURRENT_DESKTOP || '').toLowerCase();
|
const xdgCurrentDesktop = (process.env.XDG_CURRENT_DESKTOP || '').toLowerCase();
|
||||||
const xdgSessionDesktop = (process.env.XDG_SESSION_DESKTOP || '').toLowerCase();
|
const xdgSessionDesktop = (process.env.XDG_SESSION_DESKTOP || '').toLowerCase();
|
||||||
@@ -243,18 +246,49 @@ export function detectBackend(backend: Backend): Exclude<Backend, 'auto'> {
|
|||||||
fail('Could not detect display backend');
|
fail('Could not detect display backend');
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMacAppBinaryCandidate(candidate: string): string {
|
function resolveAppBinaryCandidate(candidate: string, pathModule: PathModule = path): string {
|
||||||
const direct = resolveBinaryPathCandidate(candidate);
|
const direct = resolveBinaryPathCandidate(candidate);
|
||||||
if (!direct) return '';
|
if (!direct) return '';
|
||||||
|
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform === 'win32') {
|
||||||
return isExecutable(direct) ? direct : '';
|
try {
|
||||||
|
if (fs.existsSync(direct) && fs.statSync(direct).isDirectory()) {
|
||||||
|
for (const candidateBinary of ['SubMiner.exe', 'subminer.exe']) {
|
||||||
|
const nestedCandidate = pathModule.join(direct, candidateBinary);
|
||||||
|
if (isExecutable(nestedCandidate)) {
|
||||||
|
return nestedCandidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isExecutable(direct)) {
|
if (isExecutable(direct)) {
|
||||||
return direct;
|
return direct;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!pathModule.extname(direct)) {
|
||||||
|
for (const extension of ['.exe', '.cmd', '.bat']) {
|
||||||
|
const withExtension = `${direct}${extension}`;
|
||||||
|
if (isExecutable(withExtension)) {
|
||||||
|
return withExtension;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExecutable(direct)) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
const appIndex = direct.indexOf('.app/');
|
const appIndex = direct.indexOf('.app/');
|
||||||
const appPath =
|
const appPath =
|
||||||
direct.endsWith('.app') && direct.includes('.app')
|
direct.endsWith('.app') && direct.includes('.app')
|
||||||
@@ -265,8 +299,8 @@ function resolveMacAppBinaryCandidate(candidate: string): string {
|
|||||||
if (!appPath) return '';
|
if (!appPath) return '';
|
||||||
|
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(appPath, 'Contents', 'MacOS', 'SubMiner'),
|
pathModule.join(appPath, 'Contents', 'MacOS', 'SubMiner'),
|
||||||
path.join(appPath, 'Contents', 'MacOS', 'subminer'),
|
pathModule.join(appPath, 'Contents', 'MacOS', 'subminer'),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const candidateBinary of candidates) {
|
for (const candidateBinary of candidates) {
|
||||||
@@ -278,37 +312,78 @@ function resolveMacAppBinaryCandidate(candidate: string): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findAppBinary(selfPath: string): string | null {
|
function findCommandOnPath(candidates: string[], pathModule: PathModule = path): string {
|
||||||
|
const pathDirs = getPathEnv().split(pathModule.delimiter);
|
||||||
|
for (const candidateName of candidates) {
|
||||||
|
for (const dir of pathDirs) {
|
||||||
|
if (!dir) continue;
|
||||||
|
|
||||||
|
const directCandidate = pathModule.join(dir, candidateName);
|
||||||
|
if (isExecutable(directCandidate)) {
|
||||||
|
return directCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'win32' && !pathModule.extname(candidateName)) {
|
||||||
|
for (const extension of ['.exe', '.cmd', '.bat']) {
|
||||||
|
const extendedCandidate = pathModule.join(dir, `${candidateName}${extension}`);
|
||||||
|
if (isExecutable(extendedCandidate)) {
|
||||||
|
return extendedCandidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findAppBinary(selfPath: string, pathModule: PathModule = path): string | null {
|
||||||
const envPaths = [process.env.SUBMINER_APPIMAGE_PATH, process.env.SUBMINER_BINARY_PATH].filter(
|
const envPaths = [process.env.SUBMINER_APPIMAGE_PATH, process.env.SUBMINER_BINARY_PATH].filter(
|
||||||
(candidate): candidate is string => Boolean(candidate),
|
(candidate): candidate is string => Boolean(candidate),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const envPath of envPaths) {
|
for (const envPath of envPaths) {
|
||||||
const resolved = resolveMacAppBinaryCandidate(envPath);
|
const resolved = resolveAppBinaryCandidate(envPath, pathModule);
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates: string[] = [];
|
const candidates: string[] = [];
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'win32') {
|
||||||
|
const localAppData =
|
||||||
|
process.env.LOCALAPPDATA?.trim() ||
|
||||||
|
(process.env.APPDATA?.trim() || '').replace(/[\\/]Roaming$/i, `${pathModule.sep}Local`) ||
|
||||||
|
pathModule.join(os.homedir(), 'AppData', 'Local');
|
||||||
|
const programFiles = process.env.ProgramFiles?.trim() || 'C:\\Program Files';
|
||||||
|
const programFilesX86 = process.env['ProgramFiles(x86)']?.trim() || 'C:\\Program Files (x86)';
|
||||||
|
candidates.push(pathModule.join(localAppData, 'Programs', 'SubMiner', 'SubMiner.exe'));
|
||||||
|
candidates.push(pathModule.join(programFiles, 'SubMiner', 'SubMiner.exe'));
|
||||||
|
candidates.push(pathModule.join(programFilesX86, 'SubMiner', 'SubMiner.exe'));
|
||||||
|
candidates.push('C:\\SubMiner\\SubMiner.exe');
|
||||||
|
} else if (process.platform === 'darwin') {
|
||||||
candidates.push('/Applications/SubMiner.app/Contents/MacOS/SubMiner');
|
candidates.push('/Applications/SubMiner.app/Contents/MacOS/SubMiner');
|
||||||
candidates.push('/Applications/SubMiner.app/Contents/MacOS/subminer');
|
candidates.push('/Applications/SubMiner.app/Contents/MacOS/subminer');
|
||||||
candidates.push(path.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/SubMiner'));
|
candidates.push(
|
||||||
candidates.push(path.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/subminer'));
|
pathModule.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/SubMiner'),
|
||||||
}
|
);
|
||||||
|
candidates.push(
|
||||||
candidates.push(path.join(os.homedir(), '.local/bin/SubMiner.AppImage'));
|
pathModule.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/subminer'),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
candidates.push(pathModule.join(os.homedir(), '.local/bin/SubMiner.AppImage'));
|
||||||
candidates.push('/opt/SubMiner/SubMiner.AppImage');
|
candidates.push('/opt/SubMiner/SubMiner.AppImage');
|
||||||
|
}
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (isExecutable(candidate)) return candidate;
|
const resolved = resolveAppBinaryCandidate(candidate, pathModule);
|
||||||
|
if (resolved) return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromPath = getPathEnv()
|
const fromPath = findCommandOnPath(
|
||||||
.split(path.delimiter)
|
process.platform === 'win32' ? ['SubMiner', 'subminer'] : ['subminer'],
|
||||||
.map((dir) => path.join(dir, 'subminer'))
|
pathModule,
|
||||||
.find((candidate) => isExecutable(candidate));
|
);
|
||||||
|
|
||||||
if (fromPath) {
|
if (fromPath) {
|
||||||
const resolvedSelf = realpathMaybe(selfPath);
|
const resolvedSelf = realpathMaybe(selfPath);
|
||||||
@@ -634,7 +709,9 @@ export async function startMpv(
|
|||||||
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
||||||
mpvArgs.push(target);
|
mpvArgs.push(target);
|
||||||
|
|
||||||
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs);
|
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs, {
|
||||||
|
normalizeWindowsShellArgs: false,
|
||||||
|
});
|
||||||
state.mpvProc = spawn(mpvTarget.command, mpvTarget.args, { stdio: 'inherit' });
|
state.mpvProc = spawn(mpvTarget.command, mpvTarget.args, { stdio: 'inherit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1076,7 +1153,9 @@ export function launchMpvIdleDetached(
|
|||||||
);
|
);
|
||||||
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
|
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
|
||||||
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
||||||
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs);
|
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs, {
|
||||||
|
normalizeWindowsShellArgs: false,
|
||||||
|
});
|
||||||
const proc = spawn(mpvTarget.command, mpvTarget.args, {
|
const proc = spawn(mpvTarget.command, mpvTarget.args, {
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
detached: true,
|
detached: true,
|
||||||
|
|||||||
@@ -116,6 +116,36 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf
|
|||||||
assert.deepEqual(calls, []);
|
assert.deepEqual(calls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ensureLauncherSetupReady bypasses setup gate when plugin is already installed', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
const ready = await ensureLauncherSetupReady({
|
||||||
|
readSetupState: () => ({
|
||||||
|
version: 3,
|
||||||
|
status: 'cancelled',
|
||||||
|
completedAt: null,
|
||||||
|
completionSource: null,
|
||||||
|
yomitanSetupMode: null,
|
||||||
|
lastSeenYomitanDictionaryCount: 0,
|
||||||
|
pluginInstallStatus: 'unknown',
|
||||||
|
pluginInstallPathSummary: null,
|
||||||
|
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||||
|
windowsMpvShortcutLastStatus: 'unknown',
|
||||||
|
}),
|
||||||
|
isPluginInstalled: () => true,
|
||||||
|
launchSetupApp: () => {
|
||||||
|
calls.push('launch');
|
||||||
|
},
|
||||||
|
sleep: async () => undefined,
|
||||||
|
now: () => 0,
|
||||||
|
timeoutMs: 5_000,
|
||||||
|
pollIntervalMs: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(ready, true);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
||||||
const result = await ensureLauncherSetupReady({
|
const result = await ensureLauncherSetupReady({
|
||||||
readSetupState: () => ({
|
readSetupState: () => ({
|
||||||
@@ -132,10 +162,73 @@ test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
|||||||
}),
|
}),
|
||||||
launchSetupApp: () => undefined,
|
launchSetupApp: () => undefined,
|
||||||
sleep: async () => undefined,
|
sleep: async () => undefined,
|
||||||
now: () => 0,
|
now: (() => {
|
||||||
|
let value = 0;
|
||||||
|
return () => (value += 100);
|
||||||
|
})(),
|
||||||
timeoutMs: 5_000,
|
timeoutMs: 5_000,
|
||||||
pollIntervalMs: 100,
|
pollIntervalMs: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result, false);
|
assert.equal(result, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ensureLauncherSetupReady ignores stale cancelled state after launching setup app', async () => {
|
||||||
|
let reads = 0;
|
||||||
|
|
||||||
|
const result = await ensureLauncherSetupReady({
|
||||||
|
readSetupState: () => {
|
||||||
|
reads += 1;
|
||||||
|
if (reads <= 2) {
|
||||||
|
return {
|
||||||
|
version: 3,
|
||||||
|
status: 'cancelled',
|
||||||
|
completedAt: null,
|
||||||
|
completionSource: null,
|
||||||
|
yomitanSetupMode: null,
|
||||||
|
lastSeenYomitanDictionaryCount: 0,
|
||||||
|
pluginInstallStatus: 'unknown',
|
||||||
|
pluginInstallPathSummary: null,
|
||||||
|
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||||
|
windowsMpvShortcutLastStatus: 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (reads === 3) {
|
||||||
|
return {
|
||||||
|
version: 3,
|
||||||
|
status: 'in_progress',
|
||||||
|
completedAt: null,
|
||||||
|
completionSource: null,
|
||||||
|
yomitanSetupMode: null,
|
||||||
|
lastSeenYomitanDictionaryCount: 0,
|
||||||
|
pluginInstallStatus: 'unknown',
|
||||||
|
pluginInstallPathSummary: null,
|
||||||
|
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||||
|
windowsMpvShortcutLastStatus: 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: 3,
|
||||||
|
status: 'completed',
|
||||||
|
completedAt: '2026-03-07T00:00:00.000Z',
|
||||||
|
completionSource: 'legacy_auto_detected',
|
||||||
|
yomitanSetupMode: 'internal',
|
||||||
|
lastSeenYomitanDictionaryCount: 1,
|
||||||
|
pluginInstallStatus: 'installed',
|
||||||
|
pluginInstallPathSummary: '/tmp/mpv',
|
||||||
|
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||||
|
windowsMpvShortcutLastStatus: 'unknown',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
launchSetupApp: () => undefined,
|
||||||
|
sleep: async () => undefined,
|
||||||
|
now: (() => {
|
||||||
|
let value = 0;
|
||||||
|
return () => (value += 100);
|
||||||
|
})(),
|
||||||
|
timeoutMs: 5_000,
|
||||||
|
pollIntervalMs: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,15 +6,24 @@ export async function waitForSetupCompletion(deps: {
|
|||||||
now: () => number;
|
now: () => number;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
pollIntervalMs: number;
|
pollIntervalMs: number;
|
||||||
|
ignoreInitialCancelledState?: boolean;
|
||||||
}): Promise<'completed' | 'cancelled' | 'timeout'> {
|
}): Promise<'completed' | 'cancelled' | 'timeout'> {
|
||||||
const deadline = deps.now() + deps.timeoutMs;
|
const deadline = deps.now() + deps.timeoutMs;
|
||||||
|
let ignoringCancelled = deps.ignoreInitialCancelledState === true;
|
||||||
|
|
||||||
while (deps.now() <= deadline) {
|
while (deps.now() <= deadline) {
|
||||||
const state = deps.readSetupState();
|
const state = deps.readSetupState();
|
||||||
if (isSetupCompleted(state)) {
|
if (isSetupCompleted(state)) {
|
||||||
return 'completed';
|
return 'completed';
|
||||||
}
|
}
|
||||||
|
if (ignoringCancelled && state != null && state.status !== 'cancelled') {
|
||||||
|
ignoringCancelled = false;
|
||||||
|
}
|
||||||
if (state?.status === 'cancelled') {
|
if (state?.status === 'cancelled') {
|
||||||
|
if (ignoringCancelled) {
|
||||||
|
await deps.sleep(deps.pollIntervalMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
return 'cancelled';
|
return 'cancelled';
|
||||||
}
|
}
|
||||||
await deps.sleep(deps.pollIntervalMs);
|
await deps.sleep(deps.pollIntervalMs);
|
||||||
@@ -26,6 +35,7 @@ export async function waitForSetupCompletion(deps: {
|
|||||||
export async function ensureLauncherSetupReady(deps: {
|
export async function ensureLauncherSetupReady(deps: {
|
||||||
readSetupState: () => SetupState | null;
|
readSetupState: () => SetupState | null;
|
||||||
isExternalYomitanConfigured?: () => boolean;
|
isExternalYomitanConfigured?: () => boolean;
|
||||||
|
isPluginInstalled?: () => boolean;
|
||||||
launchSetupApp: () => void;
|
launchSetupApp: () => void;
|
||||||
sleep: (ms: number) => Promise<void>;
|
sleep: (ms: number) => Promise<void>;
|
||||||
now: () => number;
|
now: () => number;
|
||||||
@@ -35,11 +45,18 @@ export async function ensureLauncherSetupReady(deps: {
|
|||||||
if (deps.isExternalYomitanConfigured?.()) {
|
if (deps.isExternalYomitanConfigured?.()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (isSetupCompleted(deps.readSetupState())) {
|
if (deps.isPluginInstalled?.()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const initialState = deps.readSetupState();
|
||||||
|
if (isSetupCompleted(initialState)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
deps.launchSetupApp();
|
deps.launchSetupApp();
|
||||||
const result = await waitForSetupCompletion(deps);
|
const result = await waitForSetupCompletion({
|
||||||
|
...deps,
|
||||||
|
ignoreInitialCancelledState: initialState?.status === 'cancelled',
|
||||||
|
});
|
||||||
return result === 'completed';
|
return result === 'completed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function createSmokeCase(name: string): SmokeCase {
|
|||||||
|
|
||||||
writeExecutable(
|
writeExecutable(
|
||||||
fakeMpvPath,
|
fakeMpvPath,
|
||||||
`#!/usr/bin/env node
|
`#!/usr/bin/env bun
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const net = require('node:net');
|
const net = require('node:net');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
@@ -118,7 +118,7 @@ process.on('SIGTERM', closeAndExit);
|
|||||||
|
|
||||||
writeExecutable(
|
writeExecutable(
|
||||||
fakeAppPath,
|
fakeAppPath,
|
||||||
`#!/usr/bin/env node
|
`#!/usr/bin/env bun
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
|
|
||||||
const logPath = ${JSON.stringify(fakeAppLogPath)};
|
const logPath = ${JSON.stringify(fakeAppLogPath)};
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export const DEFAULT_MPV_SUBMINER_ARGS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||||
export type Backend = 'auto' | 'hyprland' | 'x11' | 'macos';
|
export type Backend = 'auto' | 'hyprland' | 'sway' | 'x11' | 'macos' | 'windows';
|
||||||
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
|
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
|
||||||
|
|
||||||
export interface LauncherAiConfig {
|
export interface LauncherAiConfig {
|
||||||
|
|||||||
@@ -244,13 +244,19 @@ export function inferWhisperLanguage(langCodes: string[], fallback: string): str
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CommandInvocationOptions {
|
||||||
|
normalizeWindowsShellArgs?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveCommandInvocation(
|
export function resolveCommandInvocation(
|
||||||
executable: string,
|
executable: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
|
options: CommandInvocationOptions = {},
|
||||||
): { command: string; args: string[] } {
|
): { command: string; args: string[] } {
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
return { command: executable, args };
|
return { command: executable, args };
|
||||||
}
|
}
|
||||||
|
const { normalizeWindowsShellArgs = true } = options;
|
||||||
|
|
||||||
const resolvedExecutable = resolveExecutablePath(executable) ?? executable;
|
const resolvedExecutable = resolveExecutablePath(executable) ?? executable;
|
||||||
const extension = path.extname(resolvedExecutable).toLowerCase();
|
const extension = path.extname(resolvedExecutable).toLowerCase();
|
||||||
@@ -267,7 +273,9 @@ export function resolveCommandInvocation(
|
|||||||
command: bashTarget.command,
|
command: bashTarget.command,
|
||||||
args: [
|
args: [
|
||||||
normalizeWindowsShellArg(resolvedExecutable, bashTarget.flavor),
|
normalizeWindowsShellArg(resolvedExecutable, bashTarget.flavor),
|
||||||
...args.map((arg) => normalizeWindowsShellArg(arg, bashTarget.flavor)),
|
...args.map((arg) =>
|
||||||
|
normalizeWindowsShellArgs ? normalizeWindowsShellArg(arg, bashTarget.flavor) : arg,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -280,7 +288,9 @@ export function resolveCommandInvocation(
|
|||||||
command: bashTarget.command,
|
command: bashTarget.command,
|
||||||
args: [
|
args: [
|
||||||
normalizeWindowsShellArg(resolvedExecutable, bashTarget.flavor),
|
normalizeWindowsShellArg(resolvedExecutable, bashTarget.flavor),
|
||||||
...args.map((arg) => normalizeWindowsShellArg(arg, bashTarget.flavor)),
|
...args.map((arg) =>
|
||||||
|
normalizeWindowsShellArgs ? normalizeWindowsShellArg(arg, bashTarget.flavor) : arg,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
15
package.json
15
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"version": "0.10.0",
|
"version": "0.11.0",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -13,9 +13,10 @@
|
|||||||
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js",
|
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js",
|
||||||
"build:yomitan": "bun scripts/build-yomitan.mjs",
|
"build:yomitan": "bun scripts/build-yomitan.mjs",
|
||||||
"build:assets": "bun scripts/prepare-build-assets.mjs",
|
"build:assets": "bun scripts/prepare-build-assets.mjs",
|
||||||
|
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --outfile=dist/launcher/subminer",
|
||||||
"build:stats": "cd stats && bun run build",
|
"build:stats": "cd stats && bun run build",
|
||||||
"dev:stats": "cd stats && bun run dev",
|
"dev:stats": "cd stats && bun run dev",
|
||||||
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:assets",
|
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:launcher && bun run build:assets",
|
||||||
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
||||||
"changelog:build": "bun run scripts/build-changelog.ts build",
|
"changelog:build": "bun run scripts/build-changelog.ts build",
|
||||||
"changelog:check": "bun run scripts/build-changelog.ts check",
|
"changelog:check": "bun run scripts/build-changelog.ts check",
|
||||||
@@ -37,8 +38,8 @@
|
|||||||
"docs:preview": "bun run --cwd docs-site docs:preview",
|
"docs:preview": "bun run --cwd docs-site docs:preview",
|
||||||
"docs:test": "bun run --cwd docs-site test",
|
"docs:test": "bun run --cwd docs-site test",
|
||||||
"test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts",
|
"test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts",
|
||||||
"test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts src/verify-config-example.test.ts",
|
"test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/integrations.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts src/verify-config-example.test.ts",
|
||||||
"test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js dist/verify-config-example.test.js",
|
"test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/integrations.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js dist/verify-config-example.test.js",
|
||||||
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
|
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
|
||||||
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-binary-windows.lua",
|
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-binary-windows.lua",
|
||||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
||||||
@@ -122,7 +123,7 @@
|
|||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"executableName": "SubMiner",
|
"executableName": "SubMiner",
|
||||||
"artifactName": "SubMiner-${version}.${ext}",
|
"artifactName": "SubMiner-${version}.${ext}",
|
||||||
"icon": "assets/SubMiner.png",
|
"icon": "assets/SubMiner-square.png",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"output": "release"
|
||||||
},
|
},
|
||||||
@@ -141,7 +142,7 @@
|
|||||||
"zip"
|
"zip"
|
||||||
],
|
],
|
||||||
"category": "public.app-category.video",
|
"category": "public.app-category.video",
|
||||||
"icon": "assets/SubMiner.png",
|
"icon": "assets/SubMiner-square.png",
|
||||||
"hardenedRuntime": true,
|
"hardenedRuntime": true,
|
||||||
"entitlements": "build/entitlements.mac.plist",
|
"entitlements": "build/entitlements.mac.plist",
|
||||||
"entitlementsInherit": "build/entitlements.mac.plist",
|
"entitlementsInherit": "build/entitlements.mac.plist",
|
||||||
@@ -157,7 +158,7 @@
|
|||||||
"nsis",
|
"nsis",
|
||||||
"zip"
|
"zip"
|
||||||
],
|
],
|
||||||
"icon": "assets/SubMiner.png"
|
"icon": "assets/SubMiner.ico"
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ texthooker_enabled=yes
|
|||||||
# Texthooker WebSocket port
|
# Texthooker WebSocket port
|
||||||
texthooker_port=5174
|
texthooker_port=5174
|
||||||
|
|
||||||
# Window manager backend: auto, hyprland, sway, x11
|
# Window manager backend: auto, hyprland, sway, x11, macos, windows
|
||||||
# "auto" detects based on environment variables
|
# "auto" detects based on environment variables
|
||||||
backend=auto
|
backend=auto
|
||||||
|
|
||||||
|
|||||||
@@ -29,13 +29,25 @@ function M.create(ctx)
|
|||||||
return options_helper.coerce_bool(raw_auto_start, false)
|
return options_helper.coerce_bool(raw_auto_start, false)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function rearm_managed_subtitle_defaults()
|
||||||
|
if not process.has_matching_mpv_ipc_socket(opts.socket_path) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
mp.set_property_native("sub-auto", "fuzzy")
|
||||||
|
mp.set_property_native("sid", "auto")
|
||||||
|
mp.set_property_native("secondary-sid", "auto")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
local function on_file_loaded()
|
local function on_file_loaded()
|
||||||
aniskip.clear_aniskip_state()
|
aniskip.clear_aniskip_state()
|
||||||
process.disarm_auto_play_ready_gate()
|
process.disarm_auto_play_ready_gate()
|
||||||
|
local has_matching_socket = rearm_managed_subtitle_defaults()
|
||||||
|
|
||||||
local should_auto_start = resolve_auto_start_enabled()
|
local should_auto_start = resolve_auto_start_enabled()
|
||||||
if should_auto_start then
|
if should_auto_start then
|
||||||
if not process.has_matching_mpv_ipc_socket(opts.socket_path) then
|
if not has_matching_socket then
|
||||||
subminer_log(
|
subminer_log(
|
||||||
"info",
|
"info",
|
||||||
"lifecycle",
|
"lifecycle",
|
||||||
|
|||||||
@@ -38,7 +38,11 @@ const lanes: Record<string, LaneConfig> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function collectFiles(rootDir: string, includeSuffixes: string[], excludeSet: Set<string>): string[] {
|
function collectFiles(
|
||||||
|
rootDir: string,
|
||||||
|
includeSuffixes: string[],
|
||||||
|
excludeSet: Set<string>,
|
||||||
|
): string[] {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
const visit = (currentDir: string) => {
|
const visit = (currentDir: string) => {
|
||||||
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
||||||
@@ -145,7 +149,12 @@ function parseLcovReport(report: string): LcovRecord[] {
|
|||||||
}
|
}
|
||||||
if (line.startsWith('BRDA:')) {
|
if (line.startsWith('BRDA:')) {
|
||||||
const [lineNumber, block, branch, hits] = line.slice(5).split(',');
|
const [lineNumber, block, branch, hits] = line.slice(5).split(',');
|
||||||
if (lineNumber === undefined || block === undefined || branch === undefined || hits === undefined) {
|
if (
|
||||||
|
lineNumber === undefined ||
|
||||||
|
block === undefined ||
|
||||||
|
branch === undefined ||
|
||||||
|
hits === undefined
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
ensureCurrent().branches.set(`${lineNumber}:${block}:${branch}`, {
|
ensureCurrent().branches.set(`${lineNumber}:${block}:${branch}`, {
|
||||||
@@ -224,7 +233,9 @@ export function mergeLcovReports(reports: string[]): string {
|
|||||||
chunks.push(`FNDA:${record.functionHits.get(name) ?? 0},${name}`);
|
chunks.push(`FNDA:${record.functionHits.get(name) ?? 0},${name}`);
|
||||||
}
|
}
|
||||||
chunks.push(`FNF:${functions.length}`);
|
chunks.push(`FNF:${functions.length}`);
|
||||||
chunks.push(`FNH:${functions.filter(([name]) => (record.functionHits.get(name) ?? 0) > 0).length}`);
|
chunks.push(
|
||||||
|
`FNH:${functions.filter(([name]) => (record.functionHits.get(name) ?? 0) > 0).length}`,
|
||||||
|
);
|
||||||
|
|
||||||
const branches = [...record.branches.values()].sort((a, b) =>
|
const branches = [...record.branches.values()].sort((a, b) =>
|
||||||
a.line === b.line
|
a.line === b.line
|
||||||
@@ -298,7 +309,9 @@ function runCoverageLane(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8');
|
writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8');
|
||||||
process.stdout.write(`Merged LCOV written to ${relative(repoRoot, join(coverageDir, 'lcov.info'))}\n`);
|
process.stdout.write(
|
||||||
|
`Merged LCOV written to ${relative(repoRoot, join(coverageDir, 'lcov.info'))}\n`,
|
||||||
|
);
|
||||||
return 0;
|
return 0;
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(shardRoot, { recursive: true, force: true });
|
rmSync(shardRoot, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -178,6 +178,12 @@ local function run_plugin_scenario(config)
|
|||||||
value = value,
|
value = value,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
function mp.set_property(name, value)
|
||||||
|
recorded.property_sets[#recorded.property_sets + 1] = {
|
||||||
|
name = name,
|
||||||
|
value = value,
|
||||||
|
}
|
||||||
|
end
|
||||||
function mp.get_script_name()
|
function mp.get_script_name()
|
||||||
return "subminer"
|
return "subminer"
|
||||||
end
|
end
|
||||||
@@ -531,6 +537,38 @@ do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local recorded, err = run_plugin_scenario({
|
||||||
|
process_list = "",
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "yes",
|
||||||
|
auto_start_visible_overlay = "yes",
|
||||||
|
auto_start_pause_until_ready = "no",
|
||||||
|
socket_path = "/tmp/subminer-socket",
|
||||||
|
},
|
||||||
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
|
media_title = "Random Movie",
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for subtitle rearm scenario: " .. tostring(err))
|
||||||
|
fire_event(recorded, "file-loaded")
|
||||||
|
assert_true(
|
||||||
|
has_property_set(recorded.property_sets, "sub-auto", "fuzzy"),
|
||||||
|
"managed file-loaded should rearm sub-auto for idle mpv sessions"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
has_property_set(recorded.property_sets, "sid", "auto"),
|
||||||
|
"managed file-loaded should rearm primary subtitle selection for idle mpv sessions"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
has_property_set(recorded.property_sets, "secondary-sid", "auto"),
|
||||||
|
"managed file-loaded should rearm secondary subtitle selection for idle mpv sessions"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
local recorded, err = run_plugin_scenario({
|
local recorded, err = run_plugin_scenario({
|
||||||
process_list = "",
|
process_list = "",
|
||||||
@@ -1037,6 +1075,10 @@ do
|
|||||||
start_call == nil,
|
start_call == nil,
|
||||||
"auto-start should be skipped when mpv input-ipc-server does not match configured socket_path"
|
"auto-start should be skipped when mpv input-ipc-server does not match configured socket_path"
|
||||||
)
|
)
|
||||||
|
assert_true(
|
||||||
|
not has_property_set(recorded.property_sets, "sid", "auto"),
|
||||||
|
"subtitle rearm should not run when mpv input-ipc-server does not match configured socket_path"
|
||||||
|
)
|
||||||
assert_true(
|
assert_true(
|
||||||
not has_property_set(recorded.property_sets, "pause", true),
|
not has_property_set(recorded.property_sets, "pause", true),
|
||||||
"pause-until-ready gate should not arm when socket_path does not match"
|
"pause-until-ready gate should not arm when socket_path does not match"
|
||||||
|
|||||||
@@ -369,7 +369,8 @@ export class AnkiIntegration {
|
|||||||
trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||||
this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]);
|
this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]);
|
||||||
},
|
},
|
||||||
findDuplicateNoteIds: (expression, noteInfo) => this.findDuplicateNoteIds(expression, noteInfo),
|
findDuplicateNoteIds: (expression, noteInfo) =>
|
||||||
|
this.findDuplicateNoteIds(expression, noteInfo),
|
||||||
recordCardsMinedCallback: (count, noteIds) => {
|
recordCardsMinedCallback: (count, noteIds) => {
|
||||||
this.recordCardsMinedSafely(count, noteIds, 'card creation');
|
this.recordCardsMinedSafely(count, noteIds, 'card creation');
|
||||||
},
|
},
|
||||||
@@ -1082,10 +1083,7 @@ export class AnkiIntegration {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findDuplicateNoteIds(
|
private async findDuplicateNoteIds(expression: string, noteInfo: NoteInfo): Promise<number[]> {
|
||||||
expression: string,
|
|
||||||
noteInfo: NoteInfo,
|
|
||||||
): Promise<number[]> {
|
|
||||||
return findDuplicateNoteIdsForAnkiIntegration(expression, -1, noteInfo, {
|
return findDuplicateNoteIdsForAnkiIntegration(expression, -1, noteInfo, {
|
||||||
findNotes: async (query, options) => (await this.client.findNotes(query, options)) as unknown,
|
findNotes: async (query, options) => (await this.client.findNotes(query, options)) as unknown,
|
||||||
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
|
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
|
||||||
|
|||||||
@@ -162,7 +162,8 @@ export class AnkiConnectProxyServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const forwardedBody = req.method === 'POST' ? this.getForwardRequestBody(rawBody, requestJson) : rawBody;
|
const forwardedBody =
|
||||||
|
req.method === 'POST' ? this.getForwardRequestBody(rawBody, requestJson) : rawBody;
|
||||||
const targetUrl = new URL(req.url || '/', upstreamUrl).toString();
|
const targetUrl = new URL(req.url || '/', upstreamUrl).toString();
|
||||||
const contentType =
|
const contentType =
|
||||||
typeof req.headers['content-type'] === 'string'
|
typeof req.headers['content-type'] === 'string'
|
||||||
@@ -272,7 +273,9 @@ export class AnkiConnectProxyServer {
|
|||||||
|
|
||||||
private sanitizeRequestJson(requestJson: Record<string, unknown>): Record<string, unknown> {
|
private sanitizeRequestJson(requestJson: Record<string, unknown>): Record<string, unknown> {
|
||||||
const action =
|
const action =
|
||||||
typeof requestJson.action === 'string' ? requestJson.action : String(requestJson.action ?? '');
|
typeof requestJson.action === 'string'
|
||||||
|
? requestJson.action
|
||||||
|
: String(requestJson.action ?? '');
|
||||||
if (action !== 'addNote') {
|
if (action !== 'addNote') {
|
||||||
return requestJson;
|
return requestJson;
|
||||||
}
|
}
|
||||||
@@ -301,9 +304,13 @@ export class AnkiConnectProxyServer {
|
|||||||
const rawNoteIds = Array.isArray(params?.subminerDuplicateNoteIds)
|
const rawNoteIds = Array.isArray(params?.subminerDuplicateNoteIds)
|
||||||
? params.subminerDuplicateNoteIds
|
? params.subminerDuplicateNoteIds
|
||||||
: [];
|
: [];
|
||||||
return [...new Set(rawNoteIds.filter((entry): entry is number => {
|
return [
|
||||||
|
...new Set(
|
||||||
|
rawNoteIds.filter((entry): entry is number => {
|
||||||
return typeof entry === 'number' && Number.isInteger(entry) && entry > 0;
|
return typeof entry === 'number' && Number.isInteger(entry) && entry > 0;
|
||||||
}))].sort((left, right) => left - right);
|
}),
|
||||||
|
),
|
||||||
|
].sort((left, right) => left - right);
|
||||||
}
|
}
|
||||||
|
|
||||||
private requestIncludesAddAction(action: string, requestJson: Record<string, unknown>): boolean {
|
private requestIncludesAddAction(action: string, requestJson: Record<string, unknown>): boolean {
|
||||||
|
|||||||
@@ -113,10 +113,7 @@ interface CardCreationDeps {
|
|||||||
setUpdateInProgress: (value: boolean) => void;
|
setUpdateInProgress: (value: boolean) => void;
|
||||||
trackLastAddedNoteId?: (noteId: number) => void;
|
trackLastAddedNoteId?: (noteId: number) => void;
|
||||||
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||||
findDuplicateNoteIds?: (
|
findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
|
||||||
expression: string,
|
|
||||||
noteInfo: CardCreationNoteInfo,
|
|
||||||
) => Promise<number[]>;
|
|
||||||
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
|
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,10 +570,7 @@ export class CardCreationService {
|
|||||||
await this.deps.findDuplicateNoteIds(pendingExpressionText, pendingNoteInfo),
|
await this.deps.findDuplicateNoteIds(pendingExpressionText, pendingNoteInfo),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn(
|
log.warn('Failed to capture pre-add duplicate note ids:', (error as Error).message);
|
||||||
'Failed to capture pre-add duplicate note ids:',
|
|
||||||
(error as Error).message,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,9 +722,7 @@ export class CardCreationService {
|
|||||||
private createPendingNoteInfo(fields: Record<string, string>): CardCreationNoteInfo {
|
private createPendingNoteInfo(fields: Record<string, string>): CardCreationNoteInfo {
|
||||||
return {
|
return {
|
||||||
noteId: -1,
|
noteId: -1,
|
||||||
fields: Object.fromEntries(
|
fields: Object.fromEntries(Object.entries(fields).map(([name, value]) => [name, { value }])),
|
||||||
Object.entries(fields).map(([name, value]) => [name, { value }]),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -307,7 +307,11 @@ test('findDuplicateNoteIds returns no matches when maxMatches is zero', async ()
|
|||||||
};
|
};
|
||||||
|
|
||||||
let notesInfoCalls = 0;
|
let notesInfoCalls = 0;
|
||||||
const duplicateIds = await findDuplicateNoteIds('貴様', 100, currentNote, {
|
const duplicateIds = await findDuplicateNoteIds(
|
||||||
|
'貴様',
|
||||||
|
100,
|
||||||
|
currentNote,
|
||||||
|
{
|
||||||
findNotes: async () => [200],
|
findNotes: async () => [200],
|
||||||
notesInfo: async (noteIds) => {
|
notesInfo: async (noteIds) => {
|
||||||
notesInfoCalls += 1;
|
notesInfoCalls += 1;
|
||||||
@@ -321,7 +325,9 @@ test('findDuplicateNoteIds returns no matches when maxMatches is zero', async ()
|
|||||||
getDeck: () => 'Japanese::Mining',
|
getDeck: () => 'Japanese::Mining',
|
||||||
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
|
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
|
||||||
logWarn: () => {},
|
logWarn: () => {},
|
||||||
}, 0);
|
},
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
assert.deepEqual(duplicateIds, []);
|
assert.deepEqual(duplicateIds, []);
|
||||||
assert.equal(notesInfoCalls, 0);
|
assert.equal(notesInfoCalls, 0);
|
||||||
|
|||||||
@@ -24,13 +24,7 @@ export async function findDuplicateNote(
|
|||||||
noteInfo: NoteInfo,
|
noteInfo: NoteInfo,
|
||||||
deps: DuplicateDetectionDeps,
|
deps: DuplicateDetectionDeps,
|
||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
const duplicateNoteIds = await findDuplicateNoteIds(
|
const duplicateNoteIds = await findDuplicateNoteIds(expression, excludeNoteId, noteInfo, deps, 1);
|
||||||
expression,
|
|
||||||
excludeNoteId,
|
|
||||||
noteInfo,
|
|
||||||
deps,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
return duplicateNoteIds[0] ?? null;
|
return duplicateNoteIds[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ test('printHelp includes configured texthooker port', () => {
|
|||||||
|
|
||||||
assert.match(output, /--help\s+Show this help/);
|
assert.match(output, /--help\s+Show this help/);
|
||||||
assert.match(output, /default: 7777/);
|
assert.match(output, /default: 7777/);
|
||||||
assert.match(output, /--launch-mpv/);
|
assert.match(output, /--launch-mpv.*Launch mpv with SubMiner defaults and exit/);
|
||||||
assert.match(output, /--stats\s+Open the stats dashboard in your browser/);
|
assert.match(output, /--stats\s+Open the stats dashboard in your browser/);
|
||||||
assert.doesNotMatch(output, /--refresh-known-words/);
|
assert.doesNotMatch(output, /--refresh-known-words/);
|
||||||
assert.match(output, /--setup\s+Open first-run setup window/);
|
assert.match(output, /--setup\s+Open first-run setup window/);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ ${B}Usage:${R} subminer ${D}[command] [options]${R}
|
|||||||
${B}Session${R}
|
${B}Session${R}
|
||||||
--background Start in tray/background mode
|
--background Start in tray/background mode
|
||||||
--start Connect to mpv and launch overlay
|
--start Connect to mpv and launch overlay
|
||||||
--launch-mpv ${D}[targets...]${R} Launch mpv with the SubMiner mpv profile and exit
|
--launch-mpv ${D}[targets...]${R} Launch mpv with SubMiner defaults and exit
|
||||||
--stop Stop the running instance
|
--stop Stop the running instance
|
||||||
--stats Open the stats dashboard in your browser
|
--stats Open the stats dashboard in your browser
|
||||||
--texthooker Start texthooker server only ${D}(no overlay)${R}
|
--texthooker Start texthooker server only ${D}(no overlay)${R}
|
||||||
|
|||||||
@@ -2138,7 +2138,7 @@ test('template generator includes known keys', () => {
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
output,
|
output,
|
||||||
/"primarySubLanguages": \[\s*"ja",\s*"jpn"\s*\],? \/\/ Comma-separated primary subtitle language priority for YouTube auto-loading\./,
|
/"primarySubLanguages": \[\s*"ja",\s*"jpn"\s*\],? \/\/ Comma-separated primary subtitle language priority for managed subtitle auto-selection\./,
|
||||||
);
|
);
|
||||||
assert.doesNotMatch(output, /"mode": "automatic"/);
|
assert.doesNotMatch(output, /"mode": "automatic"/);
|
||||||
assert.doesNotMatch(output, /"fixWithAi": false/);
|
assert.doesNotMatch(output, /"fixWithAi": false/);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const {
|
|||||||
startupWarmups,
|
startupWarmups,
|
||||||
auto_start_overlay,
|
auto_start_overlay,
|
||||||
} = CORE_DEFAULT_CONFIG;
|
} = CORE_DEFAULT_CONFIG;
|
||||||
const { ankiConnect, jimaku, anilist, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
|
const { ankiConnect, jimaku, anilist, mpv, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
|
||||||
INTEGRATIONS_DEFAULT_CONFIG;
|
INTEGRATIONS_DEFAULT_CONFIG;
|
||||||
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
|
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
|
||||||
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
||||||
@@ -60,6 +60,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
|||||||
auto_start_overlay,
|
auto_start_overlay,
|
||||||
jimaku,
|
jimaku,
|
||||||
anilist,
|
anilist,
|
||||||
|
mpv,
|
||||||
yomitan,
|
yomitan,
|
||||||
jellyfin,
|
jellyfin,
|
||||||
discordPresence,
|
discordPresence,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
| 'ankiConnect'
|
| 'ankiConnect'
|
||||||
| 'jimaku'
|
| 'jimaku'
|
||||||
| 'anilist'
|
| 'anilist'
|
||||||
|
| 'mpv'
|
||||||
| 'yomitan'
|
| 'yomitan'
|
||||||
| 'jellyfin'
|
| 'jellyfin'
|
||||||
| 'discordPresence'
|
| 'discordPresence'
|
||||||
@@ -90,6 +91,9 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
languagePreference: 'ja',
|
languagePreference: 'ja',
|
||||||
maxEntryResults: 10,
|
maxEntryResults: 10,
|
||||||
},
|
},
|
||||||
|
mpv: {
|
||||||
|
executablePath: '',
|
||||||
|
},
|
||||||
anilist: {
|
anilist: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ test('config option registry includes critical paths and has unique entries', ()
|
|||||||
'ankiConnect.enabled',
|
'ankiConnect.enabled',
|
||||||
'anilist.characterDictionary.enabled',
|
'anilist.characterDictionary.enabled',
|
||||||
'anilist.characterDictionary.collapsibleSections.description',
|
'anilist.characterDictionary.collapsibleSections.description',
|
||||||
|
'mpv.executablePath',
|
||||||
'yomitan.externalProfilePath',
|
'yomitan.externalProfilePath',
|
||||||
'immersionTracking.enabled',
|
'immersionTracking.enabled',
|
||||||
]) {
|
]) {
|
||||||
@@ -48,6 +49,7 @@ test('config template sections include expected domains and unique keys', () =>
|
|||||||
'subtitleStyle',
|
'subtitleStyle',
|
||||||
'ankiConnect',
|
'ankiConnect',
|
||||||
'yomitan',
|
'yomitan',
|
||||||
|
'mpv',
|
||||||
'immersionTracking',
|
'immersionTracking',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
path: 'youtube.primarySubLanguages',
|
path: 'youtube.primarySubLanguages',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
defaultValue: defaultConfig.youtube.primarySubLanguages.join(','),
|
defaultValue: defaultConfig.youtube.primarySubLanguages.join(','),
|
||||||
description: 'Comma-separated primary subtitle language priority for YouTube auto-loading.',
|
description:
|
||||||
|
'Comma-separated primary subtitle language priority for managed subtitle auto-selection.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'controller.enabled',
|
path: 'controller.enabled',
|
||||||
|
|||||||
@@ -238,6 +238,13 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay',
|
'Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'mpv.executablePath',
|
||||||
|
kind: 'string',
|
||||||
|
defaultValue: defaultConfig.mpv.executablePath,
|
||||||
|
description:
|
||||||
|
'Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'jellyfin.enabled',
|
path: 'jellyfin.enabled',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
title: 'Secondary Subtitles',
|
title: 'Secondary Subtitles',
|
||||||
description: [
|
description: [
|
||||||
'Dual subtitle track options.',
|
'Dual subtitle track options.',
|
||||||
'Used by the YouTube subtitle loading flow as secondary language preferences.',
|
'Used by managed subtitle loading as secondary language preferences for local and YouTube playback.',
|
||||||
],
|
],
|
||||||
notes: ['Hot-reload: defaultMode updates live while SubMiner is running.'],
|
notes: ['Hot-reload: defaultMode updates live while SubMiner is running.'],
|
||||||
key: 'secondarySub',
|
key: 'secondarySub',
|
||||||
@@ -131,7 +131,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'YouTube Playback Settings',
|
title: 'YouTube Playback Settings',
|
||||||
description: ['Defaults for SubMiner YouTube subtitle loading and languages.'],
|
description: [
|
||||||
|
'Defaults for managed subtitle language preferences and YouTube subtitle loading.',
|
||||||
|
],
|
||||||
key: 'youtube',
|
key: 'youtube',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -153,6 +155,14 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
],
|
],
|
||||||
key: 'yomitan',
|
key: 'yomitan',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'MPV Launcher',
|
||||||
|
description: [
|
||||||
|
'Optional mpv.exe override for Windows playback entry points.',
|
||||||
|
'Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.',
|
||||||
|
],
|
||||||
|
key: 'mpv',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Jellyfin',
|
title: 'Jellyfin',
|
||||||
description: [
|
description: [
|
||||||
|
|||||||
31
src/config/resolve/integrations.test.ts
Normal file
31
src/config/resolve/integrations.test.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { resolveConfig } from '../resolve';
|
||||||
|
|
||||||
|
test('resolveConfig trims configured mpv executable path', () => {
|
||||||
|
const { resolved, warnings } = resolveConfig({
|
||||||
|
mpv: {
|
||||||
|
executablePath: ' C:\\Program Files\\mpv\\mpv.exe ',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resolved.mpv.executablePath, 'C:\\Program Files\\mpv\\mpv.exe');
|
||||||
|
assert.deepEqual(warnings, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveConfig warns for invalid mpv executable path type', () => {
|
||||||
|
const { resolved, warnings } = resolveConfig({
|
||||||
|
mpv: {
|
||||||
|
executablePath: 42 as never,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resolved.mpv.executablePath, '');
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
assert.deepEqual(warnings[0], {
|
||||||
|
path: 'mpv.executablePath',
|
||||||
|
value: 42,
|
||||||
|
fallback: '',
|
||||||
|
message: 'Expected string.',
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -228,6 +228,22 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
|||||||
warn('yomitan', src.yomitan, resolved.yomitan, 'Expected object.');
|
warn('yomitan', src.yomitan, resolved.yomitan, 'Expected object.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isObject(src.mpv)) {
|
||||||
|
const executablePath = asString(src.mpv.executablePath);
|
||||||
|
if (executablePath !== undefined) {
|
||||||
|
resolved.mpv.executablePath = executablePath.trim();
|
||||||
|
} else if (src.mpv.executablePath !== undefined) {
|
||||||
|
warn(
|
||||||
|
'mpv.executablePath',
|
||||||
|
src.mpv.executablePath,
|
||||||
|
resolved.mpv.executablePath,
|
||||||
|
'Expected string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (src.mpv !== undefined) {
|
||||||
|
warn('mpv', src.mpv, resolved.mpv, 'Expected object.');
|
||||||
|
}
|
||||||
|
|
||||||
if (isObject(src.jellyfin)) {
|
if (isObject(src.jellyfin)) {
|
||||||
const enabled = asBoolean(src.jellyfin.enabled);
|
const enabled = asBoolean(src.jellyfin.enabled);
|
||||||
if (enabled !== undefined) {
|
if (enabled !== undefined) {
|
||||||
|
|||||||
@@ -83,7 +83,9 @@ const PRESENCE_STYLES: Record<DiscordPresenceStylePreset, PresenceStyleDefinitio
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolvePresenceStyle(preset: DiscordPresenceStylePreset | undefined): PresenceStyleDefinition {
|
function resolvePresenceStyle(
|
||||||
|
preset: DiscordPresenceStylePreset | undefined,
|
||||||
|
): PresenceStyleDefinition {
|
||||||
return PRESENCE_STYLES[preset ?? 'default'] ?? PRESENCE_STYLES.default;
|
return PRESENCE_STYLES[preset ?? 'default'] ?? PRESENCE_STYLES.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,9 +132,7 @@ export function buildDiscordPresenceActivity(
|
|||||||
const status = buildStatus(snapshot);
|
const status = buildStatus(snapshot);
|
||||||
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media');
|
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media');
|
||||||
const details =
|
const details =
|
||||||
snapshot.connected && snapshot.mediaPath
|
snapshot.connected && snapshot.mediaPath ? trimField(title) : style.fallbackDetails;
|
||||||
? trimField(title)
|
|
||||||
: style.fallbackDetails;
|
|
||||||
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
|
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
|
||||||
const state =
|
const state =
|
||||||
snapshot.connected && snapshot.mediaPath
|
snapshot.connected && snapshot.mediaPath
|
||||||
@@ -157,10 +157,7 @@ export function buildDiscordPresenceActivity(
|
|||||||
if (style.smallImageText.trim().length > 0) {
|
if (style.smallImageText.trim().length > 0) {
|
||||||
activity.smallImageText = trimField(style.smallImageText.trim());
|
activity.smallImageText = trimField(style.smallImageText.trim());
|
||||||
}
|
}
|
||||||
if (
|
if (style.buttonLabel.trim().length > 0 && /^https?:\/\//.test(style.buttonUrl.trim())) {
|
||||||
style.buttonLabel.trim().length > 0 &&
|
|
||||||
/^https?:\/\//.test(style.buttonUrl.trim())
|
|
||||||
) {
|
|
||||||
activity.buttons = [
|
activity.buttons = [
|
||||||
{
|
{
|
||||||
label: trimField(style.buttonLabel.trim(), 32),
|
label: trimField(style.buttonLabel.trim(), 32),
|
||||||
|
|||||||
@@ -380,42 +380,22 @@ export class ImmersionTrackerService {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const eventsRetention = daysToRetentionWindow(
|
const eventsRetention = daysToRetentionWindow(retention.eventsDays, 7, 3650);
|
||||||
retention.eventsDays,
|
const telemetryRetention = daysToRetentionWindow(retention.telemetryDays, 30, 3650);
|
||||||
7,
|
const sessionsRetention = daysToRetentionWindow(retention.sessionsDays, 30, 3650);
|
||||||
3650,
|
|
||||||
);
|
|
||||||
const telemetryRetention = daysToRetentionWindow(
|
|
||||||
retention.telemetryDays,
|
|
||||||
30,
|
|
||||||
3650,
|
|
||||||
);
|
|
||||||
const sessionsRetention = daysToRetentionWindow(
|
|
||||||
retention.sessionsDays,
|
|
||||||
30,
|
|
||||||
3650,
|
|
||||||
);
|
|
||||||
this.eventsRetentionMs = eventsRetention.ms;
|
this.eventsRetentionMs = eventsRetention.ms;
|
||||||
this.eventsRetentionDays = eventsRetention.days;
|
this.eventsRetentionDays = eventsRetention.days;
|
||||||
this.telemetryRetentionMs = telemetryRetention.ms;
|
this.telemetryRetentionMs = telemetryRetention.ms;
|
||||||
this.telemetryRetentionDays = telemetryRetention.days;
|
this.telemetryRetentionDays = telemetryRetention.days;
|
||||||
this.sessionsRetentionMs = sessionsRetention.ms;
|
this.sessionsRetentionMs = sessionsRetention.ms;
|
||||||
this.sessionsRetentionDays = sessionsRetention.days;
|
this.sessionsRetentionDays = sessionsRetention.days;
|
||||||
this.dailyRollupRetentionMs = daysToRetentionWindow(
|
this.dailyRollupRetentionMs = daysToRetentionWindow(retention.dailyRollupsDays, 365, 36500).ms;
|
||||||
retention.dailyRollupsDays,
|
|
||||||
365,
|
|
||||||
36500,
|
|
||||||
).ms;
|
|
||||||
this.monthlyRollupRetentionMs = daysToRetentionWindow(
|
this.monthlyRollupRetentionMs = daysToRetentionWindow(
|
||||||
retention.monthlyRollupsDays,
|
retention.monthlyRollupsDays,
|
||||||
5 * 365,
|
5 * 365,
|
||||||
36500,
|
36500,
|
||||||
).ms;
|
).ms;
|
||||||
this.vacuumIntervalMs = daysToRetentionWindow(
|
this.vacuumIntervalMs = daysToRetentionWindow(retention.vacuumIntervalDays, 7, 3650).ms;
|
||||||
retention.vacuumIntervalDays,
|
|
||||||
7,
|
|
||||||
3650,
|
|
||||||
).ms;
|
|
||||||
this.db = new Database(this.dbPath);
|
this.db = new Database(this.dbPath);
|
||||||
applyPragmas(this.db);
|
applyPragmas(this.db);
|
||||||
ensureSchema(this.db);
|
ensureSchema(this.db);
|
||||||
|
|||||||
@@ -1230,18 +1230,7 @@ test('getQueryHints counts new words by distinct headword first-seen time', () =
|
|||||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
).run(
|
).run('猫', '猫', 'ねこ', 'noun', '名詞', '', '', String(twoDaysAgo), String(twoDaysAgo), 1);
|
||||||
'猫',
|
|
||||||
'猫',
|
|
||||||
'ねこ',
|
|
||||||
'noun',
|
|
||||||
'名詞',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
String(twoDaysAgo),
|
|
||||||
String(twoDaysAgo),
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
|
|
||||||
const hints = getQueryHints(db);
|
const hints = getQueryHints(db);
|
||||||
assert.equal(hints.newWordsToday, 1);
|
assert.equal(hints.newWordsToday, 1);
|
||||||
|
|||||||
@@ -82,12 +82,9 @@ function hasRetainedPriorSession(
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.get(
|
.get(videoId, toDbTimestamp(startedAtMs), toDbTimestamp(startedAtMs), currentSessionId) as {
|
||||||
videoId,
|
found: number;
|
||||||
toDbTimestamp(startedAtMs),
|
} | null;
|
||||||
toDbTimestamp(startedAtMs),
|
|
||||||
currentSessionId,
|
|
||||||
) as { found: number } | null;
|
|
||||||
return Boolean(row);
|
return Boolean(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,9 +126,9 @@ test('pruneRawRetention skips disabled retention windows', () => {
|
|||||||
const remainingTelemetry = db
|
const remainingTelemetry = db
|
||||||
.prepare('SELECT COUNT(*) AS count FROM imm_session_telemetry')
|
.prepare('SELECT COUNT(*) AS count FROM imm_session_telemetry')
|
||||||
.get() as { count: number };
|
.get() as { count: number };
|
||||||
const remainingSessions = db
|
const remainingSessions = db.prepare('SELECT COUNT(*) AS count FROM imm_sessions').get() as {
|
||||||
.prepare('SELECT COUNT(*) AS count FROM imm_sessions')
|
count: number;
|
||||||
.get() as { count: number };
|
};
|
||||||
|
|
||||||
assert.equal(result.deletedSessionEvents, 0);
|
assert.equal(result.deletedSessionEvents, 0);
|
||||||
assert.equal(result.deletedTelemetryRows, 0);
|
assert.equal(result.deletedTelemetryRows, 0);
|
||||||
|
|||||||
@@ -56,10 +56,7 @@ export function pruneRawRetention(
|
|||||||
sessionsRetentionDays?: number;
|
sessionsRetentionDays?: number;
|
||||||
},
|
},
|
||||||
): RawRetentionResult {
|
): RawRetentionResult {
|
||||||
const resolveCutoff = (
|
const resolveCutoff = (retentionMs: number, retentionDays: number | undefined): string => {
|
||||||
retentionMs: number,
|
|
||||||
retentionDays: number | undefined,
|
|
||||||
): string => {
|
|
||||||
if (retentionDays !== undefined) {
|
if (retentionDays !== undefined) {
|
||||||
return subtractDbTimestamp(currentMs, BigInt(retentionDays) * 86_400_000n);
|
return subtractDbTimestamp(currentMs, BigInt(retentionDays) * 86_400_000n);
|
||||||
}
|
}
|
||||||
@@ -68,9 +65,11 @@ export function pruneRawRetention(
|
|||||||
|
|
||||||
const deletedSessionEvents = Number.isFinite(policy.eventsRetentionMs)
|
const deletedSessionEvents = Number.isFinite(policy.eventsRetentionMs)
|
||||||
? (
|
? (
|
||||||
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(
|
db
|
||||||
resolveCutoff(policy.eventsRetentionMs, policy.eventsRetentionDays),
|
.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`)
|
||||||
) as { changes: number }
|
.run(resolveCutoff(policy.eventsRetentionMs, policy.eventsRetentionDays)) as {
|
||||||
|
changes: number;
|
||||||
|
}
|
||||||
).changes
|
).changes
|
||||||
: 0;
|
: 0;
|
||||||
const deletedTelemetryRows = Number.isFinite(policy.telemetryRetentionMs)
|
const deletedTelemetryRows = Number.isFinite(policy.telemetryRetentionMs)
|
||||||
|
|||||||
@@ -150,9 +150,11 @@ export function getSessionEvents(
|
|||||||
ORDER BY ts_ms ASC
|
ORDER BY ts_ms ASC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`);
|
`);
|
||||||
const rows = stmt.all(sessionId, ...eventTypes, limit) as Array<SessionEventRow & {
|
const rows = stmt.all(sessionId, ...eventTypes, limit) as Array<
|
||||||
|
SessionEventRow & {
|
||||||
tsMs: number | string;
|
tsMs: number | string;
|
||||||
}>;
|
}
|
||||||
|
>;
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
tsMs: fromDbTimestamp(row.tsMs) ?? 0,
|
tsMs: fromDbTimestamp(row.tsMs) ?? 0,
|
||||||
|
|||||||
@@ -355,9 +355,7 @@ export function upsertCoverArt(
|
|||||||
const fetchedAtMs = toDbTimestamp(nowMs());
|
const fetchedAtMs = toDbTimestamp(nowMs());
|
||||||
const coverBlob = normalizeCoverBlobBytes(art.coverBlob);
|
const coverBlob = normalizeCoverBlobBytes(art.coverBlob);
|
||||||
const computedCoverBlobHash =
|
const computedCoverBlobHash =
|
||||||
coverBlob && coverBlob.length > 0
|
coverBlob && coverBlob.length > 0 ? createHash('sha256').update(coverBlob).digest('hex') : null;
|
||||||
? createHash('sha256').update(coverBlob).digest('hex')
|
|
||||||
: null;
|
|
||||||
let coverBlobHash = computedCoverBlobHash ?? sharedCoverBlobHash ?? null;
|
let coverBlobHash = computedCoverBlobHash ?? sharedCoverBlobHash ?? null;
|
||||||
if (!coverBlobHash && (!coverBlob || coverBlob.length === 0)) {
|
if (!coverBlobHash && (!coverBlob || coverBlob.length === 0)) {
|
||||||
coverBlobHash = existing?.coverBlobHash ?? null;
|
coverBlobHash = existing?.coverBlobHash ?? null;
|
||||||
|
|||||||
@@ -39,10 +39,12 @@ export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummar
|
|||||||
ORDER BY s.started_at_ms DESC
|
ORDER BY s.started_at_ms DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`);
|
`);
|
||||||
const rows = prepared.all(limit) as Array<SessionSummaryQueryRow & {
|
const rows = prepared.all(limit) as Array<
|
||||||
|
SessionSummaryQueryRow & {
|
||||||
startedAtMs: number | string;
|
startedAtMs: number | string;
|
||||||
endedAtMs: number | string | null;
|
endedAtMs: number | string | null;
|
||||||
}>;
|
}
|
||||||
|
>;
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
startedAtMs: fromDbTimestamp(row.startedAtMs) ?? 0,
|
startedAtMs: fromDbTimestamp(row.startedAtMs) ?? 0,
|
||||||
@@ -69,19 +71,21 @@ export function getSessionTimeline(
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
if (limit === undefined) {
|
if (limit === undefined) {
|
||||||
const rows = db.prepare(select).all(sessionId) as Array<SessionTimelineRow & {
|
const rows = db.prepare(select).all(sessionId) as Array<
|
||||||
|
SessionTimelineRow & {
|
||||||
sampleMs: number | string;
|
sampleMs: number | string;
|
||||||
}>;
|
}
|
||||||
|
>;
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,
|
sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
const rows = db
|
const rows = db.prepare(`${select}\n LIMIT ?`).all(sessionId, limit) as Array<
|
||||||
.prepare(`${select}\n LIMIT ?`)
|
SessionTimelineRow & {
|
||||||
.all(sessionId, limit) as Array<SessionTimelineRow & {
|
|
||||||
sampleMs: number | string;
|
sampleMs: number | string;
|
||||||
}>;
|
}
|
||||||
|
>;
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,
|
sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,
|
||||||
|
|||||||
@@ -359,10 +359,7 @@ function getNumericCalendarValue(
|
|||||||
return Number(row?.value ?? 0);
|
return Number(row?.value ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLocalEpochDay(
|
export function getLocalEpochDay(db: DatabaseSync, timestampMs: number | bigint | string): number {
|
||||||
db: DatabaseSync,
|
|
||||||
timestampMs: number | bigint | string,
|
|
||||||
): number {
|
|
||||||
return getNumericCalendarValue(
|
return getNumericCalendarValue(
|
||||||
db,
|
db,
|
||||||
`
|
`
|
||||||
@@ -375,10 +372,7 @@ export function getLocalEpochDay(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLocalMonthKey(
|
export function getLocalMonthKey(db: DatabaseSync, timestampMs: number | bigint | string): number {
|
||||||
db: DatabaseSync,
|
|
||||||
timestampMs: number | bigint | string,
|
|
||||||
): number {
|
|
||||||
return getNumericCalendarValue(
|
return getNumericCalendarValue(
|
||||||
db,
|
db,
|
||||||
`
|
`
|
||||||
@@ -391,10 +385,7 @@ export function getLocalMonthKey(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLocalDayOfWeek(
|
export function getLocalDayOfWeek(db: DatabaseSync, timestampMs: number | bigint | string): number {
|
||||||
db: DatabaseSync,
|
|
||||||
timestampMs: number | bigint | string,
|
|
||||||
): number {
|
|
||||||
return getNumericCalendarValue(
|
return getNumericCalendarValue(
|
||||||
db,
|
db,
|
||||||
`
|
`
|
||||||
@@ -407,10 +398,7 @@ export function getLocalDayOfWeek(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLocalHourOfDay(
|
export function getLocalHourOfDay(db: DatabaseSync, timestampMs: number | bigint | string): number {
|
||||||
db: DatabaseSync,
|
|
||||||
timestampMs: number | bigint | string,
|
|
||||||
): number {
|
|
||||||
return getNumericCalendarValue(
|
return getNumericCalendarValue(
|
||||||
db,
|
db,
|
||||||
`
|
`
|
||||||
@@ -458,7 +446,8 @@ export function getShiftedLocalDayTimestamp(
|
|||||||
dayOffset: number,
|
dayOffset: number,
|
||||||
): string {
|
): string {
|
||||||
const normalizedDayOffset = Math.trunc(dayOffset);
|
const normalizedDayOffset = Math.trunc(dayOffset);
|
||||||
const modifier = normalizedDayOffset >= 0 ? `+${normalizedDayOffset} days` : `${normalizedDayOffset} days`;
|
const modifier =
|
||||||
|
normalizedDayOffset >= 0 ? `+${normalizedDayOffset} days` : `${normalizedDayOffset} days`;
|
||||||
const row = db
|
const row = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -87,7 +87,20 @@ const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
|
|||||||
'90d': 90,
|
'90d': 90,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
const MONTH_NAMES = [
|
||||||
|
'Jan',
|
||||||
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec',
|
||||||
|
];
|
||||||
|
|
||||||
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
@@ -101,7 +114,11 @@ function getTrendMonthlyLimit(db: DatabaseSync, range: TrendRange): number {
|
|||||||
}
|
}
|
||||||
const currentTimestamp = currentDbTimestamp();
|
const currentTimestamp = currentDbTimestamp();
|
||||||
const todayStartMs = getShiftedLocalDayTimestamp(db, currentTimestamp, 0);
|
const todayStartMs = getShiftedLocalDayTimestamp(db, currentTimestamp, 0);
|
||||||
const cutoffMs = getShiftedLocalDayTimestamp(db, currentTimestamp, -(TREND_DAY_LIMITS[range] - 1));
|
const cutoffMs = getShiftedLocalDayTimestamp(
|
||||||
|
db,
|
||||||
|
currentTimestamp,
|
||||||
|
-(TREND_DAY_LIMITS[range] - 1),
|
||||||
|
);
|
||||||
const currentMonthKey = getLocalMonthKey(db, todayStartMs);
|
const currentMonthKey = getLocalMonthKey(db, todayStartMs);
|
||||||
const cutoffMonthKey = getLocalMonthKey(db, cutoffMs);
|
const cutoffMonthKey = getLocalMonthKey(db, cutoffMs);
|
||||||
const currentYear = Math.floor(currentMonthKey / 100);
|
const currentYear = Math.floor(currentMonthKey / 100);
|
||||||
@@ -630,8 +647,10 @@ export function getTrendsDashboard(
|
|||||||
|
|
||||||
const animePerDay = {
|
const animePerDay = {
|
||||||
episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId),
|
episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId),
|
||||||
watchTime: buildPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId, (rollup) =>
|
watchTime: buildPerAnimeFromDailyRollups(
|
||||||
rollup.totalActiveMin,
|
dailyRollups,
|
||||||
|
titlesByVideoId,
|
||||||
|
(rollup) => rollup.totalActiveMin,
|
||||||
),
|
),
|
||||||
cards: buildPerAnimeFromDailyRollups(
|
cards: buildPerAnimeFromDailyRollups(
|
||||||
dailyRollups,
|
dailyRollups,
|
||||||
|
|||||||
@@ -184,6 +184,39 @@ test('dispatchMpvProtocolMessage sets secondary subtitle track based on track li
|
|||||||
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 2] }]);
|
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 2] }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('dispatchMpvProtocolMessage prefers the already selected matching secondary track', async () => {
|
||||||
|
const { deps, state } = createDeps();
|
||||||
|
|
||||||
|
await dispatchMpvProtocolMessage(
|
||||||
|
{
|
||||||
|
request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 2,
|
||||||
|
lang: 'ja',
|
||||||
|
title: 'ja.srt',
|
||||||
|
selected: false,
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/dupe.srt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 3,
|
||||||
|
lang: 'ja',
|
||||||
|
title: 'ja.srt',
|
||||||
|
selected: true,
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/dupe.srt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 3] }]);
|
||||||
|
});
|
||||||
|
|
||||||
test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', async () => {
|
test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', async () => {
|
||||||
const { deps, state } = createDeps();
|
const { deps, state } = createDeps();
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,101 @@ export interface MpvProtocolHandleMessageDeps {
|
|||||||
restorePreviousSecondarySubVisibility: () => void;
|
restorePreviousSecondarySubVisibility: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SubtitleTrackCandidate = {
|
||||||
|
id: number;
|
||||||
|
lang: string;
|
||||||
|
title: string;
|
||||||
|
selected: boolean;
|
||||||
|
external: boolean;
|
||||||
|
externalFilename: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeSubtitleTrackCandidate(
|
||||||
|
track: Record<string, unknown>,
|
||||||
|
): SubtitleTrackCandidate | null {
|
||||||
|
const id =
|
||||||
|
typeof track.id === 'number'
|
||||||
|
? track.id
|
||||||
|
: typeof track.id === 'string'
|
||||||
|
? Number(track.id.trim())
|
||||||
|
: Number.NaN;
|
||||||
|
if (!Number.isInteger(id)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalFilename =
|
||||||
|
typeof track['external-filename'] === 'string' && track['external-filename'].trim().length > 0
|
||||||
|
? track['external-filename'].trim()
|
||||||
|
: typeof track.external_filename === 'string' && track.external_filename.trim().length > 0
|
||||||
|
? track.external_filename.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
lang: String(track.lang || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
title: String(track.title || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
selected: track.selected === true,
|
||||||
|
external: track.external === true,
|
||||||
|
externalFilename,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubtitleTrackIdentity(track: SubtitleTrackCandidate): string {
|
||||||
|
if (track.externalFilename) {
|
||||||
|
return `external:${track.externalFilename.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
if (track.title.length > 0) {
|
||||||
|
return `title:${track.title}`;
|
||||||
|
}
|
||||||
|
return `id:${track.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSecondarySubtitleTrackId(
|
||||||
|
tracks: Array<Record<string, unknown>>,
|
||||||
|
preferredLanguages: string[],
|
||||||
|
): number | null {
|
||||||
|
const normalizedLanguages = preferredLanguages
|
||||||
|
.map((language) => language.trim().toLowerCase())
|
||||||
|
.filter((language) => language.length > 0);
|
||||||
|
if (normalizedLanguages.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtitleTracks = tracks
|
||||||
|
.filter((track) => track.type === 'sub')
|
||||||
|
.map(normalizeSubtitleTrackCandidate)
|
||||||
|
.filter((track): track is SubtitleTrackCandidate => track !== null);
|
||||||
|
|
||||||
|
const dedupedTracks = new Map<string, SubtitleTrackCandidate>();
|
||||||
|
for (const track of subtitleTracks) {
|
||||||
|
const identity = getSubtitleTrackIdentity(track);
|
||||||
|
const existing = dedupedTracks.get(identity);
|
||||||
|
if (!existing || (track.selected && !existing.selected)) {
|
||||||
|
dedupedTracks.set(identity, track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueTracks = [...dedupedTracks.values()];
|
||||||
|
|
||||||
|
for (const language of normalizedLanguages) {
|
||||||
|
const selectedMatch = uniqueTracks.find((track) => track.selected && track.lang === language);
|
||||||
|
if (selectedMatch) {
|
||||||
|
return selectedMatch.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = uniqueTracks.find((track) => track.lang === language);
|
||||||
|
if (match) {
|
||||||
|
return match.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function splitMpvMessagesFromBuffer(
|
export function splitMpvMessagesFromBuffer(
|
||||||
buffer: string,
|
buffer: string,
|
||||||
onMessage?: MpvMessageParser,
|
onMessage?: MpvMessageParser,
|
||||||
@@ -283,15 +378,11 @@ export async function dispatchMpvProtocolMessage(
|
|||||||
if (Array.isArray(tracks)) {
|
if (Array.isArray(tracks)) {
|
||||||
const config = deps.getResolvedConfig();
|
const config = deps.getResolvedConfig();
|
||||||
const languages = config.secondarySub?.secondarySubLanguages || [];
|
const languages = config.secondarySub?.secondarySubLanguages || [];
|
||||||
const subTracks = tracks.filter((track) => track.type === 'sub');
|
const secondaryTrackId = pickSecondarySubtitleTrackId(tracks, languages);
|
||||||
for (const language of languages) {
|
if (secondaryTrackId !== null) {
|
||||||
const match = subTracks.find((track) => track.lang === language);
|
|
||||||
if (match) {
|
|
||||||
deps.sendCommand({
|
deps.sendCommand({
|
||||||
command: ['set_property', 'secondary-sid', match.id],
|
command: ['set_property', 'secondary-sid', secondaryTrackId],
|
||||||
});
|
});
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_AUDIO) {
|
} else if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_AUDIO) {
|
||||||
|
|||||||
@@ -102,10 +102,7 @@ async function writeFetchResponse(res: ServerResponse, response: Response): Prom
|
|||||||
res.end(Buffer.from(body));
|
res.end(Buffer.from(body));
|
||||||
}
|
}
|
||||||
|
|
||||||
function startNodeHttpServer(
|
function startNodeHttpServer(app: Hono, config: StatsServerConfig): { close: () => void } {
|
||||||
app: Hono,
|
|
||||||
config: StatsServerConfig,
|
|
||||||
): { close: () => void } {
|
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1075,11 +1072,9 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
|
|||||||
|
|
||||||
const bunRuntime = globalThis as typeof globalThis & {
|
const bunRuntime = globalThis as typeof globalThis & {
|
||||||
Bun?: {
|
Bun?: {
|
||||||
serve?: (options: {
|
serve?: (options: { fetch: (typeof app)['fetch']; port: number; hostname: string }) => {
|
||||||
fetch: (typeof app)['fetch'];
|
stop: () => void;
|
||||||
port: number;
|
};
|
||||||
hostname: string;
|
|
||||||
}) => { stop: () => void };
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,52 @@ test('triggerSubsyncFromConfig opens manual picker in manual mode', async () =>
|
|||||||
assert.equal(inProgressState, false);
|
assert.equal(inProgressState, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => {
|
||||||
|
let payloadTrackCount = 0;
|
||||||
|
|
||||||
|
await triggerSubsyncFromConfig(
|
||||||
|
makeDeps({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentAudioStreamIndex: null,
|
||||||
|
send: () => {},
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
if (name === 'path') return '/tmp/video.mkv';
|
||||||
|
if (name === 'sid') return 1;
|
||||||
|
if (name === 'secondary-sid') return 2;
|
||||||
|
if (name === 'track-list') {
|
||||||
|
return [
|
||||||
|
{ id: 1, type: 'sub', selected: true, lang: 'jpn' },
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: 'sub',
|
||||||
|
selected: true,
|
||||||
|
external: true,
|
||||||
|
lang: 'eng',
|
||||||
|
'external-filename': '/tmp/ref.srt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
type: 'sub',
|
||||||
|
selected: false,
|
||||||
|
external: true,
|
||||||
|
lang: 'eng',
|
||||||
|
'external-filename': '/tmp/ref.srt',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
openManualPicker: (payload) => {
|
||||||
|
payloadTrackCount = payload.sourceTracks.length;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(payloadTrackCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('triggerSubsyncFromConfig reports failures to OSD', async () => {
|
test('triggerSubsyncFromConfig reports failures to OSD', async () => {
|
||||||
const osd: string[] = [];
|
const osd: string[] = [];
|
||||||
await triggerSubsyncFromConfig(
|
await triggerSubsyncFromConfig(
|
||||||
|
|||||||
@@ -76,6 +76,35 @@ function normalizeTrackIds(tracks: unknown[]): MpvTrack[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSourceTrackIdentity(track: MpvTrack): string {
|
||||||
|
if (
|
||||||
|
track.external &&
|
||||||
|
typeof track['external-filename'] === 'string' &&
|
||||||
|
track['external-filename'].length > 0
|
||||||
|
) {
|
||||||
|
return `external:${track['external-filename'].toLowerCase()}`;
|
||||||
|
}
|
||||||
|
if (typeof track.id === 'number') {
|
||||||
|
return `id:${track.id}`;
|
||||||
|
}
|
||||||
|
if (typeof track.title === 'string' && track.title.length > 0) {
|
||||||
|
return `title:${track.title.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeSourceTracks(tracks: MpvTrack[]): MpvTrack[] {
|
||||||
|
const deduped = new Map<string, MpvTrack>();
|
||||||
|
for (const track of tracks) {
|
||||||
|
const identity = getSourceTrackIdentity(track);
|
||||||
|
const existing = deduped.get(identity);
|
||||||
|
if (!existing || (track.selected && !existing.selected)) {
|
||||||
|
deduped.set(identity, track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...deduped.values()];
|
||||||
|
}
|
||||||
|
|
||||||
export interface TriggerSubsyncFromConfigDeps extends SubsyncCoreDeps {
|
export interface TriggerSubsyncFromConfigDeps extends SubsyncCoreDeps {
|
||||||
isSubsyncInProgress: () => boolean;
|
isSubsyncInProgress: () => boolean;
|
||||||
setSubsyncInProgress: (inProgress: boolean) => void;
|
setSubsyncInProgress: (inProgress: boolean) => void;
|
||||||
@@ -123,12 +152,13 @@ async function gatherSubsyncContext(client: MpvClientLike): Promise<SubsyncConte
|
|||||||
const filename = track['external-filename'];
|
const filename = track['external-filename'];
|
||||||
return typeof filename === 'string' && filename.length > 0;
|
return typeof filename === 'string' && filename.length > 0;
|
||||||
});
|
});
|
||||||
|
const uniqueSourceTracks = dedupeSourceTracks(sourceTracks);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
videoPath,
|
videoPath,
|
||||||
primaryTrack,
|
primaryTrack,
|
||||||
secondaryTrack,
|
secondaryTrack,
|
||||||
sourceTracks,
|
sourceTracks: uniqueSourceTracks,
|
||||||
audioStreamIndex: client.currentAudioStreamIndex,
|
audioStreamIndex: client.currentAudioStreamIndex,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2029,7 +2029,8 @@ export async function addYomitanNoteViaSearch(
|
|||||||
: null,
|
: null,
|
||||||
duplicateNoteIds: Array.isArray(envelope.duplicateNoteIds)
|
duplicateNoteIds: Array.isArray(envelope.duplicateNoteIds)
|
||||||
? envelope.duplicateNoteIds.filter(
|
? envelope.duplicateNoteIds.filter(
|
||||||
(entry): entry is number => typeof entry === 'number' && Number.isInteger(entry) && entry > 0,
|
(entry): entry is number =>
|
||||||
|
typeof entry === 'number' && Number.isInteger(entry) && entry > 0,
|
||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ export function resolveExternalYomitanExtensionPath(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidate = path.join(path.resolve(normalizedProfilePath), 'extensions', 'yomitan');
|
const candidate = path.join(normalizedProfilePath, 'extensions', 'yomitan');
|
||||||
return existsSync(path.join(candidate, 'manifest.json')) ? candidate : null;
|
const fallbackCandidate = path.join(path.resolve(normalizedProfilePath), 'extensions', 'yomitan');
|
||||||
|
|
||||||
|
const candidates = candidate === fallbackCandidate ? [candidate] : [candidate, fallbackCandidate];
|
||||||
|
for (const root of candidates) {
|
||||||
|
if (existsSync(path.join(root, 'manifest.json'))) {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,15 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|||||||
|
|
||||||
function makeFakeYtDlpScript(dir: string, payload: string): void {
|
function makeFakeYtDlpScript(dir: string, payload: string): void {
|
||||||
const scriptPath = path.join(dir, 'yt-dlp');
|
const scriptPath = path.join(dir, 'yt-dlp');
|
||||||
const script = `#!/usr/bin/env node
|
const script =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? `#!/usr/bin/env bun
|
||||||
process.stdout.write(${JSON.stringify(payload)});
|
process.stdout.write(${JSON.stringify(payload)});
|
||||||
|
`
|
||||||
|
: `#!/usr/bin/env sh
|
||||||
|
cat <<'EOF' | base64 -d
|
||||||
|
${Buffer.from(payload).toString('base64')}
|
||||||
|
EOF
|
||||||
`;
|
`;
|
||||||
fs.writeFileSync(scriptPath, script, 'utf8');
|
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
@@ -28,8 +35,15 @@ process.stdout.write(${JSON.stringify(payload)});
|
|||||||
|
|
||||||
function makeHangingFakeYtDlpScript(dir: string): void {
|
function makeHangingFakeYtDlpScript(dir: string): void {
|
||||||
const scriptPath = path.join(dir, 'yt-dlp');
|
const scriptPath = path.join(dir, 'yt-dlp');
|
||||||
const script = `#!/usr/bin/env node
|
const script =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? `#!/usr/bin/env bun
|
||||||
setInterval(() => {}, 1000);
|
setInterval(() => {}, 1000);
|
||||||
|
`
|
||||||
|
: `#!/usr/bin/env sh
|
||||||
|
while :; do
|
||||||
|
sleep 1;
|
||||||
|
done
|
||||||
`;
|
`;
|
||||||
fs.writeFileSync(scriptPath, script, 'utf8');
|
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
@@ -44,11 +58,19 @@ async function withFakeYtDlp<T>(payload: string, fn: () => Promise<T>): Promise<
|
|||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
makeFakeYtDlpScript(binDir, payload);
|
makeFakeYtDlpScript(binDir, payload);
|
||||||
const originalPath = process.env.PATH ?? '';
|
const originalPath = process.env.PATH ?? '';
|
||||||
|
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
|
||||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
||||||
|
process.env.SUBMINER_YTDLP_BIN =
|
||||||
|
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} finally {
|
} finally {
|
||||||
process.env.PATH = originalPath;
|
process.env.PATH = originalPath;
|
||||||
|
if (originalCommand === undefined) {
|
||||||
|
delete process.env.SUBMINER_YTDLP_BIN;
|
||||||
|
} else {
|
||||||
|
process.env.SUBMINER_YTDLP_BIN = originalCommand;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -59,11 +81,19 @@ async function withHangingFakeYtDlp<T>(fn: () => Promise<T>): Promise<T> {
|
|||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
makeHangingFakeYtDlpScript(binDir);
|
makeHangingFakeYtDlpScript(binDir);
|
||||||
const originalPath = process.env.PATH ?? '';
|
const originalPath = process.env.PATH ?? '';
|
||||||
|
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
|
||||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
||||||
|
process.env.SUBMINER_YTDLP_BIN =
|
||||||
|
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} finally {
|
} finally {
|
||||||
process.env.PATH = originalPath;
|
process.env.PATH = originalPath;
|
||||||
|
if (originalCommand === undefined) {
|
||||||
|
delete process.env.SUBMINER_YTDLP_BIN;
|
||||||
|
} else {
|
||||||
|
process.env.SUBMINER_YTDLP_BIN = originalCommand;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import type { YoutubeVideoMetadata } from '../immersion-tracker/types';
|
import type { YoutubeVideoMetadata } from '../immersion-tracker/types';
|
||||||
|
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
||||||
|
|
||||||
const YOUTUBE_METADATA_PROBE_TIMEOUT_MS = 15_000;
|
const YOUTUBE_METADATA_PROBE_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ function pickChannelThumbnail(thumbnails: YtDlpThumbnail[] | undefined): string
|
|||||||
export async function probeYoutubeVideoMetadata(
|
export async function probeYoutubeVideoMetadata(
|
||||||
targetUrl: string,
|
targetUrl: string,
|
||||||
): Promise<YoutubeVideoMetadata | null> {
|
): Promise<YoutubeVideoMetadata | null> {
|
||||||
const { stdout } = await runCapture('yt-dlp', [
|
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [
|
||||||
'--dump-single-json',
|
'--dump-single-json',
|
||||||
'--no-warnings',
|
'--no-warnings',
|
||||||
'--skip-download',
|
'--skip-download',
|
||||||
|
|||||||
@@ -16,8 +16,15 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|||||||
|
|
||||||
function makeFakeYtDlpScript(dir: string, payload: string): void {
|
function makeFakeYtDlpScript(dir: string, payload: string): void {
|
||||||
const scriptPath = path.join(dir, 'yt-dlp');
|
const scriptPath = path.join(dir, 'yt-dlp');
|
||||||
const script = `#!/usr/bin/env node
|
const script =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? `#!/usr/bin/env bun
|
||||||
process.stdout.write(${JSON.stringify(payload)});
|
process.stdout.write(${JSON.stringify(payload)});
|
||||||
|
`
|
||||||
|
: `#!/usr/bin/env sh
|
||||||
|
cat <<'EOF' | base64 -d
|
||||||
|
${Buffer.from(payload).toString('base64')}
|
||||||
|
EOF
|
||||||
`;
|
`;
|
||||||
fs.writeFileSync(scriptPath, script, 'utf8');
|
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
|
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
||||||
|
|
||||||
const YOUTUBE_PLAYBACK_RESOLVE_TIMEOUT_MS = 15_000;
|
const YOUTUBE_PLAYBACK_RESOLVE_TIMEOUT_MS = 15_000;
|
||||||
const DEFAULT_PLAYBACK_FORMAT = 'b';
|
const DEFAULT_PLAYBACK_FORMAT = 'b';
|
||||||
@@ -88,8 +89,7 @@ export async function resolveYoutubePlaybackUrl(
|
|||||||
targetUrl: string,
|
targetUrl: string,
|
||||||
format = DEFAULT_PLAYBACK_FORMAT,
|
format = DEFAULT_PLAYBACK_FORMAT,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const ytDlpCommand = process.env.SUBMINER_YTDLP_BIN?.trim() || 'yt-dlp';
|
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [
|
||||||
const { stdout } = await runCapture(ytDlpCommand, [
|
|
||||||
'--get-url',
|
'--get-url',
|
||||||
'--no-warnings',
|
'--no-warnings',
|
||||||
'-f',
|
'-f',
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|||||||
|
|
||||||
function makeFakeYtDlpScript(dir: string): string {
|
function makeFakeYtDlpScript(dir: string): string {
|
||||||
const scriptPath = path.join(dir, 'yt-dlp');
|
const scriptPath = path.join(dir, 'yt-dlp');
|
||||||
const script = `#!/usr/bin/env node
|
const script = `#!/usr/bin/env bun
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
@@ -87,6 +87,87 @@ if (process.env.YTDLP_FAKE_MODE === 'multi') {
|
|||||||
fs.writeFileSync(\`\${prefix}.vtt\`, 'WEBVTT\\n');
|
fs.writeFileSync(\`\${prefix}.vtt\`, 'WEBVTT\\n');
|
||||||
}
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||||
|
fs.chmodSync(scriptPath, 0o755);
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
fs.writeFileSync(scriptPath + '.cmd', `@echo off\r\nbun "${scriptPath}" %*\r\n`, 'utf8');
|
||||||
|
}
|
||||||
|
return scriptPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakeYtDlpShellScript(dir: string): string {
|
||||||
|
const scriptPath = path.join(dir, 'yt-dlp');
|
||||||
|
const script = `#!/bin/sh
|
||||||
|
has_auto_subs=0
|
||||||
|
wants_auto_subs=0
|
||||||
|
wants_manual_subs=0
|
||||||
|
sub_lang=''
|
||||||
|
output_template=''
|
||||||
|
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--write-auto-subs)
|
||||||
|
wants_auto_subs=1
|
||||||
|
;;
|
||||||
|
--write-subs)
|
||||||
|
wants_manual_subs=1
|
||||||
|
;;
|
||||||
|
--sub-langs)
|
||||||
|
sub_lang="$2"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-o)
|
||||||
|
output_template="$2"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$YTDLP_EXPECT_AUTO_SUBS" = "1" ] && [ "$wants_auto_subs" != "1" ]; then
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [ "$YTDLP_EXPECT_MANUAL_SUBS" = "1" ] && [ "$wants_manual_subs" != "1" ]; then
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
if [ -n "$YTDLP_EXPECT_SUB_LANG" ] && [ "$sub_lang" != "$YTDLP_EXPECT_SUB_LANG" ]; then
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
|
||||||
|
prefix="\${output_template%.%(ext)s}"
|
||||||
|
if [ -z "$prefix" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
dir="\${prefix%/*}"
|
||||||
|
[ -d "$dir" ] || /bin/mkdir -p "$dir"
|
||||||
|
|
||||||
|
if [ "$YTDLP_FAKE_MODE" = "multi" ]; then
|
||||||
|
OLD_IFS="$IFS"
|
||||||
|
IFS=","
|
||||||
|
for lang in $sub_lang; do
|
||||||
|
if [ -n "$lang" ]; then
|
||||||
|
printf 'WEBVTT\\n' > "\${prefix}.\${lang}.vtt"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
IFS="$OLD_IFS"
|
||||||
|
elif [ "$YTDLP_FAKE_MODE" = "rolling-auto" ]; then
|
||||||
|
printf 'WEBVTT\\n\\n00:00:01.000 --> 00:00:02.000\\n今日は\\n\\n00:00:02.000 --> 00:00:03.000\\n今日はいい天気ですね\\n\\n00:00:03.000 --> 00:00:04.000\\n今日はいい天気ですね本当に\\n' > "\${prefix}.vtt"
|
||||||
|
elif [ "$YTDLP_FAKE_MODE" = "multi-primary-only-fail" ]; then
|
||||||
|
primary_lang="\${sub_lang%%,*}"
|
||||||
|
if [ -n "$primary_lang" ]; then
|
||||||
|
printf 'WEBVTT\\n' > "\${prefix}.\${primary_lang}.vtt"
|
||||||
|
fi
|
||||||
|
printf "ERROR: Unable to download video subtitles for 'en': HTTP Error 429: Too Many Requests\\n" 1>&2
|
||||||
|
exit 1
|
||||||
|
elif [ "$YTDLP_FAKE_MODE" = "both" ]; then
|
||||||
|
printf 'WEBVTT\\n' > "\${prefix}.vtt"
|
||||||
|
printf 'webp' > "\${prefix}.orig.webp"
|
||||||
|
elif [ "$YTDLP_FAKE_MODE" = "webp-only" ]; then
|
||||||
|
printf 'webp' > "\${prefix}.orig.webp"
|
||||||
|
else
|
||||||
|
printf 'WEBVTT\\n' > "\${prefix}.vtt"
|
||||||
|
fi
|
||||||
`;
|
`;
|
||||||
fs.writeFileSync(scriptPath, script, 'utf8');
|
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||||
fs.chmodSync(scriptPath, 0o755);
|
fs.chmodSync(scriptPath, 0o755);
|
||||||
@@ -100,7 +181,11 @@ async function withFakeYtDlp<T>(
|
|||||||
return await withTempDir(async (root) => {
|
return await withTempDir(async (root) => {
|
||||||
const binDir = path.join(root, 'bin');
|
const binDir = path.join(root, 'bin');
|
||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
|
if (process.platform === 'win32') {
|
||||||
makeFakeYtDlpScript(binDir);
|
makeFakeYtDlpScript(binDir);
|
||||||
|
} else {
|
||||||
|
makeFakeYtDlpShellScript(binDir);
|
||||||
|
}
|
||||||
|
|
||||||
const originalPath = process.env.PATH ?? '';
|
const originalPath = process.env.PATH ?? '';
|
||||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
||||||
@@ -114,6 +199,43 @@ async function withFakeYtDlp<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function withFakeYtDlpCommand<T>(
|
||||||
|
mode: 'both' | 'webp-only' | 'multi' | 'multi-primary-only-fail' | 'rolling-auto',
|
||||||
|
fn: (dir: string, binDir: string) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
return await withTempDir(async (root) => {
|
||||||
|
const binDir = path.join(root, 'bin');
|
||||||
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
|
|
||||||
|
const originalPath = process.env.PATH;
|
||||||
|
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
|
||||||
|
process.env.PATH = '';
|
||||||
|
process.env.YTDLP_FAKE_MODE = mode;
|
||||||
|
process.env.SUBMINER_YTDLP_BIN =
|
||||||
|
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
makeFakeYtDlpScript(binDir);
|
||||||
|
} else {
|
||||||
|
makeFakeYtDlpShellScript(binDir);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await fn(root, binDir);
|
||||||
|
} finally {
|
||||||
|
if (originalPath === undefined) {
|
||||||
|
delete process.env.PATH;
|
||||||
|
} else {
|
||||||
|
process.env.PATH = originalPath;
|
||||||
|
}
|
||||||
|
delete process.env.YTDLP_FAKE_MODE;
|
||||||
|
if (originalCommand === undefined) {
|
||||||
|
delete process.env.SUBMINER_YTDLP_BIN;
|
||||||
|
} else {
|
||||||
|
process.env.SUBMINER_YTDLP_BIN = originalCommand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function withFakeYtDlpExpectations<T>(
|
async function withFakeYtDlpExpectations<T>(
|
||||||
expectations: Partial<
|
expectations: Partial<
|
||||||
Record<'YTDLP_EXPECT_AUTO_SUBS' | 'YTDLP_EXPECT_MANUAL_SUBS' | 'YTDLP_EXPECT_SUB_LANG', string>
|
Record<'YTDLP_EXPECT_AUTO_SUBS' | 'YTDLP_EXPECT_MANUAL_SUBS' | 'YTDLP_EXPECT_SUB_LANG', string>
|
||||||
@@ -179,6 +301,29 @@ test('downloadYoutubeSubtitleTrack prefers subtitle files over later webp artifa
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTrack honors SUBMINER_YTDLP_BIN when yt-dlp is not on PATH', async () => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withFakeYtDlpCommand('both', async (root) => {
|
||||||
|
const result = await downloadYoutubeSubtitleTrack({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir: path.join(root, 'out'),
|
||||||
|
track: {
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(path.extname(result.path), '.vtt');
|
||||||
|
assert.match(path.basename(result.path), /^auto-ja-orig\./);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('downloadYoutubeSubtitleTrack ignores stale subtitle files from prior runs', async () => {
|
test('downloadYoutubeSubtitleTrack ignores stale subtitle files from prior runs', async () => {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import type { YoutubeTrackOption } from './track-probe';
|
import type { YoutubeTrackOption } from './track-probe';
|
||||||
|
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
||||||
import {
|
import {
|
||||||
convertYoutubeTimedTextToVtt,
|
convertYoutubeTimedTextToVtt,
|
||||||
isYoutubeTimedTextExtension,
|
isYoutubeTimedTextExtension,
|
||||||
@@ -237,7 +238,7 @@ export async function downloadYoutubeSubtitleTrack(input: {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
await runCapture('yt-dlp', args);
|
await runCapture(getYoutubeYtDlpCommand(), args);
|
||||||
const subtitlePath = pickLatestSubtitleFile(input.outputDir, prefix);
|
const subtitlePath = pickLatestSubtitleFile(input.outputDir, prefix);
|
||||||
if (!subtitlePath) {
|
if (!subtitlePath) {
|
||||||
throw new Error(`No subtitle file was downloaded for ${input.track.sourceLanguage}`);
|
throw new Error(`No subtitle file was downloaded for ${input.track.sourceLanguage}`);
|
||||||
@@ -281,7 +282,7 @@ export async function downloadYoutubeSubtitleTracks(input: {
|
|||||||
const includeManualSubs = input.tracks.some((track) => track.kind === 'manual');
|
const includeManualSubs = input.tracks.some((track) => track.kind === 'manual');
|
||||||
|
|
||||||
const result = await runCaptureDetailed(
|
const result = await runCaptureDetailed(
|
||||||
'yt-dlp',
|
getYoutubeYtDlpCommand(),
|
||||||
buildDownloadArgs({
|
buildDownloadArgs({
|
||||||
targetUrl: input.targetUrl,
|
targetUrl: input.targetUrl,
|
||||||
outputTemplate,
|
outputTemplate,
|
||||||
|
|||||||
@@ -17,10 +17,18 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|||||||
function makeFakeYtDlpScript(dir: string, payload: unknown, rawScript = false): void {
|
function makeFakeYtDlpScript(dir: string, payload: unknown, rawScript = false): void {
|
||||||
const scriptPath = path.join(dir, 'yt-dlp');
|
const scriptPath = path.join(dir, 'yt-dlp');
|
||||||
const stdoutBody = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
const stdoutBody = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
||||||
const script = rawScript
|
const script =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? rawScript
|
||||||
? stdoutBody
|
? stdoutBody
|
||||||
: `#!/usr/bin/env node
|
: `#!/usr/bin/env bun
|
||||||
process.stdout.write(${JSON.stringify(stdoutBody)});
|
process.stdout.write(${JSON.stringify(stdoutBody)});
|
||||||
|
`
|
||||||
|
: `#!/bin/sh
|
||||||
|
PATH=/usr/bin:/bin:/usr/local/bin
|
||||||
|
cat <<'SUBMINER_EOF' | base64 -d
|
||||||
|
${Buffer.from(stdoutBody).toString('base64')}
|
||||||
|
SUBMINER_EOF
|
||||||
`;
|
`;
|
||||||
fs.writeFileSync(scriptPath, script, 'utf8');
|
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
@@ -39,11 +47,50 @@ async function withFakeYtDlp<T>(
|
|||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
makeFakeYtDlpScript(binDir, payload, options.rawScript === true);
|
makeFakeYtDlpScript(binDir, payload, options.rawScript === true);
|
||||||
const originalPath = process.env.PATH ?? '';
|
const originalPath = process.env.PATH ?? '';
|
||||||
|
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
|
||||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
||||||
|
process.env.SUBMINER_YTDLP_BIN =
|
||||||
|
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} finally {
|
} finally {
|
||||||
process.env.PATH = originalPath;
|
process.env.PATH = originalPath;
|
||||||
|
if (originalCommand === undefined) {
|
||||||
|
delete process.env.SUBMINER_YTDLP_BIN;
|
||||||
|
} else {
|
||||||
|
process.env.SUBMINER_YTDLP_BIN = originalCommand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withFakeYtDlpCommand<T>(
|
||||||
|
payload: unknown,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
options: { rawScript?: boolean } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
return await withTempDir(async (root) => {
|
||||||
|
const binDir = path.join(root, 'bin');
|
||||||
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
|
makeFakeYtDlpScript(binDir, payload, options.rawScript === true);
|
||||||
|
const originalPath = process.env.PATH;
|
||||||
|
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
|
||||||
|
process.env.PATH = '';
|
||||||
|
process.env.SUBMINER_YTDLP_BIN =
|
||||||
|
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
if (originalPath === undefined) {
|
||||||
|
delete process.env.PATH;
|
||||||
|
} else {
|
||||||
|
process.env.PATH = originalPath;
|
||||||
|
}
|
||||||
|
if (originalCommand === undefined) {
|
||||||
|
delete process.env.SUBMINER_YTDLP_BIN;
|
||||||
|
} else {
|
||||||
|
process.env.SUBMINER_YTDLP_BIN = originalCommand;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -69,6 +116,28 @@ test('probeYoutubeTracks prefers srv3 over vtt for automatic captions', async ()
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('probeYoutubeTracks honors SUBMINER_YTDLP_BIN when yt-dlp is not on PATH', async () => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withFakeYtDlpCommand(
|
||||||
|
{
|
||||||
|
id: 'abc123',
|
||||||
|
title: 'Example',
|
||||||
|
subtitles: {
|
||||||
|
ja: [{ ext: 'vtt', url: 'https://example.com/ja.vtt', name: 'Japanese manual' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await probeYoutubeTracks('https://www.youtube.com/watch?v=abc123');
|
||||||
|
assert.equal(result.videoId, 'abc123');
|
||||||
|
assert.equal(result.tracks[0]?.downloadUrl, 'https://example.com/ja.vtt');
|
||||||
|
assert.equal(result.tracks[0]?.fileExtension, 'vtt');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('probeYoutubeTracks keeps preferring srt for manual captions', async () => {
|
test('probeYoutubeTracks keeps preferring srt for manual captions', async () => {
|
||||||
await withFakeYtDlp(
|
await withFakeYtDlp(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import type { YoutubeTrackOption } from '../../../types';
|
import type { YoutubeTrackOption } from '../../../types';
|
||||||
import { formatYoutubeTrackLabel, normalizeYoutubeLangCode, type YoutubeTrackKind } from './labels';
|
import { formatYoutubeTrackLabel, normalizeYoutubeLangCode, type YoutubeTrackKind } from './labels';
|
||||||
|
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
||||||
|
|
||||||
const YOUTUBE_TRACK_PROBE_TIMEOUT_MS = 15_000;
|
const YOUTUBE_TRACK_PROBE_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
@@ -111,7 +112,11 @@ function toTracks(entries: Record<string, YtDlpSubtitleEntry> | undefined, kind:
|
|||||||
export type { YoutubeTrackOption };
|
export type { YoutubeTrackOption };
|
||||||
|
|
||||||
export async function probeYoutubeTracks(targetUrl: string): Promise<YoutubeTrackProbeResult> {
|
export async function probeYoutubeTracks(targetUrl: string): Promise<YoutubeTrackProbeResult> {
|
||||||
const { stdout } = await runCapture('yt-dlp', ['--dump-single-json', '--no-warnings', targetUrl]);
|
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [
|
||||||
|
'--dump-single-json',
|
||||||
|
'--no-warnings',
|
||||||
|
targetUrl,
|
||||||
|
]);
|
||||||
const trimmedStdout = stdout.trim();
|
const trimmedStdout = stdout.trim();
|
||||||
if (!trimmedStdout) {
|
if (!trimmedStdout) {
|
||||||
throw new Error('yt-dlp returned empty output while probing subtitle tracks');
|
throw new Error('yt-dlp returned empty output while probing subtitle tracks');
|
||||||
|
|||||||
44
src/core/services/youtube/ytdlp-command.ts
Normal file
44
src/core/services/youtube/ytdlp-command.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const DEFAULT_YTDLP_COMMAND = 'yt-dlp';
|
||||||
|
const WINDOWS_YTDLP_COMMANDS = ['yt-dlp.cmd', 'yt-dlp.exe', 'yt-dlp'];
|
||||||
|
|
||||||
|
function resolveFromPath(commandName: string): string | null {
|
||||||
|
if (!process.env.PATH) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchPaths = process.env.PATH.split(path.delimiter);
|
||||||
|
for (const searchPath of searchPaths) {
|
||||||
|
const candidate = path.join(searchPath, commandName);
|
||||||
|
try {
|
||||||
|
fs.accessSync(candidate, fs.constants.X_OK);
|
||||||
|
return candidate;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getYoutubeYtDlpCommand(): string {
|
||||||
|
const explicitCommand = process.env.SUBMINER_YTDLP_BIN?.trim();
|
||||||
|
if (explicitCommand) {
|
||||||
|
return explicitCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return DEFAULT_YTDLP_COMMAND;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const commandName of WINDOWS_YTDLP_COMMANDS) {
|
||||||
|
const resolved = resolveFromPath(commandName);
|
||||||
|
if (resolved) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_YTDLP_COMMAND;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
configureEarlyAppPaths,
|
configureEarlyAppPaths,
|
||||||
|
normalizeLaunchMpvExtraArgs,
|
||||||
normalizeStartupArgv,
|
normalizeStartupArgv,
|
||||||
normalizeLaunchMpvTargets,
|
normalizeLaunchMpvTargets,
|
||||||
sanitizeHelpEnv,
|
sanitizeHelpEnv,
|
||||||
@@ -70,6 +71,79 @@ test('launch-mpv entry helpers detect and normalize targets', () => {
|
|||||||
assert.deepEqual(normalizeLaunchMpvTargets(['SubMiner.exe', '--launch-mpv', 'C:\\a.mkv']), [
|
assert.deepEqual(normalizeLaunchMpvTargets(['SubMiner.exe', '--launch-mpv', 'C:\\a.mkv']), [
|
||||||
'C:\\a.mkv',
|
'C:\\a.mkv',
|
||||||
]);
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeLaunchMpvExtraArgs([
|
||||||
|
'SubMiner.exe',
|
||||||
|
'--launch-mpv',
|
||||||
|
'--sub-file',
|
||||||
|
'track.srt',
|
||||||
|
'C:\\a.mkv',
|
||||||
|
]),
|
||||||
|
['--sub-file', 'track.srt'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeLaunchMpvTargets([
|
||||||
|
'SubMiner.exe',
|
||||||
|
'--launch-mpv',
|
||||||
|
'--sub-file',
|
||||||
|
'track.srt',
|
||||||
|
'C:\\a.mkv',
|
||||||
|
]),
|
||||||
|
['C:\\a.mkv'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeLaunchMpvExtraArgs([
|
||||||
|
'SubMiner.exe',
|
||||||
|
'--launch-mpv',
|
||||||
|
'--profile=subminer',
|
||||||
|
'--pause=yes',
|
||||||
|
'C:\\a.mkv',
|
||||||
|
]),
|
||||||
|
['--profile=subminer', '--pause=yes'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeLaunchMpvExtraArgs([
|
||||||
|
'SubMiner.exe',
|
||||||
|
'--launch-mpv',
|
||||||
|
'--input-ipc-server',
|
||||||
|
'\\\\.\\pipe\\custom-subminer-socket',
|
||||||
|
'--alang',
|
||||||
|
'ja,jpn',
|
||||||
|
'C:\\a.mkv',
|
||||||
|
]),
|
||||||
|
['--input-ipc-server', '\\\\.\\pipe\\custom-subminer-socket', '--alang', 'ja,jpn'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeLaunchMpvExtraArgs(['SubMiner.exe', '--launch-mpv', '--fullscreen', 'C:\\a.mkv']),
|
||||||
|
['--fullscreen'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeLaunchMpvTargets([
|
||||||
|
'SubMiner.exe',
|
||||||
|
'--launch-mpv',
|
||||||
|
'--input-ipc-server',
|
||||||
|
'\\\\.\\pipe\\custom-subminer-socket',
|
||||||
|
'--alang',
|
||||||
|
'ja,jpn',
|
||||||
|
'C:\\a.mkv',
|
||||||
|
'C:\\b.mkv',
|
||||||
|
]),
|
||||||
|
['C:\\a.mkv', 'C:\\b.mkv'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeLaunchMpvTargets(['SubMiner.exe', '--launch-mpv', '--fullscreen', 'C:\\a.mkv']),
|
||||||
|
['C:\\a.mkv'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeLaunchMpvExtraArgs([
|
||||||
|
'SubMiner.exe',
|
||||||
|
'--launch-mpv',
|
||||||
|
'--msg-level',
|
||||||
|
'all=warn',
|
||||||
|
'C:\\a.mkv',
|
||||||
|
]),
|
||||||
|
['--msg-level', 'all=warn'],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats-daemon entry helper detects internal daemon commands', () => {
|
test('stats-daemon entry helper detects internal daemon commands', () => {
|
||||||
|
|||||||
@@ -8,6 +8,23 @@ const START_ARG = '--start';
|
|||||||
const PASSWORD_STORE_ARG = '--password-store';
|
const PASSWORD_STORE_ARG = '--password-store';
|
||||||
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
|
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
|
||||||
const APP_NAME = 'SubMiner';
|
const APP_NAME = 'SubMiner';
|
||||||
|
const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
|
||||||
|
'--alang',
|
||||||
|
'--audio-file',
|
||||||
|
'--input-ipc-server',
|
||||||
|
'--log-file',
|
||||||
|
'--msg-level',
|
||||||
|
'--profile',
|
||||||
|
'--script',
|
||||||
|
'--script-opts',
|
||||||
|
'--scripts',
|
||||||
|
'--slang',
|
||||||
|
'--sub-file',
|
||||||
|
'--sub-file-paths',
|
||||||
|
'--title',
|
||||||
|
'--volume',
|
||||||
|
'--ytdl-format',
|
||||||
|
]);
|
||||||
|
|
||||||
type EarlyAppLike = {
|
type EarlyAppLike = {
|
||||||
setName: (name: string) => void;
|
setName: (name: string) => void;
|
||||||
@@ -53,6 +70,15 @@ function removePassiveStartupArgs(argv: string[]): string[] {
|
|||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function consumesLaunchMpvValue(token: string): boolean {
|
||||||
|
return (
|
||||||
|
token.startsWith('--') &&
|
||||||
|
token !== '--' &&
|
||||||
|
!token.includes('=') &&
|
||||||
|
MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES.has(token)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function parseCliArgs(argv: string[]): CliArgs {
|
function parseCliArgs(argv: string[]): CliArgs {
|
||||||
return parseArgs(argv);
|
return parseArgs(argv);
|
||||||
}
|
}
|
||||||
@@ -121,7 +147,82 @@ export function shouldHandleStatsDaemonCommandAtEntry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeLaunchMpvTargets(argv: string[]): string[] {
|
export function normalizeLaunchMpvTargets(argv: string[]): string[] {
|
||||||
return parseCliArgs(argv).launchMpvTargets;
|
const launchMpvIndex = argv.findIndex((arg) => arg === '--launch-mpv');
|
||||||
|
if (launchMpvIndex < 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets: string[] = [];
|
||||||
|
|
||||||
|
let parsingTargets = false;
|
||||||
|
for (let i = launchMpvIndex + 1; i < argv.length; i += 1) {
|
||||||
|
const token = argv[i];
|
||||||
|
if (!token) continue;
|
||||||
|
|
||||||
|
if (parsingTargets) {
|
||||||
|
targets.push(token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token === '--') {
|
||||||
|
parsingTargets = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.startsWith('--')) {
|
||||||
|
if (consumesLaunchMpvValue(token) && i + 1 < argv.length) {
|
||||||
|
const value = argv[i + 1];
|
||||||
|
if (value && !value.startsWith('-')) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.startsWith('-')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
parsingTargets = true;
|
||||||
|
targets.push(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLaunchMpvExtraArgs(argv: string[]): string[] {
|
||||||
|
const launchMpvIndex = argv.findIndex((arg) => arg === '--launch-mpv');
|
||||||
|
if (launchMpvIndex < 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraArgs: string[] = [];
|
||||||
|
for (let i = launchMpvIndex + 1; i < argv.length; i += 1) {
|
||||||
|
const token = argv[i];
|
||||||
|
if (!token) continue;
|
||||||
|
if (token === '--') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (token.startsWith('--')) {
|
||||||
|
extraArgs.push(token);
|
||||||
|
if (consumesLaunchMpvValue(token) && i + 1 < argv.length) {
|
||||||
|
const value = argv[i + 1];
|
||||||
|
if (value && !value.startsWith('-')) {
|
||||||
|
extraArgs.push(value);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token.startsWith('-')) {
|
||||||
|
extraArgs.push(token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!token.startsWith('-')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return extraArgs;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeStartupEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
export function sanitizeStartupEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import path from 'node:path';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { app, dialog } from 'electron';
|
import { app, dialog } from 'electron';
|
||||||
import { printHelp } from './cli/help';
|
import { printHelp } from './cli/help';
|
||||||
|
import { loadRawConfigStrict } from './config/load';
|
||||||
import {
|
import {
|
||||||
configureEarlyAppPaths,
|
configureEarlyAppPaths,
|
||||||
|
normalizeLaunchMpvExtraArgs,
|
||||||
normalizeLaunchMpvTargets,
|
normalizeLaunchMpvTargets,
|
||||||
normalizeStartupArgv,
|
normalizeStartupArgv,
|
||||||
sanitizeStartupEnv,
|
sanitizeStartupEnv,
|
||||||
@@ -15,6 +18,7 @@ import {
|
|||||||
shouldHandleStatsDaemonCommandAtEntry,
|
shouldHandleStatsDaemonCommandAtEntry,
|
||||||
} from './main-entry-runtime';
|
} from './main-entry-runtime';
|
||||||
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
||||||
|
import { resolvePackagedFirstRunPluginAssets } from './main/runtime/first-run-setup-plugin';
|
||||||
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
||||||
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
|
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
|
||||||
|
|
||||||
@@ -32,9 +36,37 @@ function applySanitizedEnv(sanitizedEnv: NodeJS.ProcessEnv): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readConfiguredWindowsMpvExecutablePath(configDir: string): string {
|
||||||
|
const loadResult = loadRawConfigStrict({
|
||||||
|
configDir,
|
||||||
|
configFileJsonc: path.join(configDir, 'config.jsonc'),
|
||||||
|
configFileJson: path.join(configDir, 'config.json'),
|
||||||
|
});
|
||||||
|
if (!loadResult.ok) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof loadResult.config.mpv?.executablePath === 'string'
|
||||||
|
? loadResult.config.mpv.executablePath.trim()
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined {
|
||||||
|
const assets = resolvePackagedFirstRunPluginAssets({
|
||||||
|
dirname: __dirname,
|
||||||
|
appPath: app.getAppPath(),
|
||||||
|
resourcesPath: process.resourcesPath,
|
||||||
|
});
|
||||||
|
if (!assets) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(assets.pluginDirSource, 'main.lua');
|
||||||
|
}
|
||||||
|
|
||||||
process.argv = normalizeStartupArgv(process.argv, process.env);
|
process.argv = normalizeStartupArgv(process.argv, process.env);
|
||||||
applySanitizedEnv(sanitizeStartupEnv(process.env));
|
applySanitizedEnv(sanitizeStartupEnv(process.env));
|
||||||
configureEarlyAppPaths(app);
|
const userDataPath = configureEarlyAppPaths(app);
|
||||||
|
|
||||||
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
|
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
|
||||||
const child = spawn(process.execPath, process.argv.slice(1), {
|
const child = spawn(process.execPath, process.argv.slice(1), {
|
||||||
@@ -59,8 +91,8 @@ if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) {
|
|||||||
if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
|
if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
|
||||||
const sanitizedEnv = sanitizeLaunchMpvEnv(process.env);
|
const sanitizedEnv = sanitizeLaunchMpvEnv(process.env);
|
||||||
applySanitizedEnv(sanitizedEnv);
|
applySanitizedEnv(sanitizedEnv);
|
||||||
void app.whenReady().then(() => {
|
void app.whenReady().then(async () => {
|
||||||
const result = launchWindowsMpv(
|
const result = await launchWindowsMpv(
|
||||||
normalizeLaunchMpvTargets(process.argv),
|
normalizeLaunchMpvTargets(process.argv),
|
||||||
createWindowsMpvLaunchDeps({
|
createWindowsMpvLaunchDeps({
|
||||||
getEnv: (name) => process.env[name],
|
getEnv: (name) => process.env[name],
|
||||||
@@ -68,6 +100,10 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
|
|||||||
dialog.showErrorBox(title, content);
|
dialog.showErrorBox(title, content);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
normalizeLaunchMpvExtraArgs(process.argv),
|
||||||
|
process.execPath,
|
||||||
|
resolveBundledWindowsMpvPluginEntrypoint(),
|
||||||
|
readConfiguredWindowsMpvExecutablePath(userDataPath),
|
||||||
);
|
);
|
||||||
app.exit(result.ok ? 0 : 1);
|
app.exit(result.ok ? 0 : 1);
|
||||||
});
|
});
|
||||||
|
|||||||
88
src/main.ts
88
src/main.ts
@@ -123,7 +123,12 @@ import { AnkiIntegration } from './anki-integration';
|
|||||||
import { SubtitleTimingTracker } from './subtitle-timing-tracker';
|
import { SubtitleTimingTracker } from './subtitle-timing-tracker';
|
||||||
import { RuntimeOptionsManager } from './runtime-options';
|
import { RuntimeOptionsManager } from './runtime-options';
|
||||||
import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils';
|
import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils';
|
||||||
import { createLogger, setLogLevel, resolveDefaultLogFilePath, type LogLevelSource } from './logger';
|
import {
|
||||||
|
createLogger,
|
||||||
|
setLogLevel,
|
||||||
|
resolveDefaultLogFilePath,
|
||||||
|
type LogLevelSource,
|
||||||
|
} from './logger';
|
||||||
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
|
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
|
||||||
import {
|
import {
|
||||||
commandNeedsOverlayStartupPrereqs,
|
commandNeedsOverlayStartupPrereqs,
|
||||||
@@ -339,6 +344,7 @@ import { startStatsServer } from './core/services/stats-server';
|
|||||||
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js';
|
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js';
|
||||||
import {
|
import {
|
||||||
createFirstRunSetupService,
|
createFirstRunSetupService,
|
||||||
|
getFirstRunSetupCompletionMessage,
|
||||||
shouldAutoOpenFirstRunSetup,
|
shouldAutoOpenFirstRunSetup,
|
||||||
} from './main/runtime/first-run-setup-service';
|
} from './main/runtime/first-run-setup-service';
|
||||||
import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow';
|
import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow';
|
||||||
@@ -348,6 +354,7 @@ import {
|
|||||||
createYoutubePrimarySubtitleNotificationRuntime,
|
createYoutubePrimarySubtitleNotificationRuntime,
|
||||||
} from './main/runtime/youtube-primary-subtitle-notification';
|
} from './main/runtime/youtube-primary-subtitle-notification';
|
||||||
import { createAutoplayReadyGate } from './main/runtime/autoplay-ready-gate';
|
import { createAutoplayReadyGate } from './main/runtime/autoplay-ready-gate';
|
||||||
|
import { createManagedLocalSubtitleSelectionRuntime } from './main/runtime/local-subtitle-selection';
|
||||||
import {
|
import {
|
||||||
buildFirstRunSetupHtml,
|
buildFirstRunSetupHtml,
|
||||||
createMaybeFocusExistingFirstRunSetupWindowHandler,
|
createMaybeFocusExistingFirstRunSetupWindowHandler,
|
||||||
@@ -365,7 +372,11 @@ import {
|
|||||||
detectWindowsMpvShortcuts,
|
detectWindowsMpvShortcuts,
|
||||||
resolveWindowsMpvShortcutPaths,
|
resolveWindowsMpvShortcutPaths,
|
||||||
} from './main/runtime/windows-mpv-shortcuts';
|
} from './main/runtime/windows-mpv-shortcuts';
|
||||||
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
import {
|
||||||
|
createWindowsMpvLaunchDeps,
|
||||||
|
getConfiguredWindowsMpvPathStatus,
|
||||||
|
launchWindowsMpv,
|
||||||
|
} from './main/runtime/windows-mpv-launch';
|
||||||
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
|
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
|
||||||
import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch';
|
import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch';
|
||||||
import { shouldEnsureTrayOnStartupForInitialArgs } from './main/runtime/startup-tray-policy';
|
import { shouldEnsureTrayOnStartupForInitialArgs } from './main/runtime/startup-tray-policy';
|
||||||
@@ -490,7 +501,10 @@ import {
|
|||||||
} from './config';
|
} from './config';
|
||||||
import { resolveConfigDir } from './config/path-resolution';
|
import { resolveConfigDir } from './config/path-resolution';
|
||||||
import { parseSubtitleCues } from './core/services/subtitle-cue-parser';
|
import { parseSubtitleCues } from './core/services/subtitle-cue-parser';
|
||||||
import { createSubtitlePrefetchService, type SubtitlePrefetchService } from './core/services/subtitle-prefetch';
|
import {
|
||||||
|
createSubtitlePrefetchService,
|
||||||
|
type SubtitlePrefetchService,
|
||||||
|
} from './core/services/subtitle-prefetch';
|
||||||
import {
|
import {
|
||||||
buildSubtitleSidebarSourceKey,
|
buildSubtitleSidebarSourceKey,
|
||||||
resolveSubtitleSourcePath,
|
resolveSubtitleSourcePath,
|
||||||
@@ -1000,6 +1014,17 @@ const autoplayReadyGate = createAutoplayReadyGate({
|
|||||||
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||||
logDebug: (message) => logger.debug(message),
|
logDebug: (message) => logger.debug(message),
|
||||||
});
|
});
|
||||||
|
const managedLocalSubtitleSelectionRuntime = createManagedLocalSubtitleSelectionRuntime({
|
||||||
|
getCurrentMediaPath: () => appState.currentMediaPath,
|
||||||
|
getMpvClient: () => appState.mpvClient,
|
||||||
|
getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages,
|
||||||
|
getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages,
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
sendMpvCommandRuntime(appState.mpvClient, command);
|
||||||
|
},
|
||||||
|
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||||
|
clearScheduled: (timer) => clearTimeout(timer),
|
||||||
|
});
|
||||||
const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
|
const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
directPlaybackFormat: YOUTUBE_DIRECT_PLAYBACK_FORMAT,
|
directPlaybackFormat: YOUTUBE_DIRECT_PLAYBACK_FORMAT,
|
||||||
@@ -1024,6 +1049,9 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
|
|||||||
showError: (title, content) => dialog.showErrorBox(title, content),
|
showError: (title, content) => dialog.showErrorBox(title, content),
|
||||||
}),
|
}),
|
||||||
[...args, `--log-file=${DEFAULT_MPV_LOG_PATH}`],
|
[...args, `--log-file=${DEFAULT_MPV_LOG_PATH}`],
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
getResolvedConfig().mpv.executablePath,
|
||||||
),
|
),
|
||||||
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
|
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
|
||||||
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
|
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
|
||||||
@@ -1392,8 +1420,7 @@ const refreshSubtitlePrefetchFromActiveTrackHandler =
|
|||||||
getMpvClient: () => appState.mpvClient,
|
getMpvClient: () => appState.mpvClient,
|
||||||
getLastObservedTimePos: () => lastObservedTimePos,
|
getLastObservedTimePos: () => lastObservedTimePos,
|
||||||
subtitlePrefetchInitController,
|
subtitlePrefetchInitController,
|
||||||
resolveActiveSubtitleSidebarSource: (input) =>
|
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
|
||||||
resolveActiveSubtitleSidebarSourceHandler(input),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
|
function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
|
||||||
@@ -1406,7 +1433,8 @@ function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
|
|||||||
const subtitlePrefetchRuntime = {
|
const subtitlePrefetchRuntime = {
|
||||||
cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(),
|
cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(),
|
||||||
initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch,
|
initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch,
|
||||||
refreshSubtitleSidebarFromSource: (sourcePath: string) => refreshSubtitleSidebarFromSource(sourcePath),
|
refreshSubtitleSidebarFromSource: (sourcePath: string) =>
|
||||||
|
refreshSubtitleSidebarFromSource(sourcePath),
|
||||||
refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(),
|
refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(),
|
||||||
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => scheduleSubtitlePrefetchRefresh(delayMs),
|
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => scheduleSubtitlePrefetchRefresh(delayMs),
|
||||||
clearScheduledSubtitlePrefetchRefresh: () => clearScheduledSubtitlePrefetchRefresh(),
|
clearScheduledSubtitlePrefetchRefresh: () => clearScheduledSubtitlePrefetchRefresh(),
|
||||||
@@ -1841,10 +1869,11 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
|||||||
},
|
},
|
||||||
})(),
|
})(),
|
||||||
);
|
);
|
||||||
const buildGetRuntimeOptionsStateMainDepsHandler =
|
const buildGetRuntimeOptionsStateMainDepsHandler = createBuildGetRuntimeOptionsStateMainDepsHandler(
|
||||||
createBuildGetRuntimeOptionsStateMainDepsHandler({
|
{
|
||||||
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
|
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
const getRuntimeOptionsStateMainDeps = buildGetRuntimeOptionsStateMainDepsHandler();
|
const getRuntimeOptionsStateMainDeps = buildGetRuntimeOptionsStateMainDepsHandler();
|
||||||
const getRuntimeOptionsStateHandler = createGetRuntimeOptionsStateHandler(
|
const getRuntimeOptionsStateHandler = createGetRuntimeOptionsStateHandler(
|
||||||
getRuntimeOptionsStateMainDeps,
|
getRuntimeOptionsStateMainDeps,
|
||||||
@@ -2200,6 +2229,7 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
|||||||
}),
|
}),
|
||||||
getSetupSnapshot: async () => {
|
getSetupSnapshot: async () => {
|
||||||
const snapshot = await firstRunSetupService.getSetupStatus();
|
const snapshot = await firstRunSetupService.getSetupStatus();
|
||||||
|
const mpvExecutablePath = getResolvedConfig().mpv.executablePath;
|
||||||
return {
|
return {
|
||||||
configReady: snapshot.configReady,
|
configReady: snapshot.configReady,
|
||||||
dictionaryCount: snapshot.dictionaryCount,
|
dictionaryCount: snapshot.dictionaryCount,
|
||||||
@@ -2207,6 +2237,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
|||||||
externalYomitanConfigured: snapshot.externalYomitanConfigured,
|
externalYomitanConfigured: snapshot.externalYomitanConfigured,
|
||||||
pluginStatus: snapshot.pluginStatus,
|
pluginStatus: snapshot.pluginStatus,
|
||||||
pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
|
pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
|
||||||
|
mpvExecutablePath,
|
||||||
|
mpvExecutablePathStatus: getConfiguredWindowsMpvPathStatus(mpvExecutablePath),
|
||||||
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
|
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
|
||||||
message: firstRunSetupMessage,
|
message: firstRunSetupMessage,
|
||||||
};
|
};
|
||||||
@@ -2219,6 +2251,22 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
|||||||
firstRunSetupMessage = snapshot.message;
|
firstRunSetupMessage = snapshot.message;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (submission.action === 'configure-mpv-executable-path') {
|
||||||
|
const mpvExecutablePath = submission.mpvExecutablePath?.trim() ?? '';
|
||||||
|
const pathStatus = getConfiguredWindowsMpvPathStatus(mpvExecutablePath);
|
||||||
|
configService.patchRawConfig({
|
||||||
|
mpv: {
|
||||||
|
executablePath: mpvExecutablePath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
firstRunSetupMessage =
|
||||||
|
pathStatus === 'invalid'
|
||||||
|
? `Saved mpv executable path, but the file was not found: ${mpvExecutablePath}`
|
||||||
|
: mpvExecutablePath
|
||||||
|
? `Saved mpv executable path: ${mpvExecutablePath}`
|
||||||
|
: 'Cleared mpv executable path. SubMiner will auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (submission.action === 'configure-windows-mpv-shortcuts') {
|
if (submission.action === 'configure-windows-mpv-shortcuts') {
|
||||||
const snapshot = await firstRunSetupService.configureWindowsMpvShortcuts({
|
const snapshot = await firstRunSetupService.configureWindowsMpvShortcuts({
|
||||||
startMenuEnabled: submission.startMenuEnabled === true,
|
startMenuEnabled: submission.startMenuEnabled === true,
|
||||||
@@ -2238,18 +2286,15 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
|||||||
firstRunSetupMessage = snapshot.message;
|
firstRunSetupMessage = snapshot.message;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (submission.action === 'skip-plugin') {
|
|
||||||
await firstRunSetupService.skipPluginInstall();
|
|
||||||
firstRunSetupMessage = 'mpv plugin installation skipped.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshot = await firstRunSetupService.markSetupCompleted();
|
const snapshot = await firstRunSetupService.markSetupCompleted();
|
||||||
if (snapshot.state.status === 'completed') {
|
if (snapshot.state.status === 'completed') {
|
||||||
firstRunSetupMessage = null;
|
firstRunSetupMessage = null;
|
||||||
return { closeWindow: true };
|
return { closeWindow: true };
|
||||||
}
|
}
|
||||||
firstRunSetupMessage = 'Install at least one Yomitan dictionary before finishing setup.';
|
firstRunSetupMessage =
|
||||||
|
getFirstRunSetupCompletionMessage(snapshot) ??
|
||||||
|
'Finish setup requires the mpv plugin and Yomitan dictionaries.';
|
||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
markSetupInProgress: async () => {
|
markSetupInProgress: async () => {
|
||||||
@@ -3006,7 +3051,8 @@ const runStatsCliCommand = createRunStatsCliCommandHandler({
|
|||||||
},
|
},
|
||||||
getImmersionTracker: () => appState.immersionTracker,
|
getImmersionTracker: () => appState.immersionTracker,
|
||||||
ensureStatsServerStarted: () => statsStartupRuntime.ensureStatsServerStarted(),
|
ensureStatsServerStarted: () => statsStartupRuntime.ensureStatsServerStarted(),
|
||||||
ensureBackgroundStatsServerStarted: () => statsStartupRuntime.ensureBackgroundStatsServerStarted(),
|
ensureBackgroundStatsServerStarted: () =>
|
||||||
|
statsStartupRuntime.ensureBackgroundStatsServerStarted(),
|
||||||
stopBackgroundStatsServer: () => statsStartupRuntime.stopBackgroundStatsServer(),
|
stopBackgroundStatsServer: () => statsStartupRuntime.stopBackgroundStatsServer(),
|
||||||
openExternal: (url: string) => shell.openExternal(url),
|
openExternal: (url: string) => shell.openExternal(url),
|
||||||
writeResponse: (responsePath, payload) => {
|
writeResponse: (responsePath, payload) => {
|
||||||
@@ -3222,8 +3268,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
|||||||
Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
||||||
shouldUseMinimalStartup: () =>
|
shouldUseMinimalStartup: () =>
|
||||||
getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup,
|
getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup,
|
||||||
shouldSkipHeavyStartup: () =>
|
shouldSkipHeavyStartup: () => getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup,
|
||||||
getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup,
|
|
||||||
createImmersionTracker: () => {
|
createImmersionTracker: () => {
|
||||||
ensureImmersionTrackerStarted();
|
ensureImmersionTrackerStarted();
|
||||||
},
|
},
|
||||||
@@ -3328,6 +3373,7 @@ const {
|
|||||||
updateCurrentMediaPath: (path) => {
|
updateCurrentMediaPath: (path) => {
|
||||||
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
|
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
|
||||||
currentMediaTokenizationGate.updateCurrentMediaPath(path);
|
currentMediaTokenizationGate.updateCurrentMediaPath(path);
|
||||||
|
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
|
||||||
startupOsdSequencer.reset();
|
startupOsdSequencer.reset();
|
||||||
subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh();
|
subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh();
|
||||||
subtitlePrefetchRuntime.cancelPendingInit();
|
subtitlePrefetchRuntime.cancelPendingInit();
|
||||||
@@ -3394,6 +3440,7 @@ const {
|
|||||||
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid);
|
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid);
|
||||||
},
|
},
|
||||||
onSubtitleTrackListChange: (trackList) => {
|
onSubtitleTrackListChange: (trackList) => {
|
||||||
|
managedLocalSubtitleSelectionRuntime.handleSubtitleTrackListChange(trackList);
|
||||||
scheduleSubtitlePrefetchRefresh();
|
scheduleSubtitlePrefetchRefresh();
|
||||||
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList);
|
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList);
|
||||||
},
|
},
|
||||||
@@ -4135,7 +4182,10 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
|
|||||||
showMpvOsd: (text) => showMpvOsd(text),
|
showMpvOsd: (text) => showMpvOsd(text),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient);
|
const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient, {
|
||||||
|
getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages,
|
||||||
|
getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages,
|
||||||
|
});
|
||||||
|
|
||||||
const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||||
mpvCommandMainDeps: {
|
mpvCommandMainDeps: {
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
|||||||
{ scope: string; warn: () => void; info: () => void; error: () => void },
|
{ scope: string; warn: () => void; info: () => void; error: () => void },
|
||||||
{ registry: boolean },
|
{ registry: boolean },
|
||||||
{ getModalWindow: () => null },
|
{ getModalWindow: () => null },
|
||||||
{ inputState: boolean; getModalInputExclusive: () => boolean; handleModalInputStateChange: (isActive: boolean) => void },
|
{
|
||||||
|
inputState: boolean;
|
||||||
|
getModalInputExclusive: () => boolean;
|
||||||
|
handleModalInputStateChange: (isActive: boolean) => void;
|
||||||
|
},
|
||||||
{ measurementStore: boolean },
|
{ measurementStore: boolean },
|
||||||
{ modalRuntime: boolean },
|
{ modalRuntime: boolean },
|
||||||
{ mpvSocketPath: string; texthookerPort: number },
|
{ mpvSocketPath: string; texthookerPort: number },
|
||||||
@@ -80,7 +84,11 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
|||||||
createOverlayManager: () => ({
|
createOverlayManager: () => ({
|
||||||
getModalWindow: () => null,
|
getModalWindow: () => null,
|
||||||
}),
|
}),
|
||||||
createOverlayModalInputState: () => ({ inputState: true, getModalInputExclusive: () => false, handleModalInputStateChange: () => {} }),
|
createOverlayModalInputState: () => ({
|
||||||
|
inputState: true,
|
||||||
|
getModalInputExclusive: () => false,
|
||||||
|
handleModalInputStateChange: () => {},
|
||||||
|
}),
|
||||||
createOverlayContentMeasurementStore: () => ({ measurementStore: true }),
|
createOverlayContentMeasurementStore: () => ({ measurementStore: true }),
|
||||||
getSyncOverlayShortcutsForModal: () => () => {},
|
getSyncOverlayShortcutsForModal: () => () => {},
|
||||||
getSyncOverlayVisibilityForModal: () => () => {},
|
getSyncOverlayVisibilityForModal: () => () => {},
|
||||||
@@ -106,8 +114,14 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
|||||||
mpvSocketPath: '/tmp/subminer.sock',
|
mpvSocketPath: '/tmp/subminer.sock',
|
||||||
texthookerPort: 5174,
|
texthookerPort: 5174,
|
||||||
});
|
});
|
||||||
assert.equal(services.appLifecycleApp.on('ready', () => {}), services.appLifecycleApp);
|
assert.equal(
|
||||||
assert.equal(services.appLifecycleApp.on('second-instance', () => {}), services.appLifecycleApp);
|
services.appLifecycleApp.on('ready', () => {}),
|
||||||
|
services.appLifecycleApp,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
services.appLifecycleApp.on('second-instance', () => {}),
|
||||||
|
services.appLifecycleApp,
|
||||||
|
);
|
||||||
assert.deepEqual(appOnCalls, ['ready']);
|
assert.deepEqual(appOnCalls, ['ready']);
|
||||||
assert.equal(secondInstanceHandlerRegistered, true);
|
assert.equal(secondInstanceHandlerRegistered, true);
|
||||||
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config']);
|
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config']);
|
||||||
|
|||||||
@@ -56,9 +56,7 @@ export interface MainBootServicesParams<
|
|||||||
};
|
};
|
||||||
shouldBypassSingleInstanceLock: () => boolean;
|
shouldBypassSingleInstanceLock: () => boolean;
|
||||||
requestSingleInstanceLockEarly: () => boolean;
|
requestSingleInstanceLockEarly: () => boolean;
|
||||||
registerSecondInstanceHandlerEarly: (
|
registerSecondInstanceHandlerEarly: (listener: (_event: unknown, argv: string[]) => void) => void;
|
||||||
listener: (_event: unknown, argv: string[]) => void,
|
|
||||||
) => void;
|
|
||||||
onConfigStartupParseError: (error: ConfigStartupParseError) => void;
|
onConfigStartupParseError: (error: ConfigStartupParseError) => void;
|
||||||
createConfigService: (configDir: string) => TConfigService;
|
createConfigService: (configDir: string) => TConfigService;
|
||||||
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
|
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
|
||||||
@@ -87,10 +85,7 @@ export interface MainBootServicesParams<
|
|||||||
overlayModalInputState: TOverlayModalInputState;
|
overlayModalInputState: TOverlayModalInputState;
|
||||||
onModalStateChange: (isActive: boolean) => void;
|
onModalStateChange: (isActive: boolean) => void;
|
||||||
}) => TOverlayModalRuntime;
|
}) => TOverlayModalRuntime;
|
||||||
createAppState: (input: {
|
createAppState: (input: { mpvSocketPath: string; texthookerPort: number }) => TAppState;
|
||||||
mpvSocketPath: string;
|
|
||||||
texthookerPort: number;
|
|
||||||
}) => TAppState;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MainBootServicesResult<
|
export interface MainBootServicesResult<
|
||||||
@@ -239,9 +234,7 @@ export function createMainBootServices<
|
|||||||
|
|
||||||
const appLifecycleApp = {
|
const appLifecycleApp = {
|
||||||
requestSingleInstanceLock: () =>
|
requestSingleInstanceLock: () =>
|
||||||
params.shouldBypassSingleInstanceLock()
|
params.shouldBypassSingleInstanceLock() ? true : params.requestSingleInstanceLockEarly(),
|
||||||
? true
|
|
||||||
: params.requestSingleInstanceLockEarly(),
|
|
||||||
quit: () => params.app.quit(),
|
quit: () => params.app.quit(),
|
||||||
on: (event: string, listener: (...args: unknown[]) => void) => {
|
on: (event: string, listener: (...args: unknown[]) => void) => {
|
||||||
if (event === 'second-instance') {
|
if (event === 'second-instance') {
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ function readStoredZipEntries(zipPath: string): Map<string, Buffer> {
|
|||||||
const extraLength = archive.readUInt16LE(cursor + 28);
|
const extraLength = archive.readUInt16LE(cursor + 28);
|
||||||
const fileNameStart = cursor + 30;
|
const fileNameStart = cursor + 30;
|
||||||
const dataStart = fileNameStart + fileNameLength + extraLength;
|
const dataStart = fileNameStart + fileNameLength + extraLength;
|
||||||
const fileName = archive.subarray(fileNameStart, fileNameStart + fileNameLength).toString(
|
const fileName = archive
|
||||||
'utf8',
|
.subarray(fileNameStart, fileNameStart + fileNameLength)
|
||||||
);
|
.toString('utf8');
|
||||||
const data = archive.subarray(dataStart, dataStart + compressedSize);
|
const data = archive.subarray(dataStart, dataStart + compressedSize);
|
||||||
entries.set(fileName, Buffer.from(data));
|
entries.set(fileName, Buffer.from(data));
|
||||||
cursor = dataStart + compressedSize;
|
cursor = dataStart + compressedSize;
|
||||||
@@ -57,7 +57,9 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
|
|||||||
}) as typeof fs.writeFileSync;
|
}) as typeof fs.writeFileSync;
|
||||||
|
|
||||||
Buffer.concat = ((...args: Parameters<typeof Buffer.concat>) => {
|
Buffer.concat = ((...args: Parameters<typeof Buffer.concat>) => {
|
||||||
throw new Error(`buildDictionaryZip should not Buffer.concat the full archive (${args[0].length} chunks)`);
|
throw new Error(
|
||||||
|
`buildDictionaryZip should not Buffer.concat the full archive (${args[0].length} chunks)`,
|
||||||
|
);
|
||||||
}) as typeof Buffer.concat;
|
}) as typeof Buffer.concat;
|
||||||
|
|
||||||
const result = buildDictionaryZip(
|
const result = buildDictionaryZip(
|
||||||
@@ -91,8 +93,9 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
|
|||||||
assert.equal(indexJson.revision, '2026-03-27');
|
assert.equal(indexJson.revision, '2026-03-27');
|
||||||
assert.equal(indexJson.format, 3);
|
assert.equal(indexJson.format, 3);
|
||||||
|
|
||||||
const termBank = JSON.parse(entries.get('term_bank_1.json')!.toString('utf8')) as
|
const termBank = JSON.parse(
|
||||||
CharacterDictionaryTermEntry[];
|
entries.get('term_bank_1.json')!.toString('utf8'),
|
||||||
|
) as CharacterDictionaryTermEntry[];
|
||||||
assert.equal(termBank.length, 1);
|
assert.equal(termBank.length, 1);
|
||||||
assert.equal(termBank[0]?.[0], 'アルファ');
|
assert.equal(termBank[0]?.[0], 'アルファ');
|
||||||
assert.deepEqual(entries.get('images/alpha.bin'), Buffer.from([1, 2, 3]));
|
assert.deepEqual(entries.get('images/alpha.bin'), Buffer.from([1, 2, 3]));
|
||||||
|
|||||||
@@ -138,7 +138,11 @@ function createCentralDirectoryHeader(entry: ZipEntry): Buffer {
|
|||||||
return central;
|
return central;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEndOfCentralDirectory(entriesLength: number, centralSize: number, centralStart: number): Buffer {
|
function createEndOfCentralDirectory(
|
||||||
|
entriesLength: number,
|
||||||
|
centralSize: number,
|
||||||
|
centralStart: number,
|
||||||
|
): Buffer {
|
||||||
const end = Buffer.alloc(22);
|
const end = Buffer.alloc(22);
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
writeUint32LE(end, 0x06054b50, cursor);
|
writeUint32LE(end, 0x06054b50, cursor);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { createAutoplayReadyGate } from './autoplay-ready-gate';
|
import { createAutoplayReadyGate } from './autoplay-ready-gate';
|
||||||
|
|
||||||
test('autoplay ready gate suppresses duplicate media signals unless forced while paused', async () => {
|
test('autoplay ready gate suppresses duplicate media signals for the same media', async () => {
|
||||||
const commands: Array<Array<string | boolean>> = [];
|
const commands: Array<Array<string | boolean>> = [];
|
||||||
const scheduled: Array<() => void> = [];
|
const scheduled: Array<() => void> = [];
|
||||||
|
|
||||||
@@ -31,20 +31,19 @@ test('autoplay ready gate suppresses duplicate media signals unless forced while
|
|||||||
|
|
||||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null });
|
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null });
|
||||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null });
|
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null });
|
||||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
const firstScheduled = scheduled.shift();
|
const firstScheduled = scheduled.shift();
|
||||||
firstScheduled?.();
|
firstScheduled?.();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
assert.deepEqual(commands.filter((command) => command[0] === 'script-message'), [
|
assert.deepEqual(
|
||||||
['script-message', 'subminer-autoplay-ready'],
|
commands.filter((command) => command[0] === 'script-message'),
|
||||||
]);
|
[['script-message', 'subminer-autoplay-ready']],
|
||||||
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
commands.some(
|
commands.some(
|
||||||
(command) =>
|
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||||
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
assert.equal(scheduled.length > 0, true);
|
assert.equal(scheduled.length > 0, true);
|
||||||
@@ -85,14 +84,62 @@ test('autoplay ready gate retry loop does not re-signal plugin readiness', async
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.deepEqual(commands.filter((command) => command[0] === 'script-message'), [
|
assert.deepEqual(
|
||||||
['script-message', 'subminer-autoplay-ready'],
|
commands.filter((command) => command[0] === 'script-message'),
|
||||||
]);
|
[['script-message', 'subminer-autoplay-ready']],
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
commands.filter(
|
commands.filter(
|
||||||
(command) =>
|
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||||
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
|
||||||
).length > 0,
|
).length > 0,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('autoplay ready gate does not unpause again after a later manual pause on the same media', async () => {
|
||||||
|
const commands: Array<Array<string | boolean>> = [];
|
||||||
|
let playbackPaused = true;
|
||||||
|
|
||||||
|
const gate = createAutoplayReadyGate({
|
||||||
|
isAppOwnedFlowInFlight: () => false,
|
||||||
|
getCurrentMediaPath: () => '/media/video.mkv',
|
||||||
|
getCurrentVideoPath: () => null,
|
||||||
|
getPlaybackPaused: () => playbackPaused,
|
||||||
|
getMpvClient: () =>
|
||||||
|
({
|
||||||
|
connected: true,
|
||||||
|
requestProperty: async () => playbackPaused,
|
||||||
|
send: ({ command }: { command: Array<string | boolean> }) => {
|
||||||
|
commands.push(command);
|
||||||
|
if (command[0] === 'set_property' && command[1] === 'pause' && command[2] === false) {
|
||||||
|
playbackPaused = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}) as never,
|
||||||
|
signalPluginAutoplayReady: () => {
|
||||||
|
commands.push(['script-message', 'subminer-autoplay-ready']);
|
||||||
|
},
|
||||||
|
schedule: (callback) => {
|
||||||
|
queueMicrotask(callback);
|
||||||
|
return 1 as never;
|
||||||
|
},
|
||||||
|
logDebug: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
playbackPaused = true;
|
||||||
|
gate.maybeSignalPluginAutoplayReady(
|
||||||
|
{ text: '字幕その2', tokens: null },
|
||||||
|
{ forceWhilePaused: true },
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
commands.filter(
|
||||||
|
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||||
|
).length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -40,12 +40,8 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mediaPath =
|
const mediaPath =
|
||||||
deps.getCurrentMediaPath()?.trim() ||
|
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
|
||||||
deps.getCurrentVideoPath()?.trim() ||
|
|
||||||
'__unknown__';
|
|
||||||
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
|
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
|
||||||
const allowDuplicateWhilePaused =
|
|
||||||
options?.forceWhilePaused === true && deps.getPlaybackPaused() !== false;
|
|
||||||
const releaseRetryDelayMs = 200;
|
const releaseRetryDelayMs = 200;
|
||||||
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
|
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
|
||||||
forceWhilePaused: options?.forceWhilePaused === true,
|
forceWhilePaused: options?.forceWhilePaused === true,
|
||||||
@@ -87,7 +83,10 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
|||||||
const mpvClient = deps.getMpvClient();
|
const mpvClient = deps.getMpvClient();
|
||||||
if (!mpvClient?.connected) {
|
if (!mpvClient?.connected) {
|
||||||
if (attempt < maxReleaseAttempts) {
|
if (attempt < maxReleaseAttempts) {
|
||||||
deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
|
deps.schedule(
|
||||||
|
() => attemptRelease(playbackGeneration, attempt + 1),
|
||||||
|
releaseRetryDelayMs,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -104,20 +103,14 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
|||||||
})();
|
})();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (duplicateMediaSignal && !allowDuplicateWhilePaused) {
|
if (duplicateMediaSignal) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!duplicateMediaSignal) {
|
|
||||||
autoPlayReadySignalMediaPath = mediaPath;
|
autoPlayReadySignalMediaPath = mediaPath;
|
||||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||||
deps.signalPluginAutoplayReady();
|
deps.signalPluginAutoplayReady();
|
||||||
attemptRelease(playbackGeneration, 0);
|
attemptRelease(playbackGeneration, 0);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
|
||||||
attemptRelease(playbackGeneration, 0);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ test('composeAnilistSetupHandlers returns callable setup handlers', () => {
|
|||||||
assert.equal(handled, false);
|
assert.equal(handled, false);
|
||||||
|
|
||||||
// handleAnilistSetupProtocolUrl returns true for subminer:// URLs
|
// handleAnilistSetupProtocolUrl returns true for subminer:// URLs
|
||||||
const handledProtocol = composed.handleAnilistSetupProtocolUrl('subminer://anilist-setup?code=abc');
|
const handledProtocol = composed.handleAnilistSetupProtocolUrl(
|
||||||
|
'subminer://anilist-setup?code=abc',
|
||||||
|
);
|
||||||
assert.equal(handledProtocol, true);
|
assert.equal(handledProtocol, true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,8 +36,13 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
|
|||||||
openJellyfinSetupWindow: () => {},
|
openJellyfinSetupWindow: () => {},
|
||||||
getAnilistQueueStatus: () => ({}) as never,
|
getAnilistQueueStatus: () => ({}) as never,
|
||||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'done' }),
|
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'done' }),
|
||||||
generateCharacterDictionary: async () =>
|
generateCharacterDictionary: async () => ({
|
||||||
({ zipPath: '/tmp/test.zip', fromCache: false, mediaId: 1, mediaTitle: 'Test', entryCount: 1 }),
|
zipPath: '/tmp/test.zip',
|
||||||
|
fromCache: false,
|
||||||
|
mediaId: 1,
|
||||||
|
mediaTitle: 'Test',
|
||||||
|
entryCount: 1,
|
||||||
|
}),
|
||||||
runJellyfinCommand: async () => {},
|
runJellyfinCommand: async () => {},
|
||||||
runStatsCommand: async () => {},
|
runStatsCommand: async () => {},
|
||||||
runYoutubePlaybackFlow: async () => {},
|
runYoutubePlaybackFlow: async () => {},
|
||||||
|
|||||||
@@ -30,9 +30,7 @@ export type CliStartupComposerResult = ComposerOutputs<{
|
|||||||
export function composeCliStartupHandlers(
|
export function composeCliStartupHandlers(
|
||||||
options: CliStartupComposerOptions,
|
options: CliStartupComposerOptions,
|
||||||
): CliStartupComposerResult {
|
): CliStartupComposerResult {
|
||||||
const createCliCommandContext = createCliCommandContextFactory(
|
const createCliCommandContext = createCliCommandContextFactory(options.cliCommandContextMainDeps);
|
||||||
options.cliCommandContextMainDeps,
|
|
||||||
);
|
|
||||||
const handleCliCommand = createCliCommandRuntimeHandler({
|
const handleCliCommand = createCliCommandRuntimeHandler({
|
||||||
...options.cliCommandRuntimeHandlerMainDeps,
|
...options.cliCommandRuntimeHandlerMainDeps,
|
||||||
createCliCommandContext: () => createCliCommandContext(),
|
createCliCommandContext: () => createCliCommandContext(),
|
||||||
|
|||||||
@@ -8,11 +8,8 @@ type StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDep
|
|||||||
typeof createStartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>
|
typeof createStartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type HeadlessStartupComposerOptions<
|
export type HeadlessStartupComposerOptions<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps> =
|
||||||
TCliArgs,
|
ComposerInputs<{
|
||||||
TStartupState,
|
|
||||||
TStartupBootstrapRuntimeDeps,
|
|
||||||
> = ComposerInputs<{
|
|
||||||
startupRuntimeHandlersDeps: StartupRuntimeHandlersDeps<
|
startupRuntimeHandlersDeps: StartupRuntimeHandlersDeps<
|
||||||
TCliArgs,
|
TCliArgs,
|
||||||
TStartupState,
|
TStartupState,
|
||||||
@@ -20,11 +17,8 @@ export type HeadlessStartupComposerOptions<
|
|||||||
>;
|
>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type HeadlessStartupComposerResult<
|
export type HeadlessStartupComposerResult<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps> =
|
||||||
TCliArgs,
|
ComposerOutputs<
|
||||||
TStartupState,
|
|
||||||
TStartupBootstrapRuntimeDeps,
|
|
||||||
> = ComposerOutputs<
|
|
||||||
Pick<
|
Pick<
|
||||||
StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>,
|
StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>,
|
||||||
'appLifecycleRuntimeRunner' | 'runAndApplyStartupState'
|
'appLifecycleRuntimeRunner' | 'runAndApplyStartupState'
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user