mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-03 06:12:07 -07:00
Compare commits
9 Commits
dups
...
codex/wind
| Author | SHA1 | Date | |
|---|---|---|---|
|
aa0385904e
|
|||
|
bf06463bb3
|
|||
|
61ab1b76fc
|
|||
| 8a5805550f | |||
| 78d0da03dd | |||
| 8b9ac99f3d | |||
| 85e3aa4c6b | |||
|
640c8acd7c
|
|||
| d6c72806bb |
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
### 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.
|
- 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.
|
||||||
|
- 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/secondary subtitle language priorities instead of staying on mpv's initial `sid=auto` guess.
|
||||||
|
|
||||||
## v0.10.0 (2026-03-29)
|
## v0.10.0 (2026-03-29)
|
||||||
|
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -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>
|
||||||
@@ -108,12 +112,12 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
| | Required | Optional |
|
| | Required | Optional |
|
||||||
| -------------- | --------------------------------------- | -------------------------------------- |
|
| -------------- | --------------------------------------- | ---------------------------------------------------------- |
|
||||||
| **Player** | [`mpv`](https://mpv.io) with IPC socket | — |
|
| **Player** | [`mpv`](https://mpv.io) with IPC socket | — |
|
||||||
| **Processing** | `ffmpeg`, `mecab` + `mecab-ipadic` | `guessit` (AniSkip), `alass` / `ffsubsync` (subtitle sync) |
|
| **Processing** | `ffmpeg`, `mecab` + `mecab-ipadic` | `guessit` (AniSkip), `alass` / `ffsubsync` (subtitle sync) |
|
||||||
| **Media** | — | `yt-dlp`, `chafa`, `ffmpegthumbnailer` |
|
| **Media** | — | `yt-dlp`, `chafa`, `ffmpegthumbnailer` |
|
||||||
| **Selection** | — | `fzf` / `rofi` |
|
| **Selection** | — | `fzf` / `rofi` |
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> [`bun`](https://bun.sh) is required if building from source or using the CLI wrapper: `subminer`. Pre-built releases (AppImage, DMG, installer) do not require it.
|
> [`bun`](https://bun.sh) is required if building from source or using the CLI wrapper: `subminer`. Pre-built releases (AppImage, DMG, installer) do not require it.
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -236,8 +240,6 @@ subminer stats -b # stats daemon in background
|
|||||||
subminer stats -s # stop background stats daemon
|
subminer stats -s # stop background stats daemon
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
Full guides on configuration, Anki setup, Jellyfin, immersion tracking, and more: **[docs.subminer.moe](https://docs.subminer.moe)**
|
Full guides on configuration, Anki setup, Jellyfin, immersion tracking, and more: **[docs.subminer.moe](https://docs.subminer.moe)**
|
||||||
|
|||||||
@@ -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,39 @@
|
|||||||
|
---
|
||||||
|
id: TASK-263
|
||||||
|
title: Reuse pre-add duplicate IDs for generic Kiku field grouping
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-31 20:44'
|
||||||
|
updated_date: '2026-03-31 20:48'
|
||||||
|
labels:
|
||||||
|
- anki
|
||||||
|
- kiku
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Avoid the extra post-add duplicate lookup on the generic sentence-card creation path by capturing duplicate note IDs before add and reusing that result for Kiku field grouping. Keep Yomitan semantics aligned where practical so duplicate selection is consistent across mining paths.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Generic sentence-card creation captures duplicate note IDs before add and reuses them for Kiku field grouping instead of running the existing post-add duplicate finder
|
||||||
|
- [x] #2 Duplicate selection remains deterministic when multiple matching notes exist
|
||||||
|
- [x] #3 Regression tests cover the generic path duplicate reuse behavior and preserve existing non-Kiku behavior
|
||||||
|
- [x] #4 Internal docs/config comments are updated if the behavior or operator-facing semantics changed
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
No docs update was required because this is internal duplicate-selection plumbing and does not change user-facing config surface.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Generic sentence-card creation now captures exact duplicate note IDs before add when Kiku field grouping is enabled and stores that context by created note ID. Manual field grouping reuses the tracked duplicate IDs first and deterministically picks the most recent matching note, falling back to the legacy duplicate finder only when no tracked context exists. Verified with bun test src/anki-integration/duplicate.test.ts src/anki-integration/card-creation.test.ts src/anki-integration/field-grouping.test.ts and bun run typecheck.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
id: TASK-263.1
|
||||||
|
title: Reuse Yomitan popup duplicate IDs in SubMiner bridge
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-31 22:15'
|
||||||
|
updated_date: '2026-03-31 22:21'
|
||||||
|
labels:
|
||||||
|
- anki
|
||||||
|
- kiku
|
||||||
|
- yomitan
|
||||||
|
dependencies: []
|
||||||
|
parent_task_id: TASK-263
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Thread Yomitan popup/search duplicate note IDs through the existing SubMiner bridge so Kiku/manual grouping can reuse the same duplicate context that already drives the Add duplicate button. Implement and test against the vendored Yomitan copy first; do not rely on upstreamed fork changes yet.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Vendored Yomitan bridge returns duplicate note IDs for popup/search mining when available
|
||||||
|
- [x] #2 SubMiner consumes the bridged duplicate IDs and prefers them for Kiku/manual grouping on the Yomitan mining path
|
||||||
|
- [x] #3 Regression tests cover the popup/search bridge payload and duplicate-id reuse behavior
|
||||||
|
- [x] #4 No commit is made for vendored Yomitan-only changes in this repo state
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Vendored files changed locally for validation only: vendor/subminer-yomitan/ext/js/display/display-anki.js, vendor/subminer-yomitan/ext/js/comm/api.js, vendor/subminer-yomitan/ext/js/comm/anki-connect.js, vendor/subminer-yomitan/ext/js/background/backend.js. Do not commit those vendor changes in this repo; port them to the fork instead.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Vendored Yomitan popup/search mining now precomputes duplicate note IDs, sends them to the SubMiner Anki proxy as private addNote metadata, and still returns note/duplicate data through the parser bridge. The proxy strips the private metadata before forwarding to upstream AnkiConnect, associates the duplicate IDs with the created note before auto-enrichment begins, and SubMiner also records the bridge result as a secondary cache path. Verified with bun test src/anki-integration/duplicate.test.ts src/anki-integration/card-creation.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/anki-connect-proxy.test.ts src/core/services/tokenizer/yomitan-parser-runtime.test.ts and bun run typecheck.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
id: TASK-263.2
|
||||||
|
title: >-
|
||||||
|
Keep Yomitan popup responsive during background add and pause/close before
|
||||||
|
Kiku modal
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-04-01 00:42'
|
||||||
|
updated_date: '2026-04-01 02:35'
|
||||||
|
labels:
|
||||||
|
- anki
|
||||||
|
- yomitan
|
||||||
|
- kiku
|
||||||
|
- ux
|
||||||
|
dependencies: []
|
||||||
|
parent_task_id: TASK-263
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Make Yomitan popup add run in background without blocking popup responsiveness. Before opening Kiku field-grouping modal, pause MPV and close the Yomitan popup/parser window if open.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
<!-- DOD:BEGIN -->
|
||||||
|
- [x] #1 Popup save path returns immediately and prevents duplicate submits
|
||||||
|
- [x] #2 Field-grouping modal request pauses MPV and closes Yomitan popup window first
|
||||||
|
- [x] #3 Regression tests cover async save dispatch and main-side pause/close hook
|
||||||
|
<!-- DOD:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
2026-03-31: Removed the custom pending label/gray save-button presentation from vendored Yomitan. Background add still runs asynchronously with the internal pending-save guard, so duplicate clicks are ignored while the button keeps its stock appearance.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Yomitan popup save dispatches note creation/add in the background with an internal pending-save guard so repeated clicks are ignored without blocking the popup. Before opening the Kiku field-grouping modal, the renderer now closes the visible lookup popup and pauses MPV. Follow-up UX polish removed the custom pending label/gray styling so the save button keeps Yomitan’s stock presentation while the background action runs.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
id: TASK-264
|
||||||
|
title: Replace axios with native fetch across the project
|
||||||
|
status: To Do
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-04-01 00:44'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Remove axios from the codebase and migrate all project HTTP requests to the platform fetch API, preserving existing request behavior and error handling where applicable.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [ ] #1 No production code paths import or depend on axios.
|
||||||
|
- [ ] #2 All existing HTTP requests use fetch or a project-local abstraction built on fetch.
|
||||||
|
- [ ] #3 Request behavior remains functionally equivalent for headers, query params, bodies, status handling, and abort/error cases that are currently supported.
|
||||||
|
- [ ] #4 Tests are updated or added to cover the migrated request flows.
|
||||||
|
- [ ] #5 Documentation is updated if any request semantics or setup steps change.
|
||||||
|
- [ ] #6 axios is removed from project dependencies if it is no longer needed.
|
||||||
|
<!-- AC:END -->
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
id: TASK-265
|
||||||
|
title: Add remote backend for immersion tracking and stats (prefer Postgres)
|
||||||
|
status: To Do
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-04-01 00:47'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- >-
|
||||||
|
/home/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker-service.ts
|
||||||
|
- >-
|
||||||
|
/home/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker/storage.ts
|
||||||
|
- >-
|
||||||
|
/home/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker/sqlite.ts
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/stats-daemon-runner.ts
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/core/services/stats-server.ts
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/main/boot/services.ts
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/package.json
|
||||||
|
documentation:
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/docs/architecture/README.md
|
||||||
|
- >-
|
||||||
|
/home/sudacode/projects/japanese/SubMiner/docs/architecture/stats-trends-data-flow.md
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/README.md
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/config.example.jsonc
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Enable immersion tracking/stats to use a remote authoritative backend so multiple devices can share the same history.
|
||||||
|
|
||||||
|
Current state: `ImmersionTrackerService` opens a local `immersion.sqlite` file from the app data/config path, `stats-daemon-runner` points at that same local file, and `config.example.jsonc` only exposes `immersionTracking.dbPath` for a local path override. The stats API/dashboard reads from the same tracker service and assumes the local database is the source of truth.
|
||||||
|
|
||||||
|
Goal: add a remote backend option that avoids shared filesystem/database-file syncing between devices. Do not use SSH/rsync/shared network filesystem as the primary sync strategy for live multi-device use.
|
||||||
|
|
||||||
|
Backend choice: prefer Postgres if it can be integrated without a broad new dependency surface or destabilizing the current runtime; otherwise use the least invasive remote backend that can be shipped with the current stack and document the tradeoff clearly. Preserve the current local SQLite mode as the default/offline fallback if possible.
|
||||||
|
|
||||||
|
This ticket should cover the full product/architecture change: configuration, storage access, stats reads, startup/error handling, migration/bootstrap from existing local data, tests, and docs.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [ ] #1 The app can be configured to use a remote authoritative backend for immersion tracking instead of only a local `immersion.sqlite` file.
|
||||||
|
- [ ] #2 The chosen backend persists tracker writes and serves the existing stats read models across app restarts.
|
||||||
|
- [ ] #3 Two devices can point at the same remote backend without relying on a shared filesystem or raw SQLite file sync.
|
||||||
|
- [ ] #4 Local SQLite remains supported as the default or fallback mode for offline use.
|
||||||
|
- [ ] #5 If the remote backend is unavailable or misconfigured, startup/write paths fail with actionable errors instead of silent data loss.
|
||||||
|
- [ ] #6 A migration or bootstrap path exists to move existing local immersion data into the remote backend or seed a new device from it.
|
||||||
|
- [ ] #7 Config/examples/docs explain the backend choice, required connection/setup details, and any security/network assumptions.
|
||||||
|
- [ ] #8 Tests cover backend selection plus at least one representative write/read path against the remote backend.
|
||||||
|
<!-- AC:END -->
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
id: TASK-266
|
||||||
|
title: Preserve paused state for configured subtitle-jump keybindings
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-04-01 03:19'
|
||||||
|
updated_date: '2026-04-01 03:19'
|
||||||
|
labels:
|
||||||
|
- renderer
|
||||||
|
- mpv
|
||||||
|
- keybindings
|
||||||
|
- regression
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Regression: configured overlay keybindings that forward raw mpv subtitle-jump commands (for example previous-subtitle on H) can resume playback when invoked while paused. Keyboard-driven edge jumps already preserve paused state; configured keybindings should match that behavior.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Configured subtitle-jump keybindings preserve paused playback state after backward seek
|
||||||
|
- [x] #2 Existing keyboard-driven subtitle navigation behavior remains unchanged
|
||||||
|
- [x] #3 Regression test covers paused configured subtitle-jump keybinding handling
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Configured overlay keybindings that forward `sub-seek` commands now re-check paused state and reapply pause after the seek when playback was already paused. This aligns raw configured subtitle-jump keybindings with the existing keyboard-driven edge-jump behavior and adds regression coverage for the paused backward-seek case.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
id: TASK-267
|
||||||
|
title: Port validated Yomitan popup changes to fork and resync submodule
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-04-01 03:30'
|
||||||
|
updated_date: '2026-04-01 03:33'
|
||||||
|
labels:
|
||||||
|
- yomitan
|
||||||
|
- submodule
|
||||||
|
- git
|
||||||
|
- integration
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Take the locally validated Yomitan popup/bridge changes from the vendored copy, apply them to the standalone `../subminer-yomitan` fork, verify the fork, push the fork commit, then reset the vendored working tree in SubMiner and update the submodule pointer to the pushed fork commit.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Standalone `../subminer-yomitan` contains the validated popup/bridge changes and passes the relevant regression test
|
||||||
|
- [x] #2 The fork commit is pushed to its configured remote branch
|
||||||
|
- [x] #3 SubMiner vendored Yomitan working tree is reset and the submodule pointer is updated to the pushed fork commit
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Applied the validated popup/bridge changes from the vendored Yomitan copy into `../subminer-yomitan`, added the focused async-save regression test there, installed fork deps, and verified with `npx vitest run test/display-anki-save.test.js`. Committed the fork changes as `feat: preserve async popup save state and duplicate metadata`, rebased onto the updated remote `main`, and pushed commit `69620abc` to `origin/main`. Then reset the vendored submodule working tree in SubMiner, checked it out at `69620abc`, and left the superproject with the submodule pointer updated from `3c9ee577` to `69620abc`.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
id: TASK-268
|
||||||
|
title: 'Address CodeRabbit review action items for PR #38'
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-04-01 05:35'
|
||||||
|
updated_date: '2026-04-01 06:07'
|
||||||
|
labels:
|
||||||
|
- pr-review
|
||||||
|
- coderabbit
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- 'https://github.com/ksyasuda/SubMiner/pull/38'
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Review unresolved CodeRabbit feedback on PR #38 and implement the actionable fixes without regressing duplicate grouping or popup behavior.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 All unresolved actionable CodeRabbit review comments on PR #38 are triaged and either fixed in code or explicitly identified as non-actionable or ambiguous.
|
||||||
|
- [x] #2 Code changes preserve duplicate grouping and popup flow behavior covered by existing or added regression tests.
|
||||||
|
- [x] #3 Relevant local verification for the affected areas passes.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
2026-04-01: Reopened for follow-up CodeRabbit round after commit 233bde58. Remaining actionable items: guard maxMatches <= 0 in duplicate exact-match helper and strengthen the duplicate tracking test fixture to prove deduplication as well as sorting.
|
||||||
|
|
||||||
|
2026-04-01: Follow-up round addressed locally. Added guard for maxMatches <= 0 in duplicate exact-match scanning and strengthened the pre-add duplicate tracking test fixture to prove deduplication as well as sorting.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Addressed all unresolved actionable CodeRabbit comments on PR #38. Fixed duplicate tracking so empty duplicate lists are not persisted after sentence-card creation, sanitized Yomitan add-note noteId values to accept only positive integers, preserved paused playback for configured subtitle-seek keybindings when pause state is unknown, and short-circuited duplicate exact-match scanning for single-result lookups. Added regression tests for each case and verified with `bun test` on the affected suites plus `bun run typecheck`, `bun run test:fast`, `bun run test:env`, `bun run build`, and `bun run test:smoke:dist`.
|
||||||
|
|
||||||
|
Follow-up CodeRabbit round addressed locally: `findExactDuplicateNoteIds()` now returns early when `maxMatches <= 0`, and the sentence-card duplicate tracking regression test now uses a repeated duplicate ID to assert deduplication plus sorting. Re-verified with targeted duplicate/card tests, `bun run typecheck`, and `bun run test:fast`.
|
||||||
|
<!-- 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 -->
|
||||||
6
changes/267-yomitan-kiku-popup.md
Normal file
6
changes/267-yomitan-kiku-popup.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
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.
|
||||||
6
changes/268-windows-mpv-shortcut-defaults.md
Normal file
6
changes/268-windows-mpv-shortcut-defaults.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
type: fixed
|
||||||
|
area: 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`.
|
||||||
|
- 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.
|
||||||
|
- Hardened the first-run setup blocker copy and stale custom-scheme handling so setup messages stay aligned with config, plugin, and dictionary readiness.
|
||||||
5
changes/269-windows-mpv-shortcut-idle-overlay.md
Normal file
5
changes/269-windows-mpv-shortcut-idle-overlay.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: 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.
|
||||||
|
- 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.
|
||||||
5
changes/270-first-run-setup-requires-mpv-plugin.md
Normal file
5
changes/270-first-run-setup-requires-mpv-plugin.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
type: changed
|
||||||
|
area: 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.
|
||||||
|
- 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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: playback
|
||||||
|
|
||||||
|
- Fixed managed local playback so duplicate startup-ready retries no longer unpause media after a later manual pause on the same file.
|
||||||
|
- 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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -969,6 +971,7 @@ To refresh roughly once per day, set:
|
|||||||
| `disabled` | No field grouping; duplicate cards are left as-is |
|
| `disabled` | No field grouping; duplicate cards are left as-is |
|
||||||
|
|
||||||
`deleteDuplicateInAuto` controls whether `auto` mode deletes the duplicate after merge (default: `true`). In `manual` mode, the popup asks each time whether to delete the duplicate.
|
`deleteDuplicateInAuto` controls whether `auto` mode deletes the duplicate after merge (default: `true`). In `manual` mode, the popup asks each time whether to delete the duplicate.
|
||||||
|
When the manual merge popup opens, SubMiner pauses playback and closes any open Yomitan popup first so the merge flow can take focus.
|
||||||
|
|
||||||
<video controls playsinline preload="metadata" poster="/assets/kiku-integration-poster.jpg" style="width: 100%; max-width: 960px;">
|
<video controls playsinline preload="metadata" poster="/assets/kiku-integration-poster.jpg" style="width: 100%; max-width: 960px;">
|
||||||
<source :src="'/assets/kiku-integration.webm'" type="video/webm" />
|
<source :src="'/assets/kiku-integration.webm'" type="video/webm" />
|
||||||
@@ -1341,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
|
||||||
{
|
{
|
||||||
@@ -1353,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:
|
||||||
|
|
||||||
@@ -1369,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.
|
||||||
|
|||||||
@@ -171,7 +171,8 @@ 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.
|
||||||
|
- `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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 on `PATH`. If it is installed elsewhere, set `SUBMINER_MPV_PATH` to the full `mpv.exe` path before launching. On Windows, `--launch-mpv` does not require an `mpv.conf` profile named `subminer`.
|
||||||
|
|
||||||
### Launcher Subcommands
|
### Launcher Subcommands
|
||||||
|
|
||||||
@@ -157,12 +160,12 @@ 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`)
|
||||||
- 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 +192,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 +213,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 +237,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 +292,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.
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -97,7 +97,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 || '',
|
||||||
|
|||||||
@@ -427,6 +427,19 @@ 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 +460,7 @@ function withAccessSyncStub(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test('findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists', () => {
|
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,8 +468,8 @@ 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 {
|
||||||
@@ -465,16 +478,16 @@ test('findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists', ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -485,7 +498,7 @@ test('findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin c
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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 +510,12 @@ 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);
|
assert.equal(result, wrapperPath);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -513,3 +526,98 @@ 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 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
os.homedir = originalHomedir;
|
||||||
|
process.env.PATH = originalPath;
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
111
launcher/mpv.ts
111
launcher/mpv.ts
@@ -14,7 +14,6 @@ import {
|
|||||||
isExecutable,
|
isExecutable,
|
||||||
resolveBinaryPathCandidate,
|
resolveBinaryPathCandidate,
|
||||||
resolveCommandInvocation,
|
resolveCommandInvocation,
|
||||||
realpathMaybe,
|
|
||||||
isYoutubeTarget,
|
isYoutubeTarget,
|
||||||
uniqueNormalizedLangCodes,
|
uniqueNormalizedLangCodes,
|
||||||
sleep,
|
sleep,
|
||||||
@@ -35,6 +34,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;
|
||||||
@@ -243,18 +244,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)) {
|
||||||
|
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)) {
|
if (isExecutable(direct)) {
|
||||||
return 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 +297,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,41 +310,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(pathModule.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/SubMiner'));
|
||||||
candidates.push(path.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/subminer'));
|
candidates.push(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(path.join(os.homedir(), '.local/bin/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 = pathModule.resolve(selfPath);
|
||||||
const resolvedCandidate = realpathMaybe(fromPath);
|
const resolvedCandidate = pathModule.resolve(fromPath);
|
||||||
if (resolvedSelf !== resolvedCandidate) return fromPath;
|
if (resolvedSelf !== resolvedCandidate) return fromPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"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:assets",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -51,9 +51,16 @@ function ensureSubmodulePresent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSourceState() {
|
function getSourceState() {
|
||||||
const revision = readCommand('git', ['rev-parse', 'HEAD'], submoduleDir);
|
try {
|
||||||
const dirty = readCommand('git', ['status', '--short', '--untracked-files=no'], submoduleDir);
|
const revision = readCommand('git', ['rev-parse', 'HEAD'], submoduleDir);
|
||||||
return { revision, dirty };
|
const dirty = readCommand('git', ['status', '--short', '--untracked-files=no'], submoduleDir);
|
||||||
|
return { revision, dirty };
|
||||||
|
} catch (error) {
|
||||||
|
if (process.env.SUBMINER_YOMITAN_ALLOW_MISSING_GIT === '1') {
|
||||||
|
return { revision: 'unknown', dirty: '' };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBuildCurrent(force) {
|
function isBuildCurrent(force) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { KnownWordCacheManager } from './anki-integration/known-word-cache';
|
|||||||
import { PollingRunner } from './anki-integration/polling';
|
import { PollingRunner } from './anki-integration/polling';
|
||||||
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
|
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
|
||||||
import { findDuplicateNote as findDuplicateNoteForAnkiIntegration } from './anki-integration/duplicate';
|
import { findDuplicateNote as findDuplicateNoteForAnkiIntegration } from './anki-integration/duplicate';
|
||||||
|
import { findDuplicateNoteIds as findDuplicateNoteIdsForAnkiIntegration } from './anki-integration/duplicate';
|
||||||
import { CardCreationService } from './anki-integration/card-creation';
|
import { CardCreationService } from './anki-integration/card-creation';
|
||||||
import { FieldGroupingService } from './anki-integration/field-grouping';
|
import { FieldGroupingService } from './anki-integration/field-grouping';
|
||||||
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
|
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
|
||||||
@@ -148,6 +149,7 @@ export class AnkiIntegration {
|
|||||||
private aiConfig: AiConfig;
|
private aiConfig: AiConfig;
|
||||||
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
||||||
private noteIdRedirects = new Map<number, number>();
|
private noteIdRedirects = new Map<number, number>();
|
||||||
|
private trackedDuplicateNoteIds = new Map<number, number[]>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: AnkiConnectConfig,
|
config: AnkiConnectConfig,
|
||||||
@@ -264,6 +266,9 @@ export class AnkiIntegration {
|
|||||||
recordCardsAdded: (count, noteIds) => {
|
recordCardsAdded: (count, noteIds) => {
|
||||||
this.recordCardsMinedSafely(count, noteIds, 'proxy');
|
this.recordCardsMinedSafely(count, noteIds, 'proxy');
|
||||||
},
|
},
|
||||||
|
trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||||
|
this.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds);
|
||||||
|
},
|
||||||
getDeck: () => this.config.deck,
|
getDeck: () => this.config.deck,
|
||||||
findNotes: async (query, options) =>
|
findNotes: async (query, options) =>
|
||||||
(await this.client.findNotes(query, options)) as number[],
|
(await this.client.findNotes(query, options)) as number[],
|
||||||
@@ -361,6 +366,10 @@ export class AnkiIntegration {
|
|||||||
trackLastAddedNoteId: (noteId) => {
|
trackLastAddedNoteId: (noteId) => {
|
||||||
this.previousNoteIds.add(noteId);
|
this.previousNoteIds.add(noteId);
|
||||||
},
|
},
|
||||||
|
trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||||
|
this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]);
|
||||||
|
},
|
||||||
|
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');
|
||||||
},
|
},
|
||||||
@@ -382,6 +391,10 @@ export class AnkiIntegration {
|
|||||||
extractFields: (fields) => this.extractFields(fields),
|
extractFields: (fields) => this.extractFields(fields),
|
||||||
findDuplicateNote: (expression, noteId, noteInfo) =>
|
findDuplicateNote: (expression, noteId, noteInfo) =>
|
||||||
this.findDuplicateNote(expression, noteId, noteInfo),
|
this.findDuplicateNote(expression, noteId, noteInfo),
|
||||||
|
getTrackedDuplicateNoteIds: (noteId) =>
|
||||||
|
this.trackedDuplicateNoteIds.has(noteId)
|
||||||
|
? [...(this.trackedDuplicateNoteIds.get(noteId) ?? [])]
|
||||||
|
: null,
|
||||||
hasAllConfiguredFields: (noteInfo, configuredFieldNames) =>
|
hasAllConfiguredFields: (noteInfo, configuredFieldNames) =>
|
||||||
this.hasAllConfiguredFields(noteInfo, configuredFieldNames),
|
this.hasAllConfiguredFields(noteInfo, configuredFieldNames),
|
||||||
processNewCard: (noteId, options) => this.processNewCard(noteId, options),
|
processNewCard: (noteId, options) => this.processNewCard(noteId, options),
|
||||||
@@ -1042,6 +1055,10 @@ export class AnkiIntegration {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trackDuplicateNoteIdsForNote(noteId: number, duplicateNoteIds: number[]): void {
|
||||||
|
this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]);
|
||||||
|
}
|
||||||
|
|
||||||
private async findDuplicateNote(
|
private async findDuplicateNote(
|
||||||
expression: string,
|
expression: string,
|
||||||
excludeNoteId: number,
|
excludeNoteId: number,
|
||||||
@@ -1065,6 +1082,28 @@ export class AnkiIntegration {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async findDuplicateNoteIds(
|
||||||
|
expression: string,
|
||||||
|
noteInfo: NoteInfo,
|
||||||
|
): Promise<number[]> {
|
||||||
|
return findDuplicateNoteIdsForAnkiIntegration(expression, -1, noteInfo, {
|
||||||
|
findNotes: async (query, options) => (await this.client.findNotes(query, options)) as unknown,
|
||||||
|
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
|
||||||
|
getDeck: () => this.config.deck,
|
||||||
|
getWordFieldCandidates: () => this.getConfiguredWordFieldCandidates(),
|
||||||
|
resolveFieldName: (info, preferredName) => this.resolveNoteFieldName(info, preferredName),
|
||||||
|
logInfo: (message) => {
|
||||||
|
log.info(message);
|
||||||
|
},
|
||||||
|
logDebug: (message) => {
|
||||||
|
log.debug(message);
|
||||||
|
},
|
||||||
|
logWarn: (message, error) => {
|
||||||
|
log.warn(message, (error as Error).message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private getPreferredSentenceAudioFieldName(): string {
|
private getPreferredSentenceAudioFieldName(): string {
|
||||||
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
|
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
|
||||||
return sentenceCardConfig.audioField || 'SentenceAudio';
|
return sentenceCardConfig.audioField || 'SentenceAudio';
|
||||||
|
|||||||
@@ -324,6 +324,123 @@ test('proxy fallback-enqueues latest note for addNote responses without note IDs
|
|||||||
assert.deepEqual(recordedCards, [1]);
|
assert.deepEqual(recordedCards, [1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('proxy tracks duplicate note ids from addNote request metadata before enrichment', async () => {
|
||||||
|
const processed: number[] = [];
|
||||||
|
const tracked: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
|
||||||
|
const proxy = new AnkiConnectProxyServer({
|
||||||
|
shouldAutoUpdateNewCards: () => true,
|
||||||
|
processNewCard: async (noteId) => {
|
||||||
|
processed.push(noteId);
|
||||||
|
},
|
||||||
|
trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||||
|
tracked.push({ noteId, duplicateNoteIds });
|
||||||
|
},
|
||||||
|
logInfo: () => undefined,
|
||||||
|
logWarn: () => undefined,
|
||||||
|
logError: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
(
|
||||||
|
proxy as unknown as {
|
||||||
|
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
||||||
|
}
|
||||||
|
).maybeEnqueueFromRequest(
|
||||||
|
{
|
||||||
|
action: 'addNote',
|
||||||
|
params: {
|
||||||
|
note: {},
|
||||||
|
subminerDuplicateNoteIds: [11, -1, 40, 11, 25],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Buffer.from(JSON.stringify({ result: 42, error: null }), 'utf8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForCondition(() => processed.length === 1);
|
||||||
|
assert.deepEqual(tracked, [{ noteId: 42, duplicateNoteIds: [11, 25, 40] }]);
|
||||||
|
assert.deepEqual(processed, [42]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('proxy strips SubMiner duplicate metadata before forwarding upstream addNote request', async () => {
|
||||||
|
let upstreamBody = '';
|
||||||
|
const upstream = http.createServer(async (req, res) => {
|
||||||
|
upstreamBody = await new Promise<string>((resolve) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
||||||
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||||
|
});
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader('content-type', 'application/json');
|
||||||
|
res.end(JSON.stringify({ result: 42, error: null }));
|
||||||
|
});
|
||||||
|
upstream.listen(0, '127.0.0.1');
|
||||||
|
await once(upstream, 'listening');
|
||||||
|
const upstreamAddress = upstream.address();
|
||||||
|
assert.ok(upstreamAddress && typeof upstreamAddress === 'object');
|
||||||
|
const upstreamPort = upstreamAddress.port;
|
||||||
|
|
||||||
|
const tracked: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
|
||||||
|
const proxy = new AnkiConnectProxyServer({
|
||||||
|
shouldAutoUpdateNewCards: () => true,
|
||||||
|
processNewCard: async () => undefined,
|
||||||
|
trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||||
|
tracked.push({ noteId, duplicateNoteIds });
|
||||||
|
},
|
||||||
|
logInfo: () => undefined,
|
||||||
|
logWarn: () => undefined,
|
||||||
|
logError: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
proxy.start({
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 0,
|
||||||
|
upstreamUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxyServer = (
|
||||||
|
proxy as unknown as {
|
||||||
|
server: http.Server | null;
|
||||||
|
}
|
||||||
|
).server;
|
||||||
|
assert.ok(proxyServer);
|
||||||
|
if (!proxyServer.listening) {
|
||||||
|
await once(proxyServer, 'listening');
|
||||||
|
}
|
||||||
|
const proxyAddress = proxyServer.address();
|
||||||
|
assert.ok(proxyAddress && typeof proxyAddress === 'object');
|
||||||
|
const proxyPort = proxyAddress.port;
|
||||||
|
|
||||||
|
const response = await fetch(`http://127.0.0.1:${proxyPort}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'addNote',
|
||||||
|
version: 6,
|
||||||
|
params: {
|
||||||
|
note: {
|
||||||
|
deckName: 'Mining',
|
||||||
|
modelName: 'Sentence',
|
||||||
|
fields: { Expression: '食べる' },
|
||||||
|
},
|
||||||
|
subminerDuplicateNoteIds: [18, 7],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.deepEqual(await response.json(), { result: 42, error: null });
|
||||||
|
await waitForCondition(() => tracked.length === 1);
|
||||||
|
assert.equal(upstreamBody.includes('subminerDuplicateNoteIds'), false);
|
||||||
|
assert.deepEqual(tracked, [{ noteId: 42, duplicateNoteIds: [7, 18] }]);
|
||||||
|
} finally {
|
||||||
|
proxy.stop();
|
||||||
|
upstream.close();
|
||||||
|
await once(upstream, 'close');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('proxy returns addNote response without waiting for background enrichment', async () => {
|
test('proxy returns addNote response without waiting for background enrichment', async () => {
|
||||||
const processed: number[] = [];
|
const processed: number[] = [];
|
||||||
let releaseProcessing: (() => void) | undefined;
|
let releaseProcessing: (() => void) | undefined;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface AnkiConnectProxyServerDeps {
|
|||||||
shouldAutoUpdateNewCards: () => boolean;
|
shouldAutoUpdateNewCards: () => boolean;
|
||||||
processNewCard: (noteId: number) => Promise<void>;
|
processNewCard: (noteId: number) => Promise<void>;
|
||||||
recordCardsAdded?: (count: number, noteIds: number[]) => void;
|
recordCardsAdded?: (count: number, noteIds: number[]) => void;
|
||||||
|
trackAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||||
getDeck?: () => string | undefined;
|
getDeck?: () => string | undefined;
|
||||||
findNotes?: (
|
findNotes?: (
|
||||||
query: string,
|
query: string,
|
||||||
@@ -161,6 +162,7 @@ export class AnkiConnectProxyServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
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'
|
||||||
@@ -169,7 +171,7 @@ export class AnkiConnectProxyServer {
|
|||||||
const upstreamResponse = await this.client.request<ArrayBuffer>({
|
const upstreamResponse = await this.client.request<ArrayBuffer>({
|
||||||
url: targetUrl,
|
url: targetUrl,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
data: req.method === 'POST' ? rawBody : undefined,
|
data: req.method === 'POST' ? forwardedBody : undefined,
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': contentType,
|
'content-type': contentType,
|
||||||
},
|
},
|
||||||
@@ -219,6 +221,8 @@ export class AnkiConnectProxyServer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.maybeTrackDuplicateNoteIds(requestJson, action, responseResult);
|
||||||
|
|
||||||
const noteIds =
|
const noteIds =
|
||||||
action === 'multi'
|
action === 'multi'
|
||||||
? this.collectMultiResultIds(requestJson, responseResult)
|
? this.collectMultiResultIds(requestJson, responseResult)
|
||||||
@@ -231,6 +235,77 @@ export class AnkiConnectProxyServer {
|
|||||||
this.enqueueNotes(noteIds);
|
this.enqueueNotes(noteIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private maybeTrackDuplicateNoteIds(
|
||||||
|
requestJson: Record<string, unknown>,
|
||||||
|
action: string,
|
||||||
|
responseResult: unknown,
|
||||||
|
): void {
|
||||||
|
if (action !== 'addNote') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const duplicateNoteIds = this.getRequestDuplicateNoteIds(requestJson);
|
||||||
|
if (duplicateNoteIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const noteId = this.collectSingleResultId(responseResult)[0];
|
||||||
|
if (!noteId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.deps.trackAddedDuplicateNoteIds?.(noteId, duplicateNoteIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getForwardRequestBody(
|
||||||
|
rawBody: Buffer,
|
||||||
|
requestJson: Record<string, unknown> | null,
|
||||||
|
): Buffer {
|
||||||
|
if (!requestJson) {
|
||||||
|
return rawBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = this.sanitizeRequestJson(requestJson);
|
||||||
|
if (sanitized === requestJson) {
|
||||||
|
return rawBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(JSON.stringify(sanitized), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeRequestJson(requestJson: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const action =
|
||||||
|
typeof requestJson.action === 'string' ? requestJson.action : String(requestJson.action ?? '');
|
||||||
|
if (action !== 'addNote') {
|
||||||
|
return requestJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params =
|
||||||
|
requestJson.params && typeof requestJson.params === 'object'
|
||||||
|
? (requestJson.params as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
if (!params || !Object.prototype.hasOwnProperty.call(params, 'subminerDuplicateNoteIds')) {
|
||||||
|
return requestJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextParams = { ...params };
|
||||||
|
delete nextParams.subminerDuplicateNoteIds;
|
||||||
|
return {
|
||||||
|
...requestJson,
|
||||||
|
params: nextParams,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRequestDuplicateNoteIds(requestJson: Record<string, unknown>): number[] {
|
||||||
|
const params =
|
||||||
|
requestJson.params && typeof requestJson.params === 'object'
|
||||||
|
? (requestJson.params as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const rawNoteIds = Array.isArray(params?.subminerDuplicateNoteIds)
|
||||||
|
? params.subminerDuplicateNoteIds
|
||||||
|
: [];
|
||||||
|
return [...new Set(rawNoteIds.filter((entry): entry is number => {
|
||||||
|
return typeof entry === 'number' && Number.isInteger(entry) && entry > 0;
|
||||||
|
}))].sort((left, right) => left - right);
|
||||||
|
}
|
||||||
|
|
||||||
private requestIncludesAddAction(action: string, requestJson: Record<string, unknown>): boolean {
|
private requestIncludesAddAction(action: string, requestJson: Record<string, unknown>): boolean {
|
||||||
if (action === 'addNote' || action === 'addNotes') {
|
if (action === 'addNote' || action === 'addNotes') {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -397,3 +397,178 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
|||||||
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
|
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
|
||||||
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
|
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('CardCreationService tracks pre-add duplicate note ids for kiku sentence cards', async () => {
|
||||||
|
const trackedDuplicates: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
|
||||||
|
const duplicateLookupExpressions: string[] = [];
|
||||||
|
|
||||||
|
const service = new CardCreationService({
|
||||||
|
getConfig: () =>
|
||||||
|
({
|
||||||
|
deck: 'Mining',
|
||||||
|
fields: {
|
||||||
|
word: 'Expression',
|
||||||
|
sentence: 'Sentence',
|
||||||
|
audio: 'SentenceAudio',
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
generateAudio: false,
|
||||||
|
generateImage: false,
|
||||||
|
},
|
||||||
|
behavior: {},
|
||||||
|
ai: false,
|
||||||
|
}) as AnkiConnectConfig,
|
||||||
|
getAiConfig: () => ({}),
|
||||||
|
getTimingTracker: () => ({}) as never,
|
||||||
|
getMpvClient: () =>
|
||||||
|
({
|
||||||
|
currentVideoPath: '/video.mp4',
|
||||||
|
currentSubText: '字幕',
|
||||||
|
currentSubStart: 1,
|
||||||
|
currentSubEnd: 2,
|
||||||
|
currentTimePos: 1.5,
|
||||||
|
currentAudioStreamIndex: 0,
|
||||||
|
}) as never,
|
||||||
|
client: {
|
||||||
|
addNote: async () => 42,
|
||||||
|
addTags: async () => undefined,
|
||||||
|
notesInfo: async () => [],
|
||||||
|
updateNoteFields: async () => undefined,
|
||||||
|
storeMediaFile: async () => undefined,
|
||||||
|
findNotes: async () => [],
|
||||||
|
retrieveMediaFile: async () => '',
|
||||||
|
},
|
||||||
|
mediaGenerator: {
|
||||||
|
generateAudio: async () => null,
|
||||||
|
generateScreenshot: async () => null,
|
||||||
|
generateAnimatedImage: async () => null,
|
||||||
|
},
|
||||||
|
showOsdNotification: () => undefined,
|
||||||
|
showUpdateResult: () => undefined,
|
||||||
|
showStatusNotification: () => undefined,
|
||||||
|
showNotification: async () => undefined,
|
||||||
|
beginUpdateProgress: () => undefined,
|
||||||
|
endUpdateProgress: () => undefined,
|
||||||
|
withUpdateProgress: async (_message, action) => action(),
|
||||||
|
resolveConfiguredFieldName: () => null,
|
||||||
|
resolveNoteFieldName: () => null,
|
||||||
|
getAnimatedImageLeadInSeconds: async () => 0,
|
||||||
|
extractFields: () => ({}),
|
||||||
|
processSentence: (sentence) => sentence,
|
||||||
|
setCardTypeFields: () => undefined,
|
||||||
|
mergeFieldValue: (_existing, newValue) => newValue,
|
||||||
|
formatMiscInfoPattern: () => '',
|
||||||
|
getEffectiveSentenceCardConfig: () => ({
|
||||||
|
model: 'Sentence',
|
||||||
|
sentenceField: 'Sentence',
|
||||||
|
audioField: 'SentenceAudio',
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
kikuFieldGrouping: 'manual',
|
||||||
|
kikuDeleteDuplicateInAuto: false,
|
||||||
|
}),
|
||||||
|
getFallbackDurationSeconds: () => 10,
|
||||||
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
isUpdateInProgress: () => false,
|
||||||
|
setUpdateInProgress: () => undefined,
|
||||||
|
trackLastAddedNoteId: () => undefined,
|
||||||
|
findDuplicateNoteIds: async (expression) => {
|
||||||
|
duplicateLookupExpressions.push(expression);
|
||||||
|
return [18, 7, 30, 7];
|
||||||
|
},
|
||||||
|
trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||||
|
trackedDuplicates.push({ noteId, duplicateNoteIds });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await service.createSentenceCard('重複文', 0, 1);
|
||||||
|
|
||||||
|
assert.equal(created, true);
|
||||||
|
assert.deepEqual(duplicateLookupExpressions, ['重複文']);
|
||||||
|
assert.deepEqual(trackedDuplicates, [{ noteId: 42, duplicateNoteIds: [7, 18, 30] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CardCreationService does not track duplicate ids when pre-add lookup returns none', async () => {
|
||||||
|
const trackedDuplicates: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
|
||||||
|
|
||||||
|
const service = new CardCreationService({
|
||||||
|
getConfig: () =>
|
||||||
|
({
|
||||||
|
deck: 'Mining',
|
||||||
|
fields: {
|
||||||
|
word: 'Expression',
|
||||||
|
sentence: 'Sentence',
|
||||||
|
audio: 'SentenceAudio',
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
generateAudio: false,
|
||||||
|
generateImage: false,
|
||||||
|
},
|
||||||
|
behavior: {},
|
||||||
|
ai: false,
|
||||||
|
}) as AnkiConnectConfig,
|
||||||
|
getAiConfig: () => ({}),
|
||||||
|
getTimingTracker: () => ({}) as never,
|
||||||
|
getMpvClient: () =>
|
||||||
|
({
|
||||||
|
currentVideoPath: '/video.mp4',
|
||||||
|
currentSubText: '字幕',
|
||||||
|
currentSubStart: 1,
|
||||||
|
currentSubEnd: 2,
|
||||||
|
currentTimePos: 1.5,
|
||||||
|
currentAudioStreamIndex: 0,
|
||||||
|
}) as never,
|
||||||
|
client: {
|
||||||
|
addNote: async () => 42,
|
||||||
|
addTags: async () => undefined,
|
||||||
|
notesInfo: async () => [],
|
||||||
|
updateNoteFields: async () => undefined,
|
||||||
|
storeMediaFile: async () => undefined,
|
||||||
|
findNotes: async () => [],
|
||||||
|
retrieveMediaFile: async () => '',
|
||||||
|
},
|
||||||
|
mediaGenerator: {
|
||||||
|
generateAudio: async () => null,
|
||||||
|
generateScreenshot: async () => null,
|
||||||
|
generateAnimatedImage: async () => null,
|
||||||
|
},
|
||||||
|
showOsdNotification: () => undefined,
|
||||||
|
showUpdateResult: () => undefined,
|
||||||
|
showStatusNotification: () => undefined,
|
||||||
|
showNotification: async () => undefined,
|
||||||
|
beginUpdateProgress: () => undefined,
|
||||||
|
endUpdateProgress: () => undefined,
|
||||||
|
withUpdateProgress: async (_message, action) => action(),
|
||||||
|
resolveConfiguredFieldName: () => null,
|
||||||
|
resolveNoteFieldName: () => null,
|
||||||
|
getAnimatedImageLeadInSeconds: async () => 0,
|
||||||
|
extractFields: () => ({}),
|
||||||
|
processSentence: (sentence) => sentence,
|
||||||
|
setCardTypeFields: () => undefined,
|
||||||
|
mergeFieldValue: (_existing, newValue) => newValue,
|
||||||
|
formatMiscInfoPattern: () => '',
|
||||||
|
getEffectiveSentenceCardConfig: () => ({
|
||||||
|
model: 'Sentence',
|
||||||
|
sentenceField: 'Sentence',
|
||||||
|
audioField: 'SentenceAudio',
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
kikuFieldGrouping: 'manual',
|
||||||
|
kikuDeleteDuplicateInAuto: false,
|
||||||
|
}),
|
||||||
|
getFallbackDurationSeconds: () => 10,
|
||||||
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
isUpdateInProgress: () => false,
|
||||||
|
setUpdateInProgress: () => undefined,
|
||||||
|
trackLastAddedNoteId: () => undefined,
|
||||||
|
findDuplicateNoteIds: async () => [],
|
||||||
|
trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||||
|
trackedDuplicates.push({ noteId, duplicateNoteIds });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await service.createSentenceCard('重複なし', 0, 1);
|
||||||
|
|
||||||
|
assert.equal(created, true);
|
||||||
|
assert.deepEqual(trackedDuplicates, []);
|
||||||
|
});
|
||||||
|
|||||||
@@ -112,6 +112,11 @@ interface CardCreationDeps {
|
|||||||
isUpdateInProgress: () => boolean;
|
isUpdateInProgress: () => boolean;
|
||||||
setUpdateInProgress: (value: boolean) => void;
|
setUpdateInProgress: (value: boolean) => void;
|
||||||
trackLastAddedNoteId?: (noteId: number) => void;
|
trackLastAddedNoteId?: (noteId: number) => void;
|
||||||
|
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||||
|
findDuplicateNoteIds?: (
|
||||||
|
expression: string,
|
||||||
|
noteInfo: CardCreationNoteInfo,
|
||||||
|
) => Promise<number[]>;
|
||||||
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
|
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,6 +553,33 @@ export class CardCreationService {
|
|||||||
fields[getConfiguredWordFieldName(this.deps.getConfig())] = sentence;
|
fields[getConfiguredWordFieldName(this.deps.getConfig())] = sentence;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pendingNoteInfo = this.createPendingNoteInfo(fields);
|
||||||
|
const pendingNoteFields = Object.fromEntries(
|
||||||
|
Object.entries(fields).map(([name, value]) => [name.toLowerCase(), value]),
|
||||||
|
);
|
||||||
|
const pendingExpressionText = getPreferredWordValueFromExtractedFields(
|
||||||
|
pendingNoteFields,
|
||||||
|
this.deps.getConfig(),
|
||||||
|
).trim();
|
||||||
|
let duplicateNoteIds: number[] = [];
|
||||||
|
if (
|
||||||
|
sentenceCardConfig.kikuEnabled &&
|
||||||
|
sentenceCardConfig.kikuFieldGrouping !== 'disabled' &&
|
||||||
|
pendingExpressionText &&
|
||||||
|
this.deps.findDuplicateNoteIds
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
duplicateNoteIds = sortUniqueNoteIds(
|
||||||
|
await this.deps.findDuplicateNoteIds(pendingExpressionText, pendingNoteInfo),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
log.warn(
|
||||||
|
'Failed to capture pre-add duplicate note ids:',
|
||||||
|
(error as Error).message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const deck = this.deps.getConfig().deck || 'Default';
|
const deck = this.deps.getConfig().deck || 'Default';
|
||||||
let noteId: number;
|
let noteId: number;
|
||||||
try {
|
try {
|
||||||
@@ -570,6 +602,14 @@ export class CardCreationService {
|
|||||||
log.warn('Failed to track last added note:', (error as Error).message);
|
log.warn('Failed to track last added note:', (error as Error).message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (duplicateNoteIds.length > 0) {
|
||||||
|
try {
|
||||||
|
this.deps.trackLastAddedDuplicateNoteIds?.(noteId, duplicateNoteIds);
|
||||||
|
} catch (error) {
|
||||||
|
log.warn('Failed to track duplicate note ids:', (error as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.deps.recordCardsMinedCallback?.(1, [noteId]);
|
this.deps.recordCardsMinedCallback?.(1, [noteId]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -685,6 +725,15 @@ export class CardCreationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createPendingNoteInfo(fields: Record<string, string>): CardCreationNoteInfo {
|
||||||
|
return {
|
||||||
|
noteId: -1,
|
||||||
|
fields: Object.fromEntries(
|
||||||
|
Object.entries(fields).map(([name, value]) => [name, { value }]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async mediaGenerateAudio(
|
private async mediaGenerateAudio(
|
||||||
videoPath: string,
|
videoPath: string,
|
||||||
startTime: number,
|
startTime: number,
|
||||||
@@ -764,3 +813,7 @@ export class CardCreationService {
|
|||||||
return `image_${timestamp}.${ext}`;
|
return `image_${timestamp}.${ext}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortUniqueNoteIds(noteIds: number[]): number[] {
|
||||||
|
return [...new Set(noteIds)].sort((left, right) => left - right);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { findDuplicateNote, type NoteInfo } from './duplicate';
|
import { findDuplicateNote, findDuplicateNoteIds, type NoteInfo } from './duplicate';
|
||||||
|
|
||||||
function createFieldResolver(noteInfo: NoteInfo, preferredName: string): string | null {
|
function createFieldResolver(noteInfo: NoteInfo, preferredName: string): string | null {
|
||||||
const names = Object.keys(noteInfo.fields);
|
const names = Object.keys(noteInfo.fields);
|
||||||
@@ -267,3 +267,62 @@ test('findDuplicateNote does not disable retries on findNotes calls', async () =
|
|||||||
assert.ok(seenOptions.length > 0);
|
assert.ok(seenOptions.length > 0);
|
||||||
assert.ok(seenOptions.every((options) => options?.maxRetries !== 0));
|
assert.ok(seenOptions.every((options) => options?.maxRetries !== 0));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('findDuplicateNote stops after the first exact-match chunk', async () => {
|
||||||
|
const currentNote: NoteInfo = {
|
||||||
|
noteId: 100,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: '貴様' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let notesInfoCalls = 0;
|
||||||
|
const candidateIds = Array.from({ length: 51 }, (_, index) => 200 + index);
|
||||||
|
const duplicateId = await findDuplicateNote('貴様', 100, currentNote, {
|
||||||
|
findNotes: async () => candidateIds,
|
||||||
|
notesInfo: async (noteIds) => {
|
||||||
|
notesInfoCalls += 1;
|
||||||
|
return noteIds.map((noteId) => ({
|
||||||
|
noteId,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: noteId === 200 ? '貴様' : `別単語-${noteId}` },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
getDeck: () => 'Japanese::Mining',
|
||||||
|
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
|
||||||
|
logWarn: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(duplicateId, 200);
|
||||||
|
assert.equal(notesInfoCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findDuplicateNoteIds returns no matches when maxMatches is zero', async () => {
|
||||||
|
const currentNote: NoteInfo = {
|
||||||
|
noteId: 100,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: '貴様' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let notesInfoCalls = 0;
|
||||||
|
const duplicateIds = await findDuplicateNoteIds('貴様', 100, currentNote, {
|
||||||
|
findNotes: async () => [200],
|
||||||
|
notesInfo: async (noteIds) => {
|
||||||
|
notesInfoCalls += 1;
|
||||||
|
return noteIds.map((noteId) => ({
|
||||||
|
noteId,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: '貴様' },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
getDeck: () => 'Japanese::Mining',
|
||||||
|
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
|
||||||
|
logWarn: () => {},
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
assert.deepEqual(duplicateIds, []);
|
||||||
|
assert.equal(notesInfoCalls, 0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,13 +24,30 @@ export async function findDuplicateNote(
|
|||||||
noteInfo: NoteInfo,
|
noteInfo: NoteInfo,
|
||||||
deps: DuplicateDetectionDeps,
|
deps: DuplicateDetectionDeps,
|
||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
|
const duplicateNoteIds = await findDuplicateNoteIds(
|
||||||
|
expression,
|
||||||
|
excludeNoteId,
|
||||||
|
noteInfo,
|
||||||
|
deps,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
return duplicateNoteIds[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findDuplicateNoteIds(
|
||||||
|
expression: string,
|
||||||
|
excludeNoteId: number,
|
||||||
|
noteInfo: NoteInfo,
|
||||||
|
deps: DuplicateDetectionDeps,
|
||||||
|
maxMatches?: number,
|
||||||
|
): Promise<number[]> {
|
||||||
const configuredWordFieldCandidates = deps.getWordFieldCandidates?.() ?? ['Expression', 'Word'];
|
const configuredWordFieldCandidates = deps.getWordFieldCandidates?.() ?? ['Expression', 'Word'];
|
||||||
const sourceCandidates = getDuplicateSourceCandidates(
|
const sourceCandidates = getDuplicateSourceCandidates(
|
||||||
noteInfo,
|
noteInfo,
|
||||||
expression,
|
expression,
|
||||||
configuredWordFieldCandidates,
|
configuredWordFieldCandidates,
|
||||||
);
|
);
|
||||||
if (sourceCandidates.length === 0) return null;
|
if (sourceCandidates.length === 0) return [];
|
||||||
deps.logInfo?.(
|
deps.logInfo?.(
|
||||||
`[duplicate] start expr="${expression}" sourceCandidates=${sourceCandidates
|
`[duplicate] start expr="${expression}" sourceCandidates=${sourceCandidates
|
||||||
.map((entry) => `${entry.fieldName}:${entry.value}`)
|
.map((entry) => `${entry.fieldName}:${entry.value}`)
|
||||||
@@ -83,42 +100,49 @@ export async function findDuplicateNote(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await findFirstExactDuplicateNoteId(
|
return await findExactDuplicateNoteIds(
|
||||||
noteIds,
|
noteIds,
|
||||||
excludeNoteId,
|
excludeNoteId,
|
||||||
sourceCandidates.map((candidate) => candidate.value),
|
sourceCandidates.map((candidate) => candidate.value),
|
||||||
configuredWordFieldCandidates,
|
configuredWordFieldCandidates,
|
||||||
deps,
|
deps,
|
||||||
|
maxMatches,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
deps.logWarn('Duplicate search failed:', error);
|
deps.logWarn('Duplicate search failed:', error);
|
||||||
return null;
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function findFirstExactDuplicateNoteId(
|
function findExactDuplicateNoteIds(
|
||||||
candidateNoteIds: Iterable<number>,
|
candidateNoteIds: Iterable<number>,
|
||||||
excludeNoteId: number,
|
excludeNoteId: number,
|
||||||
sourceValues: string[],
|
sourceValues: string[],
|
||||||
candidateFieldNames: string[],
|
candidateFieldNames: string[],
|
||||||
deps: DuplicateDetectionDeps,
|
deps: DuplicateDetectionDeps,
|
||||||
): Promise<number | null> {
|
maxMatches?: number,
|
||||||
|
): Promise<number[]> {
|
||||||
|
if (maxMatches !== undefined && maxMatches <= 0) {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
const candidates = Array.from(candidateNoteIds).filter((id) => id !== excludeNoteId);
|
const candidates = Array.from(candidateNoteIds).filter((id) => id !== excludeNoteId);
|
||||||
deps.logDebug?.(`[duplicate] candidateIds=${candidates.length} exclude=${excludeNoteId}`);
|
deps.logDebug?.(`[duplicate] candidateIds=${candidates.length} exclude=${excludeNoteId}`);
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
deps.logInfo?.('[duplicate] no candidates after query + exclude');
|
deps.logInfo?.('[duplicate] no candidates after query + exclude');
|
||||||
return Promise.resolve(null);
|
return Promise.resolve([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedValues = new Set(
|
const normalizedValues = new Set(
|
||||||
sourceValues.map((value) => normalizeDuplicateValue(value)).filter((value) => value.length > 0),
|
sourceValues.map((value) => normalizeDuplicateValue(value)).filter((value) => value.length > 0),
|
||||||
);
|
);
|
||||||
if (normalizedValues.size === 0) {
|
if (normalizedValues.size === 0) {
|
||||||
return Promise.resolve(null);
|
return Promise.resolve([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunkSize = 50;
|
const chunkSize = 50;
|
||||||
return (async () => {
|
return (async () => {
|
||||||
|
const matches: number[] = [];
|
||||||
for (let i = 0; i < candidates.length; i += chunkSize) {
|
for (let i = 0; i < candidates.length; i += chunkSize) {
|
||||||
const chunk = candidates.slice(i, i + chunkSize);
|
const chunk = candidates.slice(i, i + chunkSize);
|
||||||
const notesInfoResult = (await deps.notesInfo(chunk)) as unknown[];
|
const notesInfoResult = (await deps.notesInfo(chunk)) as unknown[];
|
||||||
@@ -133,13 +157,19 @@ function findFirstExactDuplicateNoteId(
|
|||||||
`[duplicate] exact-match noteId=${noteInfo.noteId} field=${resolvedField}`,
|
`[duplicate] exact-match noteId=${noteInfo.noteId} field=${resolvedField}`,
|
||||||
);
|
);
|
||||||
deps.logInfo?.(`[duplicate] matched noteId=${noteInfo.noteId} field=${resolvedField}`);
|
deps.logInfo?.(`[duplicate] matched noteId=${noteInfo.noteId} field=${resolvedField}`);
|
||||||
return noteInfo.noteId;
|
matches.push(noteInfo.noteId);
|
||||||
|
if (maxMatches !== undefined && matches.length >= maxMatches) {
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
deps.logInfo?.('[duplicate] no exact match in candidate notes');
|
if (matches.length === 0) {
|
||||||
return null;
|
deps.logInfo?.('[duplicate] no exact match in candidate notes');
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function createHarness(
|
|||||||
noteIds?: number[];
|
noteIds?: number[];
|
||||||
notesInfo?: NoteInfo[][];
|
notesInfo?: NoteInfo[][];
|
||||||
duplicateNoteId?: number | null;
|
duplicateNoteId?: number | null;
|
||||||
|
trackedDuplicateNoteIds?: number[] | null;
|
||||||
hasAllConfiguredFields?: boolean;
|
hasAllConfiguredFields?: boolean;
|
||||||
manualHandled?: boolean;
|
manualHandled?: boolean;
|
||||||
expression?: string | null;
|
expression?: string | null;
|
||||||
@@ -74,6 +75,7 @@ function createHarness(
|
|||||||
duplicateRequests.push({ expression, excludeNoteId });
|
duplicateRequests.push({ expression, excludeNoteId });
|
||||||
return options.duplicateNoteId ?? 99;
|
return options.duplicateNoteId ?? 99;
|
||||||
},
|
},
|
||||||
|
getTrackedDuplicateNoteIds: () => options.trackedDuplicateNoteIds ?? null,
|
||||||
hasAllConfiguredFields: () => options.hasAllConfiguredFields ?? true,
|
hasAllConfiguredFields: () => options.hasAllConfiguredFields ?? true,
|
||||||
processNewCard: async (noteId, processOptions) => {
|
processNewCard: async (noteId, processOptions) => {
|
||||||
processCalls.push({ noteId, options: processOptions });
|
processCalls.push({ noteId, options: processOptions });
|
||||||
@@ -223,6 +225,46 @@ test('triggerFieldGroupingForLastAddedCard finds the newest note and hands off t
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('triggerFieldGroupingForLastAddedCard prefers tracked duplicate note ids before duplicate lookup', async () => {
|
||||||
|
const harness = createHarness({
|
||||||
|
noteIds: [7],
|
||||||
|
notesInfo: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
noteId: 7,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'word-7' },
|
||||||
|
Sentence: { value: 'line-7' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
noteId: 7,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'word-7' },
|
||||||
|
Sentence: { value: 'line-7' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
trackedDuplicateNoteIds: [12, 40, 25],
|
||||||
|
duplicateNoteId: 99,
|
||||||
|
hasAllConfiguredFields: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await harness.service.triggerFieldGroupingForLastAddedCard();
|
||||||
|
|
||||||
|
assert.deepEqual(harness.duplicateRequests, []);
|
||||||
|
assert.deepEqual(harness.autoCalls, [
|
||||||
|
{
|
||||||
|
originalNoteId: 40,
|
||||||
|
newNoteId: 7,
|
||||||
|
expression: 'word-7',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fields are missing', async () => {
|
test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fields are missing', async () => {
|
||||||
const processCalls: Array<{ noteId: number; options?: { skipKikuFieldGrouping?: boolean } }> = [];
|
const processCalls: Array<{ noteId: number; options?: { skipKikuFieldGrouping?: boolean } }> = [];
|
||||||
const harness = createHarness({
|
const harness = createHarness({
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ interface FieldGroupingDeps {
|
|||||||
excludeNoteId: number,
|
excludeNoteId: number,
|
||||||
noteInfo: FieldGroupingNoteInfo,
|
noteInfo: FieldGroupingNoteInfo,
|
||||||
) => Promise<number | null>;
|
) => Promise<number | null>;
|
||||||
|
getTrackedDuplicateNoteIds?: (noteId: number) => number[] | null;
|
||||||
hasAllConfiguredFields: (
|
hasAllConfiguredFields: (
|
||||||
noteInfo: FieldGroupingNoteInfo,
|
noteInfo: FieldGroupingNoteInfo,
|
||||||
configuredFieldNames: (string | undefined)[],
|
configuredFieldNames: (string | undefined)[],
|
||||||
@@ -117,11 +118,11 @@ export class FieldGroupingService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const duplicateNoteId = await this.deps.findDuplicateNote(
|
const trackedDuplicateNoteIds = this.deps.getTrackedDuplicateNoteIds?.(noteId) ?? null;
|
||||||
expressionText,
|
const duplicateNoteId =
|
||||||
noteId,
|
trackedDuplicateNoteIds !== null
|
||||||
noteInfoBeforeUpdate,
|
? pickMostRecentDuplicateNoteId(trackedDuplicateNoteIds, noteId)
|
||||||
);
|
: await this.deps.findDuplicateNote(expressionText, noteId, noteInfoBeforeUpdate);
|
||||||
if (duplicateNoteId === null) {
|
if (duplicateNoteId === null) {
|
||||||
this.deps.showOsdNotification('No duplicate card found');
|
this.deps.showOsdNotification('No duplicate card found');
|
||||||
return;
|
return;
|
||||||
@@ -243,3 +244,17 @@ export class FieldGroupingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickMostRecentDuplicateNoteId(
|
||||||
|
duplicateNoteIds: number[],
|
||||||
|
excludeNoteId: number,
|
||||||
|
): number | null {
|
||||||
|
let bestNoteId: number | null = null;
|
||||||
|
for (const noteId of duplicateNoteIds) {
|
||||||
|
if (noteId === excludeNoteId) continue;
|
||||||
|
if (bestNoteId === null || noteId > bestNoteId) {
|
||||||
|
bestNoteId = noteId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestNoteId;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,7 @@ 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',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,97 @@ 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 +374,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);
|
deps.sendCommand({
|
||||||
if (match) {
|
command: ['set_property', 'secondary-sid', secondaryTrackId],
|
||||||
deps.sendCommand({
|
});
|
||||||
command: ['set_property', 'secondary-sid', match.id],
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_AUDIO) {
|
} else if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_AUDIO) {
|
||||||
|
|||||||
@@ -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,31 @@ 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 +148,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as path from 'path';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import * as vm from 'node:vm';
|
import * as vm from 'node:vm';
|
||||||
import {
|
import {
|
||||||
|
addYomitanNoteViaSearch,
|
||||||
getYomitanDictionaryInfo,
|
getYomitanDictionaryInfo,
|
||||||
importYomitanDictionaryFromZip,
|
importYomitanDictionaryFromZip,
|
||||||
deleteYomitanDictionaryByTitle,
|
deleteYomitanDictionaryByTitle,
|
||||||
@@ -1373,3 +1374,48 @@ test('deleteYomitanDictionaryByTitle uses settings automation bridge instead of
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('addYomitanNoteViaSearch returns note and duplicate ids from the bridge payload', async () => {
|
||||||
|
const deps = createDeps(async (_script) => ({
|
||||||
|
noteId: 42,
|
||||||
|
duplicateNoteIds: [18, 7, 18],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await addYomitanNoteViaSearch('食べる', deps, {
|
||||||
|
error: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
noteId: 42,
|
||||||
|
duplicateNoteIds: [18, 7, 18],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addYomitanNoteViaSearch rejects invalid numeric note ids from the bridge shortcut', async () => {
|
||||||
|
const deps = createDeps(async () => NaN);
|
||||||
|
|
||||||
|
const result = await addYomitanNoteViaSearch('食べる', deps, {
|
||||||
|
error: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
noteId: null,
|
||||||
|
duplicateNoteIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addYomitanNoteViaSearch sanitizes invalid payload note ids while keeping valid duplicate ids', async () => {
|
||||||
|
const deps = createDeps(async (_script) => ({
|
||||||
|
noteId: -1,
|
||||||
|
duplicateNoteIds: [18, 0, 7.5, 7],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await addYomitanNoteViaSearch('食べる', deps, {
|
||||||
|
error: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
noteId: null,
|
||||||
|
duplicateNoteIds: [18, 7],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ interface YomitanProfileMetadata {
|
|||||||
dictionaryFrequencyModeByName: Partial<Record<string, YomitanFrequencyMode>>;
|
dictionaryFrequencyModeByName: Partial<Record<string, YomitanFrequencyMode>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface YomitanAddNoteResult {
|
||||||
|
noteId: number | null;
|
||||||
|
duplicateNoteIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_YOMITAN_SCAN_LENGTH = 40;
|
const DEFAULT_YOMITAN_SCAN_LENGTH = 40;
|
||||||
const yomitanProfileMetadataByWindow = new WeakMap<BrowserWindow, YomitanProfileMetadata>();
|
const yomitanProfileMetadataByWindow = new WeakMap<BrowserWindow, YomitanProfileMetadata>();
|
||||||
const yomitanFrequencyCacheByWindow = new WeakMap<
|
const yomitanFrequencyCacheByWindow = new WeakMap<
|
||||||
@@ -1984,11 +1989,11 @@ export async function addYomitanNoteViaSearch(
|
|||||||
word: string,
|
word: string,
|
||||||
deps: YomitanParserRuntimeDeps,
|
deps: YomitanParserRuntimeDeps,
|
||||||
logger: LoggerLike,
|
logger: LoggerLike,
|
||||||
): Promise<number | null> {
|
): Promise<YomitanAddNoteResult> {
|
||||||
const isReady = await ensureYomitanParserWindow(deps, logger);
|
const isReady = await ensureYomitanParserWindow(deps, logger);
|
||||||
const parserWindow = deps.getYomitanParserWindow();
|
const parserWindow = deps.getYomitanParserWindow();
|
||||||
if (!isReady || !parserWindow || parserWindow.isDestroyed()) {
|
if (!isReady || !parserWindow || parserWindow.isDestroyed()) {
|
||||||
return null;
|
return { noteId: null, duplicateNoteIds: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const escapedWord = JSON.stringify(word);
|
const escapedWord = JSON.stringify(word);
|
||||||
@@ -2003,10 +2008,35 @@ export async function addYomitanNoteViaSearch(
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const noteId = await parserWindow.webContents.executeJavaScript(script, true);
|
const result = await parserWindow.webContents.executeJavaScript(script, true);
|
||||||
return typeof noteId === 'number' ? noteId : null;
|
if (typeof result === 'number') {
|
||||||
|
return {
|
||||||
|
noteId: Number.isInteger(result) && result > 0 ? result : null,
|
||||||
|
duplicateNoteIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
||||||
|
const envelope = result as {
|
||||||
|
noteId?: unknown;
|
||||||
|
duplicateNoteIds?: unknown;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
noteId:
|
||||||
|
typeof envelope.noteId === 'number' &&
|
||||||
|
Number.isInteger(envelope.noteId) &&
|
||||||
|
envelope.noteId > 0
|
||||||
|
? envelope.noteId
|
||||||
|
: null,
|
||||||
|
duplicateNoteIds: Array.isArray(envelope.duplicateNoteIds)
|
||||||
|
? envelope.duplicateNoteIds.filter(
|
||||||
|
(entry): entry is number => typeof entry === 'number' && Number.isInteger(entry) && entry > 0,
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { noteId: null, duplicateNoteIds: [] };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Yomitan addNoteFromWord failed:', (err as Error).message);
|
logger.error('Yomitan addNoteFromWord failed:', (err as Error).message);
|
||||||
return null;
|
return { noteId: null, duplicateNoteIds: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,61 @@ 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(
|
||||||
|
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'],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats-daemon entry helper detects internal daemon commands', () => {
|
test('stats-daemon entry helper detects internal daemon commands', () => {
|
||||||
|
|||||||
@@ -121,7 +121,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 (!token.includes('=') && 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 (!token.includes('=') && 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,10 @@
|
|||||||
|
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 {
|
import {
|
||||||
configureEarlyAppPaths,
|
configureEarlyAppPaths,
|
||||||
|
normalizeLaunchMpvExtraArgs,
|
||||||
normalizeLaunchMpvTargets,
|
normalizeLaunchMpvTargets,
|
||||||
normalizeStartupArgv,
|
normalizeStartupArgv,
|
||||||
sanitizeStartupEnv,
|
sanitizeStartupEnv,
|
||||||
@@ -15,6 +17,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,6 +35,19 @@ function applySanitizedEnv(sanitizedEnv: NodeJS.ProcessEnv): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
configureEarlyAppPaths(app);
|
||||||
@@ -68,6 +84,9 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
|
|||||||
dialog.showErrorBox(title, content);
|
dialog.showErrorBox(title, content);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
normalizeLaunchMpvExtraArgs(process.argv),
|
||||||
|
process.execPath,
|
||||||
|
resolveBundledWindowsMpvPluginEntrypoint(),
|
||||||
);
|
);
|
||||||
app.exit(result.ok ? 0 : 1);
|
app.exit(result.ok ? 0 : 1);
|
||||||
});
|
});
|
||||||
|
|||||||
38
src/main.ts
38
src/main.ts
@@ -339,6 +339,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 +349,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,
|
||||||
@@ -1000,6 +1002,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,
|
||||||
@@ -2238,18 +2251,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 () => {
|
||||||
@@ -2809,7 +2819,14 @@ const ensureStatsServerStarted = (): string => {
|
|||||||
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
|
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
|
||||||
forceOverride: true,
|
forceOverride: true,
|
||||||
});
|
});
|
||||||
return addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
|
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
|
||||||
|
if (result.noteId && result.duplicateNoteIds.length > 0) {
|
||||||
|
appState.ankiIntegration?.trackDuplicateNoteIdsForNote(
|
||||||
|
result.noteId,
|
||||||
|
result.duplicateNoteIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result.noteId;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
appState.statsServer = statsServer;
|
appState.statsServer = statsServer;
|
||||||
@@ -3321,6 +3338,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();
|
||||||
@@ -3387,6 +3405,7 @@ const {
|
|||||||
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid);
|
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid);
|
||||||
},
|
},
|
||||||
onSubtitleTrackListChange: (trackList) => {
|
onSubtitleTrackListChange: (trackList) => {
|
||||||
|
managedLocalSubtitleSelectionRuntime.handleSubtitleTrackListChange(trackList);
|
||||||
scheduleSubtitlePrefetchRefresh();
|
scheduleSubtitlePrefetchRefresh();
|
||||||
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList);
|
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList);
|
||||||
},
|
},
|
||||||
@@ -4128,7 +4147,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: {
|
||||||
|
|||||||
@@ -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,7 +31,6 @@ 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();
|
||||||
@@ -96,3 +95,49 @@ test('autoplay ready gate retry loop does not re-signal plugin readiness', async
|
|||||||
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -44,8 +44,6 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
|||||||
deps.getCurrentVideoPath()?.trim() ||
|
deps.getCurrentVideoPath()?.trim() ||
|
||||||
'__unknown__';
|
'__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,
|
||||||
@@ -104,19 +102,13 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
|||||||
})();
|
})();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (duplicateMediaSignal && !allowDuplicateWhilePaused) {
|
if (duplicateMediaSignal) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!duplicateMediaSignal) {
|
|
||||||
autoPlayReadySignalMediaPath = mediaPath;
|
|
||||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
|
||||||
deps.signalPluginAutoplayReady();
|
|
||||||
attemptRelease(playbackGeneration, 0);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
autoPlayReadySignalMediaPath = mediaPath;
|
||||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||||
|
deps.signalPluginAutoplayReady();
|
||||||
attemptRelease(playbackGeneration, 0);
|
attemptRelease(playbackGeneration, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ test('setup service auto-completes legacy installs with config and dictionaries'
|
|||||||
const service = createFirstRunSetupService({
|
const service = createFirstRunSetupService({
|
||||||
configDir,
|
configDir,
|
||||||
getYomitanDictionaryCount: async () => 2,
|
getYomitanDictionaryCount: async () => 2,
|
||||||
detectPluginInstalled: () => false,
|
detectPluginInstalled: () => true,
|
||||||
installPlugin: async () => ({
|
installPlugin: async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
pluginInstallStatus: 'installed',
|
pluginInstallStatus: 'installed',
|
||||||
@@ -106,17 +106,18 @@ test('setup service auto-completes legacy installs with config and dictionaries'
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('setup service requires explicit finish for incomplete installs and supports plugin skip/install', async () => {
|
test('setup service requires mpv plugin install before finish', async () => {
|
||||||
await withTempDir(async (root) => {
|
await withTempDir(async (root) => {
|
||||||
const configDir = path.join(root, 'SubMiner');
|
const configDir = path.join(root, 'SubMiner');
|
||||||
fs.mkdirSync(configDir, { recursive: true });
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||||
let dictionaryCount = 0;
|
let dictionaryCount = 0;
|
||||||
|
let pluginInstalled = false;
|
||||||
|
|
||||||
const service = createFirstRunSetupService({
|
const service = createFirstRunSetupService({
|
||||||
configDir,
|
configDir,
|
||||||
getYomitanDictionaryCount: async () => dictionaryCount,
|
getYomitanDictionaryCount: async () => dictionaryCount,
|
||||||
detectPluginInstalled: () => false,
|
detectPluginInstalled: () => pluginInstalled,
|
||||||
installPlugin: async () => ({
|
installPlugin: async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
pluginInstallStatus: 'installed',
|
pluginInstallStatus: 'installed',
|
||||||
@@ -130,13 +131,11 @@ test('setup service requires explicit finish for incomplete installs and support
|
|||||||
assert.equal(initial.state.status, 'incomplete');
|
assert.equal(initial.state.status, 'incomplete');
|
||||||
assert.equal(initial.canFinish, false);
|
assert.equal(initial.canFinish, false);
|
||||||
|
|
||||||
const skipped = await service.skipPluginInstall();
|
|
||||||
assert.equal(skipped.state.pluginInstallStatus, 'skipped');
|
|
||||||
|
|
||||||
const installed = await service.installMpvPlugin();
|
const installed = await service.installMpvPlugin();
|
||||||
assert.equal(installed.state.pluginInstallStatus, 'installed');
|
assert.equal(installed.state.pluginInstallStatus, 'installed');
|
||||||
assert.equal(installed.pluginInstallPathSummary, '/tmp/mpv');
|
assert.equal(installed.pluginInstallPathSummary, '/tmp/mpv');
|
||||||
|
|
||||||
|
pluginInstalled = true;
|
||||||
dictionaryCount = 1;
|
dictionaryCount = 1;
|
||||||
const refreshed = await service.refreshStatus();
|
const refreshed = await service.refreshStatus();
|
||||||
assert.equal(refreshed.canFinish, true);
|
assert.equal(refreshed.canFinish, true);
|
||||||
@@ -158,7 +157,7 @@ test('setup service allows completion without internal dictionaries when externa
|
|||||||
configDir,
|
configDir,
|
||||||
getYomitanDictionaryCount: async () => 0,
|
getYomitanDictionaryCount: async () => 0,
|
||||||
isExternalYomitanConfigured: () => true,
|
isExternalYomitanConfigured: () => true,
|
||||||
detectPluginInstalled: () => false,
|
detectPluginInstalled: () => true,
|
||||||
installPlugin: async () => ({
|
installPlugin: async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
pluginInstallStatus: 'installed',
|
pluginInstallStatus: 'installed',
|
||||||
@@ -190,7 +189,7 @@ test('setup service does not probe internal dictionaries when external yomitan i
|
|||||||
throw new Error('should not probe internal dictionaries in external mode');
|
throw new Error('should not probe internal dictionaries in external mode');
|
||||||
},
|
},
|
||||||
isExternalYomitanConfigured: () => true,
|
isExternalYomitanConfigured: () => true,
|
||||||
detectPluginInstalled: () => false,
|
detectPluginInstalled: () => true,
|
||||||
installPlugin: async () => ({
|
installPlugin: async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
pluginInstallStatus: 'installed',
|
pluginInstallStatus: 'installed',
|
||||||
@@ -218,7 +217,7 @@ test('setup service reopens when external-yomitan completion later has no extern
|
|||||||
configDir,
|
configDir,
|
||||||
getYomitanDictionaryCount: async () => 0,
|
getYomitanDictionaryCount: async () => 0,
|
||||||
isExternalYomitanConfigured: () => true,
|
isExternalYomitanConfigured: () => true,
|
||||||
detectPluginInstalled: () => false,
|
detectPluginInstalled: () => true,
|
||||||
installPlugin: async () => ({
|
installPlugin: async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
pluginInstallStatus: 'installed',
|
pluginInstallStatus: 'installed',
|
||||||
@@ -235,7 +234,7 @@ test('setup service reopens when external-yomitan completion later has no extern
|
|||||||
configDir,
|
configDir,
|
||||||
getYomitanDictionaryCount: async () => 0,
|
getYomitanDictionaryCount: async () => 0,
|
||||||
isExternalYomitanConfigured: () => false,
|
isExternalYomitanConfigured: () => false,
|
||||||
detectPluginInstalled: () => false,
|
detectPluginInstalled: () => true,
|
||||||
installPlugin: async () => ({
|
installPlugin: async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
pluginInstallStatus: 'installed',
|
pluginInstallStatus: 'installed',
|
||||||
@@ -252,6 +251,48 @@ test('setup service reopens when external-yomitan completion later has no extern
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('setup service reopens when a completed setup no longer has the mpv plugin installed', async () => {
|
||||||
|
await withTempDir(async (root) => {
|
||||||
|
const configDir = path.join(root, 'SubMiner');
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||||
|
|
||||||
|
const completedService = createFirstRunSetupService({
|
||||||
|
configDir,
|
||||||
|
getYomitanDictionaryCount: async () => 2,
|
||||||
|
detectPluginInstalled: () => true,
|
||||||
|
installPlugin: async () => ({
|
||||||
|
ok: true,
|
||||||
|
pluginInstallStatus: 'installed',
|
||||||
|
pluginInstallPathSummary: '/tmp/mpv',
|
||||||
|
message: 'ok',
|
||||||
|
}),
|
||||||
|
onStateChanged: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await completedService.ensureSetupStateInitialized();
|
||||||
|
await completedService.markSetupCompleted();
|
||||||
|
|
||||||
|
const service = createFirstRunSetupService({
|
||||||
|
configDir,
|
||||||
|
getYomitanDictionaryCount: async () => 2,
|
||||||
|
detectPluginInstalled: () => false,
|
||||||
|
installPlugin: async () => ({
|
||||||
|
ok: true,
|
||||||
|
pluginInstallStatus: 'installed',
|
||||||
|
pluginInstallPathSummary: null,
|
||||||
|
message: 'ok',
|
||||||
|
}),
|
||||||
|
onStateChanged: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await service.ensureSetupStateInitialized();
|
||||||
|
assert.equal(snapshot.state.status, 'incomplete');
|
||||||
|
assert.equal(snapshot.canFinish, false);
|
||||||
|
assert.equal(snapshot.pluginStatus, 'required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('setup service keeps completed when external-yomitan completion later has internal dictionaries available', async () => {
|
test('setup service keeps completed when external-yomitan completion later has internal dictionaries available', async () => {
|
||||||
await withTempDir(async (root) => {
|
await withTempDir(async (root) => {
|
||||||
const configDir = path.join(root, 'SubMiner');
|
const configDir = path.join(root, 'SubMiner');
|
||||||
@@ -262,7 +303,7 @@ test('setup service keeps completed when external-yomitan completion later has i
|
|||||||
configDir,
|
configDir,
|
||||||
getYomitanDictionaryCount: async () => 0,
|
getYomitanDictionaryCount: async () => 0,
|
||||||
isExternalYomitanConfigured: () => true,
|
isExternalYomitanConfigured: () => true,
|
||||||
detectPluginInstalled: () => false,
|
detectPluginInstalled: () => true,
|
||||||
installPlugin: async () => ({
|
installPlugin: async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
pluginInstallStatus: 'installed',
|
pluginInstallStatus: 'installed',
|
||||||
@@ -279,7 +320,7 @@ test('setup service keeps completed when external-yomitan completion later has i
|
|||||||
configDir,
|
configDir,
|
||||||
getYomitanDictionaryCount: async () => 2,
|
getYomitanDictionaryCount: async () => 2,
|
||||||
isExternalYomitanConfigured: () => false,
|
isExternalYomitanConfigured: () => false,
|
||||||
detectPluginInstalled: () => false,
|
detectPluginInstalled: () => true,
|
||||||
installPlugin: async () => ({
|
installPlugin: async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
pluginInstallStatus: 'installed',
|
pluginInstallStatus: 'installed',
|
||||||
@@ -304,7 +345,7 @@ test('setup service marks cancelled when popup closes before completion', async
|
|||||||
const service = createFirstRunSetupService({
|
const service = createFirstRunSetupService({
|
||||||
configDir,
|
configDir,
|
||||||
getYomitanDictionaryCount: async () => 0,
|
getYomitanDictionaryCount: async () => 0,
|
||||||
detectPluginInstalled: () => false,
|
detectPluginInstalled: () => true,
|
||||||
installPlugin: async () => ({
|
installPlugin: async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
pluginInstallStatus: 'installed',
|
pluginInstallStatus: 'installed',
|
||||||
@@ -331,7 +372,7 @@ test('setup service reflects detected Windows mpv shortcuts before preferences a
|
|||||||
platform: 'win32',
|
platform: 'win32',
|
||||||
configDir,
|
configDir,
|
||||||
getYomitanDictionaryCount: async () => 0,
|
getYomitanDictionaryCount: async () => 0,
|
||||||
detectPluginInstalled: () => false,
|
detectPluginInstalled: () => true,
|
||||||
installPlugin: async () => ({
|
installPlugin: async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
pluginInstallStatus: 'installed',
|
pluginInstallStatus: 'installed',
|
||||||
@@ -364,7 +405,7 @@ test('setup service persists Windows mpv shortcut preferences and status with on
|
|||||||
platform: 'win32',
|
platform: 'win32',
|
||||||
configDir,
|
configDir,
|
||||||
getYomitanDictionaryCount: async () => 0,
|
getYomitanDictionaryCount: async () => 0,
|
||||||
detectPluginInstalled: () => false,
|
detectPluginInstalled: () => true,
|
||||||
installPlugin: async () => ({
|
installPlugin: async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
pluginInstallStatus: 'installed',
|
pluginInstallStatus: 'installed',
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export interface SetupStatusSnapshot {
|
|||||||
dictionaryCount: number;
|
dictionaryCount: number;
|
||||||
canFinish: boolean;
|
canFinish: boolean;
|
||||||
externalYomitanConfigured: boolean;
|
externalYomitanConfigured: boolean;
|
||||||
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
|
pluginStatus: 'installed' | 'required' | 'failed';
|
||||||
pluginInstallPathSummary: string | null;
|
pluginInstallPathSummary: string | null;
|
||||||
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
|
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
|
||||||
message: string | null;
|
message: string | null;
|
||||||
@@ -48,7 +48,6 @@ export interface FirstRunSetupService {
|
|||||||
markSetupInProgress: () => Promise<SetupStatusSnapshot>;
|
markSetupInProgress: () => Promise<SetupStatusSnapshot>;
|
||||||
markSetupCancelled: () => Promise<SetupStatusSnapshot>;
|
markSetupCancelled: () => Promise<SetupStatusSnapshot>;
|
||||||
markSetupCompleted: () => Promise<SetupStatusSnapshot>;
|
markSetupCompleted: () => Promise<SetupStatusSnapshot>;
|
||||||
skipPluginInstall: () => Promise<SetupStatusSnapshot>;
|
|
||||||
installMpvPlugin: () => Promise<SetupStatusSnapshot>;
|
installMpvPlugin: () => Promise<SetupStatusSnapshot>;
|
||||||
configureWindowsMpvShortcuts: (preferences: {
|
configureWindowsMpvShortcuts: (preferences: {
|
||||||
startMenuEnabled: boolean;
|
startMenuEnabled: boolean;
|
||||||
@@ -108,9 +107,8 @@ function getPluginStatus(
|
|||||||
pluginInstalled: boolean,
|
pluginInstalled: boolean,
|
||||||
): SetupStatusSnapshot['pluginStatus'] {
|
): SetupStatusSnapshot['pluginStatus'] {
|
||||||
if (pluginInstalled) return 'installed';
|
if (pluginInstalled) return 'installed';
|
||||||
if (state.pluginInstallStatus === 'skipped') return 'skipped';
|
|
||||||
if (state.pluginInstallStatus === 'failed') return 'failed';
|
if (state.pluginInstallStatus === 'failed') return 'failed';
|
||||||
return 'optional';
|
return 'required';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWindowsMpvShortcutStatus(
|
function getWindowsMpvShortcutStatus(
|
||||||
@@ -151,6 +149,24 @@ function isYomitanSetupSatisfied(options: {
|
|||||||
return options.externalYomitanConfigured || options.dictionaryCount >= 1;
|
return options.externalYomitanConfigured || options.dictionaryCount >= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getFirstRunSetupCompletionMessage(snapshot: {
|
||||||
|
configReady: boolean;
|
||||||
|
dictionaryCount: number;
|
||||||
|
externalYomitanConfigured: boolean;
|
||||||
|
pluginStatus: SetupStatusSnapshot['pluginStatus'];
|
||||||
|
}): string | null {
|
||||||
|
if (!snapshot.configReady) {
|
||||||
|
return 'Create or provide the config file before finishing setup.';
|
||||||
|
}
|
||||||
|
if (snapshot.pluginStatus !== 'installed') {
|
||||||
|
return 'Install the mpv plugin before finishing setup.';
|
||||||
|
}
|
||||||
|
if (!snapshot.externalYomitanConfigured && snapshot.dictionaryCount < 1) {
|
||||||
|
return 'Install at least one Yomitan dictionary before finishing setup.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveYomitanSetupStatus(deps: {
|
async function resolveYomitanSetupStatus(deps: {
|
||||||
configFilePaths: { jsoncPath: string; jsonPath: string };
|
configFilePaths: { jsoncPath: string; jsonPath: string };
|
||||||
getYomitanDictionaryCount: () => Promise<number>;
|
getYomitanDictionaryCount: () => Promise<number>;
|
||||||
@@ -230,11 +246,13 @@ export function createFirstRunSetupService(deps: {
|
|||||||
return {
|
return {
|
||||||
configReady,
|
configReady,
|
||||||
dictionaryCount,
|
dictionaryCount,
|
||||||
canFinish: isYomitanSetupSatisfied({
|
canFinish:
|
||||||
configReady,
|
pluginInstalled &&
|
||||||
dictionaryCount,
|
isYomitanSetupSatisfied({
|
||||||
externalYomitanConfigured,
|
configReady,
|
||||||
}),
|
dictionaryCount,
|
||||||
|
externalYomitanConfigured,
|
||||||
|
}),
|
||||||
externalYomitanConfigured,
|
externalYomitanConfigured,
|
||||||
pluginStatus: getPluginStatus(state, pluginInstalled),
|
pluginStatus: getPluginStatus(state, pluginInstalled),
|
||||||
pluginInstallPathSummary: state.pluginInstallPathSummary,
|
pluginInstallPathSummary: state.pluginInstallPathSummary,
|
||||||
@@ -272,24 +290,20 @@ export function createFirstRunSetupService(deps: {
|
|||||||
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
|
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
|
||||||
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
|
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
|
||||||
});
|
});
|
||||||
const yomitanSetupSatisfied = isYomitanSetupSatisfied({
|
const pluginInstalled = await deps.detectPluginInstalled();
|
||||||
configReady,
|
const canFinish =
|
||||||
dictionaryCount,
|
pluginInstalled &&
|
||||||
externalYomitanConfigured,
|
isYomitanSetupSatisfied({
|
||||||
});
|
configReady,
|
||||||
if (
|
dictionaryCount,
|
||||||
isSetupCompleted(state) &&
|
externalYomitanConfigured,
|
||||||
!(
|
});
|
||||||
state.yomitanSetupMode === 'external' &&
|
if (isSetupCompleted(state) && canFinish) {
|
||||||
!externalYomitanConfigured &&
|
|
||||||
!yomitanSetupSatisfied
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
completed = true;
|
completed = true;
|
||||||
return refreshWithState(state);
|
return refreshWithState(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (yomitanSetupSatisfied) {
|
if (canFinish) {
|
||||||
const completedState = writeState({
|
const completedState = writeState({
|
||||||
...state,
|
...state,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
@@ -347,8 +361,6 @@ export function createFirstRunSetupService(deps: {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
skipPluginInstall: async () =>
|
|
||||||
refreshWithState(writeState({ ...readState(), pluginInstallStatus: 'skipped' })),
|
|
||||||
installMpvPlugin: async () => {
|
installMpvPlugin: async () => {
|
||||||
const result = await deps.installPlugin();
|
const result = await deps.installPlugin();
|
||||||
return refreshWithState(
|
return refreshWithState(
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
|
|||||||
dictionaryCount: 0,
|
dictionaryCount: 0,
|
||||||
canFinish: false,
|
canFinish: false,
|
||||||
externalYomitanConfigured: false,
|
externalYomitanConfigured: false,
|
||||||
pluginStatus: 'optional',
|
pluginStatus: 'required',
|
||||||
pluginInstallPathSummary: null,
|
pluginInstallPathSummary: null,
|
||||||
windowsMpvShortcuts: {
|
windowsMpvShortcuts: {
|
||||||
supported: false,
|
supported: false,
|
||||||
@@ -29,6 +29,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
|
|||||||
|
|
||||||
assert.match(html, /SubMiner setup/);
|
assert.match(html, /SubMiner setup/);
|
||||||
assert.match(html, /Install mpv plugin/);
|
assert.match(html, /Install mpv plugin/);
|
||||||
|
assert.match(html, /Required before SubMiner setup can finish/);
|
||||||
assert.match(html, /Open Yomitan Settings/);
|
assert.match(html, /Open Yomitan Settings/);
|
||||||
assert.match(html, /Finish setup/);
|
assert.match(html, /Finish setup/);
|
||||||
assert.match(html, /disabled/);
|
assert.match(html, /disabled/);
|
||||||
@@ -54,6 +55,32 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.match(html, /Reinstall mpv plugin/);
|
assert.match(html, /Reinstall mpv plugin/);
|
||||||
|
assert.match(
|
||||||
|
html,
|
||||||
|
/Finish stays unlocked once the mpv plugin is installed and Yomitan reports at least one installed dictionary\./,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildFirstRunSetupHtml explains the config blocker when setup is missing config', () => {
|
||||||
|
const html = buildFirstRunSetupHtml({
|
||||||
|
configReady: false,
|
||||||
|
dictionaryCount: 0,
|
||||||
|
canFinish: false,
|
||||||
|
externalYomitanConfigured: false,
|
||||||
|
pluginStatus: 'required',
|
||||||
|
pluginInstallPathSummary: null,
|
||||||
|
windowsMpvShortcuts: {
|
||||||
|
supported: false,
|
||||||
|
startMenuEnabled: true,
|
||||||
|
desktopEnabled: true,
|
||||||
|
startMenuInstalled: false,
|
||||||
|
desktopInstalled: false,
|
||||||
|
status: 'optional',
|
||||||
|
},
|
||||||
|
message: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(html, /Create or provide the config file before finishing setup\./);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish enabled', () => {
|
test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish enabled', () => {
|
||||||
@@ -62,7 +89,7 @@ test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish ena
|
|||||||
dictionaryCount: 0,
|
dictionaryCount: 0,
|
||||||
canFinish: true,
|
canFinish: true,
|
||||||
externalYomitanConfigured: true,
|
externalYomitanConfigured: true,
|
||||||
pluginStatus: 'optional',
|
pluginStatus: 'installed',
|
||||||
pluginInstallPathSummary: null,
|
pluginInstallPathSummary: null,
|
||||||
windowsMpvShortcuts: {
|
windowsMpvShortcuts: {
|
||||||
supported: false,
|
supported: false,
|
||||||
@@ -76,16 +103,14 @@ test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish ena
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.match(html, /External profile configured/);
|
assert.match(html, /External profile configured/);
|
||||||
assert.match(
|
assert.match(html, /Finish stays unlocked while SubMiner is reusing an external Yomitan profile\./);
|
||||||
html,
|
|
||||||
/Finish stays unlocked while SubMiner is reusing an external Yomitan profile\./,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
|
test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
|
||||||
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
|
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
|
||||||
action: 'refresh',
|
action: 'refresh',
|
||||||
});
|
});
|
||||||
|
assert.equal(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'), null);
|
||||||
assert.equal(parseFirstRunSetupSubmissionUrl('https://example.com'), null);
|
assert.equal(parseFirstRunSetupSubmissionUrl('https://example.com'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,6 +146,25 @@ test('first-run setup navigation handler prevents default and dispatches action'
|
|||||||
assert.deepEqual(calls, ['preventDefault', 'install-plugin']);
|
assert.deepEqual(calls, ['preventDefault', 'install-plugin']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('first-run setup navigation handler swallows stale custom-scheme actions', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
|
||||||
|
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
|
||||||
|
handleAction: async (submission) => {
|
||||||
|
calls.push(submission.action);
|
||||||
|
},
|
||||||
|
logError: (message) => calls.push(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const prevented = handleNavigation({
|
||||||
|
url: 'subminer://first-run-setup?action=skip-plugin',
|
||||||
|
preventDefault: () => calls.push('preventDefault'),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(prevented, true);
|
||||||
|
assert.deepEqual(calls, ['preventDefault']);
|
||||||
|
});
|
||||||
|
|
||||||
test('closing incomplete first-run setup quits app outside background mode', async () => {
|
test('closing incomplete first-run setup quits app outside background mode', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
let closedHandler: (() => void) | undefined;
|
let closedHandler: (() => void) | undefined;
|
||||||
@@ -146,7 +190,7 @@ test('closing incomplete first-run setup quits app outside background mode', asy
|
|||||||
dictionaryCount: 0,
|
dictionaryCount: 0,
|
||||||
canFinish: false,
|
canFinish: false,
|
||||||
externalYomitanConfigured: false,
|
externalYomitanConfigured: false,
|
||||||
pluginStatus: 'optional',
|
pluginStatus: 'required',
|
||||||
pluginInstallPathSummary: null,
|
pluginInstallPathSummary: null,
|
||||||
windowsMpvShortcuts: {
|
windowsMpvShortcuts: {
|
||||||
supported: false,
|
supported: false,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getFirstRunSetupCompletionMessage } from './first-run-setup-service';
|
||||||
|
|
||||||
type FocusableWindowLike = {
|
type FocusableWindowLike = {
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
};
|
};
|
||||||
@@ -19,7 +21,6 @@ export type FirstRunSetupAction =
|
|||||||
| 'configure-windows-mpv-shortcuts'
|
| 'configure-windows-mpv-shortcuts'
|
||||||
| 'open-yomitan-settings'
|
| 'open-yomitan-settings'
|
||||||
| 'refresh'
|
| 'refresh'
|
||||||
| 'skip-plugin'
|
|
||||||
| 'finish';
|
| 'finish';
|
||||||
|
|
||||||
export interface FirstRunSetupSubmission {
|
export interface FirstRunSetupSubmission {
|
||||||
@@ -33,7 +34,7 @@ export interface FirstRunSetupHtmlModel {
|
|||||||
dictionaryCount: number;
|
dictionaryCount: number;
|
||||||
canFinish: boolean;
|
canFinish: boolean;
|
||||||
externalYomitanConfigured: boolean;
|
externalYomitanConfigured: boolean;
|
||||||
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
|
pluginStatus: 'installed' | 'required' | 'failed';
|
||||||
pluginInstallPathSummary: string | null;
|
pluginInstallPathSummary: string | null;
|
||||||
windowsMpvShortcuts: {
|
windowsMpvShortcuts: {
|
||||||
supported: boolean;
|
supported: boolean;
|
||||||
@@ -64,19 +65,15 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
|||||||
const pluginLabel =
|
const pluginLabel =
|
||||||
model.pluginStatus === 'installed'
|
model.pluginStatus === 'installed'
|
||||||
? 'Installed'
|
? 'Installed'
|
||||||
: model.pluginStatus === 'skipped'
|
: model.pluginStatus === 'failed'
|
||||||
? 'Skipped'
|
? 'Failed'
|
||||||
: model.pluginStatus === 'failed'
|
: 'Required';
|
||||||
? 'Failed'
|
|
||||||
: 'Optional';
|
|
||||||
const pluginTone =
|
const pluginTone =
|
||||||
model.pluginStatus === 'installed'
|
model.pluginStatus === 'installed'
|
||||||
? 'ready'
|
? 'ready'
|
||||||
: model.pluginStatus === 'failed'
|
: model.pluginStatus === 'failed'
|
||||||
? 'danger'
|
? 'danger'
|
||||||
: model.pluginStatus === 'skipped'
|
: 'warn';
|
||||||
? 'muted'
|
|
||||||
: 'warn';
|
|
||||||
const windowsShortcutLabel =
|
const windowsShortcutLabel =
|
||||||
model.windowsMpvShortcuts.status === 'installed'
|
model.windowsMpvShortcuts.status === 'installed'
|
||||||
? 'Installed'
|
? 'Installed'
|
||||||
@@ -128,9 +125,14 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
|||||||
: model.dictionaryCount >= 1
|
: model.dictionaryCount >= 1
|
||||||
? 'ready'
|
? 'ready'
|
||||||
: 'warn';
|
: 'warn';
|
||||||
const footerMessage = model.externalYomitanConfigured
|
const blockerMessage = getFirstRunSetupCompletionMessage(model);
|
||||||
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
|
const footerMessage = blockerMessage
|
||||||
: 'Finish stays locked until Yomitan reports at least one installed dictionary.';
|
? blockerMessage
|
||||||
|
: model.canFinish
|
||||||
|
? model.externalYomitanConfigured
|
||||||
|
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
|
||||||
|
: 'Finish stays unlocked once the mpv plugin is installed and Yomitan reports at least one installed dictionary.'
|
||||||
|
: 'Finish stays locked until the mpv plugin is installed and Yomitan reports at least one installed dictionary.';
|
||||||
|
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
@@ -269,6 +271,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
|||||||
<div>
|
<div>
|
||||||
<strong>mpv plugin</strong>
|
<strong>mpv plugin</strong>
|
||||||
<div class="meta">${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}</div>
|
<div class="meta">${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}</div>
|
||||||
|
<div class="meta">Required before SubMiner setup can finish.</div>
|
||||||
</div>
|
</div>
|
||||||
${renderStatusBadge(pluginLabel, pluginTone)}
|
${renderStatusBadge(pluginLabel, pluginTone)}
|
||||||
</div>
|
</div>
|
||||||
@@ -284,7 +287,6 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
|||||||
<button onclick="window.location.href='subminer://first-run-setup?action=install-plugin'">${pluginActionLabel}</button>
|
<button onclick="window.location.href='subminer://first-run-setup?action=install-plugin'">${pluginActionLabel}</button>
|
||||||
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
|
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
|
||||||
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
|
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
|
||||||
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=skip-plugin'">Skip plugin</button>
|
|
||||||
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">Finish setup</button>
|
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">Finish setup</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
|
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
|
||||||
@@ -305,7 +307,6 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
|
|||||||
action !== 'configure-windows-mpv-shortcuts' &&
|
action !== 'configure-windows-mpv-shortcuts' &&
|
||||||
action !== 'open-yomitan-settings' &&
|
action !== 'open-yomitan-settings' &&
|
||||||
action !== 'refresh' &&
|
action !== 'refresh' &&
|
||||||
action !== 'skip-plugin' &&
|
|
||||||
action !== 'finish'
|
action !== 'finish'
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
@@ -337,9 +338,17 @@ export function createHandleFirstRunSetupNavigationHandler(deps: {
|
|||||||
logError: (message: string, error: unknown) => void;
|
logError: (message: string, error: unknown) => void;
|
||||||
}) {
|
}) {
|
||||||
return (params: { url: string; preventDefault: () => void }): boolean => {
|
return (params: { url: string; preventDefault: () => void }): boolean => {
|
||||||
const submission = deps.parseSubmissionUrl(params.url);
|
if (!params.url.startsWith('subminer://first-run-setup')) {
|
||||||
if (!submission) return false;
|
return false;
|
||||||
|
}
|
||||||
params.preventDefault();
|
params.preventDefault();
|
||||||
|
let submission: FirstRunSetupSubmission | null;
|
||||||
|
try {
|
||||||
|
submission = deps.parseSubmissionUrl(params.url);
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!submission) return true;
|
||||||
void deps.handleAction(submission).catch((error) => {
|
void deps.handleAction(submission).catch((error) => {
|
||||||
deps.logError('Failed handling first-run setup action', error);
|
deps.logError('Failed handling first-run setup action', error);
|
||||||
});
|
});
|
||||||
|
|||||||
77
src/main/runtime/local-subtitle-selection.test.ts
Normal file
77
src/main/runtime/local-subtitle-selection.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createManagedLocalSubtitleSelectionRuntime,
|
||||||
|
resolveManagedLocalSubtitleSelection,
|
||||||
|
} from './local-subtitle-selection';
|
||||||
|
|
||||||
|
const mixedLanguageTrackList = [
|
||||||
|
{ type: 'sub', id: 1, lang: 'pt', title: '[Infinite]', external: false, selected: true },
|
||||||
|
{ type: 'sub', id: 2, lang: 'pt', title: '[Moshi Moshi]', external: false },
|
||||||
|
{ type: 'sub', id: 3, lang: 'en', title: '(Vivid)', external: false },
|
||||||
|
{ type: 'sub', id: 9, lang: 'en', title: 'English(US)', external: false },
|
||||||
|
{ type: 'sub', id: 11, lang: 'en', title: 'en.srt', external: true },
|
||||||
|
{ type: 'sub', id: 12, lang: 'ja', title: 'ja.srt', external: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
test('resolveManagedLocalSubtitleSelection prefers default Japanese primary and English secondary tracks', () => {
|
||||||
|
const result = resolveManagedLocalSubtitleSelection({
|
||||||
|
trackList: mixedLanguageTrackList,
|
||||||
|
primaryLanguages: [],
|
||||||
|
secondaryLanguages: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.primaryTrackId, 12);
|
||||||
|
assert.equal(result.secondaryTrackId, 11);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveManagedLocalSubtitleSelection respects configured language overrides', () => {
|
||||||
|
const result = resolveManagedLocalSubtitleSelection({
|
||||||
|
trackList: mixedLanguageTrackList,
|
||||||
|
primaryLanguages: ['pt'],
|
||||||
|
secondaryLanguages: ['ja'],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.primaryTrackId, 1);
|
||||||
|
assert.equal(result.secondaryTrackId, 12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('managed local subtitle selection runtime applies preferred tracks once for a local media path', async () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
const scheduled: Array<() => void> = [];
|
||||||
|
|
||||||
|
const runtime = createManagedLocalSubtitleSelectionRuntime({
|
||||||
|
getCurrentMediaPath: () => '/videos/example.mkv',
|
||||||
|
getMpvClient: () =>
|
||||||
|
({
|
||||||
|
connected: true,
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
if (name === 'track-list') {
|
||||||
|
return mixedLanguageTrackList;
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected property: ${name}`);
|
||||||
|
},
|
||||||
|
}) as never,
|
||||||
|
getPrimarySubtitleLanguages: () => [],
|
||||||
|
getSecondarySubtitleLanguages: () => [],
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
commands.push(command);
|
||||||
|
},
|
||||||
|
schedule: (callback) => {
|
||||||
|
scheduled.push(callback);
|
||||||
|
return 1 as never;
|
||||||
|
},
|
||||||
|
clearScheduled: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.handleMediaPathChange('/videos/example.mkv');
|
||||||
|
scheduled.shift()?.();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
runtime.handleSubtitleTrackListChange(mixedLanguageTrackList);
|
||||||
|
|
||||||
|
assert.deepEqual(commands, [
|
||||||
|
['set_property', 'sid', 12],
|
||||||
|
['set_property', 'secondary-sid', 11],
|
||||||
|
]);
|
||||||
|
});
|
||||||
261
src/main/runtime/local-subtitle-selection.ts
Normal file
261
src/main/runtime/local-subtitle-selection.ts
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { isRemoteMediaPath } from '../../jimaku/utils';
|
||||||
|
import { normalizeYoutubeLangCode } from '../../core/services/youtube/labels';
|
||||||
|
|
||||||
|
const DEFAULT_PRIMARY_SUBTITLE_LANGUAGES = ['ja', 'jpn'];
|
||||||
|
const DEFAULT_SECONDARY_SUBTITLE_LANGUAGES = ['en', 'eng', 'english', 'enus', 'en-us'];
|
||||||
|
const HEARING_IMPAIRED_PATTERN = /\b(hearing impaired|sdh|closed captions?|cc)\b/i;
|
||||||
|
|
||||||
|
type SubtitleTrackLike = {
|
||||||
|
type?: unknown;
|
||||||
|
id?: unknown;
|
||||||
|
lang?: unknown;
|
||||||
|
title?: unknown;
|
||||||
|
external?: unknown;
|
||||||
|
selected?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NormalizedSubtitleTrack = {
|
||||||
|
id: number;
|
||||||
|
lang: string;
|
||||||
|
title: string;
|
||||||
|
external: boolean;
|
||||||
|
selected: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ManagedLocalSubtitleSelection = {
|
||||||
|
primaryTrackId: number | null;
|
||||||
|
secondaryTrackId: number | null;
|
||||||
|
hasPrimaryMatch: boolean;
|
||||||
|
hasSecondaryMatch: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseTrackId(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value.trim());
|
||||||
|
return Number.isInteger(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTrack(entry: unknown): NormalizedSubtitleTrack | null {
|
||||||
|
if (!entry || typeof entry !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const track = entry as SubtitleTrackLike;
|
||||||
|
const id = parseTrackId(track.id);
|
||||||
|
if (id === null || (track.type !== undefined && track.type !== 'sub')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
lang: String(track.lang || '').trim(),
|
||||||
|
title: String(track.title || '').trim(),
|
||||||
|
external: track.external === true,
|
||||||
|
selected: track.selected === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLanguageList(values: string[], fallback: string[]): string[] {
|
||||||
|
const normalized = values
|
||||||
|
.map((value) => normalizeYoutubeLangCode(value))
|
||||||
|
.filter((value, index, items) => value.length > 0 && items.indexOf(value) === index);
|
||||||
|
if (normalized.length > 0) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
.map((value) => normalizeYoutubeLangCode(value))
|
||||||
|
.filter((value, index, items) => value.length > 0 && items.indexOf(value) === index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLanguageRank(language: string, preferredLanguages: string[]): number {
|
||||||
|
const normalized = normalizeYoutubeLangCode(language);
|
||||||
|
if (!normalized) {
|
||||||
|
return Number.POSITIVE_INFINITY;
|
||||||
|
}
|
||||||
|
const directIndex = preferredLanguages.indexOf(normalized);
|
||||||
|
if (directIndex >= 0) {
|
||||||
|
return directIndex;
|
||||||
|
}
|
||||||
|
const base = normalized.split('-')[0] || normalized;
|
||||||
|
const baseIndex = preferredLanguages.indexOf(base);
|
||||||
|
return baseIndex >= 0 ? baseIndex : Number.POSITIVE_INFINITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyHearingImpaired(title: string): boolean {
|
||||||
|
return HEARING_IMPAIRED_PATTERN.test(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBestTrackId(
|
||||||
|
tracks: NormalizedSubtitleTrack[],
|
||||||
|
preferredLanguages: string[],
|
||||||
|
excludeId: number | null = null,
|
||||||
|
): { trackId: number | null; hasMatch: boolean } {
|
||||||
|
const ranked = tracks
|
||||||
|
.filter((track) => track.id !== excludeId)
|
||||||
|
.map((track) => ({
|
||||||
|
track,
|
||||||
|
languageRank: resolveLanguageRank(track.lang, preferredLanguages),
|
||||||
|
}))
|
||||||
|
.filter(({ languageRank }) => Number.isFinite(languageRank))
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.languageRank !== right.languageRank) {
|
||||||
|
return left.languageRank - right.languageRank;
|
||||||
|
}
|
||||||
|
if (left.track.external !== right.track.external) {
|
||||||
|
return left.track.external ? -1 : 1;
|
||||||
|
}
|
||||||
|
if (isLikelyHearingImpaired(left.track.title) !== isLikelyHearingImpaired(right.track.title)) {
|
||||||
|
return isLikelyHearingImpaired(left.track.title) ? 1 : -1;
|
||||||
|
}
|
||||||
|
if (/\bdefault\b/i.test(left.track.title) !== /\bdefault\b/i.test(right.track.title)) {
|
||||||
|
return /\bdefault\b/i.test(left.track.title) ? -1 : 1;
|
||||||
|
}
|
||||||
|
return left.track.id - right.track.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
trackId: ranked[0]?.track.id ?? null,
|
||||||
|
hasMatch: ranked.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveManagedLocalSubtitleSelection(input: {
|
||||||
|
trackList: unknown[] | null;
|
||||||
|
primaryLanguages: string[];
|
||||||
|
secondaryLanguages: string[];
|
||||||
|
}): ManagedLocalSubtitleSelection {
|
||||||
|
const tracks = Array.isArray(input.trackList)
|
||||||
|
? input.trackList.map(normalizeTrack).filter((track): track is NormalizedSubtitleTrack => track !== null)
|
||||||
|
: [];
|
||||||
|
const preferredPrimaryLanguages = normalizeLanguageList(
|
||||||
|
input.primaryLanguages,
|
||||||
|
DEFAULT_PRIMARY_SUBTITLE_LANGUAGES,
|
||||||
|
);
|
||||||
|
const preferredSecondaryLanguages = normalizeLanguageList(
|
||||||
|
input.secondaryLanguages,
|
||||||
|
DEFAULT_SECONDARY_SUBTITLE_LANGUAGES,
|
||||||
|
);
|
||||||
|
|
||||||
|
const primary = pickBestTrackId(tracks, preferredPrimaryLanguages);
|
||||||
|
const secondary = pickBestTrackId(tracks, preferredSecondaryLanguages, primary.trackId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
primaryTrackId: primary.trackId,
|
||||||
|
secondaryTrackId: secondary.trackId,
|
||||||
|
hasPrimaryMatch: primary.hasMatch,
|
||||||
|
hasSecondaryMatch: secondary.hasMatch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLocalMediaPath(mediaPath: string | null | undefined): string | null {
|
||||||
|
if (typeof mediaPath !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = mediaPath.trim();
|
||||||
|
if (!trimmed || isRemoteMediaPath(trimmed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return path.resolve(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||||
|
getCurrentMediaPath: () => string | null;
|
||||||
|
getMpvClient: () =>
|
||||||
|
| {
|
||||||
|
connected?: boolean;
|
||||||
|
requestProperty?: (name: string) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
| null;
|
||||||
|
getPrimarySubtitleLanguages: () => string[];
|
||||||
|
getSecondarySubtitleLanguages: () => string[];
|
||||||
|
sendMpvCommand: (command: ['set_property', 'sid' | 'secondary-sid', number]) => void;
|
||||||
|
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||||
|
clearScheduled: (timer: ReturnType<typeof setTimeout>) => void;
|
||||||
|
delayMs?: number;
|
||||||
|
}) {
|
||||||
|
const delayMs = deps.delayMs ?? 400;
|
||||||
|
let currentMediaPath: string | null = null;
|
||||||
|
let appliedMediaPath: string | null = null;
|
||||||
|
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const clearPendingTimer = (): void => {
|
||||||
|
if (!pendingTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.clearScheduled(pendingTimer);
|
||||||
|
pendingTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const maybeApplySelection = (trackList: unknown[] | null): void => {
|
||||||
|
if (!currentMediaPath || appliedMediaPath === currentMediaPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selection = resolveManagedLocalSubtitleSelection({
|
||||||
|
trackList,
|
||||||
|
primaryLanguages: deps.getPrimarySubtitleLanguages(),
|
||||||
|
secondaryLanguages: deps.getSecondarySubtitleLanguages(),
|
||||||
|
});
|
||||||
|
if (!selection.hasPrimaryMatch && !selection.hasSecondaryMatch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selection.primaryTrackId !== null) {
|
||||||
|
deps.sendMpvCommand(['set_property', 'sid', selection.primaryTrackId]);
|
||||||
|
}
|
||||||
|
if (selection.secondaryTrackId !== null) {
|
||||||
|
deps.sendMpvCommand(['set_property', 'secondary-sid', selection.secondaryTrackId]);
|
||||||
|
}
|
||||||
|
appliedMediaPath = currentMediaPath;
|
||||||
|
clearPendingTimer();
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshFromMpv = async (): Promise<void> => {
|
||||||
|
const client = deps.getMpvClient();
|
||||||
|
if (!client?.connected || !client.requestProperty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mediaPath = normalizeLocalMediaPath(deps.getCurrentMediaPath());
|
||||||
|
if (!mediaPath || mediaPath !== currentMediaPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const trackList = await client.requestProperty('track-list');
|
||||||
|
maybeApplySelection(Array.isArray(trackList) ? trackList : null);
|
||||||
|
} catch {
|
||||||
|
// Skip selection when mpv track inspection fails.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleRefresh = (): void => {
|
||||||
|
clearPendingTimer();
|
||||||
|
if (!currentMediaPath || appliedMediaPath === currentMediaPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingTimer = deps.schedule(() => {
|
||||||
|
pendingTimer = null;
|
||||||
|
void refreshFromMpv();
|
||||||
|
}, delayMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleMediaPathChange: (mediaPath: string | null | undefined): void => {
|
||||||
|
const normalizedPath = normalizeLocalMediaPath(mediaPath);
|
||||||
|
if (normalizedPath !== currentMediaPath) {
|
||||||
|
appliedMediaPath = null;
|
||||||
|
}
|
||||||
|
currentMediaPath = normalizedPath;
|
||||||
|
if (!currentMediaPath) {
|
||||||
|
clearPendingTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduleRefresh();
|
||||||
|
},
|
||||||
|
handleSubtitleTrackListChange: (trackList: unknown[] | null): void => {
|
||||||
|
maybeApplySelection(trackList);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -24,9 +24,15 @@ export type PlaylistBrowserIpcRuntime = {
|
|||||||
|
|
||||||
export function createPlaylistBrowserIpcRuntime(
|
export function createPlaylistBrowserIpcRuntime(
|
||||||
getMpvClient: PlaylistBrowserRuntimeDeps['getMpvClient'],
|
getMpvClient: PlaylistBrowserRuntimeDeps['getMpvClient'],
|
||||||
|
options?: Pick<
|
||||||
|
PlaylistBrowserRuntimeDeps,
|
||||||
|
'getPrimarySubtitleLanguages' | 'getSecondarySubtitleLanguages'
|
||||||
|
>,
|
||||||
): PlaylistBrowserIpcRuntime {
|
): PlaylistBrowserIpcRuntime {
|
||||||
const playlistBrowserRuntimeDeps: PlaylistBrowserRuntimeDeps = {
|
const playlistBrowserRuntimeDeps: PlaylistBrowserRuntimeDeps = {
|
||||||
getMpvClient,
|
getMpvClient,
|
||||||
|
getPrimarySubtitleLanguages: options?.getPrimarySubtitleLanguages,
|
||||||
|
getSecondarySubtitleLanguages: options?.getSecondarySubtitleLanguages,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ test('playlist-browser mutation runtimes mutate queue and return refreshed snaps
|
|||||||
]);
|
]);
|
||||||
assert.deepEqual(scheduled.map((entry) => entry.delayMs), [400]);
|
assert.deepEqual(scheduled.map((entry) => entry.delayMs), [400]);
|
||||||
scheduled[0]?.callback();
|
scheduled[0]?.callback();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
assert.deepEqual(mpvClient.getCommands().slice(-2), [
|
assert.deepEqual(mpvClient.getCommands().slice(-2), [
|
||||||
['set_property', 'sid', 'auto'],
|
['set_property', 'sid', 'auto'],
|
||||||
['set_property', 'secondary-sid', 'auto'],
|
['set_property', 'secondary-sid', 'auto'],
|
||||||
@@ -472,6 +473,7 @@ test('playPlaylistBrowserIndexRuntime ignores superseded local subtitle rearm ca
|
|||||||
|
|
||||||
scheduled[0]?.();
|
scheduled[0]?.();
|
||||||
scheduled[1]?.();
|
scheduled[1]?.();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
mpvClient.getCommands().slice(-6),
|
mpvClient.getCommands().slice(-6),
|
||||||
@@ -485,3 +487,52 @@ test('playPlaylistBrowserIndexRuntime ignores superseded local subtitle rearm ca
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('playlist-browser playback reapplies configured preferred subtitle tracks when track metadata is available', async (t) => {
|
||||||
|
const dir = createTempVideoDir(t);
|
||||||
|
const episode1 = path.join(dir, 'Show - S01E01.mkv');
|
||||||
|
const episode2 = path.join(dir, 'Show - S01E02.mkv');
|
||||||
|
fs.writeFileSync(episode1, '');
|
||||||
|
fs.writeFileSync(episode2, '');
|
||||||
|
|
||||||
|
const mpvClient = createFakeMpvClient({
|
||||||
|
currentVideoPath: episode1,
|
||||||
|
playlist: [
|
||||||
|
{ filename: episode1, current: true, title: 'Episode 1' },
|
||||||
|
{ filename: episode2, title: 'Episode 2' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const requestProperty = mpvClient.requestProperty.bind(mpvClient);
|
||||||
|
mpvClient.requestProperty = async (name: string): Promise<unknown> => {
|
||||||
|
if (name === 'track-list') {
|
||||||
|
return [
|
||||||
|
{ type: 'sub', id: 1, lang: 'pt', title: '[Infinite]', external: false, selected: true },
|
||||||
|
{ type: 'sub', id: 3, lang: 'en', title: 'English', external: false },
|
||||||
|
{ type: 'sub', id: 11, lang: 'en', title: 'en.srt', external: true },
|
||||||
|
{ type: 'sub', id: 12, lang: 'ja', title: 'ja.srt', external: true },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return requestProperty(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduled: Array<() => void> = [];
|
||||||
|
const deps = {
|
||||||
|
getMpvClient: () => mpvClient,
|
||||||
|
getPrimarySubtitleLanguages: () => [],
|
||||||
|
getSecondarySubtitleLanguages: () => [],
|
||||||
|
schedule: (callback: () => void) => {
|
||||||
|
scheduled.push(callback);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await playPlaylistBrowserIndexRuntime(deps, 1);
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
|
||||||
|
scheduled[0]?.();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.deepEqual(mpvClient.getCommands().slice(-2), [
|
||||||
|
['set_property', 'sid', 12],
|
||||||
|
['set_property', 'secondary-sid', 11],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
} from '../../types';
|
} from '../../types';
|
||||||
import { isRemoteMediaPath } from '../../jimaku/utils';
|
import { isRemoteMediaPath } from '../../jimaku/utils';
|
||||||
import { hasVideoExtension } from '../../shared/video-extensions';
|
import { hasVideoExtension } from '../../shared/video-extensions';
|
||||||
|
import { resolveManagedLocalSubtitleSelection } from './local-subtitle-selection';
|
||||||
import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort';
|
import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort';
|
||||||
|
|
||||||
type PlaylistLike = {
|
type PlaylistLike = {
|
||||||
@@ -28,6 +29,8 @@ type MpvPlaylistBrowserClientLike = {
|
|||||||
export type PlaylistBrowserRuntimeDeps = {
|
export type PlaylistBrowserRuntimeDeps = {
|
||||||
getMpvClient: () => MpvPlaylistBrowserClientLike | null;
|
getMpvClient: () => MpvPlaylistBrowserClientLike | null;
|
||||||
schedule?: (callback: () => void, delayMs: number) => void;
|
schedule?: (callback: () => void, delayMs: number) => void;
|
||||||
|
getPrimarySubtitleLanguages?: () => string[];
|
||||||
|
getSecondarySubtitleLanguages?: () => string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const pendingLocalSubtitleSelectionRearms = new WeakMap<MpvPlaylistBrowserClientLike, number>();
|
const pendingLocalSubtitleSelectionRearms = new WeakMap<MpvPlaylistBrowserClientLike, number>();
|
||||||
@@ -229,9 +232,20 @@ async function buildMutationResult(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function rearmLocalSubtitleSelection(client: MpvPlaylistBrowserClientLike): void {
|
async function rearmLocalSubtitleSelection(
|
||||||
client.send({ command: ['set_property', 'sid', 'auto'] });
|
client: MpvPlaylistBrowserClientLike,
|
||||||
client.send({ command: ['set_property', 'secondary-sid', 'auto'] });
|
deps: PlaylistBrowserRuntimeDeps,
|
||||||
|
): Promise<void> {
|
||||||
|
const trackList = await readProperty(client, 'track-list');
|
||||||
|
const selection = resolveManagedLocalSubtitleSelection({
|
||||||
|
trackList: Array.isArray(trackList) ? trackList : null,
|
||||||
|
primaryLanguages: deps.getPrimarySubtitleLanguages?.() ?? [],
|
||||||
|
secondaryLanguages: deps.getSecondarySubtitleLanguages?.() ?? [],
|
||||||
|
});
|
||||||
|
client.send({ command: ['set_property', 'sid', selection.primaryTrackId ?? 'auto'] });
|
||||||
|
client.send({
|
||||||
|
command: ['set_property', 'secondary-sid', selection.secondaryTrackId ?? 'auto'],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareLocalSubtitleAutoload(client: MpvPlaylistBrowserClientLike): void {
|
function prepareLocalSubtitleAutoload(client: MpvPlaylistBrowserClientLike): void {
|
||||||
@@ -258,7 +272,7 @@ function scheduleLocalSubtitleSelectionRearm(
|
|||||||
if (currentPath && path.resolve(currentPath) !== expectedPath) {
|
if (currentPath && path.resolve(currentPath) !== expectedPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
rearmLocalSubtitleSelection(client);
|
void rearmLocalSubtitleSelection(client, deps);
|
||||||
}, 400);
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,13 +40,85 @@ test('resolveWindowsMpvPath falls back to where.exe output', () => {
|
|||||||
assert.equal(resolved, 'C:\\tools\\mpv.exe');
|
assert.equal(resolved, 'C:\\tools\\mpv.exe');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildWindowsMpvLaunchArgs keeps pseudo-gui profile and targets', () => {
|
test('buildWindowsMpvLaunchArgs uses explicit SubMiner defaults and targets', () => {
|
||||||
assert.deepEqual(buildWindowsMpvLaunchArgs(['C:\\a.mkv', 'C:\\b.mkv']), [
|
assert.deepEqual(
|
||||||
'--player-operation-mode=pseudo-gui',
|
buildWindowsMpvLaunchArgs(
|
||||||
'--profile=subminer',
|
['C:\\a.mkv', 'C:\\b.mkv'],
|
||||||
'C:\\a.mkv',
|
[],
|
||||||
'C:\\b.mkv',
|
'C:\\SubMiner\\SubMiner.exe',
|
||||||
]);
|
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
|
||||||
|
),
|
||||||
|
[
|
||||||
|
'--player-operation-mode=pseudo-gui',
|
||||||
|
'--force-window=immediate',
|
||||||
|
'--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
|
||||||
|
'--input-ipc-server=\\\\.\\pipe\\subminer-socket',
|
||||||
|
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
|
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
|
'--sub-auto=fuzzy',
|
||||||
|
'--sub-file-paths=subs;subtitles',
|
||||||
|
'--sid=auto',
|
||||||
|
'--secondary-sid=auto',
|
||||||
|
'--secondary-sub-visibility=no',
|
||||||
|
'--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket',
|
||||||
|
'C:\\a.mkv',
|
||||||
|
'C:\\b.mkv',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildWindowsMpvLaunchArgs keeps shortcut-only launches in idle mode', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
buildWindowsMpvLaunchArgs(
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
'C:\\SubMiner\\SubMiner.exe',
|
||||||
|
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
|
||||||
|
),
|
||||||
|
[
|
||||||
|
'--player-operation-mode=pseudo-gui',
|
||||||
|
'--force-window=immediate',
|
||||||
|
'--idle=yes',
|
||||||
|
'--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
|
||||||
|
'--input-ipc-server=\\\\.\\pipe\\subminer-socket',
|
||||||
|
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
|
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
|
'--sub-auto=fuzzy',
|
||||||
|
'--sub-file-paths=subs;subtitles',
|
||||||
|
'--sid=auto',
|
||||||
|
'--secondary-sid=auto',
|
||||||
|
'--secondary-sub-visibility=no',
|
||||||
|
'--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildWindowsMpvLaunchArgs mirrors a custom input-ipc-server into script opts', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
buildWindowsMpvLaunchArgs(
|
||||||
|
['C:\\video.mkv'],
|
||||||
|
['--input-ipc-server', '\\\\.\\pipe\\custom-subminer-socket'],
|
||||||
|
'C:\\SubMiner\\SubMiner.exe',
|
||||||
|
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
|
||||||
|
),
|
||||||
|
[
|
||||||
|
'--player-operation-mode=pseudo-gui',
|
||||||
|
'--force-window=immediate',
|
||||||
|
'--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
|
||||||
|
'--input-ipc-server=\\\\.\\pipe\\custom-subminer-socket',
|
||||||
|
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
|
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
|
'--sub-auto=fuzzy',
|
||||||
|
'--sub-file-paths=subs;subtitles',
|
||||||
|
'--sid=auto',
|
||||||
|
'--secondary-sid=auto',
|
||||||
|
'--secondary-sub-visibility=no',
|
||||||
|
'--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\custom-subminer-socket',
|
||||||
|
'--input-ipc-server',
|
||||||
|
'\\\\.\\pipe\\custom-subminer-socket',
|
||||||
|
'C:\\video.mkv',
|
||||||
|
],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('launchWindowsMpv reports missing mpv path', () => {
|
test('launchWindowsMpv reports missing mpv path', () => {
|
||||||
@@ -75,13 +147,16 @@ test('launchWindowsMpv spawns detached mpv with targets', () => {
|
|||||||
calls.push(args.join('|'));
|
calls.push(args.join('|'));
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
[],
|
||||||
|
'C:\\SubMiner\\SubMiner.exe',
|
||||||
|
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(result.ok, true);
|
assert.equal(result.ok, true);
|
||||||
assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe');
|
assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe');
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'C:\\mpv\\mpv.exe',
|
'C:\\mpv\\mpv.exe',
|
||||||
'--player-operation-mode=pseudo-gui|--profile=subminer|C:\\video.mkv',
|
'--player-operation-mode=pseudo-gui|--force-window=immediate|--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua|--input-ipc-server=\\\\.\\pipe\\subminer-socket|--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--sub-auto=fuzzy|--sub-file-paths=subs;subtitles|--sid=auto|--secondary-sid=auto|--secondary-sub-visibility=no|--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket|C:\\video.mkv',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -33,14 +33,70 @@ export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWindowsMpvLaunchArgs(targets: string[], extraArgs: string[] = []): string[] {
|
const DEFAULT_WINDOWS_MPV_SOCKET = '\\\\.\\pipe\\subminer-socket';
|
||||||
return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...extraArgs, ...targets];
|
|
||||||
|
function readExtraArgValue(extraArgs: string[], flag: string): string | undefined {
|
||||||
|
let value: string | undefined;
|
||||||
|
for (let i = 0; i < extraArgs.length; i += 1) {
|
||||||
|
const arg = extraArgs[i];
|
||||||
|
if (arg === flag) {
|
||||||
|
const next = extraArgs[i + 1];
|
||||||
|
if (next && !next.startsWith('-')) {
|
||||||
|
value = next;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg?.startsWith(`${flag}=`)) {
|
||||||
|
value = arg.slice(flag.length + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWindowsMpvLaunchArgs(
|
||||||
|
targets: string[],
|
||||||
|
extraArgs: string[] = [],
|
||||||
|
binaryPath?: string,
|
||||||
|
pluginEntrypointPath?: string,
|
||||||
|
): string[] {
|
||||||
|
const launchIdle = targets.length === 0;
|
||||||
|
const inputIpcServer =
|
||||||
|
readExtraArgValue(extraArgs, '--input-ipc-server') ?? DEFAULT_WINDOWS_MPV_SOCKET;
|
||||||
|
const scriptOpts =
|
||||||
|
typeof binaryPath === 'string' && binaryPath.trim().length > 0
|
||||||
|
? `--script-opts=subminer-binary_path=${binaryPath.trim().replace(/,/g, '\\,')},subminer-socket_path=${inputIpcServer.replace(/,/g, '\\,')}`
|
||||||
|
: null;
|
||||||
|
const scriptEntrypoint =
|
||||||
|
typeof pluginEntrypointPath === 'string' && pluginEntrypointPath.trim().length > 0
|
||||||
|
? `--script=${pluginEntrypointPath.trim()}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'--player-operation-mode=pseudo-gui',
|
||||||
|
'--force-window=immediate',
|
||||||
|
...(launchIdle ? ['--idle=yes'] : []),
|
||||||
|
...(scriptEntrypoint ? [scriptEntrypoint] : []),
|
||||||
|
`--input-ipc-server=${inputIpcServer}`,
|
||||||
|
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
|
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
|
'--sub-auto=fuzzy',
|
||||||
|
'--sub-file-paths=subs;subtitles',
|
||||||
|
'--sid=auto',
|
||||||
|
'--secondary-sid=auto',
|
||||||
|
'--secondary-sub-visibility=no',
|
||||||
|
...(scriptOpts ? [scriptOpts] : []),
|
||||||
|
...extraArgs,
|
||||||
|
...targets,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function launchWindowsMpv(
|
export function launchWindowsMpv(
|
||||||
targets: string[],
|
targets: string[],
|
||||||
deps: WindowsMpvLaunchDeps,
|
deps: WindowsMpvLaunchDeps,
|
||||||
extraArgs: string[] = [],
|
extraArgs: string[] = [],
|
||||||
|
binaryPath?: string,
|
||||||
|
pluginEntrypointPath?: string,
|
||||||
): { ok: boolean; mpvPath: string } {
|
): { ok: boolean; mpvPath: string } {
|
||||||
const mpvPath = resolveWindowsMpvPath(deps);
|
const mpvPath = resolveWindowsMpvPath(deps);
|
||||||
if (!mpvPath) {
|
if (!mpvPath) {
|
||||||
@@ -52,7 +108,10 @@ export function launchWindowsMpv(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets, extraArgs));
|
deps.spawnDetached(
|
||||||
|
mpvPath,
|
||||||
|
buildWindowsMpvLaunchArgs(targets, extraArgs, binaryPath, pluginEntrypointPath),
|
||||||
|
);
|
||||||
return { ok: true, mpvPath };
|
return { ok: true, mpvPath };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ test('buildWindowsMpvShortcutDetails targets SubMiner.exe with --launch-mpv', ()
|
|||||||
target: 'C:\\Apps\\SubMiner\\SubMiner.exe',
|
target: 'C:\\Apps\\SubMiner\\SubMiner.exe',
|
||||||
args: '--launch-mpv',
|
args: '--launch-mpv',
|
||||||
cwd: 'C:\\Apps\\SubMiner',
|
cwd: 'C:\\Apps\\SubMiner',
|
||||||
description: 'Launch mpv with the SubMiner profile',
|
description: 'Launch mpv with SubMiner defaults',
|
||||||
icon: 'C:\\Apps\\SubMiner\\SubMiner.exe',
|
icon: 'C:\\Apps\\SubMiner\\SubMiner.exe',
|
||||||
iconIndex: 0,
|
iconIndex: 0,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function buildWindowsMpvShortcutDetails(exePath: string): WindowsShortcut
|
|||||||
target: exePath,
|
target: exePath,
|
||||||
args: '--launch-mpv',
|
args: '--launch-mpv',
|
||||||
cwd: path.win32.dirname(exePath),
|
cwd: path.win32.dirname(exePath),
|
||||||
description: 'Launch mpv with the SubMiner profile',
|
description: 'Launch mpv with SubMiner defaults',
|
||||||
icon: exePath,
|
icon: exePath,
|
||||||
iconIndex: 0,
|
iconIndex: 0,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -524,6 +524,56 @@ test('popup-visible mpv keybindings still fire for bound keys', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('paused configured subtitle-jump keybinding re-applies pause after backward seek', async () => {
|
||||||
|
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.updateKeybindings([
|
||||||
|
{
|
||||||
|
key: 'Shift+KeyH',
|
||||||
|
command: ['sub-seek', -1],
|
||||||
|
},
|
||||||
|
] as never);
|
||||||
|
testGlobals.setPlaybackPausedResponse(true);
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'H', code: 'KeyH', shiftKey: true });
|
||||||
|
await wait(0);
|
||||||
|
|
||||||
|
assert.deepEqual(testGlobals.mpvCommands.slice(-2), [
|
||||||
|
['sub-seek', -1],
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('configured subtitle-jump keybinding preserves pause when pause state is unknown', async () => {
|
||||||
|
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.updateKeybindings([
|
||||||
|
{
|
||||||
|
key: 'Shift+KeyH',
|
||||||
|
command: ['sub-seek', -1],
|
||||||
|
},
|
||||||
|
] as never);
|
||||||
|
testGlobals.setPlaybackPausedResponse(null);
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'H', code: 'KeyH', shiftKey: true });
|
||||||
|
await wait(0);
|
||||||
|
|
||||||
|
assert.deepEqual(testGlobals.mpvCommands.slice(-2), [
|
||||||
|
['sub-seek', -1],
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('visible-layer y-t dispatches mpv plugin toggle while overlay owns focus', async () => {
|
test('visible-layer y-t dispatches mpv plugin toggle while overlay owns focus', async () => {
|
||||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
@@ -1159,6 +1209,56 @@ test('keyboard mode: edge jump while paused re-applies paused state after subtit
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: left edge jump while paused re-applies paused state after subtitle seek', async () => {
|
||||||
|
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
setWordCount(2);
|
||||||
|
ctx.state.keyboardSelectedWordIndex = 0;
|
||||||
|
handlers.syncKeyboardTokenSelection();
|
||||||
|
testGlobals.setPlaybackPausedResponse(true);
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' });
|
||||||
|
await wait(0);
|
||||||
|
|
||||||
|
assert.deepEqual(testGlobals.mpvCommands.slice(-2), [
|
||||||
|
['sub-seek', -1],
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard mode: h edge jump while paused re-applies paused state after subtitle seek', async () => {
|
||||||
|
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.handleKeyboardModeToggleRequested();
|
||||||
|
|
||||||
|
setWordCount(2);
|
||||||
|
ctx.state.keyboardSelectedWordIndex = 0;
|
||||||
|
handlers.syncKeyboardTokenSelection();
|
||||||
|
testGlobals.setPlaybackPausedResponse(true);
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'h', code: 'KeyH' });
|
||||||
|
await wait(0);
|
||||||
|
|
||||||
|
assert.deepEqual(testGlobals.mpvCommands.slice(-2), [
|
||||||
|
['sub-seek', -1],
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
ctx.state.keyboardDrivenModeEnabled = false;
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('keyboard mode: edge jump with unknown pause state re-applies pause conservatively', async () => {
|
test('keyboard mode: edge jump with unknown pause state re-applies pause conservatively', async () => {
|
||||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -358,6 +358,33 @@ export function createKeyboardHandlers(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSubtitleSeekCommand(command: (string | number)[] | undefined): command is [string, number] {
|
||||||
|
return (
|
||||||
|
Array.isArray(command) &&
|
||||||
|
command[0] === 'sub-seek' &&
|
||||||
|
typeof command[1] === 'number'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchConfiguredMpvCommand(command: (string | number)[]): void {
|
||||||
|
if (!isSubtitleSeekCommand(command)) {
|
||||||
|
window.electronAPI.sendMpvCommand(command);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void options
|
||||||
|
.getPlaybackPaused()
|
||||||
|
.then((paused) => {
|
||||||
|
window.electronAPI.sendMpvCommand(command);
|
||||||
|
if (paused !== false) {
|
||||||
|
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'yes']);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
window.electronAPI.sendMpvCommand(command);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type ScanModifierState = {
|
type ScanModifierState = {
|
||||||
shiftKey?: boolean;
|
shiftKey?: boolean;
|
||||||
ctrlKey?: boolean;
|
ctrlKey?: boolean;
|
||||||
@@ -954,7 +981,7 @@ export function createKeyboardHandlers(
|
|||||||
|
|
||||||
if (command) {
|
if (command) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.electronAPI.sendMpvCommand(command);
|
dispatchConfiguredMpvCommand(command);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
35
src/renderer/kiku-open.test.ts
Normal file
35
src/renderer/kiku-open.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { prepareForKikuFieldGroupingOpen } from './kiku-open';
|
||||||
|
|
||||||
|
test('prepareForKikuFieldGroupingOpen closes lookup popup before pausing playback', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
prepareForKikuFieldGroupingOpen({
|
||||||
|
closeLookupWindow: () => {
|
||||||
|
calls.push('close');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
pausePlayback: () => {
|
||||||
|
calls.push('pause');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['close', 'pause']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepareForKikuFieldGroupingOpen still pauses playback when no popup is open', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
prepareForKikuFieldGroupingOpen({
|
||||||
|
closeLookupWindow: () => {
|
||||||
|
calls.push('close');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
pausePlayback: () => {
|
||||||
|
calls.push('pause');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['close', 'pause']);
|
||||||
|
});
|
||||||
7
src/renderer/kiku-open.ts
Normal file
7
src/renderer/kiku-open.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function prepareForKikuFieldGroupingOpen(options: {
|
||||||
|
closeLookupWindow: () => boolean;
|
||||||
|
pausePlayback: () => void;
|
||||||
|
}): void {
|
||||||
|
options.closeLookupWindow();
|
||||||
|
options.pausePlayback();
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ import { createControllerDebugModal } from './modals/controller-debug.js';
|
|||||||
import { createControllerSelectModal } from './modals/controller-select.js';
|
import { createControllerSelectModal } from './modals/controller-select.js';
|
||||||
import { createJimakuModal } from './modals/jimaku.js';
|
import { createJimakuModal } from './modals/jimaku.js';
|
||||||
import { createKikuModal } from './modals/kiku.js';
|
import { createKikuModal } from './modals/kiku.js';
|
||||||
|
import { prepareForKikuFieldGroupingOpen } from './kiku-open.js';
|
||||||
import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
|
import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
|
||||||
import { createSessionHelpModal } from './modals/session-help.js';
|
import { createSessionHelpModal } from './modals/session-help.js';
|
||||||
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
|
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
|
||||||
@@ -470,6 +471,12 @@ function registerModalOpenHandlers(): void {
|
|||||||
window.electronAPI.onKikuFieldGroupingRequest(
|
window.electronAPI.onKikuFieldGroupingRequest(
|
||||||
(data: { original: KikuDuplicateCardInfo; duplicate: KikuDuplicateCardInfo }) => {
|
(data: { original: KikuDuplicateCardInfo; duplicate: KikuDuplicateCardInfo }) => {
|
||||||
runGuarded('kiku:field-grouping-open', () => {
|
runGuarded('kiku:field-grouping-open', () => {
|
||||||
|
prepareForKikuFieldGroupingOpen({
|
||||||
|
closeLookupWindow: () => keyboardHandlers.closeLookupWindow(),
|
||||||
|
pausePlayback: () => {
|
||||||
|
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'yes']);
|
||||||
|
},
|
||||||
|
});
|
||||||
kikuModal.openKikuFieldGroupingModal(data);
|
kikuModal.openKikuFieldGroupingModal(data);
|
||||||
window.electronAPI.notifyOverlayModalOpened('kiku');
|
window.electronAPI.notifyOverlayModalOpened('kiku');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ async function main(): Promise<void> {
|
|||||||
{ forceOverride: true },
|
{ forceOverride: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const noteId = await addYomitanNoteViaSearch(
|
const addResult = await addYomitanNoteViaSearch(
|
||||||
word!,
|
word!,
|
||||||
{
|
{
|
||||||
getYomitanExt: () => yomitanExt,
|
getYomitanExt: () => yomitanExt,
|
||||||
@@ -168,6 +168,7 @@ async function main(): Promise<void> {
|
|||||||
logger,
|
logger,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const noteId = addResult.noteId;
|
||||||
if (typeof noteId !== 'number') {
|
if (typeof noteId !== 'number') {
|
||||||
throw new Error('Yomitan failed to create note.');
|
throw new Error('Yomitan failed to create note.');
|
||||||
}
|
}
|
||||||
|
|||||||
2
vendor/subminer-yomitan
vendored
2
vendor/subminer-yomitan
vendored
Submodule vendor/subminer-yomitan updated: 3c9ee577ac...69620abcbc
Reference in New Issue
Block a user