mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 16:19:25 -07:00
Compare commits
10 Commits
aa0385904e
...
v0.11.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
a784091ecb
|
|||
| 61c3e1e3c6 | |||
|
ce76a75630
|
|||
|
52249db5b4
|
|||
|
09d8b52fbf
|
|||
|
0edd566904
|
|||
|
6eb1b0f197
|
|||
|
e4137d9760
|
|||
|
864f4124ae
|
|||
| 7514985feb |
58
CHANGELOG.md
58
CHANGELOG.md
@@ -1,11 +1,63 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## Unreleased
|
## v0.11.1 (2026-04-04)
|
||||||
|
|
||||||
### 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.
|
- Release: Linux packaged builds now expose the canonical `SubMiner` app identity to Electron's startup metadata so native Wayland compositors stop reporting the window class/app-id as lowercase `subminer`.
|
||||||
|
- Linux: Linux now restores the runtime options, Jimaku, and Subsync shortcuts after the Electron 39 regression by routing those actions through the overlay's mpv/IPC shortcut path.
|
||||||
|
|
||||||
|
## v0.11.0 (2026-04-03)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Overlay: Added a playlist browser overlay modal for browsing sibling video files and the live mpv queue during playback.
|
||||||
|
- Overlay: Added the default `Ctrl+Alt+P` keybinding to open the playlist browser and manage queue order without leaving playback.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Setup: Made mpv plugin installation mandatory in the first-run setup flow, removed the skip path, and kept Finish disabled until the plugin is installed.
|
||||||
|
- Setup: Clarified that the mpv plugin requirement applies to setup on every platform, while the optional `SubMiner mpv` shortcut remains the recommended Windows playback entry point.
|
||||||
|
- Launcher: Streamlined Windows setup and config by making the `SubMiner mpv` shortcut self-contained and keeping `mpv.executablePath` as the simple fallback when `mpv.exe` is not on `PATH`.
|
||||||
|
- Overlay: Changed fresh-install default config to keep texthooker and stats from auto-opening browser tabs.
|
||||||
|
- Overlay: Changed fresh-install default config to enable AnkiConnect, Discord Rich Presence, subtitle-sidebar, and Yomitan-popup auto-pause by default, while disabling controller input by default.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Main: Resolve the YouTube playback socket path lazily so startup honors CLI and config overrides.
|
||||||
|
- Main: Add regression coverage for the lazy socket-path lookup during Windows mpv startup.
|
||||||
|
- Main: Keep integrated `--start --texthooker` launches on the full app-ready startup path so the texthooker page and websocket servers start together during normal playback startup.
|
||||||
|
- Main: Stop the mpv/plugin auto-start flow from spawning a separate standalone texthooker helper during normal `subminer <video>` launches.
|
||||||
|
- Overlay: Keep tracked macOS visible overlays click-through by default so subtitle sidebar passthrough works immediately without requiring a subtitle hover cycle first.
|
||||||
|
- Overlay: Add regression coverage for the macOS visible-overlay passthrough default.
|
||||||
|
- Anilist: Stop AniList post-watch from sending a second progress update when the current episode was already satisfied by a ready retry item in the same watch-completion pass.
|
||||||
|
- Anilist: Add regression coverage for the retry-queue plus live-update duplicate path.
|
||||||
|
- Overlay: Fixed Kiku duplicate grouping to reuse duplicate note IDs from both generic sentence-card creation and Yomitan popup mining instead of running extra duplicate scans after add.
|
||||||
|
- Overlay: Fixed the Yomitan popup mining flow to add cards in the background while keeping the stock popup progress feedback, then pause playback and close the lookup popup before the Kiku merge modal opens.
|
||||||
|
- Overlay: Fixed configured subtitle-jump keybindings so backward and forward subtitle seeks keep playback paused when invoked from a paused state.
|
||||||
|
- Launcher: Fixed the Windows `SubMiner mpv` shortcut and `SubMiner.exe --launch-mpv` flow to launch mpv with SubMiner's required default args directly instead of requiring an `mpv.conf` profile named `subminer`.
|
||||||
|
- Launcher: Clarified the Windows install and usage docs so the shortcut path is documented as self-contained, while the optional `subminer` mpv profile remains available for manual mpv launches.
|
||||||
|
- Launcher: Hardened the first-run setup blocker copy and stale custom-scheme handling so setup messages stay aligned with config, plugin, and dictionary readiness.
|
||||||
|
- Launcher: Fixed the Windows `SubMiner mpv` shortcut idle launch so loading a video after opening the shortcut keeps mpv in the expected SubMiner-managed session, auto-starts the overlay, and re-arms subtitle auto-selection for the newly opened file.
|
||||||
|
- Launcher: Removed the redundant `.` subtitle search path from the Windows shortcut launch args and deduped repeated subtitle source tracks in the manual sync picker so duplicate external subtitle entries no longer appear from the shortcut path.
|
||||||
- Playback: Fixed managed local playback so duplicate startup-ready retries no longer unpause media after a later manual pause on the same file.
|
- Playback: Fixed managed local 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.
|
- Playback: Fixed managed local subtitle auto-selection so local files reuse configured primary and secondary subtitle language priorities instead of staying on mpv's initial `sid=auto` guess.
|
||||||
|
- Launcher: Added a blank-by-default `mpv.executablePath` override for Windows playback so users can point SubMiner at `mpv.exe` when it is not on `PATH`.
|
||||||
|
- Launcher: Kept the Windows shortcut and `--launch-mpv` flow simple by preserving PATH auto-discovery as the default and exposing the override in first-run setup.
|
||||||
|
- Launcher: Added `windows` as a recognized launcher backend option and auto-detection target on Windows.
|
||||||
|
- Launcher: Honored `SUBMINER_YTDLP_BIN` consistently across YouTube playback URL resolution, track probing, subtitle downloads, and metadata probing.
|
||||||
|
- Launcher: Kept the first-run setup window from navigating away on unexpected URLs.
|
||||||
|
- Launcher: Made Windows mpv honor an explicitly configured executable path instead of silently falling back to PATH.
|
||||||
|
- Launcher: Hardened `--launch-mpv` parsing and Windows binary resolution so valueless flags do not swallow media targets and symlinked launcher installs do not short-circuit PATH lookup.
|
||||||
|
- Launcher: Fixed first-run setup blocking playback on macOS when the SubMiner mpv plugin was already installed at the canonical `~/.config/mpv` path.
|
||||||
|
- Launcher: Fixed setup gating so stale cancelled setup state no longer prevents playback when the canonical mpv plugin entrypoint already exists.
|
||||||
|
- Playback: Prevented stale async playlist-browser subtitle rearm callbacks from overriding newer subtitle selections during rapid file changes.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- Docs Site: Added a dedicated Subtitle Sidebar guide and linked it from the homepage and configuration docs.
|
||||||
|
- Docs Site: Linked Jimaku integration from the homepage to its dedicated docs page.
|
||||||
|
- Docs Site: Refreshed docs-site theme tokens and hover/selection styling for the updated pages.
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
- Release: Retried AUR clone and push operations in the tagged release workflow.
|
||||||
|
- Release: Kept GitHub Releases green when AUR publish flakes and needs manual follow-up.
|
||||||
|
- Release: Updated Electron to 39.8.6 and pinned patched transitive build dependencies to clear the reported high-severity audit findings.
|
||||||
|
|
||||||
## v0.10.0 (2026-03-29)
|
## v0.10.0 (2026-03-29)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Look up words with Yomitan, export to Anki in one key, track your immersion —
|
|||||||
[](https://docs.subminer.moe)
|
[](https://docs.subminer.moe)
|
||||||
[](https://aur.archlinux.org/packages/subminer-bin)
|
[](https://aur.archlinux.org/packages/subminer-bin)
|
||||||
|
|
||||||
[](./assets/minecard.mp4)
|
[](https://github.com/user-attachments/assets/89e61895-e2b7-4b47-8d50-a35afe4132b2)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
BIN
assets/SubMiner-square.png
Normal file
BIN
assets/SubMiner-square.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
BIN
assets/SubMiner.ico
Normal file
BIN
assets/SubMiner.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
id: TASK-279
|
||||||
|
title: Prepare v0.11.2 release notes and verify release gate
|
||||||
|
status: In Progress
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-04-04 07:38'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Generate release metadata for the pending changelog fragments, review the resulting changelog/release notes output, and run the documented local release gate so the repo is ready for a v0.11.2 cut if checks pass.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [ ] #1 CHANGELOG.md and release/release-notes.md are generated for v0.11.2 using the pending changes fragments.
|
||||||
|
- [ ] #2 The documented local release gate for release prep is run and the pass/fail state is recorded with any blockers.
|
||||||
|
- [ ] #3 Any release-prep file updates needed for the generated notes are left in the worktree for review without tagging or pushing.
|
||||||
|
<!-- AC:END -->
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
id: TASK-273
|
||||||
|
title: >-
|
||||||
|
Fix first-run setup false positive when canonical mpv plugin is already
|
||||||
|
installed
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- Kyle Yasuda
|
||||||
|
created_date: '2026-04-03 23:26'
|
||||||
|
updated_date: '2026-04-04 00:31'
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
- macos
|
||||||
|
- first-run-setup
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Investigate and fix launcher/app first-run setup gating so playback does not block when the SubMiner mpv plugin is already installed at the canonical mpv config path on macOS. Align mpv path resolution with the actual install location, keep plugin detection scoped to the canonical plugin entrypoint, and make launcher setup gating resilient to stale cancelled setup state.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [ ] #1 `resolveDefaultMpvInstallPaths` resolves the canonical macOS mpv config path used by existing installs.
|
||||||
|
- [ ] #2 Playback launcher bypasses first-run setup when the canonical `scripts/subminer/main.lua` plugin entrypoint already exists, even if `setup-state.json` is stale.
|
||||||
|
- [ ] #3 Regression tests cover canonical plugin detection and launcher handling of stale cancelled setup state.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Root cause ended up split across path resolution and launcher gating. No automated test command was executed in this pass by request.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Updated macOS mpv install path resolution to use the canonical `~/.config/mpv` location so first-run plugin detection matches the actual installed plugin path.
|
||||||
|
|
||||||
|
Restricted plugin detection to the canonical `scripts/subminer/main.lua` entrypoint instead of config presence or legacy loader files.
|
||||||
|
|
||||||
|
Updated the launcher setup gate to bypass stale `setup-state.json` when the mpv plugin is already installed, and to ignore an initially stale `cancelled` state after spawning setup.
|
||||||
|
|
||||||
|
Added regression coverage for canonical macOS detection and launcher setup-gate bypass behavior. No automated test command was executed in this pass by request.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
<!-- DOD:BEGIN -->
|
||||||
|
- [ ] #1 Manual verification with scenario: existing plugin installed in custom mpv config path does not open first-run setup.
|
||||||
|
<!-- DOD:END -->
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
id: TASK-274
|
||||||
|
title: Stabilize current failing test regressions
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- codex
|
||||||
|
created_date: '2026-04-04 04:40'
|
||||||
|
updated_date: '2026-04-04 05:01'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
documentation:
|
||||||
|
- docs/workflow/verification.md
|
||||||
|
- docs/architecture/README.md
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Investigate and fix the current src/test failures across stats CLI lifetime rebuild handling, immersion tracker lifetime rebuild idempotency, renderer test environment cleanup helpers, subtitle sidebar auto-follow behavior, and log retention pruning so the maintained test lanes pass again.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Stats CLI lifetime rebuild behavior passes the current regression coverage.
|
||||||
|
- [x] #2 Immersion tracker lifetime rebuild backfill remains idempotent under the existing runtime test.
|
||||||
|
- [x] #3 Renderer modal test helpers restore injected globals exactly to prior state.
|
||||||
|
- [x] #4 Log pruning removes files older than the configured retention window deterministically.
|
||||||
|
- [x] #5 Relevant targeted test files pass after the fixes.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Reproduce the failing specs in isolation to separate deterministic regressions from suite-order pollution.
|
||||||
|
2. Fix source or test-helper logic for the three isolated failures: log retention cutoff, stats CLI lifetime rebuild timestamp handling, and subtitle sidebar initial jump behavior.
|
||||||
|
3. Harden renderer modal cleanup regressions so tests verify descriptor restoration without assuming global window/document start absent.
|
||||||
|
4. Re-run the targeted failing files, then the required verification gate for the touched areas and record results.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Targeted regressions fixed in log pruning, stats CLI lifetime logging/tests, subtitle sidebar auto-follow timing, and renderer global cleanup test isolation.
|
||||||
|
|
||||||
|
Verification: `bun test src/main/runtime/stats-cli-command.test.ts src/shared/log-files.test.ts src/renderer/modals/playlist-browser.test.ts src/renderer/modals/youtube-track-picker.test.ts src/renderer/modals/subtitle-sidebar.test.ts` passed.
|
||||||
|
|
||||||
|
Verification: `bun run test:src` still exits non-zero because of unrelated existing errors in `src/core/services/anilist/anilist-token-store.test.ts` (`Bun.serve is not a function`) plus one remaining non-task failure elsewhere; the originally reported regressions are green in the maintained lane.
|
||||||
|
|
||||||
|
User reported `test:full` still failing after the first regression pass. Reopened to clear the remaining `test:src` fail plus the existing unhandled test errors before handoff.
|
||||||
|
|
||||||
|
Verified final gate with `bun run test:launcher:unit:src` and `bun run test:full`; both pass after fixing the launcher AniSkip fallback title regression and the earlier src-lane regressions.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Stabilized the failing test regressions across source and launcher lanes. Fixed log pruning cutoff math under Bun BigInt mtimes, subtitle sidebar auto-follow timing, renderer global cleanup test isolation, stats CLI lifetime rebuild logging/tests, stats-server node:http fallback isolation, and launcher AniSkip fallback title resolution so basename titles beat generic parent directories while episode-only filenames still prefer the series directory. Verification passed with `bun test launcher/aniskip-metadata.test.ts`, `bun run test:launcher:unit:src`, and `bun run test:full`.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
id: TASK-275
|
||||||
|
title: Patch high-severity audit findings with minimal dependency changes
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- codex
|
||||||
|
created_date: '2026-04-04 04:45'
|
||||||
|
updated_date: '2026-04-04 04:50'
|
||||||
|
labels:
|
||||||
|
- security
|
||||||
|
- dependencies
|
||||||
|
dependencies: []
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Update SubMiner's direct Electron runtime and vulnerable build-time transitive dependencies to patched versions using the smallest safe version moves. Keep electron-builder on the current pinned line unless verification shows a blocker. Verify that bun audit no longer reports the current high-severity findings and that the standard project gate still passes.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Electron is updated to a patched supported release on the current supported line with no broader dependency refresh
|
||||||
|
- [x] #2 Vulnerable transitive packages @xmldom/xmldom, lodash, and picomatch resolve to patched versions via targeted dependency changes
|
||||||
|
- [x] #3 `bun audit --audit-level high` no longer reports the currently listed high-severity findings
|
||||||
|
- [x] #4 The default handoff verification gate passes, or any failure is documented with the exact command and error output
|
||||||
|
- [x] #5 Any dependency or lockfile changes remain minimal and do not change the pinned electron-builder line unless required
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Update package.json with the smallest set of dependency changes: bump electron from ^37.10.3 to 39.8.6 and add overrides for @xmldom/xmldom 0.8.12, lodash 4.18.0, and picomatch 4.0.4 while leaving electron-builder pinned at 26.8.2.
|
||||||
|
2. Refresh bun.lock with a lockfile-only install/update and confirm the resolved versions for electron, @xmldom/xmldom, lodash, and picomatch.
|
||||||
|
3. Run bun audit --audit-level high and verify the current high-severity findings are gone.
|
||||||
|
4. Run the default verification gate: bun run typecheck, bun run test:fast, bun run test:env, bun run build, bun run test:smoke:dist.
|
||||||
|
5. If any verification step fails, capture the exact failing command and error, assess whether it is caused by the dependency updates, and stop without broadening scope.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Updated package.json to pin electron 39.8.6 and add overrides for @xmldom/xmldom 0.8.12, lodash 4.18.0, and picomatch 4.0.4 while keeping electron-builder pinned at 26.8.2.
|
||||||
|
|
||||||
|
Refreshed bun.lock with bun install and confirmed the patched versions resolved in the lockfile.
|
||||||
|
|
||||||
|
Verification passed: bun audit --audit-level high, bun run typecheck, bun run test:fast, bun run test:env, bun run build, bun run test:smoke:dist.
|
||||||
|
|
||||||
|
Added changelog fragment changes/patch-audit-dependencies.md for the security/dependency maintenance update. No internal docs or docs-site updates were needed because the change does not alter user-facing behavior, configuration, or workflows.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Cleared the reported high-severity audit findings with minimal dependency churn by pinning electron to 39.8.6 and overriding @xmldom/xmldom, lodash, and picomatch to patched versions. Kept electron-builder on 26.8.2. bun audit is clean and the full default handoff gate passed: typecheck, fast tests, env tests, build, and dist smoke tests.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
id: TASK-276
|
||||||
|
title: Restore canonical SubMiner Linux app-id metadata
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- codex
|
||||||
|
created_date: '2026-04-04 06:31'
|
||||||
|
updated_date: '2026-04-04 06:34'
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
- linux
|
||||||
|
- electron
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Fix the Linux/Wayland packaged app metadata so the OS-facing desktop/app-id metadata stays canonical `SubMiner` instead of the lowercase npm package name `subminer`. Add regression coverage around packaged metadata so future Electron/runtime changes do not silently reintroduce the lowercase class/app-id behavior.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Top-level package metadata provides the canonical capitalized app name used by Electron runtime bootstrap on Linux
|
||||||
|
- [x] #2 Packaged Linux app metadata resolves to `SubMiner`/`SubMiner.desktop` instead of lowercase `subminer`
|
||||||
|
- [x] #3 Regression coverage fails before the fix and passes after it
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Add a focused packaging/runtime regression test that reads the packaged app metadata source and asserts the Linux Electron bootstrap-visible fields resolve to canonical `SubMiner` / `SubMiner.desktop`.
|
||||||
|
2. Run the targeted test first to capture the failing pre-fix state.
|
||||||
|
3. Update top-level package metadata in `package.json` with the canonical Electron runtime-facing fields needed for Linux bootstrap.
|
||||||
|
4. Re-run the targeted test and a lightweight packaging validation to confirm the packaged metadata now stays canonical.
|
||||||
|
5. Record verification notes and complete the task if all acceptance criteria pass.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Added a regression in `src/release-workflow.test.ts` asserting top-level `productName` and `desktopName` stay canonical for Linux Electron runtime bootstrap. Verified the new test failed before the fix because both fields were missing from top-level package metadata.
|
||||||
|
|
||||||
|
Updated top-level `package.json` metadata with `productName: SubMiner` and `desktopName: SubMiner.desktop` so packaged `app.asar` exposes the canonical Linux startup identity Electron reads before app code runs.
|
||||||
|
|
||||||
|
Verification passed with `bun test src/release-workflow.test.ts`, `bun run build && ./node_modules/.bin/electron-builder --linux dir --publish never`, packaged `release/linux-unpacked/resources/app.asar` inspection showing `{ name: subminer, productName: SubMiner, desktopName: SubMiner.desktop }`, and `bun run changelog:lint`.
|
||||||
|
|
||||||
|
Ran the full default handoff gate after the targeted/package verification: `bun run typecheck`, `bun run test:fast`, `bun run test:env`, and `bun run test:smoke:dist` all passed.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Restored canonical Linux/Wayland app identity metadata by adding top-level Electron runtime fields to `package.json`: `productName: SubMiner` and `desktopName: SubMiner.desktop`. This fixes the packaged app metadata Electron reads before user code runs, so native Wayland compositors no longer need to derive the app-id/class from the lowercase npm package name alone.
|
||||||
|
|
||||||
|
Added a regression test in `src/release-workflow.test.ts` that asserts the runtime-visible top-level metadata stays canonical. The new test was run first and failed before the fix because `productName` was missing, then passed after the metadata update.
|
||||||
|
|
||||||
|
Verification: `bun test src/release-workflow.test.ts`; `bun run build && ./node_modules/.bin/electron-builder --linux dir --publish never`; inspected `release/linux-unpacked/resources/app.asar` and confirmed `productName: SubMiner` plus `desktopName: SubMiner.desktop`; `bun run changelog:lint`. Added changelog fragment `changes/2026-04-04-linux-app-id-metadata.md`.
|
||||||
|
|
||||||
|
Full default handoff gate also passed: `bun run typecheck`; `bun run test:fast`; `bun run test:env`; `bun run test:smoke:dist`.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
id: TASK-277
|
||||||
|
title: Restore Linux shortcut-backed modal actions after Electron 39 upgrade
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- codex
|
||||||
|
created_date: '2026-04-04 06:49'
|
||||||
|
updated_date: '2026-04-04 07:08'
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
- linux
|
||||||
|
- electron
|
||||||
|
- shortcuts
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Linux builds on Electron 39 no longer open the runtime options, Jimaku, or Subsync flows from their configured shortcuts (`Ctrl/Cmd+Shift+O`, `Ctrl+Shift+J`, `Ctrl+Alt+S`). Other renderer-driven modals like session help, subtitle sidebar, and playlist browser still work, which points to the Linux `globalShortcut` / overlay shortcut path rather than modal rendering in general. Investigate the Electron 39 regression and restore these Linux shortcut-triggered actions without regressing macOS or Windows behavior.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 On Linux, the configured runtime options, Jimaku, and Subsync shortcuts trigger their existing actions again under Electron 39.
|
||||||
|
- [x] #2 The fix is covered by automated tests around the Linux shortcut/backend behavior that regressed.
|
||||||
|
- [x] #3 A changelog fragment is added for the Linux shortcut regression fix.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Extend the special-command / mpv IPC command path to cover Jimaku alongside the existing runtime-options and subsync commands.
|
||||||
|
2. Add tests for IPC command dispatch and any keybinding/session-help metadata affected by the new special command.
|
||||||
|
3. Route Linux modal-opening shortcuts through the working mpv/IPC command path instead of depending on Electron globalShortcut delivery for those actions.
|
||||||
|
4. Re-run targeted tests, then the handoff verification gate, and update the changelog/task summary to reflect the final root cause and fix.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
User approved plan; proceeding with test-first implementation.
|
||||||
|
|
||||||
|
Root cause: Linux startup unconditionally enabled Electron's GlobalShortcutsPortal path, so Electron 39 X11 sessions were routed through the portal-backed globalShortcut path even though that path should only be used for Wayland-style launches. The affected runtime-options, Jimaku, and Subsync actions all depend on that shortcut path, while renderer-driven modals did not regress.
|
||||||
|
|
||||||
|
User retested after the portal gating fix; issue persists. Updating investigation scope from portal selection alone to Linux overlay shortcut delivery/registration under Electron 39.
|
||||||
|
|
||||||
|
Revised fix path: Linux renderer now matches configured overlay shortcuts for runtime options, subsync, and Jimaku locally, then dispatches the existing mpv/IPC special-command route instead of depending on Electron globalShortcut delivery.
|
||||||
|
|
||||||
|
Added Jimaku mpv special command plumbing through IPC/runtime deps and exposed an overlay-runtime helper for opening the Jimaku modal from that IPC path.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Restored Linux shortcut-backed modal actions by routing runtime options, Subsync, and Jimaku through the working mpv/IPC special-command path. Added renderer-side Linux shortcut matching for configured overlay shortcuts, threaded a new Jimaku special command through IPC/runtime deps, refreshed configured shortcuts on hot reload, and covered the regression with IPC/runtime keyboard tests. Verification: bun test src/core/services/ipc-command.test.ts src/renderer/handlers/keyboard.test.ts src/main/runtime/ipc-mpv-command-main-deps.test.ts; bun run typecheck; bun run test:fast; bun run test:env; bun run build; bun run test:smoke:dist.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
67
backlog/tasks/task-278 - Prepare-patch-release-0.11.1.md
Normal file
67
backlog/tasks/task-278 - Prepare-patch-release-0.11.1.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
id: TASK-278
|
||||||
|
title: Prepare patch release 0.11.1
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- '@codex'
|
||||||
|
created_date: '2026-04-04 07:16'
|
||||||
|
updated_date: '2026-04-04 07:41'
|
||||||
|
labels:
|
||||||
|
- release
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Bump SubMiner from 0.11.0 to 0.11.1, run the local release checklist, and confirm whether the branch is ready for tagging/publishing.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 package.json is bumped to 0.11.1 without overwriting unrelated local metadata edits.
|
||||||
|
- [x] #2 Release prep checks are run and summarized, including changelog/build verification and local build/test gates.
|
||||||
|
- [x] #3 Any remaining release blockers are called out explicitly.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Generate release metadata with `bun run changelog:build --version 0.11.1 --date 2026-04-04`.
|
||||||
|
2. Review the resulting `CHANGELOG.md` and `release/release-notes.md` content for the 0.11.1 section.
|
||||||
|
3. Rerun the documented local release gate: `bun run changelog:check --version 0.11.1`, `bun run verify:config-example`, `bun run typecheck`, `bun run test:fast`, `bun run test:env`, and `bun run build`.
|
||||||
|
4. Record results, remaining blockers, and ready-for-tagging status in the task notes/final summary.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Bumped package.json from 0.11.0 to 0.11.1 while preserving the existing local productName/desktopName edits.
|
||||||
|
|
||||||
|
Fixed the Linux shortcut changelog fragment metadata so changelog lint now passes and the previously failing CI cause is addressed locally.
|
||||||
|
|
||||||
|
Release checks run: bun run changelog:lint, bun run changelog:check --version 0.11.1, bun run verify:config-example, bun run typecheck, bun run test:fast, bun run test:env, bun run build. All passed except changelog:check, which is expected until pending fragments are built into CHANGELOG.md/release/release-notes.md.
|
||||||
|
|
||||||
|
Current release blocker: pending changelog fragments /changes/2026-04-04-linux-app-id-metadata.md and /changes/2026-04-04-linux-shortcut-portal-regression.md still need bun run changelog:build --version 0.11.1 --date 2026-04-04 before tagging.
|
||||||
|
|
||||||
|
Reopened to complete the remaining release-prep work: generate changelog/release notes artifacts, rerun the documented release gate, and confirm ready-for-tagging status.
|
||||||
|
|
||||||
|
Completed the remaining release-prep step with `bun run changelog:build --version 0.11.1 --date 2026-04-04`, which prepended `CHANGELOG.md`, generated `release/release-notes.md`, and removed the two pending `changes/*.md` fragments.
|
||||||
|
|
||||||
|
Reran the release gate after changelog generation: `bun run changelog:check --version 0.11.1`, `bun run verify:config-example`, `bun run typecheck`, `bun run test:fast`, `bun run test:env`, and `bun run build`; all passed.
|
||||||
|
|
||||||
|
Extra confidence checks also passed: `bun run changelog:lint`, `bun run test:smoke:dist`, and `gh run list --workflow CI --limit 5` shows the two latest `main` CI runs succeeded on 2026-04-04 after the earlier pre-fix failure.
|
||||||
|
|
||||||
|
`release/release-notes.md` is intentionally generated under the gitignored `release/` directory, so readiness evidence in git status is `CHANGELOG.md` plus deletion of the released change fragments.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Finished the 0.11.1 release prep by generating the changelog artifacts from the pending Linux fix fragments and re-running the full documented local release gate. `CHANGELOG.md` now contains the `v0.11.1 (2026-04-04)` section, `release/release-notes.md` was regenerated in the ignored `release/` directory, and the released `changes/2026-04-04-linux-app-id-metadata.md` plus `changes/2026-04-04-linux-shortcut-portal-regression.md` fragments were removed.
|
||||||
|
|
||||||
|
Verification results: `bun run changelog:check --version 0.11.1`, `bun run verify:config-example`, `bun run typecheck`, `bun run test:fast`, `bun run test:env`, `bun run build`, `bun run changelog:lint`, and `bun run test:smoke:dist` all passed locally. Remote CI also looks green for the latest release-prep head: `gh run list --workflow CI --limit 5` showed successful `main` runs for `chore: prep 0.11.1 release` and `Change demo image link to GitHub asset` on 2026-04-04, with the earlier `fix: restore linux modal shortcuts` failure already superseded by later green runs.
|
||||||
|
|
||||||
|
Remaining manual release step: commit the generated release-prep changes if desired, tag `v0.11.1`, and push the commit plus tag when ready.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
13
bun.lock
13
bun.lock
@@ -18,7 +18,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.3.0",
|
"@types/node": "^25.3.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"electron": "^37.10.3",
|
"electron": "39.8.6",
|
||||||
"electron-builder": "26.8.2",
|
"electron-builder": "26.8.2",
|
||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
@@ -27,9 +27,12 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
"@xmldom/xmldom": "0.8.12",
|
||||||
"app-builder-lib": "26.8.2",
|
"app-builder-lib": "26.8.2",
|
||||||
"electron-builder-squirrel-windows": "26.8.2",
|
"electron-builder-squirrel-windows": "26.8.2",
|
||||||
|
"lodash": "4.18.0",
|
||||||
"minimatch": "10.2.3",
|
"minimatch": "10.2.3",
|
||||||
|
"picomatch": "4.0.4",
|
||||||
"tar": "7.5.11",
|
"tar": "7.5.11",
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
@@ -185,7 +188,7 @@
|
|||||||
|
|
||||||
"@xhayper/discord-rpc": ["@xhayper/discord-rpc@1.3.3", "", { "dependencies": { "@discordjs/rest": "^2.6.1", "@vladfrangu/async_event_emitter": "^2.4.7", "discord-api-types": "^0.38.42", "ws": "^8.20.0" } }, "sha512-Ih48GHiua7TtZgKO+f0uZPhCeQqb84fY2qUys/oMh8UbUfiUkUJLVCmd/v2AK0/pV33euh0aqSXo7+9LiPSwGw=="],
|
"@xhayper/discord-rpc": ["@xhayper/discord-rpc@1.3.3", "", { "dependencies": { "@discordjs/rest": "^2.6.1", "@vladfrangu/async_event_emitter": "^2.4.7", "discord-api-types": "^0.38.42", "ws": "^8.20.0" } }, "sha512-Ih48GHiua7TtZgKO+f0uZPhCeQqb84fY2qUys/oMh8UbUfiUkUJLVCmd/v2AK0/pV33euh0aqSXo7+9LiPSwGw=="],
|
||||||
|
|
||||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
|
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
|
||||||
|
|
||||||
"abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
|
"abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
|
||||||
|
|
||||||
@@ -321,7 +324,7 @@
|
|||||||
|
|
||||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||||
|
|
||||||
"electron": ["electron@37.10.3", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-3IjCGSjQmH50IbW2PFveaTzK+KwcFX9PEhE7KXb9v5IT8cLAiryAN7qezm/XzODhDRlLu0xKG1j8xWBtZ/bx/g=="],
|
"electron": ["electron@39.8.6", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-uWX6Jh5LmwL13VwOSKBjebI+ck+03GOwc8V2Sgbmr9pJVJ/cHfli/PkjXuRDr+hq+SLHQuT9mGHSIfScebApRA=="],
|
||||||
|
|
||||||
"electron-builder": ["electron-builder@26.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.2", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-ieiiXPdgH3qrG6lcvy2mtnI5iEmAopmLuVRMSJ5j40weU0tgpNx0OAk9J5X5nnO0j9+KIkxHzwFZVUDk1U3aGw=="],
|
"electron-builder": ["electron-builder@26.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.2", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-ieiiXPdgH3qrG6lcvy2mtnI5iEmAopmLuVRMSJ5j40weU0tgpNx0OAk9J5X5nnO0j9+KIkxHzwFZVUDk1U3aGw=="],
|
||||||
|
|
||||||
@@ -479,7 +482,7 @@
|
|||||||
|
|
||||||
"libsql": ["libsql@0.5.28", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.28", "@libsql/darwin-x64": "0.5.28", "@libsql/linux-arm-gnueabihf": "0.5.28", "@libsql/linux-arm-musleabihf": "0.5.28", "@libsql/linux-arm64-gnu": "0.5.28", "@libsql/linux-arm64-musl": "0.5.28", "@libsql/linux-x64-gnu": "0.5.28", "@libsql/linux-x64-musl": "0.5.28", "@libsql/win32-x64-msvc": "0.5.28" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-wKqx9FgtPcKHdPfR/Kfm0gejsnbuf8zV+ESPmltFvsq5uXwdeN9fsWn611DmqrdXj1e94NkARcMA2f1syiAqOg=="],
|
"libsql": ["libsql@0.5.28", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.28", "@libsql/darwin-x64": "0.5.28", "@libsql/linux-arm-gnueabihf": "0.5.28", "@libsql/linux-arm-musleabihf": "0.5.28", "@libsql/linux-arm64-gnu": "0.5.28", "@libsql/linux-arm64-musl": "0.5.28", "@libsql/linux-x64-gnu": "0.5.28", "@libsql/linux-x64-musl": "0.5.28", "@libsql/win32-x64-msvc": "0.5.28" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-wKqx9FgtPcKHdPfR/Kfm0gejsnbuf8zV+ESPmltFvsq5uXwdeN9fsWn611DmqrdXj1e94NkARcMA2f1syiAqOg=="],
|
||||||
|
|
||||||
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
|
"lodash": ["lodash@4.18.0", "", {}, "sha512-l1mfj2atMqndAHI3ls7XqPxEjV2J9ZkcNyHpoZA3r2T1LLwDB69jgkMWh71YKwhBbK0G2f4WSn05ahmQXVxupA=="],
|
||||||
|
|
||||||
"log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="],
|
"log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="],
|
||||||
|
|
||||||
@@ -569,7 +572,7 @@
|
|||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||||
|
|
||||||
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
|
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
type: docs
|
|
||||||
area: docs-site
|
|
||||||
|
|
||||||
- Added a dedicated Subtitle Sidebar guide and linked it from the homepage and configuration docs.
|
|
||||||
- Linked Jimaku integration from the homepage to its dedicated docs page.
|
|
||||||
- Refreshed docs-site theme tokens and hover/selection styling for the updated pages.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: main
|
|
||||||
|
|
||||||
- Resolve the YouTube playback socket path lazily so startup honors CLI and config overrides.
|
|
||||||
- Add regression coverage for the lazy socket-path lookup during Windows mpv startup.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: release
|
|
||||||
|
|
||||||
- Retried AUR clone and push operations in the tagged release workflow.
|
|
||||||
- Kept GitHub Releases green when AUR publish flakes and needs manual follow-up.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: main
|
|
||||||
|
|
||||||
- Keep integrated `--start --texthooker` launches on the full app-ready startup path so the texthooker page and websocket servers start together during normal playback startup.
|
|
||||||
- Stop the mpv/plugin auto-start flow from spawning a separate standalone texthooker helper during normal `subminer <video>` launches.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Added a playlist browser overlay modal for browsing sibling video files and the live mpv queue during playback.
|
|
||||||
- Added the default `Ctrl+Alt+P` keybinding to open the playlist browser and manage queue order without leaving playback.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Keep tracked macOS visible overlays click-through by default so subtitle sidebar passthrough works immediately without requiring a subtitle hover cycle first.
|
|
||||||
- Add regression coverage for the macOS visible-overlay passthrough default.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: anilist
|
|
||||||
|
|
||||||
- Stop AniList post-watch from sending a second progress update when the current episode was already satisfied by a ready retry item in the same watch-completion pass.
|
|
||||||
- Add regression coverage for the retry-queue plus live-update duplicate path.
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Fixed Kiku duplicate grouping to reuse duplicate note IDs from both generic sentence-card creation and Yomitan popup mining instead of running extra duplicate scans after add.
|
|
||||||
- Fixed the Yomitan popup mining flow to add cards in the background while keeping the stock popup progress feedback, then pause playback and close the lookup popup before the Kiku merge modal opens.
|
|
||||||
- Fixed configured subtitle-jump keybindings so backward and forward subtitle seeks keep playback paused when invoked from a paused state.
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"texthooker": {
|
"texthooker": {
|
||||||
"launchAtStartup": true, // Launch texthooker server automatically when SubMiner starts. Values: true | false
|
"launchAtStartup": true, // Launch texthooker server automatically when SubMiner starts. Values: true | false
|
||||||
"openBrowser": true // Open browser setting. Values: true | false
|
"openBrowser": false // Open browser setting. Values: true | false
|
||||||
}, // Configure texthooker startup launch and browser opening behavior.
|
}, // Configure texthooker startup launch and browser opening behavior.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
// Override controller.buttonIndices when your pad reports non-standard raw button numbers.
|
// Override controller.buttonIndices when your pad reports non-standard raw button numbers.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"controller": {
|
"controller": {
|
||||||
"enabled": true, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false
|
"enabled": false, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false
|
||||||
"preferredGamepadId": "", // Preferred controller id saved from the controller config modal.
|
"preferredGamepadId": "", // Preferred controller id saved from the controller config modal.
|
||||||
"preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics.
|
"preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics.
|
||||||
"smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false
|
"smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false
|
||||||
@@ -225,7 +225,7 @@
|
|||||||
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
||||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
||||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
||||||
"autoPauseVideoOnYomitanPopup": false, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
||||||
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
||||||
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
|
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
|
||||||
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
|
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
// Hot-reload: subtitle sidebar changes apply live without restarting SubMiner.
|
// Hot-reload: subtitle sidebar changes apply live without restarting SubMiner.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"subtitleSidebar": {
|
"subtitleSidebar": {
|
||||||
"enabled": false, // Enable the subtitle sidebar feature for parsed subtitle sources. Values: true | false
|
"enabled": true, // Enable the subtitle sidebar feature for parsed subtitle sources. Values: true | false
|
||||||
"autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false
|
"autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false
|
||||||
"layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded
|
"layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded
|
||||||
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
|
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
|
||||||
@@ -330,7 +330,7 @@
|
|||||||
// Most other AnkiConnect settings still require restart.
|
// Most other AnkiConnect settings still require restart.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"ankiConnect": {
|
"ankiConnect": {
|
||||||
"enabled": false, // Enable AnkiConnect integration. Values: true | false
|
"enabled": true, // Enable AnkiConnect integration. Values: true | false
|
||||||
"url": "http://127.0.0.1:8765", // Url setting.
|
"url": "http://127.0.0.1:8765", // Url setting.
|
||||||
"pollingRate": 3000, // Polling interval in milliseconds.
|
"pollingRate": 3000, // Polling interval in milliseconds.
|
||||||
"proxy": {
|
"proxy": {
|
||||||
@@ -458,6 +458,15 @@
|
|||||||
"externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay
|
"externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay
|
||||||
}, // Optional external Yomitan profile integration.
|
}, // Optional external Yomitan profile integration.
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// MPV Launcher
|
||||||
|
// Optional mpv.exe override for Windows playback entry points.
|
||||||
|
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
||||||
|
// ==========================================
|
||||||
|
"mpv": {
|
||||||
|
"executablePath": "" // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
||||||
|
}, // Optional mpv.exe override for Windows playback entry points.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Jellyfin
|
// Jellyfin
|
||||||
// Optional Jellyfin integration for auth, browsing, and playback launch.
|
// Optional Jellyfin integration for auth, browsing, and playback launch.
|
||||||
@@ -497,7 +506,7 @@
|
|||||||
// Uses official SubMiner Discord app assets for polished card visuals.
|
// Uses official SubMiner Discord app assets for polished card visuals.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"discordPresence": {
|
"discordPresence": {
|
||||||
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
|
"enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false
|
||||||
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".
|
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".
|
||||||
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
||||||
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
||||||
@@ -544,6 +553,6 @@
|
|||||||
"markWatchedKey": "KeyW", // Key code to mark the current video as watched and advance to the next playlist entry.
|
"markWatchedKey": "KeyW", // Key code to mark the current video as watched and advance to the next playlist entry.
|
||||||
"serverPort": 6969, // Port for the stats HTTP server.
|
"serverPort": 6969, // Port for the stats HTTP server.
|
||||||
"autoStartServer": true, // Automatically start the stats server on launch. Values: true | false
|
"autoStartServer": true, // Automatically start the stats server on launch. Values: true | false
|
||||||
"autoOpenBrowser": true // Automatically open the stats dashboard in a browser when the server starts. Values: true | false
|
"autoOpenBrowser": false // Automatically open the stats dashboard in a browser when the server starts. Values: true | false
|
||||||
} // Local immersion stats dashboard served on localhost and available as an in-app overlay.
|
} // Local immersion stats dashboard served on localhost and available as an in-app overlay.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ src/
|
|||||||
handlers/ # Keyboard/mouse interaction modules
|
handlers/ # Keyboard/mouse interaction modules
|
||||||
modals/ # Jimaku/Kiku/subsync/runtime-options/session-help modals
|
modals/ # Jimaku/Kiku/subsync/runtime-options/session-help modals
|
||||||
positioning/ # Subtitle position controller (drag-to-reposition)
|
positioning/ # Subtitle position controller (drag-to-reposition)
|
||||||
window-trackers/ # Backend-specific tracker implementations (Hyprland, Sway, X11, macOS)
|
window-trackers/ # Backend-specific tracker implementations (Hyprland, Sway, X11, macOS, Windows)
|
||||||
jimaku/ # Jimaku API integration helpers
|
jimaku/ # Jimaku API integration helpers
|
||||||
subsync/ # Subtitle sync (alass/ffsubsync) helpers
|
subsync/ # Subtitle sync (alass/ffsubsync) helpers
|
||||||
subtitle/ # Subtitle processing utilities
|
subtitle/ # Subtitle processing utilities
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.11.0 (2026-04-03)
|
||||||
|
- Added a playlist browser overlay modal for browsing sibling video files and the live mpv queue during playback, with a default `Ctrl+Alt+P` keybinding.
|
||||||
|
- Made mpv plugin installation mandatory in first-run setup (removed skip path); Finish stays disabled until the plugin is installed.
|
||||||
|
- Fixed the Windows `SubMiner mpv` shortcut to launch mpv with required default args directly instead of requiring an `mpv.conf` profile named `subminer`.
|
||||||
|
- Fixed the Windows mpv idle launch so loading a video after opening the shortcut keeps mpv in the SubMiner-managed session and auto-starts the overlay.
|
||||||
|
- Added a blank-by-default `mpv.executablePath` config override for Windows playback when mpv is not on `PATH`, exposed in first-run setup.
|
||||||
|
- Fixed Kiku duplicate grouping to reuse duplicate note IDs from both sentence-card creation and Yomitan popup mining, with background card addition and proper merge-modal sequencing.
|
||||||
|
- Fixed configured subtitle-jump keybindings to keep playback paused when invoked from a paused state.
|
||||||
|
- Fixed managed local subtitle auto-selection to reuse configured language priorities instead of staying on mpv's initial `sid=auto` guess.
|
||||||
|
- Kept tracked macOS visible overlays click-through by default so subtitle sidebar passthrough works immediately.
|
||||||
|
- Stopped AniList post-watch from sending duplicate progress updates when already satisfied by a retry item.
|
||||||
|
- Kept integrated `--start --texthooker` launches on the full app-ready startup path.
|
||||||
|
- Honored `SUBMINER_YTDLP_BIN` consistently across all YouTube flows (playback URL resolution, track probing, subtitle downloads, metadata probing).
|
||||||
|
- Added `windows` as a recognized launcher backend option and auto-detection target.
|
||||||
|
- Added a dedicated Subtitle Sidebar guide to the docs site with links from homepage and configuration docs.
|
||||||
|
|
||||||
## v0.10.0 (2026-03-29)
|
## v0.10.0 (2026-03-29)
|
||||||
- Fixed stats startup so the immersion tracker can run when `Bun.serve` is unavailable.
|
- Fixed stats startup so the immersion tracker can run when `Bun.serve` is unavailable.
|
||||||
- Added a Node `http` fallback for Electron/runtime paths that do not expose Bun, so stats keeps working there too.
|
- Added a Node `http` fallback for Electron/runtime paths that do not expose Bun, so stats keeps working there too.
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
{
|
{
|
||||||
"texthooker": {
|
"texthooker": {
|
||||||
"launchAtStartup": true,
|
"launchAtStartup": true,
|
||||||
"openBrowser": true
|
"openBrowser": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -260,7 +260,7 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ---------------- | --------------- | ------------------------------------------------------------------------------------------------ |
|
| ---------------- | --------------- | ------------------------------------------------------------------------------------------------ |
|
||||||
| `launchAtStartup`| `true`, `false` | Start texthooker automatically with SubMiner startup (default: `true`) |
|
| `launchAtStartup`| `true`, `false` | Start texthooker automatically with SubMiner startup (default: `true`) |
|
||||||
| `openBrowser` | `true`, `false` | Open browser tab when texthooker starts (default: `true`) |
|
| `openBrowser` | `true`, `false` | Open browser tab when texthooker starts (default: `false`) |
|
||||||
|
|
||||||
## Subtitle Display
|
## Subtitle Display
|
||||||
|
|
||||||
@@ -307,7 +307,7 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
||||||
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
||||||
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
|
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
|
||||||
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`false` by default). |
|
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
|
||||||
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
|
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
|
||||||
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: semi-transparent dark) |
|
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: semi-transparent dark) |
|
||||||
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`true` by default) |
|
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`true` by default) |
|
||||||
@@ -355,7 +355,7 @@ Configure the parsed-subtitle sidebar modal.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"subtitleSidebar": {
|
"subtitleSidebar": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"autoOpen": false,
|
"autoOpen": false,
|
||||||
"layout": "overlay",
|
"layout": "overlay",
|
||||||
"toggleKey": "Backslash",
|
"toggleKey": "Backslash",
|
||||||
@@ -369,7 +369,7 @@ Configure the parsed-subtitle sidebar modal.
|
|||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| --------------------------- | ---------------- | -------------------------------------------------------------------------------- |
|
| --------------------------- | ---------------- | -------------------------------------------------------------------------------- |
|
||||||
| `enabled` | boolean | Enable subtitle sidebar support (`false` by default) |
|
| `enabled` | boolean | Enable subtitle sidebar support (`true` by default) |
|
||||||
| `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) |
|
| `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) |
|
||||||
| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout |
|
| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout |
|
||||||
| `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) |
|
| `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) |
|
||||||
@@ -848,7 +848,7 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| --------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `false`) |
|
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
|
||||||
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
|
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
|
||||||
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
|
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
|
||||||
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
|
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
|
||||||
@@ -1197,7 +1197,7 @@ Jellyfin remote auto-connect runs only when all three are `true`: `jellyfin.enab
|
|||||||
|
|
||||||
### Discord Rich Presence
|
### Discord Rich Presence
|
||||||
|
|
||||||
Discord Rich Presence is optional and disabled by default. When enabled, SubMiner publishes a polished activity card that reflects current media title, playback state, and session timer.
|
Discord Rich Presence is enabled by default. SubMiner publishes a polished activity card that reflects current media title, playback state, and session timer unless you turn it off.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -1212,14 +1212,14 @@ Discord Rich Presence is optional and disabled by default. When enabled, SubMine
|
|||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------------------ | ------------------------------------------------- | ---------------------------------------------------------- |
|
| ------------------ | ------------------------------------------------- | ---------------------------------------------------------- |
|
||||||
| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `false`) |
|
| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `true`) |
|
||||||
| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) |
|
| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) |
|
||||||
| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
|
| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
|
||||||
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
|
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
|
||||||
|
|
||||||
Setup steps:
|
Setup steps:
|
||||||
|
|
||||||
1. Set `discordPresence.enabled` to `true`.
|
1. Leave `discordPresence.enabled` as `true` or set it explicitly if you previously disabled it.
|
||||||
2. Optionally set `discordPresence.presenceStyle` to choose a card text preset.
|
2. Optionally set `discordPresence.presenceStyle` to choose a card text preset.
|
||||||
3. Restart SubMiner.
|
3. Restart SubMiner.
|
||||||
|
|
||||||
@@ -1323,7 +1323,7 @@ Configure the local stats UI served from SubMiner and the in-app stats overlay t
|
|||||||
"toggleKey": "Backquote",
|
"toggleKey": "Backquote",
|
||||||
"serverPort": 6969,
|
"serverPort": 6969,
|
||||||
"autoStartServer": true,
|
"autoStartServer": true,
|
||||||
"autoOpenBrowser": true
|
"autoOpenBrowser": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -1333,7 +1333,7 @@ Configure the local stats UI served from SubMiner and the in-app stats overlay t
|
|||||||
| `toggleKey` | Electron key code | Overlay-local key code used to toggle the stats overlay. Default `Backquote`. |
|
| `toggleKey` | Electron key code | Overlay-local key code used to toggle the stats overlay. Default `Backquote`. |
|
||||||
| `serverPort` | integer | Localhost port for the browser stats UI. Default `6969`. |
|
| `serverPort` | integer | Localhost port for the browser stats UI. Default `6969`. |
|
||||||
| `autoStartServer` | `true`, `false` | Start the local stats HTTP server automatically once immersion tracking is active. Default `true`. |
|
| `autoStartServer` | `true`, `false` | Start the local stats HTTP server automatically once immersion tracking is active. Default `true`. |
|
||||||
| `autoOpenBrowser` | `true`, `false` | When `subminer stats` starts the server on demand, also open the dashboard in your default browser. Default `true`. |
|
| `autoOpenBrowser` | `true`, `false` | When `subminer stats` starts the server on demand, also open the dashboard in your default browser. Default `false`. |
|
||||||
|
|
||||||
Usage notes:
|
Usage notes:
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ Stats server config lives under `stats`:
|
|||||||
"toggleKey": "Backquote",
|
"toggleKey": "Backquote",
|
||||||
"serverPort": 6969,
|
"serverPort": 6969,
|
||||||
"autoStartServer": true,
|
"autoStartServer": true,
|
||||||
"autoOpenBrowser": true
|
"autoOpenBrowser": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
**macOS** — macOS 10.13 or later. Accessibility permission required for window tracking.
|
**macOS** — macOS 10.13 or later. Accessibility permission required for window tracking.
|
||||||
|
|
||||||
**Windows** — Windows 10 or later. Install `mpv` and keep it available on `PATH`; SubMiner's packaged build handles window tracking directly.
|
**Windows** — Windows 10 or later. Install `mpv`; keep it on `PATH` for auto-discovery or set `mpv.executablePath` in config if `mpv.exe` lives elsewhere. SubMiner's packaged build handles window tracking directly.
|
||||||
|
|
||||||
### Optional Tools
|
### Optional Tools
|
||||||
|
|
||||||
@@ -172,6 +172,7 @@ 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`, 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.
|
- Launch `SubMiner.exe` once to let the first-run setup flow seed `%APPDATA%\\SubMiner\\config.jsonc`, require mpv plugin installation, and open bundled Yomitan settings. The optional `SubMiner mpv` Start Menu/Desktop shortcut can also be created during setup, and on Windows it is the recommended way to launch mpv playback with SubMiner defaults.
|
||||||
|
- If `mpv.exe` is not on `PATH`, set `mpv.executablePath` in `config.jsonc` or use the first-run setup field to point at the executable. Leave it blank to keep PATH auto-discovery.
|
||||||
- `SubMiner.exe --launch-mpv` and the optional `SubMiner mpv` shortcut pass SubMiner's default mpv socket/subtitle args directly and do not require an `mpv.conf` profile named `subminer`.
|
- `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.
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ Use `subminer <subcommand> -h` for command-specific help.
|
|||||||
| `-T, --no-texthooker` | Disable texthooker server |
|
| `-T, --no-texthooker` | Disable texthooker server |
|
||||||
| `-p, --profile` | mpv profile name (default: `subminer`) |
|
| `-p, --profile` | mpv profile name (default: `subminer`) |
|
||||||
| `-a, --args` | Pass additional mpv arguments as a quoted string |
|
| `-a, --args` | Pass additional mpv arguments as a quoted string |
|
||||||
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`) |
|
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) |
|
||||||
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
||||||
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
|
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ The visible overlay renders subtitles as tokenized hoverable word spans. Each wo
|
|||||||
|
|
||||||
- Word-level hover targets for Yomitan lookup
|
- Word-level hover targets for Yomitan lookup
|
||||||
- Auto pause/resume on subtitle hover (enabled by default via `subtitleStyle.autoPauseVideoOnHover`)
|
- Auto pause/resume on subtitle hover (enabled by default via `subtitleStyle.autoPauseVideoOnHover`)
|
||||||
- Optional pause while the Yomitan popup is open (`subtitleStyle.autoPauseVideoOnYomitanPopup`)
|
- Auto pause/resume while the Yomitan popup is open (enabled by default via `subtitleStyle.autoPauseVideoOnYomitanPopup`)
|
||||||
- Right-click to pause/resume
|
- Right-click to pause/resume
|
||||||
- Right-click + drag to reposition subtitles
|
- Right-click + drag to reposition subtitles
|
||||||
- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options
|
- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ texthooker_enabled=yes
|
|||||||
# Port for the texthooker server.
|
# Port for the texthooker server.
|
||||||
texthooker_port=5174
|
texthooker_port=5174
|
||||||
|
|
||||||
# Window manager backend: auto, hyprland, sway, x11, macos.
|
# Window manager backend: auto, hyprland, sway, x11, macos, windows.
|
||||||
backend=auto
|
backend=auto
|
||||||
|
|
||||||
# Start the overlay automatically when a file is loaded.
|
# Start the overlay automatically when a file is loaded.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"texthooker": {
|
"texthooker": {
|
||||||
"launchAtStartup": true, // Launch texthooker server automatically when SubMiner starts. Values: true | false
|
"launchAtStartup": true, // Launch texthooker server automatically when SubMiner starts. Values: true | false
|
||||||
"openBrowser": true // Open browser setting. Values: true | false
|
"openBrowser": false // Open browser setting. Values: true | false
|
||||||
}, // Configure texthooker startup launch and browser opening behavior.
|
}, // Configure texthooker startup launch and browser opening behavior.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
// Override controller.buttonIndices when your pad reports non-standard raw button numbers.
|
// Override controller.buttonIndices when your pad reports non-standard raw button numbers.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"controller": {
|
"controller": {
|
||||||
"enabled": true, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false
|
"enabled": false, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false
|
||||||
"preferredGamepadId": "", // Preferred controller id saved from the controller config modal.
|
"preferredGamepadId": "", // Preferred controller id saved from the controller config modal.
|
||||||
"preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics.
|
"preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics.
|
||||||
"smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false
|
"smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false
|
||||||
@@ -225,7 +225,7 @@
|
|||||||
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
||||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
||||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
||||||
"autoPauseVideoOnYomitanPopup": false, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
||||||
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
||||||
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
|
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
|
||||||
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
|
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
// Hot-reload: subtitle sidebar changes apply live without restarting SubMiner.
|
// Hot-reload: subtitle sidebar changes apply live without restarting SubMiner.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"subtitleSidebar": {
|
"subtitleSidebar": {
|
||||||
"enabled": false, // Enable the subtitle sidebar feature for parsed subtitle sources. Values: true | false
|
"enabled": true, // Enable the subtitle sidebar feature for parsed subtitle sources. Values: true | false
|
||||||
"autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false
|
"autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false
|
||||||
"layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded
|
"layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded
|
||||||
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
|
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
|
||||||
@@ -330,7 +330,7 @@
|
|||||||
// Most other AnkiConnect settings still require restart.
|
// Most other AnkiConnect settings still require restart.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"ankiConnect": {
|
"ankiConnect": {
|
||||||
"enabled": false, // Enable AnkiConnect integration. Values: true | false
|
"enabled": true, // Enable AnkiConnect integration. Values: true | false
|
||||||
"url": "http://127.0.0.1:8765", // Url setting.
|
"url": "http://127.0.0.1:8765", // Url setting.
|
||||||
"pollingRate": 3000, // Polling interval in milliseconds.
|
"pollingRate": 3000, // Polling interval in milliseconds.
|
||||||
"proxy": {
|
"proxy": {
|
||||||
@@ -458,6 +458,15 @@
|
|||||||
"externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay
|
"externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay
|
||||||
}, // Optional external Yomitan profile integration.
|
}, // Optional external Yomitan profile integration.
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// MPV Launcher
|
||||||
|
// Optional mpv.exe override for Windows playback entry points.
|
||||||
|
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
||||||
|
// ==========================================
|
||||||
|
"mpv": {
|
||||||
|
"executablePath": "" // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
||||||
|
}, // Optional mpv.exe override for Windows playback entry points.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Jellyfin
|
// Jellyfin
|
||||||
// Optional Jellyfin integration for auth, browsing, and playback launch.
|
// Optional Jellyfin integration for auth, browsing, and playback launch.
|
||||||
@@ -497,7 +506,7 @@
|
|||||||
// Uses official SubMiner Discord app assets for polished card visuals.
|
// Uses official SubMiner Discord app assets for polished card visuals.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"discordPresence": {
|
"discordPresence": {
|
||||||
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
|
"enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false
|
||||||
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".
|
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".
|
||||||
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
||||||
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
||||||
@@ -544,6 +553,6 @@
|
|||||||
"markWatchedKey": "KeyW", // Key code to mark the current video as watched and advance to the next playlist entry.
|
"markWatchedKey": "KeyW", // Key code to mark the current video as watched and advance to the next playlist entry.
|
||||||
"serverPort": 6969, // Port for the stats HTTP server.
|
"serverPort": 6969, // Port for the stats HTTP server.
|
||||||
"autoStartServer": true, // Automatically start the stats server on launch. Values: true | false
|
"autoStartServer": true, // Automatically start the stats server on launch. Values: true | false
|
||||||
"autoOpenBrowser": true // Automatically open the stats dashboard in a browser when the server starts. Values: true | false
|
"autoOpenBrowser": false // Automatically open the stats dashboard in a browser when the server starts. Values: true | false
|
||||||
} // Local immersion stats dashboard served on localhost and available as an in-app overlay.
|
} // Local immersion stats dashboard served on localhost and available as an in-app overlay.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
The subtitle sidebar displays the full parsed cue list for the active subtitle file as a scrollable panel alongside mpv. It lets you review past and upcoming lines, click any cue to seek directly to that moment, and follow along without depending on the transient overlay subtitles.
|
The subtitle sidebar displays the full parsed cue list for the active subtitle file as a scrollable panel alongside mpv. It lets you review past and upcoming lines, click any cue to seek directly to that moment, and follow along without depending on the transient overlay subtitles.
|
||||||
|
|
||||||
The sidebar is opt-in and disabled by default. Enable it under `subtitleSidebar.enabled` in your config.
|
The sidebar is enabled by default. Set `subtitleSidebar.enabled` to `false` if you want to turn it off.
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ Enable and configure the sidebar under `subtitleSidebar` in your config file:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"subtitleSidebar": {
|
"subtitleSidebar": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"autoOpen": false,
|
"autoOpen": false,
|
||||||
"layout": "overlay",
|
"layout": "overlay",
|
||||||
"toggleKey": "Backslash",
|
"toggleKey": "Backslash",
|
||||||
@@ -43,7 +43,7 @@ Enable and configure the sidebar under `subtitleSidebar` in your config file:
|
|||||||
|
|
||||||
| Option | Type | Default | Description |
|
| Option | Type | Default | Description |
|
||||||
| --------------------------- | ------- | ------------ | -------------------------------------------------------------------------------------------------- |
|
| --------------------------- | ------- | ------------ | -------------------------------------------------------------------------------------------------- |
|
||||||
| `enabled` | boolean | `false` | Enable subtitle sidebar support |
|
| `enabled` | boolean | `true` | Enable subtitle sidebar support |
|
||||||
| `autoOpen` | boolean | `false` | Open the sidebar automatically on overlay startup |
|
| `autoOpen` | boolean | `false` | Open the sidebar automatically on overlay startup |
|
||||||
| `layout` | string | `"overlay"` | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space |
|
| `layout` | string | `"overlay"` | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space |
|
||||||
| `toggleKey` | string | `"Backslash"` | `KeyboardEvent.code` for the toggle shortcut |
|
| `toggleKey` | string | `"Backslash"` | `KeyboardEvent.code` for the toggle shortcut |
|
||||||
|
|||||||
@@ -133,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. On Windows, `--launch-mpv` does not require an `mpv.conf` profile named `subminer`.
|
This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blank to auto-discover from `PATH`, or set it to the full `mpv.exe` path if mpv is installed elsewhere. `SUBMINER_MPV_PATH` is still honored as a fallback. On Windows, `--launch-mpv` does not require an `mpv.conf` profile named `subminer`.
|
||||||
|
|
||||||
### Launcher Subcommands
|
### Launcher Subcommands
|
||||||
|
|
||||||
@@ -164,6 +164,7 @@ Setup flow:
|
|||||||
- 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, unless an external Yomitan profile is configured
|
- dictionary check: ensure at least one bundled Yomitan dictionary is available, unless an external Yomitan profile is configured
|
||||||
- Windows: optionally create or remove `SubMiner mpv` Start Menu/Desktop shortcuts (`SubMiner.exe --launch-mpv`)
|
- Windows: optionally create or remove `SubMiner mpv` Start Menu/Desktop shortcuts (`SubMiner.exe --launch-mpv`)
|
||||||
|
- Windows: optionally set `mpv.executablePath` if `mpv.exe` is not on `PATH`
|
||||||
- refresh: re-check plugin + dictionary state without restarting
|
- refresh: re-check plugin + dictionary state without restarting
|
||||||
- `Finish setup` stays disabled until the config, plugin, and dictionary gates are satisfied
|
- `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
|
||||||
@@ -296,7 +297,7 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Hovering over subtitle text pauses mpv by default; leaving resumes it. Disable with `subtitleStyle.autoPauseVideoOnHover: false`. To also pause while the Yomitan popup is open, set `subtitleStyle.autoPauseVideoOnYomitanPopup: true`.
|
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
|
||||||
|
|
||||||
### Drag-and-Drop
|
### Drag-and-Drop
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ SubMiner's integration ports are configured in `config.jsonc`.
|
|||||||
},
|
},
|
||||||
"texthooker": {
|
"texthooker": {
|
||||||
"launchAtStartup": true,
|
"launchAtStartup": true,
|
||||||
"openBrowser": true
|
"openBrowser": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -125,6 +125,12 @@ function titleOverlapScore(expectedTitle: string, candidateTitle: string): numbe
|
|||||||
if (!expected || !candidate) return 0;
|
if (!expected || !candidate) return 0;
|
||||||
|
|
||||||
if (candidate.includes(expected)) return 120;
|
if (candidate.includes(expected)) return 120;
|
||||||
|
if (
|
||||||
|
candidate.split(' ').length >= 2 &&
|
||||||
|
` ${expected} `.includes(` ${candidate} `)
|
||||||
|
) {
|
||||||
|
return 90;
|
||||||
|
}
|
||||||
|
|
||||||
const expectedTokens = tokenizeMatchWords(expectedTitle);
|
const expectedTokens = tokenizeMatchWords(expectedTitle);
|
||||||
if (expectedTokens.length === 0) return 0;
|
if (expectedTokens.length === 0) return 0;
|
||||||
@@ -339,6 +345,12 @@ function isSeasonDirectoryName(value: string): boolean {
|
|||||||
return /^(?:season|s)[\s._-]*\d{1,2}$/i.test(value.trim());
|
return /^(?:season|s)[\s._-]*\d{1,2}$/i.test(value.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEpisodeOnlyBaseName(value: string): boolean {
|
||||||
|
return /^(?:[Ss]\d{1,2}[Ee]\d{1,3}|[Ee][Pp]?[\s._-]*\d{1,3}|\d{1,3})(?:$|[\s._-])/.test(
|
||||||
|
value.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function inferTitleFromPath(mediaPath: string): string {
|
function inferTitleFromPath(mediaPath: string): string {
|
||||||
const directory = path.dirname(mediaPath);
|
const directory = path.dirname(mediaPath);
|
||||||
const segments = directory.split(/[\\/]+/).filter((segment) => segment.length > 0);
|
const segments = directory.split(/[\\/]+/).filter((segment) => segment.length > 0);
|
||||||
@@ -445,8 +457,11 @@ export function inferAniSkipMetadataForFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseName = path.basename(mediaPath, path.extname(mediaPath));
|
const baseName = path.basename(mediaPath, path.extname(mediaPath));
|
||||||
|
const cleanedBaseName = cleanupTitle(baseName);
|
||||||
const pathTitle = inferTitleFromPath(mediaPath);
|
const pathTitle = inferTitleFromPath(mediaPath);
|
||||||
const fallbackTitle = pathTitle || cleanupTitle(baseName) || baseName;
|
const fallbackTitle = isEpisodeOnlyBaseName(baseName)
|
||||||
|
? pathTitle || cleanedBaseName || baseName
|
||||||
|
: cleanedBaseName || pathTitle || baseName;
|
||||||
return {
|
return {
|
||||||
title: fallbackTitle,
|
title: fallbackTitle,
|
||||||
season: detectSeasonFromNameOrDir(mediaPath),
|
season: detectSeasonFromNameOrDir(mediaPath),
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ import {
|
|||||||
getDefaultConfigDir,
|
getDefaultConfigDir,
|
||||||
getSetupStatePath,
|
getSetupStatePath,
|
||||||
readSetupState,
|
readSetupState,
|
||||||
|
resolveDefaultMpvInstallPaths,
|
||||||
} from '../../src/shared/setup-state.js';
|
} from '../../src/shared/setup-state.js';
|
||||||
|
import { detectInstalledFirstRunPlugin } from '../../src/main/runtime/first-run-setup-plugin.js';
|
||||||
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
||||||
|
|
||||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
@@ -105,6 +107,14 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
|||||||
const ready = await ensureLauncherSetupReady({
|
const ready = await ensureLauncherSetupReady({
|
||||||
readSetupState: () => readSetupState(statePath),
|
readSetupState: () => readSetupState(statePath),
|
||||||
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
|
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
|
||||||
|
isPluginInstalled: () => {
|
||||||
|
const installPaths = resolveDefaultMpvInstallPaths(
|
||||||
|
process.platform,
|
||||||
|
os.homedir(),
|
||||||
|
process.env.XDG_CONFIG_HOME,
|
||||||
|
);
|
||||||
|
return detectInstalledFirstRunPlugin(installPaths);
|
||||||
|
},
|
||||||
launchSetupApp: () => {
|
launchSetupApp: () => {
|
||||||
const setupArgs = ['--background', '--setup'];
|
const setupArgs = ['--background', '--setup'];
|
||||||
if (args.logLevel) {
|
if (args.logLevel) {
|
||||||
|
|||||||
@@ -49,10 +49,17 @@ function parseLogLevel(value: string): LogLevel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseBackend(value: string): Backend {
|
function parseBackend(value: string): Backend {
|
||||||
if (value === 'auto' || value === 'hyprland' || value === 'x11' || value === 'macos') {
|
if (
|
||||||
|
value === 'auto' ||
|
||||||
|
value === 'hyprland' ||
|
||||||
|
value === 'sway' ||
|
||||||
|
value === 'x11' ||
|
||||||
|
value === 'macos' ||
|
||||||
|
value === 'windows'
|
||||||
|
) {
|
||||||
return value as Backend;
|
return value as Backend;
|
||||||
}
|
}
|
||||||
fail(`Invalid backend: ${value} (must be auto, hyprland, x11, or macos)`);
|
fail(`Invalid backend: ${value} (must be auto, hyprland, sway, x11, macos, or windows)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDictionaryTarget(value: string): string {
|
function parseDictionaryTarget(value: string): string {
|
||||||
|
|||||||
@@ -17,20 +17,20 @@ test('resolveTopLevelCommand respects the app alias after root options', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('parseCliPrograms keeps root options and target when no command is present', () => {
|
test('parseCliPrograms keeps root options and target when no command is present', () => {
|
||||||
const result = parseCliPrograms(['--backend', 'x11', '/tmp/movie.mkv'], 'subminer');
|
const result = parseCliPrograms(['--backend', 'windows', '/tmp/movie.mkv'], 'subminer');
|
||||||
|
|
||||||
assert.equal(result.options.backend, 'x11');
|
assert.equal(result.options.backend, 'windows');
|
||||||
assert.equal(result.rootTarget, '/tmp/movie.mkv');
|
assert.equal(result.rootTarget, '/tmp/movie.mkv');
|
||||||
assert.equal(result.invocations.appInvocation, null);
|
assert.equal(result.invocations.appInvocation, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseCliPrograms routes app alias arguments through passthrough mode', () => {
|
test('parseCliPrograms routes app alias arguments through passthrough mode', () => {
|
||||||
const result = parseCliPrograms(
|
const result = parseCliPrograms(
|
||||||
['--backend', 'macos', 'bin', '--anilist', '--log-level', 'debug'],
|
['--backend', 'windows', 'bin', '--anilist', '--log-level', 'debug'],
|
||||||
'subminer',
|
'subminer',
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(result.options.backend, 'macos');
|
assert.equal(result.options.backend, 'windows');
|
||||||
assert.deepEqual(result.invocations.appInvocation, {
|
assert.deepEqual(result.invocations.appInvocation, {
|
||||||
appArgs: ['--anilist', '--log-level', 'debug'],
|
appArgs: ['--anilist', '--log-level', 'debug'],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ export interface CliInvocations {
|
|||||||
|
|
||||||
function applyRootOptions(program: Command): void {
|
function applyRootOptions(program: Command): void {
|
||||||
program
|
program
|
||||||
.option('-b, --backend <backend>', 'Display backend')
|
.option(
|
||||||
|
'-b, --backend <backend>',
|
||||||
|
'Display backend (auto, hyprland, sway, x11, macos, windows)',
|
||||||
|
)
|
||||||
.option('-d, --directory <dir>', 'Directory to browse')
|
.option('-d, --directory <dir>', 'Directory to browse')
|
||||||
.option('-a, --args <args>', 'Pass arguments to MPV')
|
.option('-a, --args <args>', 'Pass arguments to MPV')
|
||||||
.option('-r, --recursive', 'Search directories recursively')
|
.option('-r, --recursive', 'Search directories recursively')
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { EventEmitter } from 'node:events';
|
|||||||
import type { Args } from './types';
|
import type { Args } from './types';
|
||||||
import {
|
import {
|
||||||
cleanupPlaybackSession,
|
cleanupPlaybackSession,
|
||||||
|
detectBackend,
|
||||||
findAppBinary,
|
findAppBinary,
|
||||||
launchAppCommandDetached,
|
launchAppCommandDetached,
|
||||||
launchTexthookerOnly,
|
launchTexthookerOnly,
|
||||||
@@ -56,6 +57,22 @@ function createTempSocketPath(): { dir: string; socketPath: string } {
|
|||||||
return { dir, socketPath: path.join(dir, 'mpv.sock') };
|
return { dir, socketPath: path.join(dir, 'mpv.sock') };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withPlatform<T>(platform: NodeJS.Platform, callback: () => T): T {
|
||||||
|
const originalDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||||
|
Object.defineProperty(process, 'platform', {
|
||||||
|
configurable: true,
|
||||||
|
value: platform,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return callback();
|
||||||
|
} finally {
|
||||||
|
if (originalDescriptor) {
|
||||||
|
Object.defineProperty(process, 'platform', originalDescriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test('mpv module exposes only canonical socket readiness helper', () => {
|
test('mpv module exposes only canonical socket readiness helper', () => {
|
||||||
assert.equal('waitForSocket' in mpvModule, false);
|
assert.equal('waitForSocket' in mpvModule, false);
|
||||||
});
|
});
|
||||||
@@ -102,6 +119,12 @@ test('parseMpvArgString preserves empty quoted tokens', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('detectBackend resolves windows on win32 auto mode', () => {
|
||||||
|
withPlatform('win32', () => {
|
||||||
|
assert.equal(detectBackend('auto'), 'windows');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
|
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
|
||||||
const error = withProcessExitIntercept(() => {
|
const error = withProcessExitIntercept(() => {
|
||||||
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
|
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
|
||||||
@@ -434,7 +457,9 @@ function withFindAppBinaryPlatformSandbox(
|
|||||||
const originalPlatform = process.platform;
|
const originalPlatform = process.platform;
|
||||||
try {
|
try {
|
||||||
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
|
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
|
||||||
withFindAppBinaryEnvSandbox(() => run(platform === 'win32' ? (path.win32 as typeof path) : path));
|
withFindAppBinaryEnvSandbox(() =>
|
||||||
|
run(platform === 'win32' ? (path.win32 as typeof path) : path),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
||||||
}
|
}
|
||||||
@@ -460,7 +485,22 @@ function withAccessSyncStub(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test('findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists', { concurrency: false }, () => {
|
function withRealpathSyncStub(resolvePath: (filePath: string) => string, run: () => void): void {
|
||||||
|
const originalRealpathSync = fs.realpathSync;
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(fs as any).realpathSync = (filePath: string): string => resolvePath(filePath);
|
||||||
|
run();
|
||||||
|
} finally {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(fs as any).realpathSync = originalRealpathSync;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
'findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists',
|
||||||
|
{ concurrency: false },
|
||||||
|
() => {
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
|
||||||
const originalHomedir = os.homedir;
|
const originalHomedir = os.homedir;
|
||||||
try {
|
try {
|
||||||
@@ -476,9 +516,13 @@ test('findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists', { c
|
|||||||
os.homedir = originalHomedir;
|
os.homedir = originalHomedir;
|
||||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin candidate does not exist', { concurrency: false }, () => {
|
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 {
|
||||||
@@ -496,9 +540,13 @@ test('findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin c
|
|||||||
os.homedir = originalHomedir;
|
os.homedir = originalHomedir;
|
||||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('findAppBinary finds subminer on PATH when AppImage candidates do not exist', { concurrency: false }, () => {
|
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;
|
||||||
@@ -525,7 +573,50 @@ test('findAppBinary finds subminer on PATH when AppImage candidates do not exist
|
|||||||
process.env.PATH = originalPath;
|
process.env.PATH = originalPath;
|
||||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'findAppBinary excludes PATH matches that canonicalize to the launcher path',
|
||||||
|
{ concurrency: false },
|
||||||
|
() => {
|
||||||
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-realpath-'));
|
||||||
|
const originalHomedir = os.homedir;
|
||||||
|
const originalPath = process.env.PATH;
|
||||||
|
try {
|
||||||
|
os.homedir = () => baseDir;
|
||||||
|
const binDir = path.join(baseDir, 'bin');
|
||||||
|
const wrapperPath = path.join(binDir, 'subminer');
|
||||||
|
const canonicalPath = path.join(baseDir, 'launch', 'subminer');
|
||||||
|
makeExecutable(wrapperPath);
|
||||||
|
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
|
||||||
|
|
||||||
|
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
|
||||||
|
withAccessSyncStub(
|
||||||
|
(filePath) => filePath === wrapperPath,
|
||||||
|
() => {
|
||||||
|
withRealpathSyncStub(
|
||||||
|
(filePath) => {
|
||||||
|
if (filePath === canonicalPath || filePath === wrapperPath) {
|
||||||
|
return canonicalPath;
|
||||||
|
}
|
||||||
|
return filePath;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
const result = findAppBinary(canonicalPath, pathModule);
|
||||||
|
assert.equal(result, null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
os.homedir = originalHomedir;
|
||||||
|
process.env.PATH = originalPath;
|
||||||
|
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('findAppBinary resolves Windows install paths when present', { concurrency: false }, () => {
|
test('findAppBinary resolves Windows install paths when present', { concurrency: false }, () => {
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-'));
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-'));
|
||||||
@@ -547,7 +638,10 @@ test('findAppBinary resolves Windows install paths when present', { concurrency:
|
|||||||
withAccessSyncStub(
|
withAccessSyncStub(
|
||||||
(filePath) => filePath === appExe,
|
(filePath) => filePath === appExe,
|
||||||
() => {
|
() => {
|
||||||
const result = findAppBinary(pathModule.join(baseDir, 'launcher', 'SubMiner.exe'), pathModule);
|
const result = findAppBinary(
|
||||||
|
pathModule.join(baseDir, 'launcher', 'SubMiner.exe'),
|
||||||
|
pathModule,
|
||||||
|
);
|
||||||
assert.equal(result, appExe);
|
assert.equal(result, appExe);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -578,7 +672,10 @@ test('findAppBinary resolves SubMiner.exe on PATH on Windows', { concurrency: fa
|
|||||||
withAccessSyncStub(
|
withAccessSyncStub(
|
||||||
(filePath) => filePath === wrapperPath,
|
(filePath) => filePath === wrapperPath,
|
||||||
() => {
|
() => {
|
||||||
const result = findAppBinary(pathModule.join(baseDir, 'launcher', 'SubMiner.exe'), pathModule);
|
const result = findAppBinary(
|
||||||
|
pathModule.join(baseDir, 'launcher', 'SubMiner.exe'),
|
||||||
|
pathModule,
|
||||||
|
);
|
||||||
assert.equal(result, wrapperPath);
|
assert.equal(result, wrapperPath);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -590,7 +687,10 @@ test('findAppBinary resolves SubMiner.exe on PATH on Windows', { concurrency: fa
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('findAppBinary resolves a Windows install directory to SubMiner.exe', { concurrency: false }, () => {
|
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 baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-dir-'));
|
||||||
const originalHomedir = os.homedir;
|
const originalHomedir = os.homedir;
|
||||||
const originalSubminerBinaryPath = process.env.SUBMINER_BINARY_PATH;
|
const originalSubminerBinaryPath = process.env.SUBMINER_BINARY_PATH;
|
||||||
@@ -606,7 +706,10 @@ test('findAppBinary resolves a Windows install directory to SubMiner.exe', { con
|
|||||||
const originalPlatform = process.platform;
|
const originalPlatform = process.platform;
|
||||||
try {
|
try {
|
||||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||||
const result = findAppBinary(path.win32.join(baseDir, 'launcher', 'SubMiner.exe'), path.win32);
|
const result = findAppBinary(
|
||||||
|
path.win32.join(baseDir, 'launcher', 'SubMiner.exe'),
|
||||||
|
path.win32,
|
||||||
|
);
|
||||||
assert.equal(result, appExe);
|
assert.equal(result, appExe);
|
||||||
} finally {
|
} finally {
|
||||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
||||||
@@ -620,4 +723,5 @@ test('findAppBinary resolves a Windows install directory to SubMiner.exe', { con
|
|||||||
}
|
}
|
||||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
uniqueNormalizedLangCodes,
|
uniqueNormalizedLangCodes,
|
||||||
sleep,
|
sleep,
|
||||||
normalizeLangCode,
|
normalizeLangCode,
|
||||||
|
realpathMaybe,
|
||||||
} from './util.js';
|
} from './util.js';
|
||||||
|
|
||||||
export const state = {
|
export const state = {
|
||||||
@@ -226,6 +227,7 @@ export function makeTempDir(prefix: string): string {
|
|||||||
|
|
||||||
export function detectBackend(backend: Backend): Exclude<Backend, 'auto'> {
|
export function detectBackend(backend: Backend): Exclude<Backend, 'auto'> {
|
||||||
if (backend !== 'auto') return backend;
|
if (backend !== 'auto') return backend;
|
||||||
|
if (process.platform === 'win32') return 'windows';
|
||||||
if (process.platform === 'darwin') return 'macos';
|
if (process.platform === 'darwin') return 'macos';
|
||||||
const xdgCurrentDesktop = (process.env.XDG_CURRENT_DESKTOP || '').toLowerCase();
|
const xdgCurrentDesktop = (process.env.XDG_CURRENT_DESKTOP || '').toLowerCase();
|
||||||
const xdgSessionDesktop = (process.env.XDG_SESSION_DESKTOP || '').toLowerCase();
|
const xdgSessionDesktop = (process.env.XDG_SESSION_DESKTOP || '').toLowerCase();
|
||||||
@@ -362,8 +364,12 @@ export function findAppBinary(selfPath: string, pathModule: PathModule = path):
|
|||||||
} else if (process.platform === 'darwin') {
|
} 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(pathModule.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/SubMiner'));
|
candidates.push(
|
||||||
candidates.push(pathModule.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/subminer'));
|
pathModule.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/SubMiner'),
|
||||||
|
);
|
||||||
|
candidates.push(
|
||||||
|
pathModule.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/subminer'),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
candidates.push(pathModule.join(os.homedir(), '.local/bin/SubMiner.AppImage'));
|
candidates.push(pathModule.join(os.homedir(), '.local/bin/SubMiner.AppImage'));
|
||||||
candidates.push('/opt/SubMiner/SubMiner.AppImage');
|
candidates.push('/opt/SubMiner/SubMiner.AppImage');
|
||||||
@@ -380,8 +386,8 @@ export function findAppBinary(selfPath: string, pathModule: PathModule = path):
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (fromPath) {
|
if (fromPath) {
|
||||||
const resolvedSelf = pathModule.resolve(selfPath);
|
const resolvedSelf = realpathMaybe(selfPath);
|
||||||
const resolvedCandidate = pathModule.resolve(fromPath);
|
const resolvedCandidate = realpathMaybe(fromPath);
|
||||||
if (resolvedSelf !== resolvedCandidate) return fromPath;
|
if (resolvedSelf !== resolvedCandidate) return fromPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -703,7 +709,9 @@ export async function startMpv(
|
|||||||
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
||||||
mpvArgs.push(target);
|
mpvArgs.push(target);
|
||||||
|
|
||||||
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs);
|
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs, {
|
||||||
|
normalizeWindowsShellArgs: false,
|
||||||
|
});
|
||||||
state.mpvProc = spawn(mpvTarget.command, mpvTarget.args, { stdio: 'inherit' });
|
state.mpvProc = spawn(mpvTarget.command, mpvTarget.args, { stdio: 'inherit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1145,7 +1153,9 @@ export function launchMpvIdleDetached(
|
|||||||
);
|
);
|
||||||
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
|
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
|
||||||
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
||||||
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs);
|
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs, {
|
||||||
|
normalizeWindowsShellArgs: false,
|
||||||
|
});
|
||||||
const proc = spawn(mpvTarget.command, mpvTarget.args, {
|
const proc = spawn(mpvTarget.command, mpvTarget.args, {
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
detached: true,
|
detached: true,
|
||||||
|
|||||||
@@ -116,6 +116,36 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf
|
|||||||
assert.deepEqual(calls, []);
|
assert.deepEqual(calls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ensureLauncherSetupReady bypasses setup gate when plugin is already installed', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
const ready = await ensureLauncherSetupReady({
|
||||||
|
readSetupState: () => ({
|
||||||
|
version: 3,
|
||||||
|
status: 'cancelled',
|
||||||
|
completedAt: null,
|
||||||
|
completionSource: null,
|
||||||
|
yomitanSetupMode: null,
|
||||||
|
lastSeenYomitanDictionaryCount: 0,
|
||||||
|
pluginInstallStatus: 'unknown',
|
||||||
|
pluginInstallPathSummary: null,
|
||||||
|
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||||
|
windowsMpvShortcutLastStatus: 'unknown',
|
||||||
|
}),
|
||||||
|
isPluginInstalled: () => true,
|
||||||
|
launchSetupApp: () => {
|
||||||
|
calls.push('launch');
|
||||||
|
},
|
||||||
|
sleep: async () => undefined,
|
||||||
|
now: () => 0,
|
||||||
|
timeoutMs: 5_000,
|
||||||
|
pollIntervalMs: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(ready, true);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
||||||
const result = await ensureLauncherSetupReady({
|
const result = await ensureLauncherSetupReady({
|
||||||
readSetupState: () => ({
|
readSetupState: () => ({
|
||||||
@@ -132,10 +162,73 @@ test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
|||||||
}),
|
}),
|
||||||
launchSetupApp: () => undefined,
|
launchSetupApp: () => undefined,
|
||||||
sleep: async () => undefined,
|
sleep: async () => undefined,
|
||||||
now: () => 0,
|
now: (() => {
|
||||||
|
let value = 0;
|
||||||
|
return () => (value += 100);
|
||||||
|
})(),
|
||||||
timeoutMs: 5_000,
|
timeoutMs: 5_000,
|
||||||
pollIntervalMs: 100,
|
pollIntervalMs: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result, false);
|
assert.equal(result, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ensureLauncherSetupReady ignores stale cancelled state after launching setup app', async () => {
|
||||||
|
let reads = 0;
|
||||||
|
|
||||||
|
const result = await ensureLauncherSetupReady({
|
||||||
|
readSetupState: () => {
|
||||||
|
reads += 1;
|
||||||
|
if (reads <= 2) {
|
||||||
|
return {
|
||||||
|
version: 3,
|
||||||
|
status: 'cancelled',
|
||||||
|
completedAt: null,
|
||||||
|
completionSource: null,
|
||||||
|
yomitanSetupMode: null,
|
||||||
|
lastSeenYomitanDictionaryCount: 0,
|
||||||
|
pluginInstallStatus: 'unknown',
|
||||||
|
pluginInstallPathSummary: null,
|
||||||
|
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||||
|
windowsMpvShortcutLastStatus: 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (reads === 3) {
|
||||||
|
return {
|
||||||
|
version: 3,
|
||||||
|
status: 'in_progress',
|
||||||
|
completedAt: null,
|
||||||
|
completionSource: null,
|
||||||
|
yomitanSetupMode: null,
|
||||||
|
lastSeenYomitanDictionaryCount: 0,
|
||||||
|
pluginInstallStatus: 'unknown',
|
||||||
|
pluginInstallPathSummary: null,
|
||||||
|
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||||
|
windowsMpvShortcutLastStatus: 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: 3,
|
||||||
|
status: 'completed',
|
||||||
|
completedAt: '2026-03-07T00:00:00.000Z',
|
||||||
|
completionSource: 'legacy_auto_detected',
|
||||||
|
yomitanSetupMode: 'internal',
|
||||||
|
lastSeenYomitanDictionaryCount: 1,
|
||||||
|
pluginInstallStatus: 'installed',
|
||||||
|
pluginInstallPathSummary: '/tmp/mpv',
|
||||||
|
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||||
|
windowsMpvShortcutLastStatus: 'unknown',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
launchSetupApp: () => undefined,
|
||||||
|
sleep: async () => undefined,
|
||||||
|
now: (() => {
|
||||||
|
let value = 0;
|
||||||
|
return () => (value += 100);
|
||||||
|
})(),
|
||||||
|
timeoutMs: 5_000,
|
||||||
|
pollIntervalMs: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,15 +6,24 @@ export async function waitForSetupCompletion(deps: {
|
|||||||
now: () => number;
|
now: () => number;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
pollIntervalMs: number;
|
pollIntervalMs: number;
|
||||||
|
ignoreInitialCancelledState?: boolean;
|
||||||
}): Promise<'completed' | 'cancelled' | 'timeout'> {
|
}): Promise<'completed' | 'cancelled' | 'timeout'> {
|
||||||
const deadline = deps.now() + deps.timeoutMs;
|
const deadline = deps.now() + deps.timeoutMs;
|
||||||
|
let ignoringCancelled = deps.ignoreInitialCancelledState === true;
|
||||||
|
|
||||||
while (deps.now() <= deadline) {
|
while (deps.now() <= deadline) {
|
||||||
const state = deps.readSetupState();
|
const state = deps.readSetupState();
|
||||||
if (isSetupCompleted(state)) {
|
if (isSetupCompleted(state)) {
|
||||||
return 'completed';
|
return 'completed';
|
||||||
}
|
}
|
||||||
|
if (ignoringCancelled && state != null && state.status !== 'cancelled') {
|
||||||
|
ignoringCancelled = false;
|
||||||
|
}
|
||||||
if (state?.status === 'cancelled') {
|
if (state?.status === 'cancelled') {
|
||||||
|
if (ignoringCancelled) {
|
||||||
|
await deps.sleep(deps.pollIntervalMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
return 'cancelled';
|
return 'cancelled';
|
||||||
}
|
}
|
||||||
await deps.sleep(deps.pollIntervalMs);
|
await deps.sleep(deps.pollIntervalMs);
|
||||||
@@ -26,6 +35,7 @@ export async function waitForSetupCompletion(deps: {
|
|||||||
export async function ensureLauncherSetupReady(deps: {
|
export async function ensureLauncherSetupReady(deps: {
|
||||||
readSetupState: () => SetupState | null;
|
readSetupState: () => SetupState | null;
|
||||||
isExternalYomitanConfigured?: () => boolean;
|
isExternalYomitanConfigured?: () => boolean;
|
||||||
|
isPluginInstalled?: () => boolean;
|
||||||
launchSetupApp: () => void;
|
launchSetupApp: () => void;
|
||||||
sleep: (ms: number) => Promise<void>;
|
sleep: (ms: number) => Promise<void>;
|
||||||
now: () => number;
|
now: () => number;
|
||||||
@@ -35,11 +45,18 @@ export async function ensureLauncherSetupReady(deps: {
|
|||||||
if (deps.isExternalYomitanConfigured?.()) {
|
if (deps.isExternalYomitanConfigured?.()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (isSetupCompleted(deps.readSetupState())) {
|
if (deps.isPluginInstalled?.()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const initialState = deps.readSetupState();
|
||||||
|
if (isSetupCompleted(initialState)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
deps.launchSetupApp();
|
deps.launchSetupApp();
|
||||||
const result = await waitForSetupCompletion(deps);
|
const result = await waitForSetupCompletion({
|
||||||
|
...deps,
|
||||||
|
ignoreInitialCancelledState: initialState?.status === 'cancelled',
|
||||||
|
});
|
||||||
return result === 'completed';
|
return result === 'completed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function createSmokeCase(name: string): SmokeCase {
|
|||||||
|
|
||||||
writeExecutable(
|
writeExecutable(
|
||||||
fakeMpvPath,
|
fakeMpvPath,
|
||||||
`#!/usr/bin/env node
|
`#!/usr/bin/env bun
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const net = require('node:net');
|
const net = require('node:net');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
@@ -118,7 +118,7 @@ process.on('SIGTERM', closeAndExit);
|
|||||||
|
|
||||||
writeExecutable(
|
writeExecutable(
|
||||||
fakeAppPath,
|
fakeAppPath,
|
||||||
`#!/usr/bin/env node
|
`#!/usr/bin/env bun
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
|
|
||||||
const logPath = ${JSON.stringify(fakeAppLogPath)};
|
const logPath = ${JSON.stringify(fakeAppLogPath)};
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export const DEFAULT_MPV_SUBMINER_ARGS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||||
export type Backend = 'auto' | 'hyprland' | 'x11' | 'macos';
|
export type Backend = 'auto' | 'hyprland' | 'sway' | 'x11' | 'macos' | 'windows';
|
||||||
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
|
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
|
||||||
|
|
||||||
export interface LauncherAiConfig {
|
export interface LauncherAiConfig {
|
||||||
|
|||||||
@@ -244,13 +244,19 @@ export function inferWhisperLanguage(langCodes: string[], fallback: string): str
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CommandInvocationOptions {
|
||||||
|
normalizeWindowsShellArgs?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveCommandInvocation(
|
export function resolveCommandInvocation(
|
||||||
executable: string,
|
executable: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
|
options: CommandInvocationOptions = {},
|
||||||
): { command: string; args: string[] } {
|
): { command: string; args: string[] } {
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
return { command: executable, args };
|
return { command: executable, args };
|
||||||
}
|
}
|
||||||
|
const { normalizeWindowsShellArgs = true } = options;
|
||||||
|
|
||||||
const resolvedExecutable = resolveExecutablePath(executable) ?? executable;
|
const resolvedExecutable = resolveExecutablePath(executable) ?? executable;
|
||||||
const extension = path.extname(resolvedExecutable).toLowerCase();
|
const extension = path.extname(resolvedExecutable).toLowerCase();
|
||||||
@@ -267,7 +273,9 @@ export function resolveCommandInvocation(
|
|||||||
command: bashTarget.command,
|
command: bashTarget.command,
|
||||||
args: [
|
args: [
|
||||||
normalizeWindowsShellArg(resolvedExecutable, bashTarget.flavor),
|
normalizeWindowsShellArg(resolvedExecutable, bashTarget.flavor),
|
||||||
...args.map((arg) => normalizeWindowsShellArg(arg, bashTarget.flavor)),
|
...args.map((arg) =>
|
||||||
|
normalizeWindowsShellArgs ? normalizeWindowsShellArg(arg, bashTarget.flavor) : arg,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -280,7 +288,9 @@ export function resolveCommandInvocation(
|
|||||||
command: bashTarget.command,
|
command: bashTarget.command,
|
||||||
args: [
|
args: [
|
||||||
normalizeWindowsShellArg(resolvedExecutable, bashTarget.flavor),
|
normalizeWindowsShellArg(resolvedExecutable, bashTarget.flavor),
|
||||||
...args.map((arg) => normalizeWindowsShellArg(arg, bashTarget.flavor)),
|
...args.map((arg) =>
|
||||||
|
normalizeWindowsShellArgs ? normalizeWindowsShellArg(arg, bashTarget.flavor) : arg,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
21
package.json
21
package.json
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"version": "0.10.0",
|
"productName": "SubMiner",
|
||||||
|
"desktopName": "SubMiner.desktop",
|
||||||
|
"version": "0.11.1",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -16,7 +18,7 @@
|
|||||||
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --outfile=dist/launcher/subminer",
|
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --outfile=dist/launcher/subminer",
|
||||||
"build:stats": "cd stats && bun run build",
|
"build:stats": "cd stats && bun run build",
|
||||||
"dev:stats": "cd stats && bun run dev",
|
"dev:stats": "cd stats && bun run dev",
|
||||||
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:assets",
|
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:launcher && bun run build:assets",
|
||||||
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
||||||
"changelog:build": "bun run scripts/build-changelog.ts build",
|
"changelog:build": "bun run scripts/build-changelog.ts build",
|
||||||
"changelog:check": "bun run scripts/build-changelog.ts check",
|
"changelog:check": "bun run scripts/build-changelog.ts check",
|
||||||
@@ -38,8 +40,8 @@
|
|||||||
"docs:preview": "bun run --cwd docs-site docs:preview",
|
"docs:preview": "bun run --cwd docs-site docs:preview",
|
||||||
"docs:test": "bun run --cwd docs-site test",
|
"docs:test": "bun run --cwd docs-site test",
|
||||||
"test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts",
|
"test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts",
|
||||||
"test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts src/verify-config-example.test.ts",
|
"test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/integrations.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts src/verify-config-example.test.ts",
|
||||||
"test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js dist/verify-config-example.test.js",
|
"test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/integrations.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js dist/verify-config-example.test.js",
|
||||||
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
|
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
|
||||||
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-binary-windows.lua",
|
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-binary-windows.lua",
|
||||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
||||||
@@ -81,9 +83,12 @@
|
|||||||
"build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs"
|
"build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
"@xmldom/xmldom": "0.8.12",
|
||||||
"app-builder-lib": "26.8.2",
|
"app-builder-lib": "26.8.2",
|
||||||
"electron-builder-squirrel-windows": "26.8.2",
|
"electron-builder-squirrel-windows": "26.8.2",
|
||||||
|
"lodash": "4.18.0",
|
||||||
"minimatch": "10.2.3",
|
"minimatch": "10.2.3",
|
||||||
|
"picomatch": "4.0.4",
|
||||||
"tar": "7.5.11"
|
"tar": "7.5.11"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -112,7 +117,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.3.0",
|
"@types/node": "^25.3.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"electron": "^37.10.3",
|
"electron": "39.8.6",
|
||||||
"electron-builder": "26.8.2",
|
"electron-builder": "26.8.2",
|
||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
@@ -123,7 +128,7 @@
|
|||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"executableName": "SubMiner",
|
"executableName": "SubMiner",
|
||||||
"artifactName": "SubMiner-${version}.${ext}",
|
"artifactName": "SubMiner-${version}.${ext}",
|
||||||
"icon": "assets/SubMiner.png",
|
"icon": "assets/SubMiner-square.png",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"output": "release"
|
||||||
},
|
},
|
||||||
@@ -142,7 +147,7 @@
|
|||||||
"zip"
|
"zip"
|
||||||
],
|
],
|
||||||
"category": "public.app-category.video",
|
"category": "public.app-category.video",
|
||||||
"icon": "assets/SubMiner.png",
|
"icon": "assets/SubMiner-square.png",
|
||||||
"hardenedRuntime": true,
|
"hardenedRuntime": true,
|
||||||
"entitlements": "build/entitlements.mac.plist",
|
"entitlements": "build/entitlements.mac.plist",
|
||||||
"entitlementsInherit": "build/entitlements.mac.plist",
|
"entitlementsInherit": "build/entitlements.mac.plist",
|
||||||
@@ -158,7 +163,7 @@
|
|||||||
"nsis",
|
"nsis",
|
||||||
"zip"
|
"zip"
|
||||||
],
|
],
|
||||||
"icon": "assets/SubMiner.png"
|
"icon": "assets/SubMiner.ico"
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ texthooker_enabled=yes
|
|||||||
# Texthooker WebSocket port
|
# Texthooker WebSocket port
|
||||||
texthooker_port=5174
|
texthooker_port=5174
|
||||||
|
|
||||||
# Window manager backend: auto, hyprland, sway, x11
|
# Window manager backend: auto, hyprland, sway, x11, macos, windows
|
||||||
# "auto" detects based on environment variables
|
# "auto" detects based on environment variables
|
||||||
backend=auto
|
backend=auto
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,11 @@ const lanes: Record<string, LaneConfig> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function collectFiles(rootDir: string, includeSuffixes: string[], excludeSet: Set<string>): string[] {
|
function collectFiles(
|
||||||
|
rootDir: string,
|
||||||
|
includeSuffixes: string[],
|
||||||
|
excludeSet: Set<string>,
|
||||||
|
): string[] {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
const visit = (currentDir: string) => {
|
const visit = (currentDir: string) => {
|
||||||
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
||||||
@@ -145,7 +149,12 @@ function parseLcovReport(report: string): LcovRecord[] {
|
|||||||
}
|
}
|
||||||
if (line.startsWith('BRDA:')) {
|
if (line.startsWith('BRDA:')) {
|
||||||
const [lineNumber, block, branch, hits] = line.slice(5).split(',');
|
const [lineNumber, block, branch, hits] = line.slice(5).split(',');
|
||||||
if (lineNumber === undefined || block === undefined || branch === undefined || hits === undefined) {
|
if (
|
||||||
|
lineNumber === undefined ||
|
||||||
|
block === undefined ||
|
||||||
|
branch === undefined ||
|
||||||
|
hits === undefined
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
ensureCurrent().branches.set(`${lineNumber}:${block}:${branch}`, {
|
ensureCurrent().branches.set(`${lineNumber}:${block}:${branch}`, {
|
||||||
@@ -224,7 +233,9 @@ export function mergeLcovReports(reports: string[]): string {
|
|||||||
chunks.push(`FNDA:${record.functionHits.get(name) ?? 0},${name}`);
|
chunks.push(`FNDA:${record.functionHits.get(name) ?? 0},${name}`);
|
||||||
}
|
}
|
||||||
chunks.push(`FNF:${functions.length}`);
|
chunks.push(`FNF:${functions.length}`);
|
||||||
chunks.push(`FNH:${functions.filter(([name]) => (record.functionHits.get(name) ?? 0) > 0).length}`);
|
chunks.push(
|
||||||
|
`FNH:${functions.filter(([name]) => (record.functionHits.get(name) ?? 0) > 0).length}`,
|
||||||
|
);
|
||||||
|
|
||||||
const branches = [...record.branches.values()].sort((a, b) =>
|
const branches = [...record.branches.values()].sort((a, b) =>
|
||||||
a.line === b.line
|
a.line === b.line
|
||||||
@@ -298,7 +309,9 @@ function runCoverageLane(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8');
|
writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8');
|
||||||
process.stdout.write(`Merged LCOV written to ${relative(repoRoot, join(coverageDir, 'lcov.info'))}\n`);
|
process.stdout.write(
|
||||||
|
`Merged LCOV written to ${relative(repoRoot, join(coverageDir, 'lcov.info'))}\n`,
|
||||||
|
);
|
||||||
return 0;
|
return 0;
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(shardRoot, { recursive: true, force: true });
|
rmSync(shardRoot, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -369,7 +369,8 @@ export class AnkiIntegration {
|
|||||||
trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||||
this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]);
|
this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]);
|
||||||
},
|
},
|
||||||
findDuplicateNoteIds: (expression, noteInfo) => this.findDuplicateNoteIds(expression, noteInfo),
|
findDuplicateNoteIds: (expression, noteInfo) =>
|
||||||
|
this.findDuplicateNoteIds(expression, noteInfo),
|
||||||
recordCardsMinedCallback: (count, noteIds) => {
|
recordCardsMinedCallback: (count, noteIds) => {
|
||||||
this.recordCardsMinedSafely(count, noteIds, 'card creation');
|
this.recordCardsMinedSafely(count, noteIds, 'card creation');
|
||||||
},
|
},
|
||||||
@@ -1082,10 +1083,7 @@ export class AnkiIntegration {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findDuplicateNoteIds(
|
private async findDuplicateNoteIds(expression: string, noteInfo: NoteInfo): Promise<number[]> {
|
||||||
expression: string,
|
|
||||||
noteInfo: NoteInfo,
|
|
||||||
): Promise<number[]> {
|
|
||||||
return findDuplicateNoteIdsForAnkiIntegration(expression, -1, noteInfo, {
|
return findDuplicateNoteIdsForAnkiIntegration(expression, -1, noteInfo, {
|
||||||
findNotes: async (query, options) => (await this.client.findNotes(query, options)) as unknown,
|
findNotes: async (query, options) => (await this.client.findNotes(query, options)) as unknown,
|
||||||
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
|
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
|
||||||
|
|||||||
@@ -162,7 +162,8 @@ export class AnkiConnectProxyServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const forwardedBody = req.method === 'POST' ? this.getForwardRequestBody(rawBody, requestJson) : rawBody;
|
const forwardedBody =
|
||||||
|
req.method === 'POST' ? this.getForwardRequestBody(rawBody, requestJson) : rawBody;
|
||||||
const targetUrl = new URL(req.url || '/', upstreamUrl).toString();
|
const targetUrl = new URL(req.url || '/', upstreamUrl).toString();
|
||||||
const contentType =
|
const contentType =
|
||||||
typeof req.headers['content-type'] === 'string'
|
typeof req.headers['content-type'] === 'string'
|
||||||
@@ -272,7 +273,9 @@ export class AnkiConnectProxyServer {
|
|||||||
|
|
||||||
private sanitizeRequestJson(requestJson: Record<string, unknown>): Record<string, unknown> {
|
private sanitizeRequestJson(requestJson: Record<string, unknown>): Record<string, unknown> {
|
||||||
const action =
|
const action =
|
||||||
typeof requestJson.action === 'string' ? requestJson.action : String(requestJson.action ?? '');
|
typeof requestJson.action === 'string'
|
||||||
|
? requestJson.action
|
||||||
|
: String(requestJson.action ?? '');
|
||||||
if (action !== 'addNote') {
|
if (action !== 'addNote') {
|
||||||
return requestJson;
|
return requestJson;
|
||||||
}
|
}
|
||||||
@@ -301,9 +304,13 @@ export class AnkiConnectProxyServer {
|
|||||||
const rawNoteIds = Array.isArray(params?.subminerDuplicateNoteIds)
|
const rawNoteIds = Array.isArray(params?.subminerDuplicateNoteIds)
|
||||||
? params.subminerDuplicateNoteIds
|
? params.subminerDuplicateNoteIds
|
||||||
: [];
|
: [];
|
||||||
return [...new Set(rawNoteIds.filter((entry): entry is number => {
|
return [
|
||||||
|
...new Set(
|
||||||
|
rawNoteIds.filter((entry): entry is number => {
|
||||||
return typeof entry === 'number' && Number.isInteger(entry) && entry > 0;
|
return typeof entry === 'number' && Number.isInteger(entry) && entry > 0;
|
||||||
}))].sort((left, right) => left - right);
|
}),
|
||||||
|
),
|
||||||
|
].sort((left, right) => left - right);
|
||||||
}
|
}
|
||||||
|
|
||||||
private requestIncludesAddAction(action: string, requestJson: Record<string, unknown>): boolean {
|
private requestIncludesAddAction(action: string, requestJson: Record<string, unknown>): boolean {
|
||||||
|
|||||||
@@ -113,10 +113,7 @@ interface CardCreationDeps {
|
|||||||
setUpdateInProgress: (value: boolean) => void;
|
setUpdateInProgress: (value: boolean) => void;
|
||||||
trackLastAddedNoteId?: (noteId: number) => void;
|
trackLastAddedNoteId?: (noteId: number) => void;
|
||||||
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||||
findDuplicateNoteIds?: (
|
findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
|
||||||
expression: string,
|
|
||||||
noteInfo: CardCreationNoteInfo,
|
|
||||||
) => Promise<number[]>;
|
|
||||||
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
|
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,10 +570,7 @@ export class CardCreationService {
|
|||||||
await this.deps.findDuplicateNoteIds(pendingExpressionText, pendingNoteInfo),
|
await this.deps.findDuplicateNoteIds(pendingExpressionText, pendingNoteInfo),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn(
|
log.warn('Failed to capture pre-add duplicate note ids:', (error as Error).message);
|
||||||
'Failed to capture pre-add duplicate note ids:',
|
|
||||||
(error as Error).message,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,9 +722,7 @@ export class CardCreationService {
|
|||||||
private createPendingNoteInfo(fields: Record<string, string>): CardCreationNoteInfo {
|
private createPendingNoteInfo(fields: Record<string, string>): CardCreationNoteInfo {
|
||||||
return {
|
return {
|
||||||
noteId: -1,
|
noteId: -1,
|
||||||
fields: Object.fromEntries(
|
fields: Object.fromEntries(Object.entries(fields).map(([name, value]) => [name, { value }])),
|
||||||
Object.entries(fields).map(([name, value]) => [name, { value }]),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -307,7 +307,11 @@ test('findDuplicateNoteIds returns no matches when maxMatches is zero', async ()
|
|||||||
};
|
};
|
||||||
|
|
||||||
let notesInfoCalls = 0;
|
let notesInfoCalls = 0;
|
||||||
const duplicateIds = await findDuplicateNoteIds('貴様', 100, currentNote, {
|
const duplicateIds = await findDuplicateNoteIds(
|
||||||
|
'貴様',
|
||||||
|
100,
|
||||||
|
currentNote,
|
||||||
|
{
|
||||||
findNotes: async () => [200],
|
findNotes: async () => [200],
|
||||||
notesInfo: async (noteIds) => {
|
notesInfo: async (noteIds) => {
|
||||||
notesInfoCalls += 1;
|
notesInfoCalls += 1;
|
||||||
@@ -321,7 +325,9 @@ test('findDuplicateNoteIds returns no matches when maxMatches is zero', async ()
|
|||||||
getDeck: () => 'Japanese::Mining',
|
getDeck: () => 'Japanese::Mining',
|
||||||
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
|
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
|
||||||
logWarn: () => {},
|
logWarn: () => {},
|
||||||
}, 0);
|
},
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
assert.deepEqual(duplicateIds, []);
|
assert.deepEqual(duplicateIds, []);
|
||||||
assert.equal(notesInfoCalls, 0);
|
assert.equal(notesInfoCalls, 0);
|
||||||
|
|||||||
@@ -24,13 +24,7 @@ export async function findDuplicateNote(
|
|||||||
noteInfo: NoteInfo,
|
noteInfo: NoteInfo,
|
||||||
deps: DuplicateDetectionDeps,
|
deps: DuplicateDetectionDeps,
|
||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
const duplicateNoteIds = await findDuplicateNoteIds(
|
const duplicateNoteIds = await findDuplicateNoteIds(expression, excludeNoteId, noteInfo, deps, 1);
|
||||||
expression,
|
|
||||||
excludeNoteId,
|
|
||||||
noteInfo,
|
|
||||||
deps,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
return duplicateNoteIds[0] ?? null;
|
return duplicateNoteIds[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
|
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
|
||||||
assert.equal(config.ai.enabled, false);
|
assert.equal(config.ai.enabled, false);
|
||||||
assert.equal(config.ai.apiKeyCommand, '');
|
assert.equal(config.ai.apiKeyCommand, '');
|
||||||
|
assert.equal(config.texthooker.openBrowser, false);
|
||||||
|
assert.equal(config.controller.enabled, false);
|
||||||
|
assert.equal(config.ankiConnect.enabled, true);
|
||||||
assert.deepEqual(config.ankiConnect.ai, {
|
assert.deepEqual(config.ankiConnect.ai, {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
model: '',
|
model: '',
|
||||||
@@ -47,12 +50,13 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.startupWarmups.yomitanExtension, true);
|
assert.equal(config.startupWarmups.yomitanExtension, true);
|
||||||
assert.equal(config.startupWarmups.subtitleDictionaries, true);
|
assert.equal(config.startupWarmups.subtitleDictionaries, true);
|
||||||
assert.equal(config.startupWarmups.jellyfinRemoteSession, true);
|
assert.equal(config.startupWarmups.jellyfinRemoteSession, true);
|
||||||
assert.equal(config.discordPresence.enabled, false);
|
assert.equal(config.discordPresence.enabled, true);
|
||||||
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
|
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
|
||||||
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
|
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
|
||||||
assert.equal(config.subtitleStyle.preserveLineBreaks, false);
|
assert.equal(config.subtitleStyle.preserveLineBreaks, false);
|
||||||
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
|
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
|
||||||
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, false);
|
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true);
|
||||||
|
assert.equal(config.subtitleSidebar.enabled, true);
|
||||||
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
|
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
|
||||||
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)');
|
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -96,6 +100,7 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.immersionTracking.lifetimeSummaries?.global, true);
|
assert.equal(config.immersionTracking.lifetimeSummaries?.global, true);
|
||||||
assert.equal(config.immersionTracking.lifetimeSummaries?.anime, true);
|
assert.equal(config.immersionTracking.lifetimeSummaries?.anime, true);
|
||||||
assert.equal(config.immersionTracking.lifetimeSummaries?.media, true);
|
assert.equal(config.immersionTracking.lifetimeSummaries?.media, true);
|
||||||
|
assert.equal(config.stats.autoOpenBrowser, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('throws actionable startup parse error for malformed config at construction time', () => {
|
test('throws actionable startup parse error for malformed config at construction time', () => {
|
||||||
@@ -2122,7 +2127,23 @@ test('template generator includes known keys', () => {
|
|||||||
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
|
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
|
||||||
assert.match(
|
assert.match(
|
||||||
output,
|
output,
|
||||||
/"enabled": false,? \/\/ Enable AnkiConnect integration\. Values: true \| false/,
|
/"openBrowser": false,? \/\/ Open browser setting\. Values: true \| false/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
output,
|
||||||
|
/"enabled": false,? \/\/ Enable overlay controller support through the Chrome Gamepad API\. Values: true \| false/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
output,
|
||||||
|
/"autoPauseVideoOnYomitanPopup": true,? \/\/ Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes\. Values: true \| false/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
output,
|
||||||
|
/"enabled": true,? \/\/ Enable the subtitle sidebar feature for parsed subtitle sources\. Values: true \| false/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
output,
|
||||||
|
/"enabled": true,? \/\/ Enable AnkiConnect integration\. Values: true \| false/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
output,
|
output,
|
||||||
@@ -2136,6 +2157,14 @@ test('template generator includes known keys', () => {
|
|||||||
output,
|
output,
|
||||||
/"enabled": false,? \/\/ Enable shared OpenAI-compatible AI provider features\. Values: true \| false/,
|
/"enabled": false,? \/\/ Enable shared OpenAI-compatible AI provider features\. Values: true \| false/,
|
||||||
);
|
);
|
||||||
|
assert.match(
|
||||||
|
output,
|
||||||
|
/"enabled": true,? \/\/ Enable optional Discord Rich Presence updates\. Values: true \| false/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
output,
|
||||||
|
/"autoOpenBrowser": false,? \/\/ Automatically open the stats dashboard in a browser when the server starts\. Values: true \| false/,
|
||||||
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
output,
|
output,
|
||||||
/"primarySubLanguages": \[\s*"ja",\s*"jpn"\s*\],? \/\/ Comma-separated primary subtitle language priority for managed subtitle auto-selection\./,
|
/"primarySubLanguages": \[\s*"ja",\s*"jpn"\s*\],? \/\/ Comma-separated primary subtitle language priority for managed subtitle auto-selection\./,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const {
|
|||||||
startupWarmups,
|
startupWarmups,
|
||||||
auto_start_overlay,
|
auto_start_overlay,
|
||||||
} = CORE_DEFAULT_CONFIG;
|
} = CORE_DEFAULT_CONFIG;
|
||||||
const { ankiConnect, jimaku, anilist, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
|
const { ankiConnect, jimaku, anilist, mpv, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
|
||||||
INTEGRATIONS_DEFAULT_CONFIG;
|
INTEGRATIONS_DEFAULT_CONFIG;
|
||||||
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
|
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
|
||||||
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
||||||
@@ -60,6 +60,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
|||||||
auto_start_overlay,
|
auto_start_overlay,
|
||||||
jimaku,
|
jimaku,
|
||||||
anilist,
|
anilist,
|
||||||
|
mpv,
|
||||||
yomitan,
|
yomitan,
|
||||||
jellyfin,
|
jellyfin,
|
||||||
discordPresence,
|
discordPresence,
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
},
|
},
|
||||||
texthooker: {
|
texthooker: {
|
||||||
launchAtStartup: true,
|
launchAtStartup: true,
|
||||||
openBrowser: true,
|
openBrowser: false,
|
||||||
},
|
},
|
||||||
controller: {
|
controller: {
|
||||||
enabled: true,
|
enabled: false,
|
||||||
preferredGamepadId: '',
|
preferredGamepadId: '',
|
||||||
preferredGamepadLabel: '',
|
preferredGamepadLabel: '',
|
||||||
smoothScroll: true,
|
smoothScroll: true,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
| 'ankiConnect'
|
| 'ankiConnect'
|
||||||
| 'jimaku'
|
| 'jimaku'
|
||||||
| 'anilist'
|
| 'anilist'
|
||||||
|
| 'mpv'
|
||||||
| 'yomitan'
|
| 'yomitan'
|
||||||
| 'jellyfin'
|
| 'jellyfin'
|
||||||
| 'discordPresence'
|
| 'discordPresence'
|
||||||
@@ -12,7 +13,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
| 'youtubeSubgen'
|
| 'youtubeSubgen'
|
||||||
> = {
|
> = {
|
||||||
ankiConnect: {
|
ankiConnect: {
|
||||||
enabled: false,
|
enabled: true,
|
||||||
url: 'http://127.0.0.1:8765',
|
url: 'http://127.0.0.1:8765',
|
||||||
pollingRate: 3000,
|
pollingRate: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
@@ -90,6 +91,9 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
languagePreference: 'ja',
|
languagePreference: 'ja',
|
||||||
maxEntryResults: 10,
|
maxEntryResults: 10,
|
||||||
},
|
},
|
||||||
|
mpv: {
|
||||||
|
executablePath: '',
|
||||||
|
},
|
||||||
anilist: {
|
anilist: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
@@ -128,7 +132,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
transcodeVideoCodec: 'h264',
|
transcodeVideoCodec: 'h264',
|
||||||
},
|
},
|
||||||
discordPresence: {
|
discordPresence: {
|
||||||
enabled: false,
|
enabled: true,
|
||||||
presenceStyle: 'default' as const,
|
presenceStyle: 'default' as const,
|
||||||
updateIntervalMs: 3_000,
|
updateIntervalMs: 3_000,
|
||||||
debounceMs: 750,
|
debounceMs: 750,
|
||||||
|
|||||||
@@ -6,6 +6,6 @@ export const STATS_DEFAULT_CONFIG: Pick<ResolvedConfig, 'stats'> = {
|
|||||||
markWatchedKey: 'KeyW',
|
markWatchedKey: 'KeyW',
|
||||||
serverPort: 6969,
|
serverPort: 6969,
|
||||||
autoStartServer: true,
|
autoStartServer: true,
|
||||||
autoOpenBrowser: true,
|
autoOpenBrowser: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
|||||||
enableJlpt: false,
|
enableJlpt: false,
|
||||||
preserveLineBreaks: false,
|
preserveLineBreaks: false,
|
||||||
autoPauseVideoOnHover: true,
|
autoPauseVideoOnHover: true,
|
||||||
autoPauseVideoOnYomitanPopup: false,
|
autoPauseVideoOnYomitanPopup: true,
|
||||||
hoverTokenColor: '#f4dbd6',
|
hoverTokenColor: '#f4dbd6',
|
||||||
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||||
nameMatchEnabled: true,
|
nameMatchEnabled: true,
|
||||||
@@ -58,7 +58,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
subtitleSidebar: {
|
subtitleSidebar: {
|
||||||
enabled: false,
|
enabled: true,
|
||||||
autoOpen: false,
|
autoOpen: false,
|
||||||
layout: 'overlay',
|
layout: 'overlay',
|
||||||
toggleKey: 'Backslash',
|
toggleKey: 'Backslash',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ test('config option registry includes critical paths and has unique entries', ()
|
|||||||
'ankiConnect.enabled',
|
'ankiConnect.enabled',
|
||||||
'anilist.characterDictionary.enabled',
|
'anilist.characterDictionary.enabled',
|
||||||
'anilist.characterDictionary.collapsibleSections.description',
|
'anilist.characterDictionary.collapsibleSections.description',
|
||||||
|
'mpv.executablePath',
|
||||||
'yomitan.externalProfilePath',
|
'yomitan.externalProfilePath',
|
||||||
'immersionTracking.enabled',
|
'immersionTracking.enabled',
|
||||||
]) {
|
]) {
|
||||||
@@ -48,6 +49,7 @@ test('config template sections include expected domains and unique keys', () =>
|
|||||||
'subtitleStyle',
|
'subtitleStyle',
|
||||||
'ankiConnect',
|
'ankiConnect',
|
||||||
'yomitan',
|
'yomitan',
|
||||||
|
'mpv',
|
||||||
'immersionTracking',
|
'immersionTracking',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -238,6 +238,13 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay',
|
'Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'mpv.executablePath',
|
||||||
|
kind: 'string',
|
||||||
|
defaultValue: defaultConfig.mpv.executablePath,
|
||||||
|
description:
|
||||||
|
'Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'jellyfin.enabled',
|
path: 'jellyfin.enabled',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface ConfigTemplateSection {
|
|||||||
export const SPECIAL_COMMANDS = {
|
export const SPECIAL_COMMANDS = {
|
||||||
SUBSYNC_TRIGGER: '__subsync-trigger',
|
SUBSYNC_TRIGGER: '__subsync-trigger',
|
||||||
RUNTIME_OPTIONS_OPEN: '__runtime-options-open',
|
RUNTIME_OPTIONS_OPEN: '__runtime-options-open',
|
||||||
|
JIMAKU_OPEN: '__jimaku-open',
|
||||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||||
REPLAY_SUBTITLE: '__replay-subtitle',
|
REPLAY_SUBTITLE: '__replay-subtitle',
|
||||||
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
||||||
|
|||||||
@@ -131,7 +131,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'YouTube Playback Settings',
|
title: 'YouTube Playback Settings',
|
||||||
description: ['Defaults for managed subtitle language preferences and YouTube subtitle loading.'],
|
description: [
|
||||||
|
'Defaults for managed subtitle language preferences and YouTube subtitle loading.',
|
||||||
|
],
|
||||||
key: 'youtube',
|
key: 'youtube',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -153,6 +155,14 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
],
|
],
|
||||||
key: 'yomitan',
|
key: 'yomitan',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'MPV Launcher',
|
||||||
|
description: [
|
||||||
|
'Optional mpv.exe override for Windows playback entry points.',
|
||||||
|
'Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.',
|
||||||
|
],
|
||||||
|
key: 'mpv',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Jellyfin',
|
title: 'Jellyfin',
|
||||||
description: [
|
description: [
|
||||||
|
|||||||
31
src/config/resolve/integrations.test.ts
Normal file
31
src/config/resolve/integrations.test.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { resolveConfig } from '../resolve';
|
||||||
|
|
||||||
|
test('resolveConfig trims configured mpv executable path', () => {
|
||||||
|
const { resolved, warnings } = resolveConfig({
|
||||||
|
mpv: {
|
||||||
|
executablePath: ' C:\\Program Files\\mpv\\mpv.exe ',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resolved.mpv.executablePath, 'C:\\Program Files\\mpv\\mpv.exe');
|
||||||
|
assert.deepEqual(warnings, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveConfig warns for invalid mpv executable path type', () => {
|
||||||
|
const { resolved, warnings } = resolveConfig({
|
||||||
|
mpv: {
|
||||||
|
executablePath: 42 as never,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resolved.mpv.executablePath, '');
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
assert.deepEqual(warnings[0], {
|
||||||
|
path: 'mpv.executablePath',
|
||||||
|
value: 42,
|
||||||
|
fallback: '',
|
||||||
|
message: 'Expected string.',
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -228,6 +228,22 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
|||||||
warn('yomitan', src.yomitan, resolved.yomitan, 'Expected object.');
|
warn('yomitan', src.yomitan, resolved.yomitan, 'Expected object.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isObject(src.mpv)) {
|
||||||
|
const executablePath = asString(src.mpv.executablePath);
|
||||||
|
if (executablePath !== undefined) {
|
||||||
|
resolved.mpv.executablePath = executablePath.trim();
|
||||||
|
} else if (src.mpv.executablePath !== undefined) {
|
||||||
|
warn(
|
||||||
|
'mpv.executablePath',
|
||||||
|
src.mpv.executablePath,
|
||||||
|
resolved.mpv.executablePath,
|
||||||
|
'Expected string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (src.mpv !== undefined) {
|
||||||
|
warn('mpv', src.mpv, resolved.mpv, 'Expected object.');
|
||||||
|
}
|
||||||
|
|
||||||
if (isObject(src.jellyfin)) {
|
if (isObject(src.jellyfin)) {
|
||||||
const enabled = asBoolean(src.jellyfin.enabled);
|
const enabled = asBoolean(src.jellyfin.enabled);
|
||||||
if (enabled !== undefined) {
|
if (enabled !== undefined) {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ test('discordPresence invalid values warn and keep defaults', () => {
|
|||||||
|
|
||||||
applyIntegrationConfig(context);
|
applyIntegrationConfig(context);
|
||||||
|
|
||||||
assert.equal(context.resolved.discordPresence.enabled, false);
|
assert.equal(context.resolved.discordPresence.enabled, true);
|
||||||
assert.equal(context.resolved.discordPresence.updateIntervalMs, 3_000);
|
assert.equal(context.resolved.discordPresence.updateIntervalMs, 3_000);
|
||||||
assert.equal(context.resolved.discordPresence.debounceMs, 750);
|
assert.equal(context.resolved.discordPresence.debounceMs, 750);
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ test('subtitleSidebar falls back and warns on invalid values', () => {
|
|||||||
|
|
||||||
applySubtitleDomainConfig(context);
|
applySubtitleDomainConfig(context);
|
||||||
|
|
||||||
assert.equal(context.resolved.subtitleSidebar.enabled, false);
|
assert.equal(context.resolved.subtitleSidebar.enabled, true);
|
||||||
assert.equal(context.resolved.subtitleSidebar.autoOpen, false);
|
assert.equal(context.resolved.subtitleSidebar.autoOpen, false);
|
||||||
assert.equal(context.resolved.subtitleSidebar.layout, 'overlay');
|
assert.equal(context.resolved.subtitleSidebar.layout, 'overlay');
|
||||||
assert.equal(context.resolved.subtitleSidebar.maxWidth, 420);
|
assert.equal(context.resolved.subtitleSidebar.maxWidth, 420);
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ test('subtitleStyle autoPauseVideoOnYomitanPopup falls back on invalid value', (
|
|||||||
|
|
||||||
applySubtitleDomainConfig(context);
|
applySubtitleDomainConfig(context);
|
||||||
|
|
||||||
assert.equal(context.resolved.subtitleStyle.autoPauseVideoOnYomitanPopup, false);
|
assert.equal(context.resolved.subtitleStyle.autoPauseVideoOnYomitanPopup, true);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
warnings.some(
|
warnings.some(
|
||||||
(warning) =>
|
(warning) =>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import http from 'node:http';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { createStatsApp, startStatsServer } from '../stats-server.js';
|
import { createStatsApp, startStatsServer } from '../stats-server.js';
|
||||||
@@ -1172,7 +1173,23 @@ describe('stats server API routes', () => {
|
|||||||
|
|
||||||
const bun = globalThis as typeof globalThis & BunRuntime;
|
const bun = globalThis as typeof globalThis & BunRuntime;
|
||||||
const originalServe = bun.Bun.serve;
|
const originalServe = bun.Bun.serve;
|
||||||
|
const originalCreateServer = http.createServer;
|
||||||
|
let listenedWith: { port: number; hostname: string } | null = null;
|
||||||
|
let closeCalls = 0;
|
||||||
bun.Bun.serve = undefined;
|
bun.Bun.serve = undefined;
|
||||||
|
(
|
||||||
|
http as typeof http & {
|
||||||
|
createServer: typeof http.createServer;
|
||||||
|
}
|
||||||
|
).createServer = (() =>
|
||||||
|
({
|
||||||
|
listen: (port: number, hostname: string) => {
|
||||||
|
listenedWith = { port, hostname };
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
closeCalls += 1;
|
||||||
|
},
|
||||||
|
}) as unknown as ReturnType<typeof http.createServer>) as typeof http.createServer;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const server = startStatsServer({
|
const server = startStatsServer({
|
||||||
@@ -1181,9 +1198,16 @@ describe('stats server API routes', () => {
|
|||||||
tracker: createMockTracker(),
|
tracker: createMockTracker(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(listenedWith, { port: 0, hostname: '127.0.0.1' });
|
||||||
server.close();
|
server.close();
|
||||||
|
assert.equal(closeCalls, 1);
|
||||||
} finally {
|
} finally {
|
||||||
bun.Bun.serve = originalServe;
|
bun.Bun.serve = originalServe;
|
||||||
|
(
|
||||||
|
http as typeof http & {
|
||||||
|
createServer: typeof http.createServer;
|
||||||
|
}
|
||||||
|
).createServer = originalCreateServer;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -83,7 +83,9 @@ const PRESENCE_STYLES: Record<DiscordPresenceStylePreset, PresenceStyleDefinitio
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolvePresenceStyle(preset: DiscordPresenceStylePreset | undefined): PresenceStyleDefinition {
|
function resolvePresenceStyle(
|
||||||
|
preset: DiscordPresenceStylePreset | undefined,
|
||||||
|
): PresenceStyleDefinition {
|
||||||
return PRESENCE_STYLES[preset ?? 'default'] ?? PRESENCE_STYLES.default;
|
return PRESENCE_STYLES[preset ?? 'default'] ?? PRESENCE_STYLES.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,9 +132,7 @@ export function buildDiscordPresenceActivity(
|
|||||||
const status = buildStatus(snapshot);
|
const status = buildStatus(snapshot);
|
||||||
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media');
|
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media');
|
||||||
const details =
|
const details =
|
||||||
snapshot.connected && snapshot.mediaPath
|
snapshot.connected && snapshot.mediaPath ? trimField(title) : style.fallbackDetails;
|
||||||
? trimField(title)
|
|
||||||
: style.fallbackDetails;
|
|
||||||
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
|
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
|
||||||
const state =
|
const state =
|
||||||
snapshot.connected && snapshot.mediaPath
|
snapshot.connected && snapshot.mediaPath
|
||||||
@@ -157,10 +157,7 @@ export function buildDiscordPresenceActivity(
|
|||||||
if (style.smallImageText.trim().length > 0) {
|
if (style.smallImageText.trim().length > 0) {
|
||||||
activity.smallImageText = trimField(style.smallImageText.trim());
|
activity.smallImageText = trimField(style.smallImageText.trim());
|
||||||
}
|
}
|
||||||
if (
|
if (style.buttonLabel.trim().length > 0 && /^https?:\/\//.test(style.buttonUrl.trim())) {
|
||||||
style.buttonLabel.trim().length > 0 &&
|
|
||||||
/^https?:\/\//.test(style.buttonUrl.trim())
|
|
||||||
) {
|
|
||||||
activity.buttons = [
|
activity.buttons = [
|
||||||
{
|
{
|
||||||
label: trimField(style.buttonLabel.trim(), 32),
|
label: trimField(style.buttonLabel.trim(), 32),
|
||||||
|
|||||||
@@ -380,42 +380,22 @@ export class ImmersionTrackerService {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const eventsRetention = daysToRetentionWindow(
|
const eventsRetention = daysToRetentionWindow(retention.eventsDays, 7, 3650);
|
||||||
retention.eventsDays,
|
const telemetryRetention = daysToRetentionWindow(retention.telemetryDays, 30, 3650);
|
||||||
7,
|
const sessionsRetention = daysToRetentionWindow(retention.sessionsDays, 30, 3650);
|
||||||
3650,
|
|
||||||
);
|
|
||||||
const telemetryRetention = daysToRetentionWindow(
|
|
||||||
retention.telemetryDays,
|
|
||||||
30,
|
|
||||||
3650,
|
|
||||||
);
|
|
||||||
const sessionsRetention = daysToRetentionWindow(
|
|
||||||
retention.sessionsDays,
|
|
||||||
30,
|
|
||||||
3650,
|
|
||||||
);
|
|
||||||
this.eventsRetentionMs = eventsRetention.ms;
|
this.eventsRetentionMs = eventsRetention.ms;
|
||||||
this.eventsRetentionDays = eventsRetention.days;
|
this.eventsRetentionDays = eventsRetention.days;
|
||||||
this.telemetryRetentionMs = telemetryRetention.ms;
|
this.telemetryRetentionMs = telemetryRetention.ms;
|
||||||
this.telemetryRetentionDays = telemetryRetention.days;
|
this.telemetryRetentionDays = telemetryRetention.days;
|
||||||
this.sessionsRetentionMs = sessionsRetention.ms;
|
this.sessionsRetentionMs = sessionsRetention.ms;
|
||||||
this.sessionsRetentionDays = sessionsRetention.days;
|
this.sessionsRetentionDays = sessionsRetention.days;
|
||||||
this.dailyRollupRetentionMs = daysToRetentionWindow(
|
this.dailyRollupRetentionMs = daysToRetentionWindow(retention.dailyRollupsDays, 365, 36500).ms;
|
||||||
retention.dailyRollupsDays,
|
|
||||||
365,
|
|
||||||
36500,
|
|
||||||
).ms;
|
|
||||||
this.monthlyRollupRetentionMs = daysToRetentionWindow(
|
this.monthlyRollupRetentionMs = daysToRetentionWindow(
|
||||||
retention.monthlyRollupsDays,
|
retention.monthlyRollupsDays,
|
||||||
5 * 365,
|
5 * 365,
|
||||||
36500,
|
36500,
|
||||||
).ms;
|
).ms;
|
||||||
this.vacuumIntervalMs = daysToRetentionWindow(
|
this.vacuumIntervalMs = daysToRetentionWindow(retention.vacuumIntervalDays, 7, 3650).ms;
|
||||||
retention.vacuumIntervalDays,
|
|
||||||
7,
|
|
||||||
3650,
|
|
||||||
).ms;
|
|
||||||
this.db = new Database(this.dbPath);
|
this.db = new Database(this.dbPath);
|
||||||
applyPragmas(this.db);
|
applyPragmas(this.db);
|
||||||
ensureSchema(this.db);
|
ensureSchema(this.db);
|
||||||
|
|||||||
@@ -1230,18 +1230,7 @@ test('getQueryHints counts new words by distinct headword first-seen time', () =
|
|||||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
).run(
|
).run('猫', '猫', 'ねこ', 'noun', '名詞', '', '', String(twoDaysAgo), String(twoDaysAgo), 1);
|
||||||
'猫',
|
|
||||||
'猫',
|
|
||||||
'ねこ',
|
|
||||||
'noun',
|
|
||||||
'名詞',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
String(twoDaysAgo),
|
|
||||||
String(twoDaysAgo),
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
|
|
||||||
const hints = getQueryHints(db);
|
const hints = getQueryHints(db);
|
||||||
assert.equal(hints.newWordsToday, 1);
|
assert.equal(hints.newWordsToday, 1);
|
||||||
|
|||||||
@@ -82,12 +82,9 @@ function hasRetainedPriorSession(
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.get(
|
.get(videoId, toDbTimestamp(startedAtMs), toDbTimestamp(startedAtMs), currentSessionId) as {
|
||||||
videoId,
|
found: number;
|
||||||
toDbTimestamp(startedAtMs),
|
} | null;
|
||||||
toDbTimestamp(startedAtMs),
|
|
||||||
currentSessionId,
|
|
||||||
) as { found: number } | null;
|
|
||||||
return Boolean(row);
|
return Boolean(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,9 +126,9 @@ test('pruneRawRetention skips disabled retention windows', () => {
|
|||||||
const remainingTelemetry = db
|
const remainingTelemetry = db
|
||||||
.prepare('SELECT COUNT(*) AS count FROM imm_session_telemetry')
|
.prepare('SELECT COUNT(*) AS count FROM imm_session_telemetry')
|
||||||
.get() as { count: number };
|
.get() as { count: number };
|
||||||
const remainingSessions = db
|
const remainingSessions = db.prepare('SELECT COUNT(*) AS count FROM imm_sessions').get() as {
|
||||||
.prepare('SELECT COUNT(*) AS count FROM imm_sessions')
|
count: number;
|
||||||
.get() as { count: number };
|
};
|
||||||
|
|
||||||
assert.equal(result.deletedSessionEvents, 0);
|
assert.equal(result.deletedSessionEvents, 0);
|
||||||
assert.equal(result.deletedTelemetryRows, 0);
|
assert.equal(result.deletedTelemetryRows, 0);
|
||||||
|
|||||||
@@ -56,10 +56,7 @@ export function pruneRawRetention(
|
|||||||
sessionsRetentionDays?: number;
|
sessionsRetentionDays?: number;
|
||||||
},
|
},
|
||||||
): RawRetentionResult {
|
): RawRetentionResult {
|
||||||
const resolveCutoff = (
|
const resolveCutoff = (retentionMs: number, retentionDays: number | undefined): string => {
|
||||||
retentionMs: number,
|
|
||||||
retentionDays: number | undefined,
|
|
||||||
): string => {
|
|
||||||
if (retentionDays !== undefined) {
|
if (retentionDays !== undefined) {
|
||||||
return subtractDbTimestamp(currentMs, BigInt(retentionDays) * 86_400_000n);
|
return subtractDbTimestamp(currentMs, BigInt(retentionDays) * 86_400_000n);
|
||||||
}
|
}
|
||||||
@@ -68,9 +65,11 @@ export function pruneRawRetention(
|
|||||||
|
|
||||||
const deletedSessionEvents = Number.isFinite(policy.eventsRetentionMs)
|
const deletedSessionEvents = Number.isFinite(policy.eventsRetentionMs)
|
||||||
? (
|
? (
|
||||||
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(
|
db
|
||||||
resolveCutoff(policy.eventsRetentionMs, policy.eventsRetentionDays),
|
.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`)
|
||||||
) as { changes: number }
|
.run(resolveCutoff(policy.eventsRetentionMs, policy.eventsRetentionDays)) as {
|
||||||
|
changes: number;
|
||||||
|
}
|
||||||
).changes
|
).changes
|
||||||
: 0;
|
: 0;
|
||||||
const deletedTelemetryRows = Number.isFinite(policy.telemetryRetentionMs)
|
const deletedTelemetryRows = Number.isFinite(policy.telemetryRetentionMs)
|
||||||
|
|||||||
@@ -150,9 +150,11 @@ export function getSessionEvents(
|
|||||||
ORDER BY ts_ms ASC
|
ORDER BY ts_ms ASC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`);
|
`);
|
||||||
const rows = stmt.all(sessionId, ...eventTypes, limit) as Array<SessionEventRow & {
|
const rows = stmt.all(sessionId, ...eventTypes, limit) as Array<
|
||||||
|
SessionEventRow & {
|
||||||
tsMs: number | string;
|
tsMs: number | string;
|
||||||
}>;
|
}
|
||||||
|
>;
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
tsMs: fromDbTimestamp(row.tsMs) ?? 0,
|
tsMs: fromDbTimestamp(row.tsMs) ?? 0,
|
||||||
|
|||||||
@@ -355,9 +355,7 @@ export function upsertCoverArt(
|
|||||||
const fetchedAtMs = toDbTimestamp(nowMs());
|
const fetchedAtMs = toDbTimestamp(nowMs());
|
||||||
const coverBlob = normalizeCoverBlobBytes(art.coverBlob);
|
const coverBlob = normalizeCoverBlobBytes(art.coverBlob);
|
||||||
const computedCoverBlobHash =
|
const computedCoverBlobHash =
|
||||||
coverBlob && coverBlob.length > 0
|
coverBlob && coverBlob.length > 0 ? createHash('sha256').update(coverBlob).digest('hex') : null;
|
||||||
? createHash('sha256').update(coverBlob).digest('hex')
|
|
||||||
: null;
|
|
||||||
let coverBlobHash = computedCoverBlobHash ?? sharedCoverBlobHash ?? null;
|
let coverBlobHash = computedCoverBlobHash ?? sharedCoverBlobHash ?? null;
|
||||||
if (!coverBlobHash && (!coverBlob || coverBlob.length === 0)) {
|
if (!coverBlobHash && (!coverBlob || coverBlob.length === 0)) {
|
||||||
coverBlobHash = existing?.coverBlobHash ?? null;
|
coverBlobHash = existing?.coverBlobHash ?? null;
|
||||||
|
|||||||
@@ -39,10 +39,12 @@ export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummar
|
|||||||
ORDER BY s.started_at_ms DESC
|
ORDER BY s.started_at_ms DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`);
|
`);
|
||||||
const rows = prepared.all(limit) as Array<SessionSummaryQueryRow & {
|
const rows = prepared.all(limit) as Array<
|
||||||
|
SessionSummaryQueryRow & {
|
||||||
startedAtMs: number | string;
|
startedAtMs: number | string;
|
||||||
endedAtMs: number | string | null;
|
endedAtMs: number | string | null;
|
||||||
}>;
|
}
|
||||||
|
>;
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
startedAtMs: fromDbTimestamp(row.startedAtMs) ?? 0,
|
startedAtMs: fromDbTimestamp(row.startedAtMs) ?? 0,
|
||||||
@@ -69,19 +71,21 @@ export function getSessionTimeline(
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
if (limit === undefined) {
|
if (limit === undefined) {
|
||||||
const rows = db.prepare(select).all(sessionId) as Array<SessionTimelineRow & {
|
const rows = db.prepare(select).all(sessionId) as Array<
|
||||||
|
SessionTimelineRow & {
|
||||||
sampleMs: number | string;
|
sampleMs: number | string;
|
||||||
}>;
|
}
|
||||||
|
>;
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,
|
sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
const rows = db
|
const rows = db.prepare(`${select}\n LIMIT ?`).all(sessionId, limit) as Array<
|
||||||
.prepare(`${select}\n LIMIT ?`)
|
SessionTimelineRow & {
|
||||||
.all(sessionId, limit) as Array<SessionTimelineRow & {
|
|
||||||
sampleMs: number | string;
|
sampleMs: number | string;
|
||||||
}>;
|
}
|
||||||
|
>;
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,
|
sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,
|
||||||
|
|||||||
@@ -359,10 +359,7 @@ function getNumericCalendarValue(
|
|||||||
return Number(row?.value ?? 0);
|
return Number(row?.value ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLocalEpochDay(
|
export function getLocalEpochDay(db: DatabaseSync, timestampMs: number | bigint | string): number {
|
||||||
db: DatabaseSync,
|
|
||||||
timestampMs: number | bigint | string,
|
|
||||||
): number {
|
|
||||||
return getNumericCalendarValue(
|
return getNumericCalendarValue(
|
||||||
db,
|
db,
|
||||||
`
|
`
|
||||||
@@ -375,10 +372,7 @@ export function getLocalEpochDay(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLocalMonthKey(
|
export function getLocalMonthKey(db: DatabaseSync, timestampMs: number | bigint | string): number {
|
||||||
db: DatabaseSync,
|
|
||||||
timestampMs: number | bigint | string,
|
|
||||||
): number {
|
|
||||||
return getNumericCalendarValue(
|
return getNumericCalendarValue(
|
||||||
db,
|
db,
|
||||||
`
|
`
|
||||||
@@ -391,10 +385,7 @@ export function getLocalMonthKey(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLocalDayOfWeek(
|
export function getLocalDayOfWeek(db: DatabaseSync, timestampMs: number | bigint | string): number {
|
||||||
db: DatabaseSync,
|
|
||||||
timestampMs: number | bigint | string,
|
|
||||||
): number {
|
|
||||||
return getNumericCalendarValue(
|
return getNumericCalendarValue(
|
||||||
db,
|
db,
|
||||||
`
|
`
|
||||||
@@ -407,10 +398,7 @@ export function getLocalDayOfWeek(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLocalHourOfDay(
|
export function getLocalHourOfDay(db: DatabaseSync, timestampMs: number | bigint | string): number {
|
||||||
db: DatabaseSync,
|
|
||||||
timestampMs: number | bigint | string,
|
|
||||||
): number {
|
|
||||||
return getNumericCalendarValue(
|
return getNumericCalendarValue(
|
||||||
db,
|
db,
|
||||||
`
|
`
|
||||||
@@ -458,7 +446,8 @@ export function getShiftedLocalDayTimestamp(
|
|||||||
dayOffset: number,
|
dayOffset: number,
|
||||||
): string {
|
): string {
|
||||||
const normalizedDayOffset = Math.trunc(dayOffset);
|
const normalizedDayOffset = Math.trunc(dayOffset);
|
||||||
const modifier = normalizedDayOffset >= 0 ? `+${normalizedDayOffset} days` : `${normalizedDayOffset} days`;
|
const modifier =
|
||||||
|
normalizedDayOffset >= 0 ? `+${normalizedDayOffset} days` : `${normalizedDayOffset} days`;
|
||||||
const row = db
|
const row = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -87,7 +87,20 @@ const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
|
|||||||
'90d': 90,
|
'90d': 90,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
const MONTH_NAMES = [
|
||||||
|
'Jan',
|
||||||
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec',
|
||||||
|
];
|
||||||
|
|
||||||
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
@@ -101,7 +114,11 @@ function getTrendMonthlyLimit(db: DatabaseSync, range: TrendRange): number {
|
|||||||
}
|
}
|
||||||
const currentTimestamp = currentDbTimestamp();
|
const currentTimestamp = currentDbTimestamp();
|
||||||
const todayStartMs = getShiftedLocalDayTimestamp(db, currentTimestamp, 0);
|
const todayStartMs = getShiftedLocalDayTimestamp(db, currentTimestamp, 0);
|
||||||
const cutoffMs = getShiftedLocalDayTimestamp(db, currentTimestamp, -(TREND_DAY_LIMITS[range] - 1));
|
const cutoffMs = getShiftedLocalDayTimestamp(
|
||||||
|
db,
|
||||||
|
currentTimestamp,
|
||||||
|
-(TREND_DAY_LIMITS[range] - 1),
|
||||||
|
);
|
||||||
const currentMonthKey = getLocalMonthKey(db, todayStartMs);
|
const currentMonthKey = getLocalMonthKey(db, todayStartMs);
|
||||||
const cutoffMonthKey = getLocalMonthKey(db, cutoffMs);
|
const cutoffMonthKey = getLocalMonthKey(db, cutoffMs);
|
||||||
const currentYear = Math.floor(currentMonthKey / 100);
|
const currentYear = Math.floor(currentMonthKey / 100);
|
||||||
@@ -630,8 +647,10 @@ export function getTrendsDashboard(
|
|||||||
|
|
||||||
const animePerDay = {
|
const animePerDay = {
|
||||||
episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId),
|
episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId),
|
||||||
watchTime: buildPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId, (rollup) =>
|
watchTime: buildPerAnimeFromDailyRollups(
|
||||||
rollup.totalActiveMin,
|
dailyRollups,
|
||||||
|
titlesByVideoId,
|
||||||
|
(rollup) => rollup.totalActiveMin,
|
||||||
),
|
),
|
||||||
cards: buildPerAnimeFromDailyRollups(
|
cards: buildPerAnimeFromDailyRollups(
|
||||||
dailyRollups,
|
dailyRollups,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
|||||||
specialCommands: {
|
specialCommands: {
|
||||||
SUBSYNC_TRIGGER: '__subsync-trigger',
|
SUBSYNC_TRIGGER: '__subsync-trigger',
|
||||||
RUNTIME_OPTIONS_OPEN: '__runtime-options-open',
|
RUNTIME_OPTIONS_OPEN: '__runtime-options-open',
|
||||||
|
JIMAKU_OPEN: '__jimaku-open',
|
||||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||||
REPLAY_SUBTITLE: '__replay-subtitle',
|
REPLAY_SUBTITLE: '__replay-subtitle',
|
||||||
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
||||||
@@ -24,6 +25,9 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
|||||||
openRuntimeOptionsPalette: () => {
|
openRuntimeOptionsPalette: () => {
|
||||||
calls.push('runtime-options');
|
calls.push('runtime-options');
|
||||||
},
|
},
|
||||||
|
openJimaku: () => {
|
||||||
|
calls.push('jimaku');
|
||||||
|
},
|
||||||
openYoutubeTrackPicker: () => {
|
openYoutubeTrackPicker: () => {
|
||||||
calls.push('youtube-picker');
|
calls.push('youtube-picker');
|
||||||
},
|
},
|
||||||
@@ -114,6 +118,14 @@ test('handleMpvCommandFromIpc dispatches special youtube picker open command', (
|
|||||||
assert.deepEqual(osd, []);
|
assert.deepEqual(osd, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('handleMpvCommandFromIpc dispatches special jimaku open command', () => {
|
||||||
|
const { options, calls, sentCommands, osd } = createOptions();
|
||||||
|
handleMpvCommandFromIpc(['__jimaku-open'], options);
|
||||||
|
assert.deepEqual(calls, ['jimaku']);
|
||||||
|
assert.deepEqual(sentCommands, []);
|
||||||
|
assert.deepEqual(osd, []);
|
||||||
|
});
|
||||||
|
|
||||||
test('handleMpvCommandFromIpc dispatches special playlist browser open command', async () => {
|
test('handleMpvCommandFromIpc dispatches special playlist browser open command', async () => {
|
||||||
const { options, calls, sentCommands, osd } = createOptions();
|
const { options, calls, sentCommands, osd } = createOptions();
|
||||||
handleMpvCommandFromIpc(['__playlist-browser-open'], options);
|
handleMpvCommandFromIpc(['__playlist-browser-open'], options);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface HandleMpvCommandFromIpcOptions {
|
|||||||
specialCommands: {
|
specialCommands: {
|
||||||
SUBSYNC_TRIGGER: string;
|
SUBSYNC_TRIGGER: string;
|
||||||
RUNTIME_OPTIONS_OPEN: string;
|
RUNTIME_OPTIONS_OPEN: string;
|
||||||
|
JIMAKU_OPEN: string;
|
||||||
RUNTIME_OPTION_CYCLE_PREFIX: string;
|
RUNTIME_OPTION_CYCLE_PREFIX: string;
|
||||||
REPLAY_SUBTITLE: string;
|
REPLAY_SUBTITLE: string;
|
||||||
PLAY_NEXT_SUBTITLE: string;
|
PLAY_NEXT_SUBTITLE: string;
|
||||||
@@ -19,6 +20,7 @@ export interface HandleMpvCommandFromIpcOptions {
|
|||||||
};
|
};
|
||||||
triggerSubsyncFromConfig: () => void;
|
triggerSubsyncFromConfig: () => void;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
|
openJimaku: () => void;
|
||||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||||
openPlaylistBrowser: () => void | Promise<void>;
|
openPlaylistBrowser: () => void | Promise<void>;
|
||||||
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||||
@@ -94,6 +96,11 @@ export function handleMpvCommandFromIpc(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (first === options.specialCommands.JIMAKU_OPEN) {
|
||||||
|
options.openJimaku();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (first === options.specialCommands.YOUTUBE_PICKER_OPEN) {
|
if (first === options.specialCommands.YOUTUBE_PICKER_OPEN) {
|
||||||
void options.openYoutubeTrackPicker();
|
void options.openYoutubeTrackPicker();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ type SubtitleTrackCandidate = {
|
|||||||
externalFilename: string | null;
|
externalFilename: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeSubtitleTrackCandidate(track: Record<string, unknown>): SubtitleTrackCandidate | null {
|
function normalizeSubtitleTrackCandidate(
|
||||||
|
track: Record<string, unknown>,
|
||||||
|
): SubtitleTrackCandidate | null {
|
||||||
const id =
|
const id =
|
||||||
typeof track.id === 'number'
|
typeof track.id === 'number'
|
||||||
? track.id
|
? track.id
|
||||||
@@ -122,8 +124,12 @@ function normalizeSubtitleTrackCandidate(track: Record<string, unknown>): Subtit
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
lang: String(track.lang || '').trim().toLowerCase(),
|
lang: String(track.lang || '')
|
||||||
title: String(track.title || '').trim().toLowerCase(),
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
title: String(track.title || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
selected: track.selected === true,
|
selected: track.selected === true,
|
||||||
external: track.external === true,
|
external: track.external === true,
|
||||||
externalFilename,
|
externalFilename,
|
||||||
@@ -168,9 +174,7 @@ function pickSecondarySubtitleTrackId(
|
|||||||
const uniqueTracks = [...dedupedTracks.values()];
|
const uniqueTracks = [...dedupedTracks.values()];
|
||||||
|
|
||||||
for (const language of normalizedLanguages) {
|
for (const language of normalizedLanguages) {
|
||||||
const selectedMatch = uniqueTracks.find(
|
const selectedMatch = uniqueTracks.find((track) => track.selected && track.lang === language);
|
||||||
(track) => track.selected && track.lang === language,
|
|
||||||
);
|
|
||||||
if (selectedMatch) {
|
if (selectedMatch) {
|
||||||
return selectedMatch.id;
|
return selectedMatch.id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,10 +102,7 @@ async function writeFetchResponse(res: ServerResponse, response: Response): Prom
|
|||||||
res.end(Buffer.from(body));
|
res.end(Buffer.from(body));
|
||||||
}
|
}
|
||||||
|
|
||||||
function startNodeHttpServer(
|
function startNodeHttpServer(app: Hono, config: StatsServerConfig): { close: () => void } {
|
||||||
app: Hono,
|
|
||||||
config: StatsServerConfig,
|
|
||||||
): { close: () => void } {
|
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1075,11 +1072,9 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
|
|||||||
|
|
||||||
const bunRuntime = globalThis as typeof globalThis & {
|
const bunRuntime = globalThis as typeof globalThis & {
|
||||||
Bun?: {
|
Bun?: {
|
||||||
serve?: (options: {
|
serve?: (options: { fetch: (typeof app)['fetch']; port: number; hostname: string }) => {
|
||||||
fetch: (typeof app)['fetch'];
|
stop: () => void;
|
||||||
port: number;
|
};
|
||||||
hostname: string;
|
|
||||||
}) => { stop: () => void };
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,11 @@ function normalizeTrackIds(tracks: unknown[]): MpvTrack[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSourceTrackIdentity(track: MpvTrack): string {
|
function getSourceTrackIdentity(track: MpvTrack): string {
|
||||||
if (track.external && typeof track['external-filename'] === 'string' && track['external-filename'].length > 0) {
|
if (
|
||||||
|
track.external &&
|
||||||
|
typeof track['external-filename'] === 'string' &&
|
||||||
|
track['external-filename'].length > 0
|
||||||
|
) {
|
||||||
return `external:${track['external-filename'].toLowerCase()}`;
|
return `external:${track['external-filename'].toLowerCase()}`;
|
||||||
}
|
}
|
||||||
if (typeof track.id === 'number') {
|
if (typeof track.id === 'number') {
|
||||||
|
|||||||
@@ -2029,7 +2029,8 @@ export async function addYomitanNoteViaSearch(
|
|||||||
: null,
|
: null,
|
||||||
duplicateNoteIds: Array.isArray(envelope.duplicateNoteIds)
|
duplicateNoteIds: Array.isArray(envelope.duplicateNoteIds)
|
||||||
? envelope.duplicateNoteIds.filter(
|
? envelope.duplicateNoteIds.filter(
|
||||||
(entry): entry is number => typeof entry === 'number' && Number.isInteger(entry) && entry > 0,
|
(entry): entry is number =>
|
||||||
|
typeof entry === 'number' && Number.isInteger(entry) && entry > 0,
|
||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ export function resolveExternalYomitanExtensionPath(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidate = path.join(path.resolve(normalizedProfilePath), 'extensions', 'yomitan');
|
const candidate = path.join(normalizedProfilePath, 'extensions', 'yomitan');
|
||||||
return existsSync(path.join(candidate, 'manifest.json')) ? candidate : null;
|
const fallbackCandidate = path.join(path.resolve(normalizedProfilePath), 'extensions', 'yomitan');
|
||||||
|
|
||||||
|
const candidates = candidate === fallbackCandidate ? [candidate] : [candidate, fallbackCandidate];
|
||||||
|
for (const root of candidates) {
|
||||||
|
if (existsSync(path.join(root, 'manifest.json'))) {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,15 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|||||||
|
|
||||||
function makeFakeYtDlpScript(dir: string, payload: string): void {
|
function makeFakeYtDlpScript(dir: string, payload: string): void {
|
||||||
const scriptPath = path.join(dir, 'yt-dlp');
|
const scriptPath = path.join(dir, 'yt-dlp');
|
||||||
const script = `#!/usr/bin/env node
|
const script =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? `#!/usr/bin/env bun
|
||||||
process.stdout.write(${JSON.stringify(payload)});
|
process.stdout.write(${JSON.stringify(payload)});
|
||||||
|
`
|
||||||
|
: `#!/usr/bin/env sh
|
||||||
|
cat <<'EOF' | base64 -d
|
||||||
|
${Buffer.from(payload).toString('base64')}
|
||||||
|
EOF
|
||||||
`;
|
`;
|
||||||
fs.writeFileSync(scriptPath, script, 'utf8');
|
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
@@ -28,8 +35,15 @@ process.stdout.write(${JSON.stringify(payload)});
|
|||||||
|
|
||||||
function makeHangingFakeYtDlpScript(dir: string): void {
|
function makeHangingFakeYtDlpScript(dir: string): void {
|
||||||
const scriptPath = path.join(dir, 'yt-dlp');
|
const scriptPath = path.join(dir, 'yt-dlp');
|
||||||
const script = `#!/usr/bin/env node
|
const script =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? `#!/usr/bin/env bun
|
||||||
setInterval(() => {}, 1000);
|
setInterval(() => {}, 1000);
|
||||||
|
`
|
||||||
|
: `#!/usr/bin/env sh
|
||||||
|
while :; do
|
||||||
|
sleep 1;
|
||||||
|
done
|
||||||
`;
|
`;
|
||||||
fs.writeFileSync(scriptPath, script, 'utf8');
|
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
@@ -44,11 +58,19 @@ async function withFakeYtDlp<T>(payload: string, fn: () => Promise<T>): Promise<
|
|||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
makeFakeYtDlpScript(binDir, payload);
|
makeFakeYtDlpScript(binDir, payload);
|
||||||
const originalPath = process.env.PATH ?? '';
|
const originalPath = process.env.PATH ?? '';
|
||||||
|
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
|
||||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
||||||
|
process.env.SUBMINER_YTDLP_BIN =
|
||||||
|
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} finally {
|
} finally {
|
||||||
process.env.PATH = originalPath;
|
process.env.PATH = originalPath;
|
||||||
|
if (originalCommand === undefined) {
|
||||||
|
delete process.env.SUBMINER_YTDLP_BIN;
|
||||||
|
} else {
|
||||||
|
process.env.SUBMINER_YTDLP_BIN = originalCommand;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -59,11 +81,19 @@ async function withHangingFakeYtDlp<T>(fn: () => Promise<T>): Promise<T> {
|
|||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
makeHangingFakeYtDlpScript(binDir);
|
makeHangingFakeYtDlpScript(binDir);
|
||||||
const originalPath = process.env.PATH ?? '';
|
const originalPath = process.env.PATH ?? '';
|
||||||
|
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
|
||||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
||||||
|
process.env.SUBMINER_YTDLP_BIN =
|
||||||
|
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} finally {
|
} finally {
|
||||||
process.env.PATH = originalPath;
|
process.env.PATH = originalPath;
|
||||||
|
if (originalCommand === undefined) {
|
||||||
|
delete process.env.SUBMINER_YTDLP_BIN;
|
||||||
|
} else {
|
||||||
|
process.env.SUBMINER_YTDLP_BIN = originalCommand;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import type { YoutubeVideoMetadata } from '../immersion-tracker/types';
|
import type { YoutubeVideoMetadata } from '../immersion-tracker/types';
|
||||||
|
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
||||||
|
|
||||||
const YOUTUBE_METADATA_PROBE_TIMEOUT_MS = 15_000;
|
const YOUTUBE_METADATA_PROBE_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ function pickChannelThumbnail(thumbnails: YtDlpThumbnail[] | undefined): string
|
|||||||
export async function probeYoutubeVideoMetadata(
|
export async function probeYoutubeVideoMetadata(
|
||||||
targetUrl: string,
|
targetUrl: string,
|
||||||
): Promise<YoutubeVideoMetadata | null> {
|
): Promise<YoutubeVideoMetadata | null> {
|
||||||
const { stdout } = await runCapture('yt-dlp', [
|
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [
|
||||||
'--dump-single-json',
|
'--dump-single-json',
|
||||||
'--no-warnings',
|
'--no-warnings',
|
||||||
'--skip-download',
|
'--skip-download',
|
||||||
|
|||||||
@@ -16,8 +16,15 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|||||||
|
|
||||||
function makeFakeYtDlpScript(dir: string, payload: string): void {
|
function makeFakeYtDlpScript(dir: string, payload: string): void {
|
||||||
const scriptPath = path.join(dir, 'yt-dlp');
|
const scriptPath = path.join(dir, 'yt-dlp');
|
||||||
const script = `#!/usr/bin/env node
|
const script =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? `#!/usr/bin/env bun
|
||||||
process.stdout.write(${JSON.stringify(payload)});
|
process.stdout.write(${JSON.stringify(payload)});
|
||||||
|
`
|
||||||
|
: `#!/usr/bin/env sh
|
||||||
|
cat <<'EOF' | base64 -d
|
||||||
|
${Buffer.from(payload).toString('base64')}
|
||||||
|
EOF
|
||||||
`;
|
`;
|
||||||
fs.writeFileSync(scriptPath, script, 'utf8');
|
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
|
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
||||||
|
|
||||||
const YOUTUBE_PLAYBACK_RESOLVE_TIMEOUT_MS = 15_000;
|
const YOUTUBE_PLAYBACK_RESOLVE_TIMEOUT_MS = 15_000;
|
||||||
const DEFAULT_PLAYBACK_FORMAT = 'b';
|
const DEFAULT_PLAYBACK_FORMAT = 'b';
|
||||||
@@ -88,8 +89,7 @@ export async function resolveYoutubePlaybackUrl(
|
|||||||
targetUrl: string,
|
targetUrl: string,
|
||||||
format = DEFAULT_PLAYBACK_FORMAT,
|
format = DEFAULT_PLAYBACK_FORMAT,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const ytDlpCommand = process.env.SUBMINER_YTDLP_BIN?.trim() || 'yt-dlp';
|
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [
|
||||||
const { stdout } = await runCapture(ytDlpCommand, [
|
|
||||||
'--get-url',
|
'--get-url',
|
||||||
'--no-warnings',
|
'--no-warnings',
|
||||||
'-f',
|
'-f',
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|||||||
|
|
||||||
function makeFakeYtDlpScript(dir: string): string {
|
function makeFakeYtDlpScript(dir: string): string {
|
||||||
const scriptPath = path.join(dir, 'yt-dlp');
|
const scriptPath = path.join(dir, 'yt-dlp');
|
||||||
const script = `#!/usr/bin/env node
|
const script = `#!/usr/bin/env bun
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
@@ -87,6 +87,87 @@ if (process.env.YTDLP_FAKE_MODE === 'multi') {
|
|||||||
fs.writeFileSync(\`\${prefix}.vtt\`, 'WEBVTT\\n');
|
fs.writeFileSync(\`\${prefix}.vtt\`, 'WEBVTT\\n');
|
||||||
}
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||||
|
fs.chmodSync(scriptPath, 0o755);
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
fs.writeFileSync(scriptPath + '.cmd', `@echo off\r\nbun "${scriptPath}" %*\r\n`, 'utf8');
|
||||||
|
}
|
||||||
|
return scriptPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakeYtDlpShellScript(dir: string): string {
|
||||||
|
const scriptPath = path.join(dir, 'yt-dlp');
|
||||||
|
const script = `#!/bin/sh
|
||||||
|
has_auto_subs=0
|
||||||
|
wants_auto_subs=0
|
||||||
|
wants_manual_subs=0
|
||||||
|
sub_lang=''
|
||||||
|
output_template=''
|
||||||
|
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--write-auto-subs)
|
||||||
|
wants_auto_subs=1
|
||||||
|
;;
|
||||||
|
--write-subs)
|
||||||
|
wants_manual_subs=1
|
||||||
|
;;
|
||||||
|
--sub-langs)
|
||||||
|
sub_lang="$2"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-o)
|
||||||
|
output_template="$2"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$YTDLP_EXPECT_AUTO_SUBS" = "1" ] && [ "$wants_auto_subs" != "1" ]; then
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [ "$YTDLP_EXPECT_MANUAL_SUBS" = "1" ] && [ "$wants_manual_subs" != "1" ]; then
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
if [ -n "$YTDLP_EXPECT_SUB_LANG" ] && [ "$sub_lang" != "$YTDLP_EXPECT_SUB_LANG" ]; then
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
|
||||||
|
prefix="\${output_template%.%(ext)s}"
|
||||||
|
if [ -z "$prefix" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
dir="\${prefix%/*}"
|
||||||
|
[ -d "$dir" ] || /bin/mkdir -p "$dir"
|
||||||
|
|
||||||
|
if [ "$YTDLP_FAKE_MODE" = "multi" ]; then
|
||||||
|
OLD_IFS="$IFS"
|
||||||
|
IFS=","
|
||||||
|
for lang in $sub_lang; do
|
||||||
|
if [ -n "$lang" ]; then
|
||||||
|
printf 'WEBVTT\\n' > "\${prefix}.\${lang}.vtt"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
IFS="$OLD_IFS"
|
||||||
|
elif [ "$YTDLP_FAKE_MODE" = "rolling-auto" ]; then
|
||||||
|
printf 'WEBVTT\\n\\n00:00:01.000 --> 00:00:02.000\\n今日は\\n\\n00:00:02.000 --> 00:00:03.000\\n今日はいい天気ですね\\n\\n00:00:03.000 --> 00:00:04.000\\n今日はいい天気ですね本当に\\n' > "\${prefix}.vtt"
|
||||||
|
elif [ "$YTDLP_FAKE_MODE" = "multi-primary-only-fail" ]; then
|
||||||
|
primary_lang="\${sub_lang%%,*}"
|
||||||
|
if [ -n "$primary_lang" ]; then
|
||||||
|
printf 'WEBVTT\\n' > "\${prefix}.\${primary_lang}.vtt"
|
||||||
|
fi
|
||||||
|
printf "ERROR: Unable to download video subtitles for 'en': HTTP Error 429: Too Many Requests\\n" 1>&2
|
||||||
|
exit 1
|
||||||
|
elif [ "$YTDLP_FAKE_MODE" = "both" ]; then
|
||||||
|
printf 'WEBVTT\\n' > "\${prefix}.vtt"
|
||||||
|
printf 'webp' > "\${prefix}.orig.webp"
|
||||||
|
elif [ "$YTDLP_FAKE_MODE" = "webp-only" ]; then
|
||||||
|
printf 'webp' > "\${prefix}.orig.webp"
|
||||||
|
else
|
||||||
|
printf 'WEBVTT\\n' > "\${prefix}.vtt"
|
||||||
|
fi
|
||||||
`;
|
`;
|
||||||
fs.writeFileSync(scriptPath, script, 'utf8');
|
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||||
fs.chmodSync(scriptPath, 0o755);
|
fs.chmodSync(scriptPath, 0o755);
|
||||||
@@ -100,7 +181,11 @@ async function withFakeYtDlp<T>(
|
|||||||
return await withTempDir(async (root) => {
|
return await withTempDir(async (root) => {
|
||||||
const binDir = path.join(root, 'bin');
|
const binDir = path.join(root, 'bin');
|
||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
|
if (process.platform === 'win32') {
|
||||||
makeFakeYtDlpScript(binDir);
|
makeFakeYtDlpScript(binDir);
|
||||||
|
} else {
|
||||||
|
makeFakeYtDlpShellScript(binDir);
|
||||||
|
}
|
||||||
|
|
||||||
const originalPath = process.env.PATH ?? '';
|
const originalPath = process.env.PATH ?? '';
|
||||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
||||||
@@ -114,6 +199,43 @@ async function withFakeYtDlp<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function withFakeYtDlpCommand<T>(
|
||||||
|
mode: 'both' | 'webp-only' | 'multi' | 'multi-primary-only-fail' | 'rolling-auto',
|
||||||
|
fn: (dir: string, binDir: string) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
return await withTempDir(async (root) => {
|
||||||
|
const binDir = path.join(root, 'bin');
|
||||||
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
|
|
||||||
|
const originalPath = process.env.PATH;
|
||||||
|
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
|
||||||
|
process.env.PATH = '';
|
||||||
|
process.env.YTDLP_FAKE_MODE = mode;
|
||||||
|
process.env.SUBMINER_YTDLP_BIN =
|
||||||
|
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
makeFakeYtDlpScript(binDir);
|
||||||
|
} else {
|
||||||
|
makeFakeYtDlpShellScript(binDir);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await fn(root, binDir);
|
||||||
|
} finally {
|
||||||
|
if (originalPath === undefined) {
|
||||||
|
delete process.env.PATH;
|
||||||
|
} else {
|
||||||
|
process.env.PATH = originalPath;
|
||||||
|
}
|
||||||
|
delete process.env.YTDLP_FAKE_MODE;
|
||||||
|
if (originalCommand === undefined) {
|
||||||
|
delete process.env.SUBMINER_YTDLP_BIN;
|
||||||
|
} else {
|
||||||
|
process.env.SUBMINER_YTDLP_BIN = originalCommand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function withFakeYtDlpExpectations<T>(
|
async function withFakeYtDlpExpectations<T>(
|
||||||
expectations: Partial<
|
expectations: Partial<
|
||||||
Record<'YTDLP_EXPECT_AUTO_SUBS' | 'YTDLP_EXPECT_MANUAL_SUBS' | 'YTDLP_EXPECT_SUB_LANG', string>
|
Record<'YTDLP_EXPECT_AUTO_SUBS' | 'YTDLP_EXPECT_MANUAL_SUBS' | 'YTDLP_EXPECT_SUB_LANG', string>
|
||||||
@@ -179,6 +301,29 @@ test('downloadYoutubeSubtitleTrack prefers subtitle files over later webp artifa
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTrack honors SUBMINER_YTDLP_BIN when yt-dlp is not on PATH', async () => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withFakeYtDlpCommand('both', async (root) => {
|
||||||
|
const result = await downloadYoutubeSubtitleTrack({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir: path.join(root, 'out'),
|
||||||
|
track: {
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(path.extname(result.path), '.vtt');
|
||||||
|
assert.match(path.basename(result.path), /^auto-ja-orig\./);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('downloadYoutubeSubtitleTrack ignores stale subtitle files from prior runs', async () => {
|
test('downloadYoutubeSubtitleTrack ignores stale subtitle files from prior runs', async () => {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import type { YoutubeTrackOption } from './track-probe';
|
import type { YoutubeTrackOption } from './track-probe';
|
||||||
|
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
||||||
import {
|
import {
|
||||||
convertYoutubeTimedTextToVtt,
|
convertYoutubeTimedTextToVtt,
|
||||||
isYoutubeTimedTextExtension,
|
isYoutubeTimedTextExtension,
|
||||||
@@ -237,7 +238,7 @@ export async function downloadYoutubeSubtitleTrack(input: {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
await runCapture('yt-dlp', args);
|
await runCapture(getYoutubeYtDlpCommand(), args);
|
||||||
const subtitlePath = pickLatestSubtitleFile(input.outputDir, prefix);
|
const subtitlePath = pickLatestSubtitleFile(input.outputDir, prefix);
|
||||||
if (!subtitlePath) {
|
if (!subtitlePath) {
|
||||||
throw new Error(`No subtitle file was downloaded for ${input.track.sourceLanguage}`);
|
throw new Error(`No subtitle file was downloaded for ${input.track.sourceLanguage}`);
|
||||||
@@ -281,7 +282,7 @@ export async function downloadYoutubeSubtitleTracks(input: {
|
|||||||
const includeManualSubs = input.tracks.some((track) => track.kind === 'manual');
|
const includeManualSubs = input.tracks.some((track) => track.kind === 'manual');
|
||||||
|
|
||||||
const result = await runCaptureDetailed(
|
const result = await runCaptureDetailed(
|
||||||
'yt-dlp',
|
getYoutubeYtDlpCommand(),
|
||||||
buildDownloadArgs({
|
buildDownloadArgs({
|
||||||
targetUrl: input.targetUrl,
|
targetUrl: input.targetUrl,
|
||||||
outputTemplate,
|
outputTemplate,
|
||||||
|
|||||||
@@ -17,10 +17,18 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|||||||
function makeFakeYtDlpScript(dir: string, payload: unknown, rawScript = false): void {
|
function makeFakeYtDlpScript(dir: string, payload: unknown, rawScript = false): void {
|
||||||
const scriptPath = path.join(dir, 'yt-dlp');
|
const scriptPath = path.join(dir, 'yt-dlp');
|
||||||
const stdoutBody = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
const stdoutBody = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
||||||
const script = rawScript
|
const script =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? rawScript
|
||||||
? stdoutBody
|
? stdoutBody
|
||||||
: `#!/usr/bin/env node
|
: `#!/usr/bin/env bun
|
||||||
process.stdout.write(${JSON.stringify(stdoutBody)});
|
process.stdout.write(${JSON.stringify(stdoutBody)});
|
||||||
|
`
|
||||||
|
: `#!/bin/sh
|
||||||
|
PATH=/usr/bin:/bin:/usr/local/bin
|
||||||
|
cat <<'SUBMINER_EOF' | base64 -d
|
||||||
|
${Buffer.from(stdoutBody).toString('base64')}
|
||||||
|
SUBMINER_EOF
|
||||||
`;
|
`;
|
||||||
fs.writeFileSync(scriptPath, script, 'utf8');
|
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
@@ -39,11 +47,50 @@ async function withFakeYtDlp<T>(
|
|||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
makeFakeYtDlpScript(binDir, payload, options.rawScript === true);
|
makeFakeYtDlpScript(binDir, payload, options.rawScript === true);
|
||||||
const originalPath = process.env.PATH ?? '';
|
const originalPath = process.env.PATH ?? '';
|
||||||
|
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
|
||||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
||||||
|
process.env.SUBMINER_YTDLP_BIN =
|
||||||
|
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} finally {
|
} finally {
|
||||||
process.env.PATH = originalPath;
|
process.env.PATH = originalPath;
|
||||||
|
if (originalCommand === undefined) {
|
||||||
|
delete process.env.SUBMINER_YTDLP_BIN;
|
||||||
|
} else {
|
||||||
|
process.env.SUBMINER_YTDLP_BIN = originalCommand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withFakeYtDlpCommand<T>(
|
||||||
|
payload: unknown,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
options: { rawScript?: boolean } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
return await withTempDir(async (root) => {
|
||||||
|
const binDir = path.join(root, 'bin');
|
||||||
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
|
makeFakeYtDlpScript(binDir, payload, options.rawScript === true);
|
||||||
|
const originalPath = process.env.PATH;
|
||||||
|
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
|
||||||
|
process.env.PATH = '';
|
||||||
|
process.env.SUBMINER_YTDLP_BIN =
|
||||||
|
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
if (originalPath === undefined) {
|
||||||
|
delete process.env.PATH;
|
||||||
|
} else {
|
||||||
|
process.env.PATH = originalPath;
|
||||||
|
}
|
||||||
|
if (originalCommand === undefined) {
|
||||||
|
delete process.env.SUBMINER_YTDLP_BIN;
|
||||||
|
} else {
|
||||||
|
process.env.SUBMINER_YTDLP_BIN = originalCommand;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -69,6 +116,28 @@ test('probeYoutubeTracks prefers srv3 over vtt for automatic captions', async ()
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('probeYoutubeTracks honors SUBMINER_YTDLP_BIN when yt-dlp is not on PATH', async () => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withFakeYtDlpCommand(
|
||||||
|
{
|
||||||
|
id: 'abc123',
|
||||||
|
title: 'Example',
|
||||||
|
subtitles: {
|
||||||
|
ja: [{ ext: 'vtt', url: 'https://example.com/ja.vtt', name: 'Japanese manual' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await probeYoutubeTracks('https://www.youtube.com/watch?v=abc123');
|
||||||
|
assert.equal(result.videoId, 'abc123');
|
||||||
|
assert.equal(result.tracks[0]?.downloadUrl, 'https://example.com/ja.vtt');
|
||||||
|
assert.equal(result.tracks[0]?.fileExtension, 'vtt');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('probeYoutubeTracks keeps preferring srt for manual captions', async () => {
|
test('probeYoutubeTracks keeps preferring srt for manual captions', async () => {
|
||||||
await withFakeYtDlp(
|
await withFakeYtDlp(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import type { YoutubeTrackOption } from '../../../types';
|
import type { YoutubeTrackOption } from '../../../types';
|
||||||
import { formatYoutubeTrackLabel, normalizeYoutubeLangCode, type YoutubeTrackKind } from './labels';
|
import { formatYoutubeTrackLabel, normalizeYoutubeLangCode, type YoutubeTrackKind } from './labels';
|
||||||
|
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
||||||
|
|
||||||
const YOUTUBE_TRACK_PROBE_TIMEOUT_MS = 15_000;
|
const YOUTUBE_TRACK_PROBE_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
@@ -111,7 +112,11 @@ function toTracks(entries: Record<string, YtDlpSubtitleEntry> | undefined, kind:
|
|||||||
export type { YoutubeTrackOption };
|
export type { YoutubeTrackOption };
|
||||||
|
|
||||||
export async function probeYoutubeTracks(targetUrl: string): Promise<YoutubeTrackProbeResult> {
|
export async function probeYoutubeTracks(targetUrl: string): Promise<YoutubeTrackProbeResult> {
|
||||||
const { stdout } = await runCapture('yt-dlp', ['--dump-single-json', '--no-warnings', targetUrl]);
|
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [
|
||||||
|
'--dump-single-json',
|
||||||
|
'--no-warnings',
|
||||||
|
targetUrl,
|
||||||
|
]);
|
||||||
const trimmedStdout = stdout.trim();
|
const trimmedStdout = stdout.trim();
|
||||||
if (!trimmedStdout) {
|
if (!trimmedStdout) {
|
||||||
throw new Error('yt-dlp returned empty output while probing subtitle tracks');
|
throw new Error('yt-dlp returned empty output while probing subtitle tracks');
|
||||||
|
|||||||
44
src/core/services/youtube/ytdlp-command.ts
Normal file
44
src/core/services/youtube/ytdlp-command.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const DEFAULT_YTDLP_COMMAND = 'yt-dlp';
|
||||||
|
const WINDOWS_YTDLP_COMMANDS = ['yt-dlp.cmd', 'yt-dlp.exe', 'yt-dlp'];
|
||||||
|
|
||||||
|
function resolveFromPath(commandName: string): string | null {
|
||||||
|
if (!process.env.PATH) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchPaths = process.env.PATH.split(path.delimiter);
|
||||||
|
for (const searchPath of searchPaths) {
|
||||||
|
const candidate = path.join(searchPath, commandName);
|
||||||
|
try {
|
||||||
|
fs.accessSync(candidate, fs.constants.X_OK);
|
||||||
|
return candidate;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getYoutubeYtDlpCommand(): string {
|
||||||
|
const explicitCommand = process.env.SUBMINER_YTDLP_BIN?.trim();
|
||||||
|
if (explicitCommand) {
|
||||||
|
return explicitCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return DEFAULT_YTDLP_COMMAND;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const commandName of WINDOWS_YTDLP_COMMANDS) {
|
||||||
|
const resolved = resolveFromPath(commandName);
|
||||||
|
if (resolved) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_YTDLP_COMMAND;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user