Compare commits

..

1 Commits

Author SHA1 Message Date
sudacode c1ee0dfd2e feat(config): show default keybindings in generated example config
- Expand `keybindings` array in `config.example.jsonc` to list all built-in defaults instead of `[]`
- Inject `DEFAULT_KEYBINDINGS` into template when resolved config has an empty keybindings array
- Add regression tests for template parity, default binding compile/action mapping, overlay keyboard dispatch, and mpv plugin registration/dispatch
- Add fullscreen binding to docs shortcut tables and clarify keybindings can target mpv commands or session actions
2026-05-12 22:58:58 -07:00
55 changed files with 1143 additions and 2153 deletions
+21 -12
View File
@@ -1,9 +1,10 @@
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows install-plugin uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop
APP_NAME := subminer
THEME_SOURCE := assets/themes/subminer.rasi
LAUNCHER_OUT := dist/launcher/$(APP_NAME)
THEME_FILE := subminer.rasi
PLUGIN_CONF := plugin/subminer.conf
# Default install prefix for the wrapper script.
PREFIX ?= $(HOME)/.local
@@ -63,7 +64,8 @@ help:
" dev-stop Stop a running local Electron app" \
" install-linux Install Linux wrapper/theme/app artifacts" \
" install-macos Install macOS wrapper/theme/app artifacts" \
" install-windows Print Windows packaging/install guidance" \
" install-windows Install Windows mpv plugin artifacts" \
" install-plugin Install mpv Lua plugin and plugin config" \
" generate-config Generate ~/.config/SubMiner/config.jsonc from centralized defaults" \
"" \
"Other targets:" \
@@ -198,8 +200,6 @@ install-linux: build-launcher
@install -m 0755 "$(LAUNCHER_OUT)" "$(BINDIR)/$(APP_NAME)"
@install -d "$(LINUX_DATA_DIR)/themes"
@install -m 0644 "./$(THEME_SOURCE)" "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
@install -d "$(LINUX_DATA_DIR)/plugin/subminer"
@cp -R ./plugin/subminer/. "$(LINUX_DATA_DIR)/plugin/subminer/"
@if [ -n "$(APPIMAGE_SRC)" ]; then \
install -m 0755 "$(APPIMAGE_SRC)" "$(BINDIR)/SubMiner.AppImage"; \
else \
@@ -214,8 +214,6 @@ install-macos: build-launcher
@install -m 0755 "$(LAUNCHER_OUT)" "$(BINDIR)/$(APP_NAME)"
@install -d "$(MACOS_DATA_DIR)/themes"
@install -m 0644 "./$(THEME_SOURCE)" "$(MACOS_DATA_DIR)/themes/$(THEME_FILE)"
@install -d "$(MACOS_DATA_DIR)/plugin/subminer"
@cp -R ./plugin/subminer/. "$(MACOS_DATA_DIR)/plugin/subminer/"
@install -d "$(MACOS_APP_DIR)"
@if [ -n "$(MACOS_APP_SRC)" ]; then \
rm -rf "$(MACOS_APP_DEST)"; \
@@ -232,8 +230,21 @@ install-macos: build-launcher
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_APP_DEST)"
install-windows:
@printf '%s\n' "[INFO] Windows builds run via: bun run build:win"
@printf '%s\n' "[INFO] SubMiner-managed mpv launches inject the bundled runtime plugin; no global mpv plugin install is needed."
@printf '%s\n' "[INFO] Installing Windows mpv plugin artifacts"
@$(MAKE) --no-print-directory install-plugin
install-plugin:
@printf '%s\n' "[INFO] Installing mpv plugin artifacts"
@install -d "$(MPV_SCRIPTS_DIR)"
@rm -f "$(MPV_SCRIPTS_DIR)/subminer.lua" "$(MPV_SCRIPTS_DIR)/subminer-loader.lua"
@install -d "$(MPV_SCRIPTS_DIR)/subminer"
@install -d "$(MPV_SCRIPT_OPTS_DIR)"
@cp -R ./plugin/subminer/. "$(MPV_SCRIPTS_DIR)/subminer/"
@install -m 0644 "./$(PLUGIN_CONF)" "$(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
@if [ "$(PLATFORM)" = "windows" ]; then \
bun ./scripts/configure-plugin-binary-path.mjs "$(MPV_SCRIPT_OPTS_DIR)/subminer.conf" "$(CURDIR)" win32; \
fi
@printf '%s\n' "Installed to:" " $(MPV_SCRIPTS_DIR)/subminer/main.lua" " $(MPV_SCRIPTS_DIR)/subminer/" " $(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
uninstall:
@printf '%s\n' "[INFO] Detected platform: $(PLATFORM)"
@@ -247,15 +258,13 @@ uninstall:
uninstall-linux:
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
@rm -f "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
@rm -rf "$(LINUX_DATA_DIR)/plugin/subminer"
@printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(BINDIR)/SubMiner.AppImage" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/plugin/subminer"
@printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(BINDIR)/SubMiner.AppImage" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
uninstall-macos:
@rm -f "$(BINDIR)/subminer"
@rm -f "$(MACOS_DATA_DIR)/themes/$(THEME_FILE)"
@rm -rf "$(MACOS_DATA_DIR)/plugin/subminer"
@rm -rf "$(MACOS_APP_DEST)"
@printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_DATA_DIR)/plugin/subminer" " $(MACOS_APP_DEST)"
@printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_APP_DEST)"
uninstall-windows:
@rm -rf "$(MPV_SCRIPTS_DIR)/subminer"
@@ -1,37 +0,0 @@
---
id: TASK-351
title: Remove legacy global mpv plugin from setup
status: Done
assignee: []
created_date: '2026-05-12 19:57'
updated_date: '2026-05-12 20:03'
labels:
- setup
- mpv-plugin
- launcher
- windows
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add first-run setup support for detecting all legacy SubMiner mpv plugin auto-load entries and removing them via the OS trash after user confirmation, so regular mpv stops loading SubMiner while SubMiner-managed playback can use runtime plugin loading.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Setup detects all SubMiner mpv auto-load candidates under normal mpv scripts directories and Windows portable_config scripts directories.
- [x] #2 Setup displays detected legacy plugin paths and offers a Remove legacy mpv plugin action.
- [x] #3 Removal uses Electron shell.trashItem for detected script files/directories and never permanently deletes as fallback.
- [x] #4 script-opts/subminer.conf is not removed by the legacy plugin removal action.
- [x] #5 Partial trash failures report exact failed paths and keep legacy plugin warning visible.
- [x] #6 Successful removal refreshes setup status and reports that regular mpv will no longer load SubMiner while SubMiner-managed playback keeps working.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented setup detection for all legacy SubMiner mpv auto-load candidates in normal and portable mpv script directories, added a confirmed Remove legacy mpv plugin action that uses Electron shell.trashItem only, preserves script-opts/subminer.conf, reports exact partial failures, and refreshes setup status after successful removal. Added focused tests plus changelog/docs updates.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -1,39 +0,0 @@
---
id: TASK-352
title: Inject bundled mpv plugin for managed launches
status: Done
assignee: []
created_date: '2026-05-12 20:06'
updated_date: '2026-05-12 20:15'
labels:
- launcher
- mpv-plugin
- windows
- setup
dependencies: []
references:
- app-managed-mpv-runtime-plugin-plan.md
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement app-managed mpv runtime plugin loading so SubMiner-managed launcher and Windows mpv shortcut launches do not require a globally installed mpv plugin, while installed legacy/global plugins are detected and take precedence until removed.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Launcher-managed mpv launch injects the bundled plugin when no installed SubMiner mpv plugin is detected.
- [x] #2 Launcher idle/Jellyfin mpv launch follows the same bundled-vs-installed plugin policy.
- [x] #3 Windows SubMiner mpv shortcut launch skips bundled injection when an installed plugin is detected and injects bundled plugin otherwise.
- [x] #4 First-run setup no longer requires global mpv plugin installation to finish; plugin install remains optional compatibility action.
- [x] #5 Runtime plugin path resolution is test-covered and reports a clear failure when no bundled plugin path is available and no installed plugin exists.
- [x] #6 Release docs/changelog explain that managed launches no longer require global plugin installation and legacy plugin removal switches to bundled runtime loading.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented app-managed mpv runtime plugin policy. Launcher-managed playback and idle/Jellyfin mpv startup now inject the bundled plugin when no global SubMiner plugin is detected, and keep using/logging the installed plugin when one is present. Windows SubMiner mpv shortcut launches use the same installed-vs-bundled policy while still passing SubMiner script opts. First-run setup no longer requires global plugin installation to finish, keeps legacy install as optional compatibility, and documents/removes legacy global plugin files via OS trash.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -1,37 +0,0 @@
---
id: TASK-353
title: Remove Makefile global mpv plugin installer
status: Done
assignee: []
created_date: '2026-05-12 14:07'
updated_date: '2026-05-12 14:07'
labels:
- launcher
- mpv-plugin
- docs
dependencies: []
references:
- app-managed-mpv-runtime-plugin-plan.md
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Remove the legacy Makefile path that installs SubMiner into mpv's global scripts directory, including the Windows config rewrite script hook, because managed playback now injects the bundled runtime plugin.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Makefile no longer exposes an `install-plugin` target or help entry.
- [x] #2 Windows install no longer delegates to global mpv plugin installation.
- [x] #3 The obsolete config rewrite bun script is removed when no longer referenced.
- [x] #4 Docs no longer tell users to run `make install-plugin`.
- [x] #5 Regression coverage prevents reintroducing the target or script hook.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Removed the legacy global mpv plugin install target from the Makefile, removed the obsolete Windows config rewrite script, updated docs to describe bundled runtime plugin loading instead of separate installation, and removed the setup window's legacy install action while keeping legacy plugin removal available.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -1,38 +0,0 @@
---
id: TASK-354
title: Show legacy mpv plugin removal before managed playback
status: Done
assignee: []
created_date: '2026-05-12 14:30'
updated_date: '2026-05-12 14:35'
labels:
- launcher
- mpv-plugin
- setup
- windows
dependencies: []
references:
- app-managed-mpv-runtime-plugin-plan.md
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
When SubMiner-managed playback detects a legacy global SubMiner mpv plugin, show the removal UI before mpv starts so users can optionally trash the legacy files and then launch with the bundled runtime plugin.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Launcher playback opens first-run setup before mpv starts when legacy global plugin files are detected, even if setup is already completed.
- [x] #2 Launcher playback resumes with bundled runtime plugin after the legacy plugin is removed.
- [x] #3 Windows mpv shortcut/app launch prompts before spawning mpv and re-detects after removal so bundled injection is used.
- [x] #4 Users can continue without removal; removal remains optional.
- [x] #5 Regression coverage prevents bypassing the removal prompt due to completed setup or installed-plugin detection.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Managed launcher playback now checks for legacy global SubMiner mpv plugin files before choosing/loading a video, opens first-run setup even when setup is already complete, and waits for removal or explicit user continuation before starting mpv. Windows managed mpv launches now show a pre-launch removal dialog, move detected legacy files to the OS trash on confirmation, re-detect, and inject the bundled runtime plugin after successful removal.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -1,38 +0,0 @@
---
id: TASK-355
title: Unify AniList API throttling across dictionary stats and tracking
status: In Progress
assignee: []
created_date: '2026-05-12 21:49'
updated_date: '2026-05-13 01:21'
labels:
- anilist
- rate-limit
- bug
dependencies: []
references:
- 'https://docs.anilist.co/guide/rate-limiting'
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Audit and fix AniList GraphQL usage so character dictionary generation, stats search/cover art, and post-watch tracking share conservative request pacing and honor AniList rate-limit response headers. Current logs do not show 429s, but source has separate/unthrottled call paths and repeated dictionary lookup failures for the same title.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 All AniList GraphQL call paths use a shared/conservative limiter or equivalent pacing before requests.
- [ ] #2 429 responses honor Retry-After/X-RateLimit-Reset and do not continue hammering the API.
- [x] #3 Stats AniList search endpoint no longer bypasses the AniList rate limiter.
- [x] #4 Post-watch tracking no longer bypasses the AniList rate limiter.
- [x] #5 Focused regression tests cover limiter use for stats search and post-watch tracking, plus existing limiter behavior remains green.
- [x] #6 Stats cover-art lookup reuses already stored AniList cover data for the same anime before issuing another AniList API request.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented stats cover-art cache reuse across videos in the same anime before any AniList/image fetch. Added limiter plumbing for stats manual AniList search and post-watch tracking; both paths now call acquire before GraphQL and record response headers afterward. Character dictionary still uses its existing local pacing and remains follow-up work for fully shared limiter/header handling.
<!-- SECTION:NOTES:END -->
@@ -1,58 +0,0 @@
---
id: TASK-356
title: Close launcher-started background app when mpv exits
status: Done
assignee:
- codex
created_date: '2026-05-13 01:37'
updated_date: '2026-05-13 01:40'
labels:
- bug
- launcher
- mpv
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
When SubMiner is started through the launcher-managed mpv flow, closing the mpv window should also close the background Electron app instead of leaving it running in the tray. Preserve intentional tray/background behavior for normal app startup.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Launcher-managed mpv sessions signal or otherwise cause the spawned background app to quit when the mpv process exits.
- [x] #2 Normal background/tray startup remains available when SubMiner is launched without a launcher-managed playback session.
- [x] #3 A regression test covers the launcher mpv close/shutdown behavior.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a launcher command regression test for mpv plugin auto-start playback: no direct startOverlay call, mpv exits, launcher marks the session as managed and runs cleanup.
2. Add a small launcher mpv lifecycle helper to mark a SubMiner app session as launcher-managed when the launcher relies on plugin auto-start.
3. Wire playback-command to call that helper only for launcher-managed playback paths where mpv plugin auto-start is expected.
4. Run the focused launcher tests, then update TASK-356 acceptance criteria/notes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented launcher ownership marking for plugin-auto-start playback sessions. Direct startOverlay already marks launcher ownership; the plugin-auto-start branch now does the same before waiting for mpv exit, so existing cleanup sends the app --stop when mpv closes. Added regression coverage in launcher/commands/playback-command.test.ts. Verification: bun test launcher/commands/playback-command.test.ts; bun test launcher/mpv.test.ts launcher/commands/playback-command.test.ts; bun run test:launcher:src; bun run typecheck. Typecheck initially caught a nullable test fixture assignment and passed after fixing it.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Summary:
- Added markOverlayManagedByLauncher() to centralize launcher ownership tracking for SubMiner app sessions.
- Mark plugin-auto-start playback sessions as launcher-managed, so closing mpv triggers existing cleanup and stops the background app instead of leaving it in the tray.
- Added a regression test covering mpv exit after launcher-managed plugin auto-start playback.
Tests:
- bun test launcher/commands/playback-command.test.ts
- bun test launcher/mpv.test.ts launcher/commands/playback-command.test.ts
- bun run test:launcher:src
- bun run typecheck
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,55 @@
---
id: TASK-358
title: Show and verify default keybindings in example config
status: Done
assignee:
- '@Codex'
created_date: '2026-05-13 03:33'
updated_date: '2026-05-13 03:45'
labels:
- config
- keybindings
- overlay
- mpv
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Keep the shipped example configuration, overlay runtime, and mpv plugin aligned with the built-in default keybindings. The example `keybindings` array should show the same defaults that are active by default, and focused tests should catch drift between documented defaults and actual overlay/mpv wiring.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 `config.example.jsonc` and the docs-site copy show all default keybindings in the `keybindings` array.
- [x] #2 Default keybindings are registered without conflicts in the overlay session-binding path.
- [x] #3 Default keybindings are registered and dispatched correctly inside the mpv plugin.
- [x] #4 Focused regression tests cover default keybinding/config-example parity and mpv/plugin dispatch.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect default keybinding definitions, config-example generation, overlay shortcut/session-binding tests, and mpv plugin binding tests.
2. Add failing tests for config-example keybinding parity and any missing default overlay/mpv wiring.
3. Update generated/example config and source wiring only where tests show drift.
4. Run focused Bun/Lua tests, regenerate examples if needed, update task AC/final notes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented the config-template path by injecting `DEFAULT_KEYBINDINGS` into generated examples when the resolved config has an empty `keybindings` array, preserving runtime merge semantics. Added coverage for template parity, default binding compile/action mapping, overlay keyboard dispatch, and mpv plugin registration/dispatch. Regenerated both `config.example.jsonc` artifacts and added changelog fragment `changes/358-default-keybindings-config-example.md`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Updated generated example configuration so `config.example.jsonc` and `docs-site/public/config.example.jsonc` now show every built-in default keybinding in the `keybindings` array instead of `[]`. The template copy now describes the array as default plus custom keybindings, while runtime default merge behavior remains unchanged.
Added regression coverage that the generated template parses back to `DEFAULT_KEYBINDINGS`, that every default binding compiles to the expected mpv command or session action, that the overlay keyboard handler dispatches all compiled defaults, and that the mpv plugin registers and invokes default mpv/session-action bindings. Also updated docs tables to include the default fullscreen binding and clarified that keybindings can target mpv commands or SubMiner session actions.
Verification passed: `bun run format:check:src`, `bun run changelog:lint`, `bun run docs:test`, `bun run docs:build`, `bun run verify:config-example`, focused config/session/renderer/plugin tests, `bun run typecheck`, `bun run test:env`, `bun run test:fast`, `bun run build`, and `bun run test:smoke:dist`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -1,5 +0,0 @@
type: changed
area: setup
- SubMiner-managed mpv launches now inject the bundled mpv plugin when no global SubMiner plugin is installed, setup can remove detected legacy global plugin files via the OS trash, and legacy global plugin install entrypoints have been removed so regular mpv playback stays unaffected.
- Managed playback now surfaces the legacy plugin removal prompt before mpv starts, allowing users to trash the old global plugin and immediately relaunch with the bundled runtime plugin.
@@ -0,0 +1,4 @@
type: changed
area: config
- Config: Expanded the generated example config so `keybindings` lists every built-in default and added regression coverage that those defaults compile, dispatch in the overlay, and register through the mpv plugin.
+121 -2
View File
@@ -183,11 +183,130 @@
// ==========================================
// Keybindings (MPV Commands)
// Extra keybindings that are merged with built-in defaults.
// Default and custom keybindings that are merged with built-in defaults.
// Set command to null to disable a default keybinding.
// Hot-reload: keybinding changes apply live and update the session help modal on reopen.
// ==========================================
"keybindings": [], // Extra keybindings that are merged with built-in defaults.
"keybindings": [
{
"key": "Space", // Key setting.
"command": [
"cycle",
"pause"
] // Command setting.
},
{
"key": "KeyF", // Key setting.
"command": [
"cycle",
"fullscreen"
] // Command setting.
},
{
"key": "KeyJ", // Key setting.
"command": [
"cycle",
"sid"
] // Command setting.
},
{
"key": "Shift+KeyJ", // Key setting.
"command": [
"cycle",
"secondary-sid"
] // Command setting.
},
{
"key": "ArrowRight", // Key setting.
"command": [
"seek",
5
] // Command setting.
},
{
"key": "ArrowLeft", // Key setting.
"command": [
"seek",
-5
] // Command setting.
},
{
"key": "ArrowUp", // Key setting.
"command": [
"seek",
60
] // Command setting.
},
{
"key": "ArrowDown", // Key setting.
"command": [
"seek",
-60
] // Command setting.
},
{
"key": "Shift+KeyH", // Key setting.
"command": [
"sub-seek",
-1
] // Command setting.
},
{
"key": "Shift+KeyL", // Key setting.
"command": [
"sub-seek",
1
] // Command setting.
},
{
"key": "Shift+BracketRight", // Key setting.
"command": [
"__sub-delay-next-line"
] // Command setting.
},
{
"key": "Shift+BracketLeft", // Key setting.
"command": [
"__sub-delay-prev-line"
] // Command setting.
},
{
"key": "Ctrl+Alt+KeyC", // Key setting.
"command": [
"__youtube-picker-open"
] // Command setting.
},
{
"key": "Ctrl+Alt+KeyP", // Key setting.
"command": [
"__playlist-browser-open"
] // Command setting.
},
{
"key": "Ctrl+Shift+KeyH", // Key setting.
"command": [
"__replay-subtitle"
] // Command setting.
},
{
"key": "Ctrl+Shift+KeyL", // Key setting.
"command": [
"__play-next-subtitle"
] // Command setting.
},
{
"key": "KeyQ", // Key setting.
"command": [
"quit"
] // Command setting.
},
{
"key": "Ctrl+KeyW", // Key setting.
"command": [
"quit"
] // Command setting.
}
], // Default and custom keybindings that are merged with built-in defaults.
// ==========================================
// Secondary Subtitles
+2 -1
View File
@@ -461,7 +461,7 @@ See `config.example.jsonc` for detailed configuration options.
### Keybindings
Add a `keybindings` array to configure keyboard shortcuts that send commands to mpv:
Add a `keybindings` array to configure keyboard shortcuts that send mpv commands or SubMiner session actions:
See `config.example.jsonc` for detailed configuration options and more examples.
@@ -470,6 +470,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
| Key | Command | Description |
| -------------------- | ----------------------------- | --------------------------------------- |
| `Space` | `["cycle", "pause"]` | Toggle pause |
| `KeyF` | `["cycle", "fullscreen"]` | Toggle fullscreen |
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser |
+1
View File
@@ -202,6 +202,7 @@ Run `make help` for a full list of targets. Key ones:
| `make build` | Build platform package for detected OS |
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) |
| `make install-plugin` | Install mpv Lua plugin and config |
| `make deps` | Install JS dependencies (root + stats + texthooker-ui) |
| `make pretty` | Run scoped Prettier formatting for maintained source/config files |
| `make generate-config` | Generate default config from centralized registry |
+25 -13
View File
@@ -154,15 +154,9 @@ chmod +x ~/.local/bin/SubMiner.AppImage
# Download and install the subminer launcher (recommended)
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer
chmod +x ~/.local/bin/subminer
# Download launcher support assets used for bundled runtime plugin injection
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
mkdir -p ~/.local/share/SubMiner/plugin/subminer
cp -R /tmp/plugin/subminer/. ~/.local/share/SubMiner/plugin/subminer/
```
The `subminer` launcher is the recommended way to use SubMiner on Linux. It ensures mpv is launched with the correct IPC socket, SubMiner defaults, and the bundled runtime plugin so you don't need to configure `mpv.conf` or install a global mpv plugin.
The `subminer` launcher is the recommended way to use SubMiner on Linux. It ensures mpv is launched with the correct IPC socket and SubMiner defaults so you don't need to configure `mpv.conf` manually.
### From Source
@@ -321,7 +315,7 @@ Download the latest Windows installer from [GitHub Releases](https://github.com/
### Getting Started on Windows
1. **Run `SubMiner.exe` once** — first-run setup creates `%APPDATA%\SubMiner\config.jsonc` and opens Yomitan settings for dictionary import. The global mpv plugin install is optional for compatibility; the SubMiner mpv shortcut injects the bundled runtime plugin.
1. **Run `SubMiner.exe` once** — first-run setup creates `%APPDATA%\SubMiner\config.jsonc`, installs the mpv plugin, and opens Yomitan settings for dictionary import.
2. **Create the SubMiner mpv shortcut** _(recommended)_ — the setup popup offers to create a `SubMiner mpv` Start Menu and/or Desktop shortcut. This is the recommended way to launch playback on Windows.
3. **Play a video** — double-click the shortcut, drag a video file onto it, or run from a terminal:
@@ -329,7 +323,7 @@ Download the latest Windows installer from [GitHub Releases](https://github.com/
& "C:\Program Files\SubMiner\SubMiner.exe" --launch-mpv "C:\Videos\episode 01.mkv"
```
The shortcut and `--launch-mpv` pass SubMiner's default IPC socket, subtitle args, and bundled runtime plugin directly — no `mpv.conf` profile or global mpv plugin install is needed.
The shortcut and `--launch-mpv` pass SubMiner's default IPC socket and subtitle args directly — no `mpv.conf` profile is needed.
### Windows-Specific Notes
@@ -358,15 +352,33 @@ bun run build:win
Windows installer builds already get the required NSIS `WinShell` helper through electron-builder's cached `nsis-resources` bundle.
No extra repo-local WinShell plugin install step is required.
## MPV Plugin
## MPV Plugin (Recommended)
SubMiner-managed playback loads the bundled mpv plugin at runtime. No separate global mpv plugin install is required when launching from the app, the launcher, or the packaged Windows SubMiner mpv shortcut.
The Lua plugin provides in-player keybindings to control the overlay from mpv. It communicates with SubMiner by invoking the binary with CLI flags.
::: warning Important
If first-run setup detects an older global SubMiner mpv plugin under mpv's `scripts` directory, use **Remove legacy mpv plugin** so regular mpv playback stops loading SubMiner.
mpv must be launched with `--input-ipc-server=/tmp/subminer-socket` for SubMiner to connect.
:::
See [MPV Plugin](/mpv-plugin) for the keybindings, script messages, and runtime configuration reference.
On Windows, the packaged plugin config is rewritten to `socket_path=\\.\pipe\subminer-socket`.
First-run setup also pins `binary_path` to the current app binary so mpv launches the same SubMiner build that installed the plugin.
```bash
# Option 1: install from release assets bundle
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
mkdir -p ~/.config/SubMiner
cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
mkdir -p ~/.config/mpv/scripts/subminer
mkdir -p ~/.config/mpv/script-opts
cp -R /tmp/plugin/subminer/. ~/.config/mpv/scripts/subminer/
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
# Option 2: from source checkout
# make install-plugin
```
See [MPV Plugin](/mpv-plugin) for the full configuration reference, keybindings, script messages, and binary auto-detection details.
## Anki Setup (Recommended)
+15 -5
View File
@@ -1,12 +1,22 @@
# MPV Plugin
The SubMiner mpv plugin (`subminer/main.lua`) provides in-player keybindings to control the overlay without leaving mpv. SubMiner-managed launches inject the bundled runtime plugin, so users do not need to install it into mpv's global `scripts` directory.
The SubMiner mpv plugin (`subminer/main.lua`) provides in-player keybindings to control the overlay without leaving mpv. It communicates with SubMiner by invoking the AppImage (or binary) with CLI flags.
## Runtime Loading
## Installation
Launch mpv through the SubMiner app, the `subminer` launcher, or the packaged Windows SubMiner mpv shortcut. These paths pass mpv a bundled plugin path for that playback session only, leaving regular mpv playback untouched.
```bash
# From release bundle:
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
mkdir -p ~/.config/SubMiner
cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
mkdir -p ~/.config/mpv/scripts/subminer
mkdir -p ~/.config/mpv/script-opts
cp -R /tmp/plugin/subminer/. ~/.config/mpv/scripts/subminer/
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
If setup detects an older global SubMiner plugin in mpv's `scripts` directory, use **Remove legacy mpv plugin** in first-run setup. The global plugin is not needed once runtime loading is available.
# Or from source checkout: make install-plugin
```
mpv must have IPC enabled for SubMiner to connect:
@@ -57,7 +67,7 @@ Select an item by pressing its number.
## Configuration
For advanced/manual runtime use, create or edit `~/.config/mpv/script-opts/subminer.conf`:
Create or edit `~/.config/mpv/script-opts/subminer.conf`:
```ini
# Path to SubMiner binary. Leave empty for auto-detection.
+121 -2
View File
@@ -183,11 +183,130 @@
// ==========================================
// Keybindings (MPV Commands)
// Extra keybindings that are merged with built-in defaults.
// Default and custom keybindings that are merged with built-in defaults.
// Set command to null to disable a default keybinding.
// Hot-reload: keybinding changes apply live and update the session help modal on reopen.
// ==========================================
"keybindings": [], // Extra keybindings that are merged with built-in defaults.
"keybindings": [
{
"key": "Space", // Key setting.
"command": [
"cycle",
"pause"
] // Command setting.
},
{
"key": "KeyF", // Key setting.
"command": [
"cycle",
"fullscreen"
] // Command setting.
},
{
"key": "KeyJ", // Key setting.
"command": [
"cycle",
"sid"
] // Command setting.
},
{
"key": "Shift+KeyJ", // Key setting.
"command": [
"cycle",
"secondary-sid"
] // Command setting.
},
{
"key": "ArrowRight", // Key setting.
"command": [
"seek",
5
] // Command setting.
},
{
"key": "ArrowLeft", // Key setting.
"command": [
"seek",
-5
] // Command setting.
},
{
"key": "ArrowUp", // Key setting.
"command": [
"seek",
60
] // Command setting.
},
{
"key": "ArrowDown", // Key setting.
"command": [
"seek",
-60
] // Command setting.
},
{
"key": "Shift+KeyH", // Key setting.
"command": [
"sub-seek",
-1
] // Command setting.
},
{
"key": "Shift+KeyL", // Key setting.
"command": [
"sub-seek",
1
] // Command setting.
},
{
"key": "Shift+BracketRight", // Key setting.
"command": [
"__sub-delay-next-line"
] // Command setting.
},
{
"key": "Shift+BracketLeft", // Key setting.
"command": [
"__sub-delay-prev-line"
] // Command setting.
},
{
"key": "Ctrl+Alt+KeyC", // Key setting.
"command": [
"__youtube-picker-open"
] // Command setting.
},
{
"key": "Ctrl+Alt+KeyP", // Key setting.
"command": [
"__playlist-browser-open"
] // Command setting.
},
{
"key": "Ctrl+Shift+KeyH", // Key setting.
"command": [
"__replay-subtitle"
] // Command setting.
},
{
"key": "Ctrl+Shift+KeyL", // Key setting.
"command": [
"__play-next-subtitle"
] // Command setting.
},
{
"key": "KeyQ", // Key setting.
"command": [
"quit"
] // Command setting.
},
{
"key": "Ctrl+KeyW", // Key setting.
"command": [
"quit"
] // Command setting.
}
], // Default and custom keybindings that are merged with built-in defaults.
// ==========================================
// Secondary Subtitles
+1
View File
@@ -38,6 +38,7 @@ These control playback and subtitle display. They require overlay window focus.
| Shortcut | Action |
| -------------------- | --------------------------------------------------- |
| `Space` | Toggle mpv pause |
| `F` | Toggle fullscreen |
| `V` | Toggle primary subtitle bar visibility |
| `J` | Cycle primary subtitle track |
| `Shift+J` | Cycle secondary subtitle track |
+3 -4
View File
@@ -151,7 +151,7 @@ Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for sta
### Windows mpv Shortcut
First-run setup creates the config file, then requires Yomitan dictionaries before it can finish. The global mpv plugin install is optional because SubMiner-managed mpv launches inject the bundled runtime plugin.
First-run setup creates the config file, then requires the mpv plugin and Yomitan dictionaries before it can finish.
If you enabled the optional Windows shortcut during install, SubMiner creates a `SubMiner mpv` shortcut in the Start menu and/or on the desktop. On Windows, that shortcut is the recommended way to launch local files with SubMiner because it starts `mpv.exe` with the right defaults directly.
After setup completes, the shortcut is the normal Windows playback entry point.
@@ -195,14 +195,13 @@ SubMiner.AppImage --setup
Setup flow:
- config file: create the default config directory and prefer `config.jsonc`
- plugin compatibility: optionally install the legacy global mpv plugin; managed launches use the bundled runtime plugin without it
- legacy plugin cleanup: remove detected global SubMiner mpv plugin files from mpv script directories via the OS trash when you do not want regular mpv to load SubMiner
- plugin status: install the bundled mpv plugin before finishing setup
- 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
- 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
- `Finish setup` stays disabled until the config 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
AniList character dictionary auto-sync (optional):
+3 -13
View File
@@ -1,9 +1,5 @@
import { fail, log } from '../log.js';
import {
waitForUnixSocketReady,
launchMpvIdleDetached,
resolveLauncherRuntimePluginPath,
} from '../mpv.js';
import { waitForUnixSocketReady, launchMpvIdleDetached } from '../mpv.js';
import type { LauncherCommandContext } from './context.js';
interface MpvCommandDeps {
@@ -12,7 +8,6 @@ interface MpvCommandDeps {
socketPath: string,
appPath: string,
args: LauncherCommandContext['args'],
runtimePluginPath?: string | null,
): Promise<void>;
}
@@ -49,7 +44,7 @@ export async function runMpvPostAppCommand(
context: LauncherCommandContext,
deps: MpvCommandDeps = defaultDeps,
): Promise<boolean> {
const { args, appPath, scriptPath, mpvSocketPath } = context;
const { args, appPath, mpvSocketPath } = context;
if (!args.mpvIdle) {
return false;
}
@@ -57,12 +52,7 @@ export async function runMpvPostAppCommand(
fail('SubMiner app binary not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
}
await deps.launchMpvIdleDetached(
mpvSocketPath,
appPath,
args,
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
);
await deps.launchMpvIdleDetached(mpvSocketPath, appPath, args);
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
if (!ready) {
fail(`MPV IPC socket not ready after idle launch: ${mpvSocketPath}`);
+6 -65
View File
@@ -1,9 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import type { LauncherCommandContext } from './context.js';
import { runPlaybackCommandWithDeps } from './playback-command.js';
import { state } from '../mpv.js';
function createContext(): LauncherCommandContext {
return {
@@ -97,7 +95,7 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
};
const receivedStartMpvOptions: Record<string, unknown>[] = [];
let receivedStartMpvOptions: Record<string, unknown> | null = null;
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
@@ -113,9 +111,7 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
_preloadedSubtitles,
options,
) => {
if (options) {
receivedStartMpvOptions.push(options as Record<string, unknown>);
}
receivedStartMpvOptions = options ?? null;
calls.push('startMpv');
},
waitForUnixSocketReady: async () => true,
@@ -134,63 +130,8 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
'startMpv',
'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw --youtube-mode download',
]);
assert.equal(receivedStartMpvOptions[0]?.startPaused, true);
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
});
test('plugin auto-start playback marks background app for cleanup when mpv exits', async () => {
const context = createContext();
context.args = {
...context.args,
target: '/tmp/movie.mkv',
targetKind: 'file',
};
context.pluginRuntimeConfig = {
socketPath: '/tmp/subminer.sock',
autoStart: true,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
};
const appPath = context.appPath ?? '';
state.appPath = appPath;
state.overlayManagedByLauncher = false;
const mpvProc = new EventEmitter() as EventEmitter & {
exitCode: number | null;
killed: boolean;
kill: () => boolean;
};
mpvProc.exitCode = null;
mpvProc.killed = false;
mpvProc.kill = () => true;
let cleanupSawManagedOverlay = false;
try {
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async () => {
setTimeout(() => {
mpvProc.exitCode = 0;
mpvProc.emit('exit', 0);
}, 5);
},
waitForUnixSocketReady: async () => true,
startOverlay: async () => {
throw new Error('startOverlay should not run when plugin auto-start is used');
},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {
cleanupSawManagedOverlay = state.overlayManagedByLauncher;
},
getMpvProc: () => mpvProc as NonNullable<typeof state.mpvProc>,
});
assert.equal(cleanupSawManagedOverlay, true);
} finally {
state.appPath = '';
state.overlayManagedByLauncher = false;
}
assert.deepEqual(receivedStartMpvOptions, {
startPaused: true,
disableYoutubeSubtitleAutoLoad: true,
});
});
+10 -12
View File
@@ -7,8 +7,6 @@ import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js';
import {
cleanupPlaybackSession,
launchAppCommandDetached,
markOverlayManagedByLauncher,
resolveLauncherRuntimePluginPath,
startMpv,
startOverlay,
state,
@@ -23,8 +21,9 @@ import {
getDefaultConfigDir,
getSetupStatePath,
readSetupState,
resolveDefaultMpvInstallPaths,
} from '../../src/shared/setup-state.js';
import { detectInstalledFirstRunPluginCandidates } from '../../src/main/runtime/first-run-setup-plugin.js';
import { detectInstalledFirstRunPlugin } from '../../src/main/runtime/first-run-setup-plugin.js';
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
@@ -108,13 +107,14 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
const ready = await ensureLauncherSetupReady({
readSetupState: () => readSetupState(statePath),
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
hasLegacyMpvPlugin: () =>
detectInstalledFirstRunPluginCandidates({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
appDataDir: process.env.APPDATA,
}).length > 0,
isPluginInstalled: () => {
const installPaths = resolveDefaultMpvInstallPaths(
process.platform,
os.homedir(),
process.env.XDG_CONFIG_HOME,
);
return detectInstalledFirstRunPlugin(installPaths);
},
launchSetupApp: () => {
const setupArgs = ['--background', '--setup'];
if (args.logLevel) {
@@ -237,7 +237,6 @@ export async function runPlaybackCommandWithDeps(
{
startPaused: shouldPauseUntilOverlayReady || isAppOwnedYoutubeFlow,
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
},
);
@@ -263,7 +262,6 @@ export async function runPlaybackCommandWithDeps(
: [],
);
} else if (pluginAutoStartEnabled) {
markOverlayManagedByLauncher(appPath);
if (ready) {
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
} else {
+1 -7
View File
@@ -26,7 +26,6 @@ import {
runAppCommandCaptureOutput,
launchAppStartDetached,
launchMpvIdleDetached,
resolveLauncherRuntimePluginPath,
waitForUnixSocketReady,
} from './mpv.js';
@@ -1015,12 +1014,7 @@ export async function runJellyfinPlayMenu(
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 250);
}
if (!mpvReady) {
await launchMpvIdleDetached(
mpvSocketPath,
appPath,
args,
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
);
await launchMpvIdleDetached(mpvSocketPath, appPath, args);
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000);
}
log('debug', args.logLevel, `MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`);
-85
View File
@@ -17,8 +17,6 @@ import {
launchTexthookerOnly,
parseMpvArgString,
runAppCommandCaptureOutput,
resolveLauncherRuntimePluginPath,
resolveLauncherRuntimePluginPlan,
shouldResolveAniSkipMetadata,
stopOverlay,
startOverlay,
@@ -264,89 +262,6 @@ test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured
});
});
test('resolveLauncherRuntimePluginPath finds bundled plugin from explicit environment path', () => {
const pluginDir = '/opt/SubMiner/plugin/subminer';
assert.equal(
resolveLauncherRuntimePluginPath({
appPath: '/opt/SubMiner/SubMiner.AppImage',
env: { SUBMINER_MPV_PLUGIN_PATH: pluginDir },
existsSync: (candidate) => candidate === path.join(pluginDir, 'main.lua'),
}),
path.join(pluginDir, 'main.lua'),
);
});
test('resolveLauncherRuntimePluginPath finds Linux app-support plugin assets', () => {
const homeDir = '/home/tester';
const expected = path.join(
homeDir,
'.local',
'share',
'SubMiner',
'plugin',
'subminer',
'main.lua',
);
assert.equal(
resolveLauncherRuntimePluginPath({
appPath: '/home/tester/.local/bin/SubMiner.AppImage',
scriptPath: '/home/tester/.local/bin/subminer',
platform: 'linux',
homeDir,
env: {},
existsSync: (candidate) => candidate === expected,
}),
expected,
);
});
test('resolveLauncherRuntimePluginPlan injects bundled plugin when no installed plugin exists', () => {
const plan = resolveLauncherRuntimePluginPlan({
runtimePluginPath: '/opt/SubMiner/plugin/subminer/main.lua',
platform: 'linux',
homeDir: '/home/tester',
existsSync: () => false,
});
assert.equal(plan.scriptPath, '/opt/SubMiner/plugin/subminer/main.lua');
assert.equal(plan.installedPlugin.installed, false);
assert.equal(plan.warningMessage, null);
assert.equal(plan.errorMessage, null);
});
test('resolveLauncherRuntimePluginPlan uses installed plugin instead of bundled injection', () => {
const installedPath = '/home/tester/.config/mpv/scripts/subminer/main.lua';
const versionPath = '/home/tester/.config/mpv/scripts/subminer/version.lua';
const existing = new Set([installedPath, versionPath]);
const plan = resolveLauncherRuntimePluginPlan({
runtimePluginPath: '/opt/SubMiner/plugin/subminer/main.lua',
platform: 'linux',
homeDir: '/home/tester',
existsSync: (candidate) => existing.has(candidate),
readFileSync: () => 'return { version = "0.12.0" }',
});
assert.equal(plan.scriptPath, null);
assert.equal(plan.installedPlugin.path, installedPath);
assert.equal(plan.installedPlugin.version, '0.12.0');
assert.match(plan.warningMessage ?? '', /This mpv session will use the installed plugin/);
assert.equal(plan.errorMessage, null);
});
test('resolveLauncherRuntimePluginPlan reports missing bundled plugin when no installed plugin exists', () => {
const plan = resolveLauncherRuntimePluginPlan({
runtimePluginPath: null,
platform: 'linux',
homeDir: '/home/tester',
existsSync: () => false,
});
assert.equal(plan.scriptPath, null);
assert.equal(plan.installedPlugin.installed, false);
assert.match(plan.errorMessage ?? '', /Packaged mpv plugin assets were not found/);
});
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
const error = withProcessExitIntercept(() => {
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
+2 -216
View File
@@ -4,10 +4,6 @@ import os from 'node:os';
import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process';
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
import {
detectInstalledMpvPlugin,
type InstalledMpvPluginDetection,
} from '../src/main/runtime/first-run-setup-plugin.js';
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
@@ -46,13 +42,6 @@ const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid
const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900;
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700;
export interface LauncherRuntimePluginPlan {
scriptPath: string | null;
installedPlugin: InstalledMpvPluginDetection;
warningMessage: string | null;
errorMessage: string | null;
}
export function parseMpvArgString(input: string): string[] {
const chars = input;
const args: string[] = [];
@@ -237,182 +226,6 @@ export function makeTempDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function normalizeRuntimePluginEntrypoint(
candidate: string,
deps: {
pathModule: typeof path;
existsSync: (candidate: string) => boolean;
},
): string | null {
const trimmed = candidate.trim();
if (!trimmed) return null;
if (trimmed.endsWith('.lua')) {
return deps.existsSync(trimmed) ? trimmed : null;
}
const entrypoint = deps.pathModule.join(trimmed, 'main.lua');
return deps.existsSync(entrypoint) ? entrypoint : null;
}
function pushMacAppRuntimePluginCandidate(
candidates: string[],
appPath: string,
pathModule: typeof path,
): void {
const appIndex = appPath.indexOf('.app');
if (appIndex < 0) return;
candidates.push(
pathModule.join(
appPath.slice(0, appIndex + '.app'.length),
'Contents',
'Resources',
'plugin',
'subminer',
),
);
}
export function resolveLauncherRuntimePluginPath(options: {
appPath: string;
scriptPath?: string;
platform?: NodeJS.Platform;
homeDir?: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
dirname?: string;
pathModule?: typeof path;
existsSync?: (candidate: string) => boolean;
}): string | null {
const pathModule = options.pathModule ?? path;
const existsSync = options.existsSync ?? fs.existsSync;
const env = options.env ?? process.env;
const dirname = options.dirname ?? __dirname;
const cwd = options.cwd ?? process.cwd();
const platform = options.platform ?? process.platform;
const homeDir = options.homeDir ?? os.homedir();
const candidates: string[] = [];
if (env.SUBMINER_MPV_PLUGIN_PATH) {
candidates.push(env.SUBMINER_MPV_PLUGIN_PATH);
}
pushMacAppRuntimePluginCandidate(candidates, options.appPath, pathModule);
const appDir = pathModule.dirname(options.appPath);
candidates.push(
pathModule.join(appDir, 'resources', 'plugin', 'subminer'),
pathModule.join(appDir, 'plugin', 'subminer'),
);
if (options.scriptPath) {
const scriptDir = pathModule.dirname(realpathMaybe(options.scriptPath));
candidates.push(
pathModule.join(scriptDir, '..', 'share', 'SubMiner', 'plugin', 'subminer'),
pathModule.join(scriptDir, '..', 'lib', 'SubMiner', 'plugin', 'subminer'),
pathModule.join(scriptDir, 'plugin', 'subminer'),
);
}
if (platform === 'darwin') {
candidates.push(
pathModule.join(homeDir, 'Library', 'Application Support', 'SubMiner', 'plugin', 'subminer'),
);
} else if (platform !== 'win32') {
candidates.push(
pathModule.join(
env.XDG_DATA_HOME?.trim() || pathModule.join(homeDir, '.local', 'share'),
'SubMiner',
'plugin',
'subminer',
),
);
}
candidates.push(
pathModule.join(cwd, 'plugin', 'subminer'),
pathModule.join(dirname, '..', 'plugin', 'subminer'),
pathModule.join(dirname, '..', '..', 'plugin', 'subminer'),
);
const seen = new Set<string>();
for (const candidate of candidates) {
const resolved = pathModule.resolve(candidate);
if (seen.has(resolved)) continue;
seen.add(resolved);
const entrypoint = normalizeRuntimePluginEntrypoint(resolved, { pathModule, existsSync });
if (entrypoint) {
return entrypoint;
}
}
return null;
}
export function resolveLauncherRuntimePluginPlan(options: {
runtimePluginPath?: string | null;
platform?: NodeJS.Platform;
homeDir?: string;
xdgConfigHome?: string;
appDataDir?: string;
mpvExecutablePath?: string;
existsSync?: (candidate: string) => boolean;
readFileSync?: (candidate: string, encoding: BufferEncoding) => string;
}): LauncherRuntimePluginPlan {
const installedPlugin = detectInstalledMpvPlugin({
platform: options.platform ?? process.platform,
homeDir: options.homeDir ?? os.homedir(),
xdgConfigHome: options.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
appDataDir: options.appDataDir ?? process.env.APPDATA,
mpvExecutablePath: options.mpvExecutablePath,
existsSync: options.existsSync,
readFileSync: options.readFileSync,
});
if (installedPlugin.installed) {
const versionText = installedPlugin.version
? `Detected plugin version: ${installedPlugin.version}.`
: 'Detected plugin version: unknown or legacy.';
return {
scriptPath: null,
installedPlugin,
warningMessage: `SubMiner detected an installed mpv plugin at: ${installedPlugin.path}. This mpv session will use the installed plugin. Remove it to use SubMiner's bundled runtime plugin automatically. ${versionText}`,
errorMessage: null,
};
}
if (options.runtimePluginPath) {
return {
scriptPath: options.runtimePluginPath,
installedPlugin,
warningMessage: null,
errorMessage: null,
};
}
return {
scriptPath: null,
installedPlugin,
warningMessage: null,
errorMessage:
'Packaged mpv plugin assets were not found. Install the SubMiner assets bundle or set SUBMINER_MPV_PLUGIN_PATH to plugin/subminer/main.lua.',
};
}
function appendRuntimePluginLaunchArgs(
mpvArgs: string[],
plan: LauncherRuntimePluginPlan,
logLevel: LogLevel,
): void {
if (plan.warningMessage) {
log('warn', logLevel, plan.warningMessage);
}
if (plan.errorMessage) {
fail(plan.errorMessage);
}
if (plan.scriptPath) {
mpvArgs.push(`--script=${plan.scriptPath}`);
}
}
export function detectBackend(
backend: Backend,
env: NodeJS.ProcessEnv = process.env,
@@ -845,11 +658,7 @@ export async function startMpv(
socketPath: string,
appPath: string,
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
options?: {
startPaused?: boolean;
disableYoutubeSubtitleAutoLoad?: boolean;
runtimePluginPath?: string | null;
},
options?: { startPaused?: boolean; disableYoutubeSubtitleAutoLoad?: boolean },
): Promise<void> {
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
fail(`Video file not found: ${target}`);
@@ -863,14 +672,6 @@ export async function startMpv(
const mpvArgs: string[] = [];
mpvArgs.push(...buildConfiguredMpvDefaultArgs(args));
appendRuntimePluginLaunchArgs(
mpvArgs,
resolveLauncherRuntimePluginPlan({
runtimePluginPath:
options?.runtimePluginPath ?? resolveLauncherRuntimePluginPath({ appPath }),
}),
args.logLevel,
);
if (targetKind === 'url' && isYoutubeTarget(target)) {
log('info', args.logLevel, 'Applying URL playback options');
mpvArgs.push('--ytdl=yes');
@@ -1010,7 +811,7 @@ export async function startOverlay(
env: buildAppEnv(),
});
attachAppProcessLogging(state.overlayProc);
markOverlayManagedByLauncher(appPath);
state.overlayManagedByLauncher = true;
const [socketReady] = await Promise.all([
waitForUnixSocketReady(socketPath, OVERLAY_START_SOCKET_READY_TIMEOUT_MS),
@@ -1030,13 +831,6 @@ export async function startOverlay(
}
}
export function markOverlayManagedByLauncher(appPath?: string): void {
if (appPath) {
state.appPath = appPath;
}
state.overlayManagedByLauncher = true;
}
export function openUrlInDefaultBrowser(url: string, logLevel: LogLevel): void {
const target =
process.platform === 'darwin'
@@ -1442,7 +1236,6 @@ export function launchMpvIdleDetached(
socketPath: string,
appPath: string,
args: Args,
runtimePluginPath?: string | null,
): Promise<void> {
return (async () => {
await terminateTrackedDetachedMpv(args.logLevel);
@@ -1453,13 +1246,6 @@ export function launchMpvIdleDetached(
}
const mpvArgs: string[] = buildConfiguredMpvDefaultArgs(args);
appendRuntimePluginLaunchArgs(
mpvArgs,
resolveLauncherRuntimePluginPlan({
runtimePluginPath: runtimePluginPath ?? resolveLauncherRuntimePluginPath({ appPath }),
}),
args.logLevel,
);
if (args.mpvArgs) {
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
}
+16 -63
View File
@@ -116,81 +116,34 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf
assert.deepEqual(calls, []);
});
test('ensureLauncherSetupReady waits for finish after legacy mpv plugin removal', async () => {
test('ensureLauncherSetupReady bypasses setup gate when plugin is already installed', async () => {
const calls: string[] = [];
let legacyPluginInstalled = true;
let reads = 0;
const ready = await ensureLauncherSetupReady({
readSetupState: () => {
reads += 1;
return {
version: 3,
status: 'completed',
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:40:00.000Z',
completionSource: 'user',
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
};
},
hasLegacyMpvPlugin: () => legacyPluginInstalled,
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');
legacyPluginInstalled = false;
},
sleep: async () => undefined,
now: (() => {
let value = 0;
return () => (value += 100);
})(),
now: () => 0,
timeoutMs: 5_000,
pollIntervalMs: 100,
});
assert.equal(ready, true);
assert.deepEqual(calls, ['launch']);
assert.equal(reads >= 3, true);
});
test('ensureLauncherSetupReady lets users continue without removing a legacy mpv plugin', async () => {
const calls: string[] = [];
let reads = 0;
const ready = await ensureLauncherSetupReady({
readSetupState: () => {
reads += 1;
return {
version: 3,
status: 'completed',
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:30:00.000Z',
completionSource: 'user',
yomitanSetupMode: 'internal',
lastSeenYomitanDictionaryCount: 2,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
};
},
hasLegacyMpvPlugin: () => true,
launchSetupApp: () => {
calls.push('launch');
},
sleep: async () => undefined,
now: (() => {
let value = 0;
return () => (value += 100);
})(),
timeoutMs: 5_000,
pollIntervalMs: 100,
});
assert.equal(ready, true);
assert.deepEqual(calls, ['launch']);
assert.deepEqual(calls, []);
});
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
+8 -58
View File
@@ -32,81 +32,31 @@ export async function waitForSetupCompletion(deps: {
return 'timeout';
}
export async function waitForLegacyMpvPluginPromptResolution(deps: {
readSetupState: () => SetupState | null;
sleep: (ms: number) => Promise<void>;
now: () => number;
timeoutMs: number;
pollIntervalMs: number;
initialState?: SetupState | null;
}): Promise<'acknowledged' | 'cancelled' | 'timeout'> {
const deadline = deps.now() + deps.timeoutMs;
const initialCompleted = isSetupCompleted(deps.initialState);
const initialCompletedAt = deps.initialState?.completedAt ?? null;
while (deps.now() <= deadline) {
const state = deps.readSetupState();
if (
isSetupCompleted(state) &&
(!initialCompleted || state?.completedAt !== initialCompletedAt)
) {
return 'acknowledged';
}
if (!initialCompleted && state?.status === 'cancelled') {
return 'cancelled';
}
await deps.sleep(deps.pollIntervalMs);
}
return 'timeout';
}
export async function ensureLauncherSetupReady(deps: {
readSetupState: () => SetupState | null;
isExternalYomitanConfigured?: () => boolean;
hasLegacyMpvPlugin?: () => boolean;
isPluginInstalled?: () => boolean;
launchSetupApp: () => void;
sleep: (ms: number) => Promise<void>;
now: () => number;
timeoutMs: number;
pollIntervalMs: number;
}): Promise<boolean> {
const initialState = deps.readSetupState();
let setupLaunched = false;
const launchSetupApp = () => {
if (setupLaunched) return;
setupLaunched = true;
deps.launchSetupApp();
};
if (deps.hasLegacyMpvPlugin?.()) {
launchSetupApp();
const result = await waitForLegacyMpvPluginPromptResolution({
readSetupState: deps.readSetupState,
sleep: deps.sleep,
now: deps.now,
timeoutMs: deps.timeoutMs,
pollIntervalMs: deps.pollIntervalMs,
initialState,
});
if (result === 'cancelled' || result === 'timeout') {
return false;
}
}
if (deps.isExternalYomitanConfigured?.()) {
return true;
}
const stateAfterLegacyPrompt = deps.readSetupState();
if (isSetupCompleted(stateAfterLegacyPrompt)) {
if (deps.isPluginInstalled?.()) {
return true;
}
const initialState = deps.readSetupState();
if (isSetupCompleted(initialState)) {
return true;
}
launchSetupApp();
deps.launchSetupApp();
const result = await waitForSetupCompletion({
...deps,
ignoreInitialCancelledState: stateAfterLegacyPrompt?.status === 'cancelled',
ignoreInitialCancelledState: initialState?.status === 'cancelled',
});
return result === 'completed';
}
+1 -5
View File
@@ -14,10 +14,6 @@ function M.init()
local utils = require("mp.utils")
local options_helper = require("options")
local ok_version, version = pcall(require, "version")
if not ok_version or type(version) ~= "table" then
version = { version = "unknown" }
end
local environment = require("environment").create({ mp = mp, utils = utils })
local opts = options_helper.load(options_lib, environment.default_socket_path())
local state = require("state").new()
@@ -82,7 +78,7 @@ function M.init()
ctx.session_bindings.register_bindings()
ctx.messages.register_script_messages()
ctx.lifecycle.register_lifecycle_hooks()
ctx.log.subminer_log("info", "lifecycle", "SubMiner plugin loaded " .. tostring(version.version or "unknown"))
ctx.log.subminer_log("info", "lifecycle", "SubMiner plugin loaded")
end
return M
-4
View File
@@ -1,4 +0,0 @@
return {
name = "SubMiner mpv plugin",
version = "0.12.0",
}
+101
View File
@@ -0,0 +1,101 @@
import fs from 'node:fs';
import path from 'node:path';
function normalizeCandidate(candidate) {
if (typeof candidate !== 'string') return '';
const trimmed = candidate.trim();
return trimmed.length > 0 ? trimmed : '';
}
function fileExists(candidate) {
try {
return fs.statSync(candidate).isFile();
} catch {
return false;
}
}
function unique(values) {
return Array.from(new Set(values.filter((value) => value.length > 0)));
}
function findWindowsBinary(repoRoot) {
const homeDir = process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || '';
const appDataDir = process.env.APPDATA?.trim() || '';
const derivedLocalAppData =
appDataDir && /[\\/]Roaming$/i.test(appDataDir)
? appDataDir.replace(/[\\/]Roaming$/i, `${path.sep}Local`)
: '';
const localAppData =
process.env.LOCALAPPDATA?.trim() ||
derivedLocalAppData ||
(homeDir ? path.join(homeDir, 'AppData', 'Local') : '');
const programFiles = process.env.ProgramFiles?.trim() || 'C:\\Program Files';
const programFilesX86 = process.env['ProgramFiles(x86)']?.trim() || 'C:\\Program Files (x86)';
const candidates = unique([
normalizeCandidate(process.env.SUBMINER_BINARY_PATH),
normalizeCandidate(process.env.SUBMINER_APPIMAGE_PATH),
localAppData ? path.join(localAppData, 'Programs', 'SubMiner', 'SubMiner.exe') : '',
path.join(programFiles, 'SubMiner', 'SubMiner.exe'),
path.join(programFilesX86, 'SubMiner', 'SubMiner.exe'),
'C:\\SubMiner\\SubMiner.exe',
path.join(repoRoot, 'release', 'win-unpacked', 'SubMiner.exe'),
path.join(repoRoot, 'release', 'SubMiner', 'SubMiner.exe'),
path.join(repoRoot, 'release', 'SubMiner.exe'),
]);
return candidates.find((candidate) => fileExists(candidate)) || '';
}
function rewriteBinaryPath(configPath, binaryPath) {
const content = fs.readFileSync(configPath, 'utf8');
const normalizedPath = binaryPath.replace(/\r?\n/g, ' ').trim();
const updated = content.replace(/^binary_path=.*$/m, `binary_path=${normalizedPath}`);
if (updated !== content) {
fs.writeFileSync(configPath, updated, 'utf8');
}
}
function rewriteSocketPath(configPath, socketPath) {
const content = fs.readFileSync(configPath, 'utf8');
const normalizedPath = socketPath.replace(/\r?\n/g, ' ').trim();
const updated = content.replace(/^socket_path=.*$/m, `socket_path=${normalizedPath}`);
if (updated !== content) {
fs.writeFileSync(configPath, updated, 'utf8');
}
}
const [, , configPathArg, repoRootArg, platformArg] = process.argv;
const configPath = normalizeCandidate(configPathArg);
const repoRoot = normalizeCandidate(repoRootArg) || process.cwd();
const platform = normalizeCandidate(platformArg) || process.platform;
if (!configPath) {
console.error('[ERROR] Missing plugin config path');
process.exit(1);
}
if (!fileExists(configPath)) {
console.error(`[ERROR] Plugin config not found: ${configPath}`);
process.exit(1);
}
if (platform !== 'win32') {
console.log('[INFO] Skipping binary_path rewrite for non-Windows platform');
process.exit(0);
}
const windowsSocketPath = '\\\\.\\pipe\\subminer-socket';
rewriteSocketPath(configPath, windowsSocketPath);
const binaryPath = findWindowsBinary(repoRoot);
if (!binaryPath) {
console.warn(
`[WARN] Configured plugin socket_path=${windowsSocketPath} but could not detect SubMiner.exe; set binary_path manually or provide SUBMINER_BINARY_PATH`,
);
process.exit(0);
}
rewriteBinaryPath(configPath, binaryPath);
console.log(`[INFO] Configured plugin socket_path=${windowsSocketPath} binary_path=${binaryPath}`);
-9
View File
@@ -1,8 +1,6 @@
local MODULE_PATHS = {
"plugin/subminer/bootstrap.lua",
"plugin/subminer/hover.lua",
"plugin/subminer/environment.lua",
"plugin/subminer/version.lua",
}
local LEGACY_PARSER_CANDIDATES = {
@@ -50,12 +48,6 @@ local function assert_loadfile_ok(path)
assert_true(chunk ~= nil, "loadfile failed for " .. path .. ": " .. tostring(err))
end
local function assert_bootstrap_uses_defensive_version_load()
local source = read_file("plugin/subminer/bootstrap.lua")
assert_true(not source:find('require%("version"%)'), "bootstrap.lua must not hard-require version.lua")
assert_true(source:find('pcall%(require, "version"%)') ~= nil, "bootstrap.lua must load version.lua with pcall")
end
local function normalize_execute_result(ok, why, code)
if type(ok) == "number" then
return ok == 0, ok
@@ -136,7 +128,6 @@ for _, path in ipairs(MODULE_PATHS) do
assert_no_legacy_incompatible_continue(path)
assert_loadfile_ok(path)
end
assert_bootstrap_uses_defensive_version_load()
local parser = find_legacy_parser()
if parser then
+194 -32
View File
@@ -21,6 +21,7 @@ local recorded = {
bindings = {},
removed = {},
async_calls = {},
mpv_commands = {},
osd = {},
}
@@ -38,6 +39,10 @@ function mp.remove_key_binding(name)
recorded.removed[#recorded.removed + 1] = name
end
function mp.commandv(...)
recorded.mpv_commands[#recorded.mpv_commands + 1] = { ... }
end
function mp.add_timeout(seconds, callback)
return {
seconds = seconds,
@@ -73,19 +78,75 @@ local ctx = {
},
{
key = {
code = "KeyL",
modifiers = { "ctrl", "shift" },
code = "Space",
modifiers = {},
},
actionType = "session-action",
actionId = "playNextSubtitle",
actionType = "mpv-command",
command = { "cycle", "pause" },
},
{
key = {
code = "KeyA",
modifiers = { "alt", "meta" },
code = "KeyF",
modifiers = {},
},
actionType = "session-action",
actionId = "openCharacterDictionary",
actionType = "mpv-command",
command = { "cycle", "fullscreen" },
},
{
key = {
code = "KeyJ",
modifiers = {},
},
actionType = "mpv-command",
command = { "cycle", "sid" },
},
{
key = {
code = "KeyJ",
modifiers = { "shift" },
},
actionType = "mpv-command",
command = { "cycle", "secondary-sid" },
},
{
key = {
code = "ArrowRight",
modifiers = {},
},
actionType = "mpv-command",
command = { "seek", 5 },
},
{
key = {
code = "ArrowLeft",
modifiers = {},
},
actionType = "mpv-command",
command = { "seek", -5 },
},
{
key = {
code = "ArrowUp",
modifiers = {},
},
actionType = "mpv-command",
command = { "seek", 60 },
},
{
key = {
code = "ArrowDown",
modifiers = {},
},
actionType = "mpv-command",
command = { "seek", -60 },
},
{
key = {
code = "KeyH",
modifiers = { "shift" },
},
actionType = "mpv-command",
command = { "sub-seek", -1 },
},
{
key = {
@@ -95,6 +156,78 @@ local ctx = {
actionType = "mpv-command",
command = { "sub-seek", 1 },
},
{
key = {
code = "BracketRight",
modifiers = { "shift" },
},
actionType = "session-action",
actionId = "shiftSubDelayNextLine",
},
{
key = {
code = "BracketLeft",
modifiers = { "shift" },
},
actionType = "session-action",
actionId = "shiftSubDelayPrevLine",
},
{
key = {
code = "KeyC",
modifiers = { "ctrl", "alt" },
},
actionType = "session-action",
actionId = "openYoutubePicker",
},
{
key = {
code = "KeyP",
modifiers = { "ctrl", "alt" },
},
actionType = "session-action",
actionId = "openPlaylistBrowser",
},
{
key = {
code = "KeyH",
modifiers = { "ctrl", "shift" },
},
actionType = "session-action",
actionId = "replayCurrentSubtitle",
},
{
key = {
code = "KeyL",
modifiers = { "ctrl", "shift" },
},
actionType = "session-action",
actionId = "playNextSubtitle",
},
{
key = {
code = "KeyQ",
modifiers = {},
},
actionType = "mpv-command",
command = { "quit" },
},
{
key = {
code = "KeyW",
modifiers = { "ctrl" },
},
actionType = "mpv-command",
command = { "quit" },
},
{
key = {
code = "KeyA",
modifiers = { "alt", "meta" },
},
actionType = "session-action",
actionId = "openCharacterDictionary",
},
},
}, nil
end,
@@ -129,31 +262,66 @@ local ctx = {
local bindings = session_bindings.create(ctx)
assert_true(bindings.register_bindings(), "session bindings should register")
local starter = nil
for _, binding in ipairs(recorded.bindings) do
if binding.keys == "Ctrl+S" then
starter = binding
break
local function find_binding(keys)
for _, binding in ipairs(recorded.bindings) do
if binding.keys == keys then
return binding
end
end
return nil
end
local starter = find_binding("Ctrl+S")
assert_true(starter ~= nil, "multi-mine starter binding should be registered")
local play_next = nil
for _, binding in ipairs(recorded.bindings) do
if binding.keys == "Ctrl+L" then
play_next = binding
break
local expected_mpv_bindings = {
{ keys = "SPACE", command = { "cycle", "pause" } },
{ keys = "f", command = { "cycle", "fullscreen" } },
{ keys = "j", command = { "cycle", "sid" } },
{ keys = "J", command = { "cycle", "secondary-sid" } },
{ keys = "RIGHT", command = { "seek", 5 } },
{ keys = "LEFT", command = { "seek", -5 } },
{ keys = "UP", command = { "seek", 60 } },
{ keys = "DOWN", command = { "seek", -60 } },
{ keys = "H", command = { "sub-seek", -1 } },
{ keys = "L", command = { "sub-seek", 1 } },
{ keys = "q", command = { "quit" } },
{ keys = "Ctrl+w", command = { "quit" } },
}
for _, expected in ipairs(expected_mpv_bindings) do
local binding = find_binding(expected.keys)
assert_true(binding ~= nil, "default mpv binding should register " .. expected.keys)
binding.fn()
local command = recorded.mpv_commands[#recorded.mpv_commands]
assert_true(command ~= nil, "default mpv binding should invoke mpv command " .. expected.keys)
for index, value in ipairs(expected.command) do
assert_true(command[index] == value, "default mpv command mismatch for " .. expected.keys)
end
end
local expected_cli_bindings = {
{ keys = "Shift+]", flag = "--shift-sub-delay-next-line" },
{ keys = "Shift+[", flag = "--shift-sub-delay-prev-line" },
{ keys = "Ctrl+Alt+c", flag = "--open-youtube-picker" },
{ keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" },
{ keys = "Ctrl+H", flag = "--replay-current-subtitle" },
{ keys = "Ctrl+L", flag = "--play-next-subtitle" },
}
for _, expected in ipairs(expected_cli_bindings) do
local binding = find_binding(expected.keys)
assert_true(binding ~= nil, "default session action should register " .. expected.keys)
binding.fn()
local cli_call = recorded.async_calls[#recorded.async_calls]
assert_true(cli_call ~= nil, "default session action should invoke CLI " .. expected.keys)
assert_true(cli_call[2] == expected.flag, "default session action should pass " .. expected.flag)
end
local play_next = find_binding("Ctrl+L")
assert_true(play_next ~= nil, "play-next subtitle binding should use mpv shifted-letter form")
local subtitle_jump = nil
for _, binding in ipairs(recorded.bindings) do
if binding.keys == "L" then
subtitle_jump = binding
break
end
end
local subtitle_jump = find_binding("L")
assert_true(subtitle_jump ~= nil, "shifted subtitle jump binding should use mpv uppercase letter form")
play_next.fn()
@@ -161,13 +329,7 @@ local play_next_call = recorded.async_calls[#recorded.async_calls]
assert_true(play_next_call ~= nil, "play-next binding should invoke CLI action")
assert_true(play_next_call[2] == "--play-next-subtitle", "play-next binding should pass CLI flag")
local character_dictionary = nil
for _, binding in ipairs(recorded.bindings) do
if binding.keys == "Alt+Meta+a" then
character_dictionary = binding
break
end
end
local character_dictionary = find_binding("Alt+Meta+a")
assert_true(character_dictionary ~= nil, "character dictionary binding should be registered")
character_dictionary.fn()
+16 -1
View File
@@ -4,7 +4,13 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { ConfigService, ConfigStartupParseError } from './service';
import { DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY, deepMergeRawConfig } from './definitions';
import {
DEFAULT_CONFIG,
DEFAULT_KEYBINDINGS,
RUNTIME_OPTION_REGISTRY,
deepMergeRawConfig,
} from './definitions';
import { parseConfigContent } from './parse';
import { generateConfigTemplate } from './template';
function makeTempDir(): string {
@@ -2217,3 +2223,12 @@ test('template generator includes known keys', () => {
/"launchAtStartup": true,? \/\/ Launch texthooker server automatically when SubMiner starts\. Values: true \| false/,
);
});
test('template generator shows built-in default keybindings in the keybindings array', () => {
const output = generateConfigTemplate(DEFAULT_CONFIG);
const parsed = parseConfigContent('config.example.jsonc', output) as {
keybindings?: unknown;
};
assert.deepEqual(parsed.keybindings, DEFAULT_KEYBINDINGS);
});
+1 -1
View File
@@ -62,7 +62,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Keybindings (MPV Commands)',
description: [
'Extra keybindings that are merged with built-in defaults.',
'Default and custom keybindings that are merged with built-in defaults.',
'Set command to null to disable a default keybinding.',
],
notes: [
+14 -1
View File
@@ -3,6 +3,7 @@ import {
CONFIG_OPTION_REGISTRY,
CONFIG_TEMPLATE_SECTIONS,
DEFAULT_CONFIG,
DEFAULT_KEYBINDINGS,
deepCloneConfig,
} from './definitions';
@@ -103,9 +104,21 @@ function renderSection(
return lines.join('\n');
}
function createTemplateConfig(config: ResolvedConfig): ResolvedConfig {
const templateConfig = deepCloneConfig(config);
if (templateConfig.keybindings.length === 0) {
templateConfig.keybindings = DEFAULT_KEYBINDINGS.map((binding) => ({
key: binding.key,
command: binding.command === null ? null : [...binding.command],
}));
}
return templateConfig;
}
export function generateConfigTemplate(
config: ResolvedConfig = deepCloneConfig(DEFAULT_CONFIG),
): string {
const templateConfig = createTemplateConfig(config);
const lines: string[] = [];
lines.push('/**');
lines.push(' * SubMiner Example Configuration File');
@@ -123,7 +136,7 @@ export function generateConfigTemplate(
lines.push(
renderSection(
section.key,
config[section.key],
templateConfig[section.key],
index === CONFIG_TEMPLATE_SECTIONS.length - 1,
comments,
),
@@ -1025,46 +1025,6 @@ describe('stats server API routes', () => {
assert.equal(res.status, 400);
});
it('GET /api/stats/anilist/search uses the configured AniList rate limiter', async () => {
const originalFetch = globalThis.fetch;
let acquireCalls = 0;
let recordCalls = 0;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
data: {
Page: {
media: [{ id: 21858, title: { romaji: 'Little Witch Academia' } }],
},
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json', 'X-RateLimit-Remaining': '29' },
},
)) as typeof fetch;
try {
const app = createStatsApp(createMockTracker(), {
anilistRateLimiter: {
acquire: async () => {
acquireCalls += 1;
},
recordResponse: () => {
recordCalls += 1;
},
},
});
const res = await app.request('/api/stats/anilist/search?q=Little%20Witch%20Academia');
assert.equal(res.status, 200);
assert.equal(acquireCalls, 1);
assert.equal(recordCalls, 1);
} finally {
globalThis.fetch = originalFetch;
}
});
it('POST /api/stats/anki/notesInfo resolves stale note ids through the configured alias resolver', async () => {
const originalFetch = globalThis.fetch;
const requests: unknown[] = [];
@@ -184,57 +184,6 @@ test('updateAnilistPostWatchProgress updates progress when behind', async () =>
}
});
test('updateAnilistPostWatchProgress uses the configured AniList rate limiter', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
let acquireCalls = 0;
let recordCalls = 0;
globalThis.fetch = (async () => {
call += 1;
if (call === 1) {
return createJsonResponse({
data: {
Page: {
media: [{ id: 11, episodes: 24, title: { english: 'Demo Show' } }],
},
},
});
}
if (call === 2) {
return createJsonResponse({
data: {
Media: {
id: 11,
mediaListEntry: { progress: 2, status: 'CURRENT' },
},
},
});
}
return createJsonResponse({
data: { SaveMediaListEntry: { progress: 3, status: 'CURRENT' } },
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 3, {
rateLimiter: {
acquire: async () => {
acquireCalls += 1;
},
recordResponse: () => {
recordCalls += 1;
},
},
});
assert.equal(result.status, 'updated');
assert.equal(acquireCalls, 3);
assert.equal(recordCalls, 3);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress skips when progress already reached', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
@@ -2,7 +2,6 @@ import * as childProcess from 'child_process';
import * as path from 'path';
import { parseMediaInfo } from '../../../jimaku/utils';
import type { AnilistRateLimiter } from './rate-limiter';
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
@@ -20,10 +19,6 @@ export interface AnilistPostWatchUpdateResult {
message: string;
}
export interface AnilistPostWatchUpdateOptions {
rateLimiter?: AnilistRateLimiter;
}
interface AnilistGraphQlError {
message?: string;
}
@@ -160,10 +155,8 @@ async function anilistGraphQl<T>(
accessToken: string,
query: string,
variables: Record<string, unknown>,
options: AnilistPostWatchUpdateOptions = {},
): Promise<AnilistGraphQlResponse<T>> {
try {
await options.rateLimiter?.acquire();
const response = await fetch(ANILIST_GRAPHQL_URL, {
method: 'POST',
headers: {
@@ -173,7 +166,6 @@ async function anilistGraphQl<T>(
body: JSON.stringify({ query, variables }),
});
options.rateLimiter?.recordResponse(response.headers);
const payload = (await response.json()) as AnilistGraphQlResponse<T>;
return payload;
} catch (error) {
@@ -277,7 +269,6 @@ export async function updateAnilistPostWatchProgress(
accessToken: string,
title: string,
episode: number,
options: AnilistPostWatchUpdateOptions = {},
): Promise<AnilistPostWatchUpdateResult> {
const searchResponse = await anilistGraphQl<AnilistSearchData>(
accessToken,
@@ -297,7 +288,6 @@ export async function updateAnilistPostWatchProgress(
}
`,
{ search: title },
options,
);
const searchError = firstErrorMessage(searchResponse);
if (searchError) {
@@ -327,7 +317,6 @@ export async function updateAnilistPostWatchProgress(
}
`,
{ mediaId: picked.id },
options,
);
const entryError = firstErrorMessage(entryResponse);
if (entryError) {
@@ -356,7 +345,6 @@ export async function updateAnilistPostWatchProgress(
}
`,
{ mediaId: picked.id, progress: episode },
options,
);
const saveError = firstErrorMessage(saveResponse);
if (saveError) {
@@ -5,12 +5,7 @@ import path from 'node:path';
import test from 'node:test';
import { createCoverArtFetcher, stripFilenameTags } from './cover-art-fetcher.js';
import { Database } from '../immersion-tracker/sqlite.js';
import {
ensureSchema,
getOrCreateAnimeRecord,
getOrCreateVideoRecord,
linkVideoToAnimeRecord,
} from '../immersion-tracker/storage.js';
import { ensureSchema, getOrCreateVideoRecord } from '../immersion-tracker/storage.js';
import { getCoverArt } from '../immersion-tracker/query-library.js';
import { upsertCoverArt } from '../immersion-tracker/query-maintenance.js';
import { SOURCE_TYPE_LOCAL } from '../immersion-tracker/types.js';
@@ -105,82 +100,6 @@ test('fetchIfMissing backfills a missing blob from an existing cover URL', async
}
});
test('fetchIfMissing reuses cached cover art from another video in the same anime', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
ensureSchema(db);
const firstVideoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-cache-1.mkv', {
canonicalTitle: 'Shared Cover Show',
sourcePath: '/tmp/cover-fetcher-cache-1.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const secondVideoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-cache-2.mkv', {
canonicalTitle: 'Shared Cover Show',
sourcePath: '/tmp/cover-fetcher-cache-2.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Shared Cover Show',
canonicalTitle: 'Shared Cover Show',
anilistId: 99,
titleRomaji: 'Shared Cover Show',
titleEnglish: 'Shared Cover Show',
titleNative: null,
metadataJson: null,
});
for (const videoId of [firstVideoId, secondVideoId]) {
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: null,
parsedTitle: 'Shared Cover Show',
parsedSeason: 1,
parsedEpisode: videoId,
parserSource: 'fallback',
parserConfidence: 1,
parseMetadataJson: null,
});
}
upsertCoverArt(db, firstVideoId, {
anilistId: 99,
coverUrl: 'https://images.test/shared-cover.jpg',
coverBlob: Buffer.from([9, 8, 7, 6]),
titleRomaji: 'Shared Cover Show',
titleEnglish: 'Shared Cover Show',
episodesTotal: 12,
});
let fetchCalls = 0;
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
fetchCalls += 1;
throw new Error('unexpected AniList or image request');
}) as typeof fetch;
try {
const fetcher = createCoverArtFetcher(
{
acquire: async () => {},
recordResponse: () => {},
},
console,
);
const fetched = await fetcher.fetchIfMissing(db, secondVideoId, 'Shared Cover Show');
const stored = getCoverArt(db, secondVideoId);
assert.equal(fetched, true);
assert.equal(fetchCalls, 0);
assert.equal(stored?.anilistId, 99);
assert.equal(Buffer.from(stored?.coverBlob ?? []).toString('hex'), '09080706');
} finally {
globalThis.fetch = originalFetch;
db.close();
cleanupDbPath(dbPath);
}
});
function createJsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
+1 -34
View File
@@ -1,11 +1,6 @@
import type { AnilistRateLimiter } from './rate-limiter';
import type { DatabaseSync } from '../immersion-tracker/sqlite';
import {
getAnimeCoverArt,
getCoverArt,
upsertCoverArt,
updateAnimeAnilistInfo,
} from '../immersion-tracker/query';
import { getCoverArt, upsertCoverArt, updateAnimeAnilistInfo } from '../immersion-tracker/query';
import {
guessAnilistMediaInfo,
runGuessit,
@@ -262,30 +257,6 @@ export function createCoverArtFetcher(
logger: Logger,
options: CoverArtFetcherOptions = {},
): CoverArtFetcher {
const reuseAnimeCoverArt = (db: DatabaseSync, videoId: number): boolean => {
const row = db
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
.get(videoId) as { animeId: number | null } | undefined;
if (!row?.animeId) {
return false;
}
const shared = getAnimeCoverArt(db, row.animeId);
if (!shared?.coverBlob) {
return false;
}
upsertCoverArt(db, videoId, {
anilistId: shared.anilistId,
coverUrl: shared.coverUrl,
coverBlob: shared.coverBlob,
titleRomaji: shared.titleRomaji,
titleEnglish: shared.titleEnglish,
episodesTotal: shared.episodesTotal,
});
return true;
};
const resolveCanonicalTitle = (
db: DatabaseSync,
videoId: number,
@@ -346,10 +317,6 @@ export function createCoverArtFetcher(
}
}
if (reuseAnimeCoverArt(db, videoId)) {
return true;
}
if (
existing &&
existing.coverUrl === null &&
@@ -209,6 +209,41 @@ test('compileSessionBindings keeps default replay and next subtitle session acti
assert.equal(next?.actionId, 'playNextSubtitle');
});
test('compileSessionBindings wires every default keybinding to an overlay or mpv action', () => {
const expectedSpecialActions: Record<string, string> = {
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]: 'shiftSubDelayPrevLine',
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START]: 'shiftSubDelayNextLine',
[SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
[SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser',
[SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle',
[SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE]: 'playNextSubtitle',
};
const result = compileSessionBindings({
shortcuts: createShortcuts(),
keybindings: DEFAULT_KEYBINDINGS,
platform: 'linux',
});
assert.deepEqual(result.warnings, []);
const byOriginalKey = new Map(result.bindings.map((binding) => [binding.originalKey, binding]));
assert.equal(byOriginalKey.size, DEFAULT_KEYBINDINGS.length);
for (const defaultBinding of DEFAULT_KEYBINDINGS) {
const compiled = byOriginalKey.get(defaultBinding.key);
assert.ok(compiled, `${defaultBinding.key} should compile`);
const specialAction = expectedSpecialActions[String(defaultBinding.command?.[0])];
if (specialAction) {
assert.equal(compiled.actionType, 'session-action');
assert.equal(compiled.actionId, specialAction);
continue;
}
assert.equal(compiled.actionType, 'mpv-command');
assert.deepEqual(compiled.command, defaultBinding.command);
}
});
test('compileSessionBindings omits disabled bindings', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
-9
View File
@@ -14,7 +14,6 @@ import {
getPreferredNoteFieldValue,
} from '../../anki-field-config.js';
import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js';
import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
type StatsServerNoteInfo = {
noteId: number;
@@ -256,7 +255,6 @@ export interface StatsServerConfig {
knownWordCachePath?: string;
mpvSocketPath?: string;
ankiConnectConfig?: AnkiConnectConfig;
anilistRateLimiter?: AnilistRateLimiter;
addYomitanNote?: (word: string) => Promise<number | null>;
resolveAnkiNoteId?: (noteId: number) => number;
}
@@ -340,7 +338,6 @@ export function createStatsApp(
knownWordCachePath?: string;
mpvSocketPath?: string;
ankiConnectConfig?: AnkiConnectConfig;
anilistRateLimiter?: AnilistRateLimiter;
addYomitanNote?: (word: string) => Promise<number | null>;
resolveAnkiNoteId?: (noteId: number) => number;
},
@@ -635,7 +632,6 @@ export function createStatsApp(
const query = (c.req.query('q') ?? '').trim();
if (!query) return c.json([]);
try {
await options?.anilistRateLimiter?.acquire();
const res = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -656,10 +652,6 @@ export function createStatsApp(
variables: { search: query },
}),
});
options?.anilistRateLimiter?.recordResponse(res.headers);
if (res.status === 429) {
return c.json([]);
}
const json = (await res.json()) as { data?: { Page?: { media?: unknown[] } } };
return c.json(json.data?.Page?.media ?? []);
} catch {
@@ -1139,7 +1131,6 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
knownWordCachePath: config.knownWordCachePath,
mpvSocketPath: config.mpvSocketPath,
ankiConnectConfig: config.ankiConnectConfig,
anilistRateLimiter: config.anilistRateLimiter,
addYomitanNote: config.addYomitanNote,
resolveAnkiNoteId: config.resolveAnkiNoteId,
});
+9 -105
View File
@@ -1,7 +1,6 @@
import path from 'node:path';
import os from 'node:os';
import { spawn } from 'node:child_process';
import { app, dialog, shell } from 'electron';
import { app, dialog } from 'electron';
import { printHelp } from './cli/help';
import { loadRawConfigStrict } from './config/load';
import {
@@ -19,12 +18,7 @@ import {
shouldHandleStatsDaemonCommandAtEntry,
} from './main-entry-runtime';
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
import {
detectInstalledFirstRunPluginCandidates,
detectInstalledMpvPlugin,
removeLegacyMpvPluginCandidates,
resolvePackagedRuntimePluginPath,
} from './main/runtime/first-run-setup-plugin';
import { resolvePackagedFirstRunPluginAssets } from './main/runtime/first-run-setup-plugin';
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
import { parseMpvLaunchMode } from './shared/mpv-launch-mode';
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
@@ -44,105 +38,16 @@ function applySanitizedEnv(sanitizedEnv: NodeJS.ProcessEnv): void {
}
function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined {
return (
resolvePackagedRuntimePluginPath({
dirname: __dirname,
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
}) ?? undefined
);
}
function buildInstalledWindowsMpvPluginMessage(pathValue: string, version: string | null): string {
return [
'SubMiner detected an installed mpv plugin at:',
pathValue,
'',
"This mpv session will use the installed plugin. Remove it to use SubMiner's bundled runtime plugin automatically.",
`Detected plugin version: ${version ?? 'unknown or legacy'}`,
].join('\n');
}
async function promptForWindowsLegacyMpvPluginRemoval(
mpvPath: string,
detection: { path: string | null; version: string | null },
): Promise<'removed' | 'continue' | 'cancel'> {
const response = await dialog.showMessageBox({
type: 'warning',
title: 'SubMiner mpv plugin detected',
message: buildInstalledWindowsMpvPluginMessage(
detection.path ?? 'unknown path',
detection.version,
),
detail:
'Remove the legacy SubMiner mpv plugin files from mpv before launching this video? This moves the files to the OS trash. SubMiner-managed playback will then use the bundled runtime plugin.',
buttons: ['Remove legacy plugin', 'Continue with installed plugin', 'Cancel'],
defaultId: 0,
cancelId: 2,
const assets = resolvePackagedFirstRunPluginAssets({
dirname: __dirname,
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
});
if (response.response === 2) {
return 'cancel';
}
if (response.response === 1) {
return 'continue';
if (!assets) {
return undefined;
}
const candidates = detectInstalledFirstRunPluginCandidates({
platform: 'win32',
homeDir: os.homedir(),
appDataDir: app.getPath('appData'),
mpvExecutablePath: mpvPath,
});
const result = await removeLegacyMpvPluginCandidates({
candidates,
trashItem: (candidatePath) => shell.trashItem(candidatePath),
});
if (result.ok) {
await dialog.showMessageBox({
type: 'info',
title: 'Legacy mpv plugin removed',
message:
'Legacy mpv plugin removed. SubMiner-managed playback will use the bundled runtime plugin.',
});
return 'removed';
}
await dialog.showMessageBox({
type: 'error',
title: 'Could not remove legacy mpv plugin',
message: 'Some legacy SubMiner mpv plugin files could not be moved to the trash.',
detail: result.failedPaths.map((failure) => `${failure.path}: ${failure.message}`).join('\n'),
});
return 'cancel';
}
function createWindowsRuntimePluginPolicy() {
return {
detectInstalledMpvPlugin: (mpvPath: string) =>
detectInstalledMpvPlugin({
platform: 'win32',
homeDir: os.homedir(),
appDataDir: app.getPath('appData'),
mpvExecutablePath: mpvPath,
}),
notifyInstalledPluginDetected: (detection: {
installed: boolean;
path: string | null;
version: string | null;
}) => {
if (!detection.installed || !detection.path) return;
dialog.showMessageBoxSync({
type: 'warning',
title: 'SubMiner mpv plugin detected',
message: buildInstalledWindowsMpvPluginMessage(detection.path, detection.version),
});
},
resolveInstalledPluginBeforeLaunch: (
detection: { path: string | null; version: string | null },
mpvPath: string,
) => promptForWindowsLegacyMpvPluginRemoval(mpvPath, detection),
};
return path.join(assets.pluginDirSource, 'main.lua');
}
function readConfiguredWindowsMpvLaunch(configDir: string): {
@@ -212,7 +117,6 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
resolveBundledWindowsMpvPluginEntrypoint(),
configuredMpvLaunch.executablePath,
configuredMpvLaunch.launchMode,
createWindowsRuntimePluginPolicy(),
);
app.exit(result.ok ? 0 : 1);
});
+14 -126
View File
@@ -382,10 +382,7 @@ import {
} from './main/runtime/first-run-setup-window';
import {
detectInstalledFirstRunPlugin,
detectInstalledFirstRunPluginCandidates,
detectInstalledMpvPlugin,
removeLegacyMpvPluginCandidates,
resolvePackagedRuntimePluginPath,
installFirstRunPluginToDefaultLocation,
syncInstalledFirstRunPluginBinaryPath,
} from './main/runtime/first-run-setup-plugin';
import {
@@ -1066,89 +1063,6 @@ const managedLocalSubtitleSelectionRuntime = createManagedLocalSubtitleSelection
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
clearScheduled: (timer) => clearTimeout(timer),
});
function resolveBundledMpvRuntimePluginEntrypoint(): string | undefined {
return (
resolvePackagedRuntimePluginPath({
dirname: __dirname,
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
}) ?? undefined
);
}
function detectWindowsInstalledMpvPlugin(mpvExecutablePath: string) {
return detectInstalledMpvPlugin({
platform: 'win32',
homeDir: os.homedir(),
appDataDir: app.getPath('appData'),
mpvExecutablePath,
});
}
function logInstalledMpvPluginDetected(detection: { path: string | null; version: string | null }) {
if (!detection.path) return;
logger.warn(
`SubMiner detected an installed mpv plugin at ${detection.path}. This mpv session will use the installed plugin. Remove it to use the bundled runtime plugin automatically. Detected plugin version: ${detection.version ?? 'unknown or legacy'}.`,
);
}
async function promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(
mpvPath: string,
detection: { path: string | null; version: string | null },
): Promise<'removed' | 'continue' | 'cancel'> {
const response = await dialog.showMessageBox({
type: 'warning',
title: 'SubMiner mpv plugin detected',
message: [
'SubMiner detected an installed mpv plugin at:',
detection.path ?? 'unknown path',
'',
"This mpv session will use the installed plugin unless it is removed. Remove it now to use SubMiner's bundled runtime plugin automatically.",
`Detected plugin version: ${detection.version ?? 'unknown or legacy'}`,
].join('\n'),
detail:
'Remove the legacy SubMiner mpv plugin files from mpv before launching this video? This moves the files to the OS trash.',
buttons: ['Remove legacy plugin', 'Continue with installed plugin', 'Cancel'],
defaultId: 0,
cancelId: 2,
});
if (response.response === 2) {
return 'cancel';
}
if (response.response === 1) {
return 'continue';
}
const result = await removeLegacyMpvPluginCandidates({
candidates: detectInstalledFirstRunPluginCandidates({
platform: 'win32',
homeDir: os.homedir(),
appDataDir: app.getPath('appData'),
mpvExecutablePath: mpvPath,
}),
trashItem: (candidatePath) => shell.trashItem(candidatePath),
});
if (result.ok) {
await dialog.showMessageBox({
type: 'info',
title: 'Legacy mpv plugin removed',
message:
'Legacy mpv plugin removed. SubMiner-managed playback will use the bundled runtime plugin.',
});
return 'removed';
}
await dialog.showMessageBox({
type: 'error',
title: 'Could not remove legacy mpv plugin',
message: 'Some legacy SubMiner mpv plugin files could not be moved to the trash.',
detail: result.failedPaths.map((failure) => `${failure.path}: ${failure.message}`).join('\n'),
});
return 'cancel';
}
const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
platform: process.platform,
directPlaybackFormat: YOUTUBE_DIRECT_PLAYBACK_FORMAT,
@@ -1173,16 +1087,10 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
showError: (title, content) => dialog.showErrorBox(title, content),
}),
[...args, `--log-file=${DEFAULT_MPV_LOG_PATH}`],
process.execPath,
resolveBundledMpvRuntimePluginEntrypoint(),
undefined,
undefined,
getResolvedConfig().mpv.executablePath,
getResolvedConfig().mpv.launchMode,
{
detectInstalledMpvPlugin: detectWindowsInstalledMpvPlugin,
notifyInstalledPluginDetected: logInstalledMpvPluginDetected,
resolveInstalledPluginBeforeLaunch: (detection, mpvPath) =>
promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(mpvPath, detection),
},
),
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
@@ -1219,16 +1127,6 @@ const firstRunSetupService = createFirstRunSetupService({
isExternalYomitanConfigured: () =>
getResolvedConfig().yomitan.externalProfilePath.trim().length > 0,
detectPluginInstalled: () => {
const candidates = detectInstalledFirstRunPluginCandidates({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
appDataDir: app.getPath('appData'),
mpvExecutablePath: getResolvedConfig().mpv.executablePath,
});
if (candidates.length > 0) {
return true;
}
const installPaths = resolveDefaultMpvInstallPaths(
process.platform,
os.homedir(),
@@ -1236,18 +1134,15 @@ const firstRunSetupService = createFirstRunSetupService({
);
return detectInstalledFirstRunPlugin(installPaths);
},
detectLegacyMpvPluginCandidates: () =>
detectInstalledFirstRunPluginCandidates({
installPlugin: async () =>
installFirstRunPluginToDefaultLocation({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
appDataDir: app.getPath('appData'),
mpvExecutablePath: getResolvedConfig().mpv.executablePath,
}),
removeLegacyMpvPlugins: (candidates) =>
removeLegacyMpvPluginCandidates({
candidates,
trashItem: (candidatePath) => shell.trashItem(candidatePath),
dirname: __dirname,
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
binaryPath: process.execPath,
}),
detectWindowsMpvShortcuts: () => {
if (process.platform !== 'win32') {
@@ -1414,9 +1309,8 @@ const buildMainSubsyncRuntimeMainDepsHandler = createBuildMainSubsyncRuntimeMain
const immersionMediaRuntime = createImmersionMediaRuntime(
buildImmersionMediaRuntimeMainDepsHandler(),
);
const anilistRateLimiter = createAnilistRateLimiter();
const statsCoverArtFetcher = createCoverArtFetcher(
anilistRateLimiter,
createAnilistRateLimiter(),
createLogger('main:stats-cover-art'),
);
const anilistStateRuntime = createAnilistStateRuntime(buildAnilistStateRuntimeMainDepsHandler());
@@ -2745,7 +2639,6 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
externalYomitanConfigured: snapshot.externalYomitanConfigured,
pluginStatus: snapshot.pluginStatus,
pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
legacyMpvPluginPaths: snapshot.legacyMpvPluginPaths,
mpvExecutablePath,
mpvExecutablePathStatus: getConfiguredWindowsMpvPathStatus(mpvExecutablePath),
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
@@ -2755,8 +2648,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
buildSetupHtml: (model) => buildFirstRunSetupHtml(model),
parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl),
handleAction: async (submission: FirstRunSetupSubmission) => {
if (submission.action === 'remove-legacy-plugin') {
const snapshot = await firstRunSetupService.removeLegacyMpvPlugin();
if (submission.action === 'install-plugin') {
const snapshot = await firstRunSetupService.installMpvPlugin();
firstRunSetupMessage = snapshot.message;
return;
}
@@ -3105,9 +2998,7 @@ const {
},
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
updateAnilistPostWatchProgress(accessToken, title, episode, {
rateLimiter: anilistRateLimiter,
}),
updateAnilistPostWatchProgress(accessToken, title, episode),
markSuccess: (key) => {
anilistUpdateQueue.markSuccess(key);
},
@@ -3153,9 +3044,7 @@ const {
},
refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(),
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
updateAnilistPostWatchProgress(accessToken, title, episode, {
rateLimiter: anilistRateLimiter,
}),
updateAnilistPostWatchProgress(accessToken, title, episode),
rememberAttemptedUpdateKey: (key) => {
rememberAnilistAttemptedUpdate(key);
},
@@ -3362,7 +3251,6 @@ const startLocalStatsServer = (): void => {
knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'),
mpvSocketPath: appState.mpvSocketPath,
ankiConnectConfig: getResolvedConfig().ankiConnect,
anilistRateLimiter,
resolveAnkiNoteId: (noteId: number) =>
appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
addYomitanNote: async (word: string) => {
+114 -148
View File
@@ -5,11 +5,8 @@ import os from 'node:os';
import path from 'node:path';
import {
detectInstalledFirstRunPlugin,
detectInstalledFirstRunPluginCandidates,
detectInstalledMpvPlugin,
removeLegacyMpvPluginCandidates,
installFirstRunPluginToDefaultLocation,
resolvePackagedFirstRunPluginAssets,
resolvePackagedRuntimePluginPath,
syncInstalledFirstRunPluginBinaryPath,
} from './first-run-setup-plugin';
import { resolveDefaultMpvInstallPaths } from '../../shared/setup-state';
@@ -46,22 +43,125 @@ test('resolvePackagedFirstRunPluginAssets finds packaged plugin assets', () => {
});
});
test('resolvePackagedRuntimePluginPath returns packaged plugin entrypoint', () => {
test('installFirstRunPluginToDefaultLocation installs plugin and backs up existing files', () => {
withTempDir((root) => {
const resourcesPath = path.join(root, 'resources');
const pluginRoot = path.join(resourcesPath, 'plugin');
const entrypoint = path.join(pluginRoot, 'subminer', 'main.lua');
fs.mkdirSync(path.dirname(entrypoint), { recursive: true });
fs.writeFileSync(entrypoint, '-- plugin');
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n');
fs.mkdirSync(path.dirname(installPaths.pluginEntrypointPath), { recursive: true });
fs.mkdirSync(installPaths.pluginDir, { recursive: true });
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
fs.writeFileSync(path.join(installPaths.scriptsDir, 'subminer-loader.lua'), '-- old loader');
fs.writeFileSync(path.join(installPaths.pluginDir, 'old.lua'), '-- old plugin');
fs.writeFileSync(installPaths.pluginConfigPath, 'old=true\n');
const result = installFirstRunPluginToDefaultLocation({
platform: 'linux',
homeDir,
xdgConfigHome,
dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'),
resourcesPath,
binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
});
assert.equal(result.ok, true);
assert.equal(result.pluginInstallStatus, 'installed');
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
assert.equal(fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'), '-- packaged plugin');
assert.equal(
resolvePackagedRuntimePluginPath({
dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'),
resourcesPath,
}),
entrypoint,
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'configured=true\nbinary_path=/Applications/SubMiner.app/Contents/MacOS/SubMiner\n',
);
const scriptsDirEntries = fs.readdirSync(installPaths.scriptsDir);
const scriptOptsEntries = fs.readdirSync(installPaths.scriptOptsDir);
assert.equal(
scriptsDirEntries.some((entry) => entry.startsWith('subminer.bak.')),
true,
);
assert.equal(
scriptsDirEntries.some((entry) => entry.startsWith('subminer-loader.lua.bak.')),
true,
);
assert.equal(
scriptOptsEntries.some((entry) => entry.startsWith('subminer.conf.bak.')),
true,
);
});
});
test('installFirstRunPluginToDefaultLocation installs plugin to Windows mpv defaults', () => {
if (process.platform !== 'win32') {
return;
}
withTempDir((root) => {
const resourcesPath = path.join(root, 'resources');
const pluginRoot = path.join(resourcesPath, 'plugin');
const homeDir = path.join(root, 'home');
const installPaths = resolveDefaultMpvInstallPaths('win32', homeDir);
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n');
const result = installFirstRunPluginToDefaultLocation({
platform: 'win32',
homeDir,
dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'),
resourcesPath,
binaryPath: 'C:\\Program Files\\SubMiner\\SubMiner.exe',
});
assert.equal(result.ok, true);
assert.equal(result.pluginInstallStatus, 'installed');
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
assert.equal(fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'), '-- packaged plugin');
assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'configured=true\nbinary_path=C:\\Program Files\\SubMiner\\SubMiner.exe\n',
);
});
});
test('installFirstRunPluginToDefaultLocation rewrites Windows plugin socket_path', () => {
if (process.platform !== 'win32') {
return;
}
withTempDir((root) => {
const resourcesPath = path.join(root, 'resources');
const pluginRoot = path.join(resourcesPath, 'plugin');
const homeDir = path.join(root, 'home');
const installPaths = resolveDefaultMpvInstallPaths('win32', homeDir);
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
fs.writeFileSync(
path.join(pluginRoot, 'subminer.conf'),
'binary_path=\nsocket_path=/tmp/subminer-socket\n',
);
const result = installFirstRunPluginToDefaultLocation({
platform: 'win32',
homeDir,
dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'),
resourcesPath,
binaryPath: 'C:\\Program Files\\SubMiner\\SubMiner.exe',
});
assert.equal(result.ok, true);
assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'binary_path=C:\\Program Files\\SubMiner\\SubMiner.exe\nsocket_path=\\\\.\\pipe\\subminer-socket\n',
);
});
});
@@ -170,140 +270,6 @@ test('detectInstalledFirstRunPlugin ignores legacy loader file', () => {
});
});
test('detectInstalledFirstRunPluginCandidates returns all legacy autoload entries without script opts', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
const directoryInstall = installPaths.pluginDir;
const legacyScript = path.join(installPaths.scriptsDir, 'subminer.lua');
const legacyLoader = path.join(installPaths.scriptsDir, 'subminer-loader.lua');
fs.mkdirSync(directoryInstall, { recursive: true });
fs.writeFileSync(path.join(directoryInstall, 'main.lua'), '-- plugin');
fs.writeFileSync(legacyScript, '-- legacy plugin');
fs.writeFileSync(legacyLoader, '-- legacy loader');
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
fs.writeFileSync(installPaths.pluginConfigPath, 'socket_path=/tmp/subminer-socket\n');
const candidates = detectInstalledFirstRunPluginCandidates({
platform: 'linux',
homeDir,
xdgConfigHome,
});
assert.deepEqual(
candidates.map((candidate) => candidate.path).sort(),
[directoryInstall, legacyLoader, legacyScript].sort(),
);
assert.equal(
candidates.some((candidate) => candidate.path === installPaths.pluginConfigPath),
false,
);
});
});
test('detectInstalledFirstRunPluginCandidates includes Windows portable mpv scripts', () => {
withTempDir((root) => {
const homeDir = path.win32.join('C:\\Users', 'tester');
const appDataDir = path.win32.join(root, 'AppData', 'Roaming');
const mpvExecutablePath = path.win32.join(root, 'mpv', 'mpv.exe');
const portablePluginDir = path.win32.join(
path.win32.dirname(mpvExecutablePath),
'portable_config',
'scripts',
'subminer',
);
const portableLegacyScript = path.win32.join(
path.win32.dirname(mpvExecutablePath),
'portable_config',
'scripts',
'subminer.lua',
);
const existing = new Set([portablePluginDir, portableLegacyScript]);
const candidates = detectInstalledFirstRunPluginCandidates({
platform: 'win32',
homeDir,
appDataDir,
mpvExecutablePath,
existsSync: (candidate) => existing.has(candidate),
});
assert.deepEqual(
candidates.map((candidate) => candidate.path),
[portablePluginDir, portableLegacyScript],
);
});
});
test('detectInstalledMpvPlugin prefers Windows portable plugin and parses version', () => {
const homeDir = 'C:\\Users\\tester';
const appDataDir = 'C:\\Users\\tester\\AppData\\Roaming';
const mpvExecutablePath = 'C:\\tools\\mpv\\mpv.exe';
const portableEntrypoint = 'C:\\tools\\mpv\\portable_config\\scripts\\subminer\\main.lua';
const portableVersion = 'C:\\tools\\mpv\\portable_config\\scripts\\subminer\\version.lua';
const appDataEntrypoint = 'C:\\Users\\tester\\AppData\\Roaming\\mpv\\scripts\\subminer\\main.lua';
const existing = new Set([portableEntrypoint, portableVersion, appDataEntrypoint]);
const detection = detectInstalledMpvPlugin({
platform: 'win32',
homeDir,
appDataDir,
mpvExecutablePath,
existsSync: (candidate) => existing.has(candidate),
readFileSync: (candidate) =>
candidate === portableVersion ? 'return { version = "0.12.0" }' : '',
});
assert.equal(detection.installed, true);
assert.equal(detection.path, portableEntrypoint);
assert.equal(detection.version, '0.12.0');
assert.equal(detection.source, 'portable-config');
});
test('detectInstalledMpvPlugin detects Linux legacy single-file plugin without version', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const legacyPath = path.join(homeDir, '.config', 'mpv', 'scripts', 'subminer-loader.lua');
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
fs.writeFileSync(legacyPath, '-- legacy');
const detection = detectInstalledMpvPlugin({
platform: 'linux',
homeDir,
});
assert.equal(detection.installed, true);
assert.equal(detection.path, legacyPath);
assert.equal(detection.version, null);
assert.equal(detection.source, 'legacy-file');
});
});
test('removeLegacyMpvPluginCandidates trashes candidates and reports partial failures', async () => {
const calls: string[] = [];
const result = await removeLegacyMpvPluginCandidates({
candidates: [
{ path: '/tmp/mpv/scripts/subminer', kind: 'directory' },
{ path: '/tmp/mpv/scripts/subminer.lua', kind: 'file' },
],
trashItem: async (candidate) => {
calls.push(candidate);
if (candidate.endsWith('subminer.lua')) {
throw new Error('permission denied');
}
},
});
assert.deepEqual(calls, ['/tmp/mpv/scripts/subminer', '/tmp/mpv/scripts/subminer.lua']);
assert.equal(result.ok, false);
assert.deepEqual(result.removedPaths, ['/tmp/mpv/scripts/subminer']);
assert.deepEqual(result.failedPaths, [
{ path: '/tmp/mpv/scripts/subminer.lua', message: 'permission denied' },
]);
});
test('detectInstalledFirstRunPlugin requires main.lua in subminer directory', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
+51 -232
View File
@@ -1,30 +1,15 @@
import fs from 'node:fs';
import path from 'node:path';
import { resolveDefaultMpvInstallPaths, type MpvInstallPaths } from '../../shared/setup-state';
import type { PluginInstallResult } from './first-run-setup-service';
export interface InstalledFirstRunPluginCandidate {
path: string;
kind: 'directory' | 'file';
function timestamp(): string {
return new Date().toISOString().replaceAll(':', '-');
}
export type InstalledMpvPluginSource =
| 'default-config'
| 'xdg-config'
| 'portable-config'
| 'legacy-file';
export interface InstalledMpvPluginDetection {
installed: boolean;
path: string | null;
version: string | null;
source: InstalledMpvPluginSource | null;
message: string | null;
}
export interface LegacyMpvPluginRemovalResult {
ok: boolean;
removedPaths: string[];
failedPaths: Array<{ path: string; message: string }>;
function backupExistingPath(targetPath: string): void {
if (!fs.existsSync(targetPath)) return;
fs.renameSync(targetPath, `${targetPath}.bak.${timestamp()}`);
}
function rewriteInstalledWindowsPluginConfig(configPath: string): void {
@@ -104,30 +89,6 @@ export function resolvePackagedFirstRunPluginAssets(deps: {
return null;
}
export function resolvePackagedRuntimePluginPath(deps: {
dirname: string;
appPath: string;
resourcesPath: string;
joinPath?: (...parts: string[]) => string;
existsSync?: (candidate: string) => boolean;
}): string | null {
const joinPath = deps.joinPath ?? path.join;
const existsSync = deps.existsSync ?? fs.existsSync;
const assets = resolvePackagedFirstRunPluginAssets({
dirname: deps.dirname,
appPath: deps.appPath,
resourcesPath: deps.resourcesPath,
joinPath,
existsSync,
});
if (!assets) {
return null;
}
const entrypoint = joinPath(assets.pluginDirSource, 'main.lua');
return existsSync(entrypoint) ? entrypoint : null;
}
export function detectInstalledFirstRunPlugin(
installPaths: MpvInstallPaths,
deps?: {
@@ -139,203 +100,61 @@ export function detectInstalledFirstRunPlugin(
return existsSync(pluginEntrypointPath);
}
function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
return platform === 'win32' ? path.win32 : path.posix;
}
interface MpvConfigRootCandidate {
root: string;
source: Exclude<InstalledMpvPluginSource, 'legacy-file'>;
}
function collectMpvConfigRootCandidates(options: {
export function installFirstRunPluginToDefaultLocation(options: {
platform: NodeJS.Platform;
homeDir: string;
xdgConfigHome?: string;
appDataDir?: string;
mpvExecutablePath?: string;
}): MpvConfigRootCandidate[] {
const platformPath = getPlatformPath(options.platform);
if (options.platform === 'win32') {
const roots: MpvConfigRootCandidate[] = [];
if (options.mpvExecutablePath?.trim()) {
roots.push({
root: platformPath.join(
platformPath.dirname(options.mpvExecutablePath.trim()),
'portable_config',
),
source: 'portable-config',
});
}
roots.push({
root: platformPath.join(
options.appDataDir?.trim() || platformPath.join(options.homeDir, 'AppData', 'Roaming'),
'mpv',
),
source: 'default-config',
});
return roots;
}
const xdgRoot = options.xdgConfigHome?.trim()
? platformPath.join(options.xdgConfigHome.trim(), 'mpv')
: null;
const homeRoot = platformPath.join(options.homeDir, '.config', 'mpv');
const roots: MpvConfigRootCandidate[] = [];
if (xdgRoot) {
roots.push({ root: xdgRoot, source: 'xdg-config' });
}
if (!xdgRoot || xdgRoot !== homeRoot) {
roots.push({ root: homeRoot, source: 'default-config' });
}
return roots;
}
export function detectInstalledFirstRunPluginCandidates(options: {
platform: NodeJS.Platform;
homeDir: string;
xdgConfigHome?: string;
appDataDir?: string;
mpvExecutablePath?: string;
existsSync?: (candidate: string) => boolean;
}): InstalledFirstRunPluginCandidate[] {
const platformPath = getPlatformPath(options.platform);
const existsSync = options.existsSync ?? fs.existsSync;
const roots = collectMpvConfigRootCandidates(options);
const candidates: InstalledFirstRunPluginCandidate[] = [];
const seen = new Set<string>();
const pushIfExists = (
candidate: InstalledFirstRunPluginCandidate,
verifyPath = candidate.path,
) => {
if (seen.has(candidate.path) || !existsSync(verifyPath)) return;
seen.add(candidate.path);
candidates.push(candidate);
};
for (const root of roots) {
const scriptsDir = platformPath.join(root.root, 'scripts');
const pluginDir = platformPath.join(scriptsDir, 'subminer');
pushIfExists({ path: pluginDir, kind: 'directory' });
pushIfExists({ path: platformPath.join(scriptsDir, 'subminer.lua'), kind: 'file' });
pushIfExists({ path: platformPath.join(scriptsDir, 'subminer-loader.lua'), kind: 'file' });
}
return candidates;
}
function parseInstalledPluginVersion(content: string): string | null {
const match = content.match(/\bversion\s*=\s*["']([^"']+)["']/);
return match?.[1] ?? null;
}
function readInstalledPluginVersion(options: {
pluginEntrypointPath: string;
platformPath: typeof path.posix | typeof path.win32;
existsSync: (candidate: string) => boolean;
readFileSync: (candidate: string, encoding: BufferEncoding) => string;
}): string | null {
const versionPath = options.platformPath.join(
options.platformPath.dirname(options.pluginEntrypointPath),
'version.lua',
dirname: string;
appPath: string;
resourcesPath: string;
binaryPath: string;
}): PluginInstallResult {
const installPaths = resolveDefaultMpvInstallPaths(
options.platform,
options.homeDir,
options.xdgConfigHome,
);
if (!options.existsSync(versionPath)) {
return null;
if (!installPaths.supported) {
return {
ok: false,
pluginInstallStatus: 'failed',
pluginInstallPathSummary: installPaths.mpvConfigDir,
message: 'Automatic mpv plugin install is not supported on this platform yet.',
};
}
try {
return parseInstalledPluginVersion(options.readFileSync(versionPath, 'utf8'));
} catch {
return null;
const assets = resolvePackagedFirstRunPluginAssets({
dirname: options.dirname,
appPath: options.appPath,
resourcesPath: options.resourcesPath,
});
if (!assets) {
return {
ok: false,
pluginInstallStatus: 'failed',
pluginInstallPathSummary: installPaths.mpvConfigDir,
message: 'Packaged mpv plugin assets were not found.',
};
}
}
export function detectInstalledMpvPlugin(options: {
platform: NodeJS.Platform;
homeDir: string;
xdgConfigHome?: string;
appDataDir?: string;
mpvExecutablePath?: string;
existsSync?: (candidate: string) => boolean;
readFileSync?: (candidate: string, encoding: BufferEncoding) => string;
}): InstalledMpvPluginDetection {
const platformPath = getPlatformPath(options.platform);
const existsSync = options.existsSync ?? fs.existsSync;
const readFileSync =
options.readFileSync ?? ((candidate, encoding) => fs.readFileSync(candidate, encoding));
const roots = collectMpvConfigRootCandidates(options);
for (const root of roots) {
const scriptsDir = platformPath.join(root.root, 'scripts');
const directoryEntrypoint = platformPath.join(scriptsDir, 'subminer', 'main.lua');
if (existsSync(directoryEntrypoint)) {
const version = readInstalledPluginVersion({
pluginEntrypointPath: directoryEntrypoint,
platformPath,
existsSync,
readFileSync,
});
return {
installed: true,
path: directoryEntrypoint,
version,
source: root.source,
message: `SubMiner detected an installed mpv plugin at: ${directoryEntrypoint}`,
};
}
for (const legacyPath of [
platformPath.join(scriptsDir, 'subminer.lua'),
platformPath.join(scriptsDir, 'subminer-loader.lua'),
]) {
if (existsSync(legacyPath)) {
return {
installed: true,
path: legacyPath,
version: null,
source: root.source === 'portable-config' ? 'portable-config' : 'legacy-file',
message: `SubMiner detected an installed mpv plugin at: ${legacyPath}`,
};
}
}
fs.mkdirSync(installPaths.scriptsDir, { recursive: true });
fs.mkdirSync(installPaths.scriptOptsDir, { recursive: true });
backupExistingPath(path.join(installPaths.scriptsDir, 'subminer.lua'));
backupExistingPath(path.join(installPaths.scriptsDir, 'subminer-loader.lua'));
backupExistingPath(installPaths.pluginDir);
backupExistingPath(installPaths.pluginConfigPath);
fs.cpSync(assets.pluginDirSource, installPaths.pluginDir, { recursive: true });
fs.copyFileSync(assets.pluginConfigSource, installPaths.pluginConfigPath);
rewriteInstalledPluginBinaryPath(installPaths.pluginConfigPath, options.binaryPath);
if (options.platform === 'win32') {
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
}
return {
installed: false,
path: null,
version: null,
source: null,
message: null,
};
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export async function removeLegacyMpvPluginCandidates(options: {
candidates: InstalledFirstRunPluginCandidate[];
trashItem: (path: string) => Promise<void>;
}): Promise<LegacyMpvPluginRemovalResult> {
const removedPaths: string[] = [];
const failedPaths: Array<{ path: string; message: string }> = [];
const seen = new Set<string>();
for (const candidate of options.candidates) {
if (seen.has(candidate.path)) continue;
seen.add(candidate.path);
try {
await options.trashItem(candidate.path);
removedPaths.push(candidate.path);
} catch (error) {
failedPaths.push({ path: candidate.path, message: errorMessage(error) });
}
}
return {
ok: failedPaths.length === 0,
removedPaths,
failedPaths,
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: installPaths.mpvConfigDir,
message: `Installed mpv plugin to ${installPaths.mpvConfigDir}.`,
};
}
+11 -117
View File
@@ -159,17 +159,18 @@ test('setup service auto-completes legacy installs with config and dictionaries'
});
});
test('setup service allows finish without global mpv plugin once dictionaries are ready', async () => {
test('setup service requires mpv plugin install before finish', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
let dictionaryCount = 0;
let pluginInstalled = false;
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => dictionaryCount,
detectPluginInstalled: () => false,
detectPluginInstalled: () => pluginInstalled,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
@@ -183,6 +184,11 @@ test('setup service allows finish without global mpv plugin once dictionaries ar
assert.equal(initial.state.status, 'incomplete');
assert.equal(initial.canFinish, false);
const installed = await service.installMpvPlugin();
assert.equal(installed.state.pluginInstallStatus, 'installed');
assert.equal(installed.pluginInstallPathSummary, '/tmp/mpv');
pluginInstalled = true;
dictionaryCount = 1;
const refreshed = await service.refreshStatus();
assert.equal(refreshed.canFinish, true);
@@ -298,7 +304,7 @@ test('setup service reopens when external-yomitan completion later has no extern
});
});
test('setup service keeps completed when a global mpv plugin is removed later', async () => {
test('setup service reopens when a completed setup no longer has the mpv plugin installed', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
@@ -334,41 +340,12 @@ test('setup service keeps completed when a global mpv plugin is removed later',
});
const snapshot = await service.ensureSetupStateInitialized();
assert.equal(snapshot.state.status, 'completed');
assert.equal(snapshot.canFinish, true);
assert.equal(snapshot.state.status, 'incomplete');
assert.equal(snapshot.canFinish, false);
assert.equal(snapshot.pluginStatus, 'required');
});
});
test('setup service reopens completed setup as in-progress when legacy mpv plugin removal is needed', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 2,
detectPluginInstalled: () => true,
detectLegacyMpvPluginCandidates: () => [
{ path: '/tmp/mpv/scripts/subminer', kind: 'directory' },
],
onStateChanged: () => undefined,
});
await service.ensureSetupStateInitialized();
await service.markSetupCompleted();
const inProgress = await service.markSetupInProgress();
assert.equal(inProgress.state.status, 'in_progress');
assert.equal(inProgress.state.completedAt, null);
const completed = await service.markSetupCompleted();
assert.equal(completed.state.status, 'completed');
assert.notEqual(completed.state.completedAt, null);
});
});
test('setup service keeps completed when external-yomitan completion later has internal dictionaries available', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
@@ -513,86 +490,3 @@ test('setup service persists Windows mpv shortcut preferences and status with on
assert.deepEqual(stateChanges, ['installed']);
});
});
test('setup service removes legacy mpv plugin candidates and refreshes detection', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
let legacyCandidates = [{ path: '/tmp/mpv/scripts/subminer', kind: 'directory' as const }];
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => legacyCandidates.length > 0,
detectLegacyMpvPluginCandidates: () => legacyCandidates,
removeLegacyMpvPlugins: async (candidates) => {
assert.deepEqual(candidates, legacyCandidates);
legacyCandidates = [];
return {
ok: true,
removedPaths: ['/tmp/mpv/scripts/subminer'],
failedPaths: [],
};
},
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
const before = await service.refreshStatus();
assert.deepEqual(before.legacyMpvPluginPaths, ['/tmp/mpv/scripts/subminer']);
const removed = await service.removeLegacyMpvPlugin();
assert.equal(
removed.message,
'Legacy mpv plugin removed. Regular mpv will no longer load SubMiner. SubMiner-managed playback will use the bundled runtime plugin.',
);
assert.deepEqual(removed.legacyMpvPluginPaths, []);
});
});
test('setup service reports failed legacy mpv plugin trash paths', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const legacyCandidates = [
{ path: '/tmp/mpv/scripts/subminer', kind: 'directory' as const },
{ path: '/tmp/mpv/scripts/subminer.lua', kind: 'file' as const },
];
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => true,
detectLegacyMpvPluginCandidates: () => legacyCandidates,
removeLegacyMpvPlugins: async () => ({
ok: false,
removedPaths: ['/tmp/mpv/scripts/subminer'],
failedPaths: [{ path: '/tmp/mpv/scripts/subminer.lua', message: 'permission denied' }],
}),
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
const removed = await service.removeLegacyMpvPlugin();
assert.equal(
removed.message,
'Removed 1 legacy mpv plugin path, but failed to remove: /tmp/mpv/scripts/subminer.lua (permission denied). Delete the failed paths manually from mpv scripts.',
);
assert.deepEqual(removed.legacyMpvPluginPaths, [
'/tmp/mpv/scripts/subminer',
'/tmp/mpv/scripts/subminer.lua',
]);
});
});
+30 -66
View File
@@ -11,10 +11,6 @@ import {
type SetupState,
} from '../../shared/setup-state';
import type { CliArgs } from '../../cli/args';
import type {
InstalledFirstRunPluginCandidate,
LegacyMpvPluginRemovalResult,
} from './first-run-setup-plugin';
export interface SetupWindowsMpvShortcutSnapshot {
supported: boolean;
@@ -33,7 +29,6 @@ export interface SetupStatusSnapshot {
externalYomitanConfigured: boolean;
pluginStatus: 'installed' | 'required' | 'failed';
pluginInstallPathSummary: string | null;
legacyMpvPluginPaths: string[];
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
message: string | null;
state: SetupState;
@@ -53,7 +48,7 @@ export interface FirstRunSetupService {
markSetupInProgress: () => Promise<SetupStatusSnapshot>;
markSetupCancelled: () => Promise<SetupStatusSnapshot>;
markSetupCompleted: () => Promise<SetupStatusSnapshot>;
removeLegacyMpvPlugin: () => Promise<SetupStatusSnapshot>;
installMpvPlugin: () => Promise<SetupStatusSnapshot>;
configureWindowsMpvShortcuts: (preferences: {
startMenuEnabled: boolean;
desktopEnabled: boolean;
@@ -181,6 +176,9 @@ export function getFirstRunSetupCompletionMessage(snapshot: {
if (!snapshot.configReady) {
return 'Create or provide the config file before finishing setup.';
}
if (snapshot.pluginStatus !== 'installed') {
return 'Install the mpv plugin before finishing setup.';
}
if (!snapshot.externalYomitanConfigured && snapshot.dictionaryCount < 1) {
return 'Install at least one Yomitan dictionary before finishing setup.';
}
@@ -221,13 +219,7 @@ export function createFirstRunSetupService(deps: {
getYomitanDictionaryCount: () => Promise<number>;
isExternalYomitanConfigured?: () => boolean;
detectPluginInstalled: () => boolean | Promise<boolean>;
detectLegacyMpvPluginCandidates?: () =>
| InstalledFirstRunPluginCandidate[]
| Promise<InstalledFirstRunPluginCandidate[]>;
installPlugin?: () => Promise<PluginInstallResult>;
removeLegacyMpvPlugins?: (
candidates: InstalledFirstRunPluginCandidate[],
) => Promise<LegacyMpvPluginRemovalResult>;
installPlugin: () => Promise<PluginInstallResult>;
detectWindowsMpvShortcuts?: () =>
| { startMenuInstalled: boolean; desktopInstalled: boolean }
| Promise<{ startMenuInstalled: boolean; desktopInstalled: boolean }>;
@@ -258,7 +250,6 @@ export function createFirstRunSetupService(deps: {
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
});
const pluginInstalled = await deps.detectPluginInstalled();
const legacyMpvPluginCandidates = (await deps.detectLegacyMpvPluginCandidates?.()) ?? [];
const detectedWindowsMpvShortcuts = isWindows
? await deps.detectWindowsMpvShortcuts?.()
: undefined;
@@ -273,15 +264,16 @@ export function createFirstRunSetupService(deps: {
return {
configReady,
dictionaryCount,
canFinish: isYomitanSetupSatisfied({
configReady,
dictionaryCount,
externalYomitanConfigured,
}),
canFinish:
pluginInstalled &&
isYomitanSetupSatisfied({
configReady,
dictionaryCount,
externalYomitanConfigured,
}),
externalYomitanConfigured,
pluginStatus: getPluginStatus(state, pluginInstalled),
pluginInstallPathSummary: state.pluginInstallPathSummary,
legacyMpvPluginPaths: legacyMpvPluginCandidates.map((candidate) => candidate.path),
windowsMpvShortcuts: {
supported: isWindows,
startMenuEnabled: effectiveWindowsMpvShortcutPreferences.startMenuEnabled,
@@ -316,11 +308,14 @@ export function createFirstRunSetupService(deps: {
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
});
const canFinish = isYomitanSetupSatisfied({
configReady,
dictionaryCount,
externalYomitanConfigured,
});
const pluginInstalled = await deps.detectPluginInstalled();
const canFinish =
pluginInstalled &&
isYomitanSetupSatisfied({
configReady,
dictionaryCount,
externalYomitanConfigured,
});
if (isSetupCompleted(state) && canFinish) {
completed = true;
return refreshWithState(state);
@@ -354,20 +349,8 @@ export function createFirstRunSetupService(deps: {
markSetupInProgress: async () => {
const state = readState();
if (state.status === 'completed') {
const legacyMpvPluginCandidates = (await deps.detectLegacyMpvPluginCandidates?.()) ?? [];
if (legacyMpvPluginCandidates.length === 0) {
completed = true;
return refreshWithState(state);
}
completed = false;
return refreshWithState(
writeState({
...state,
status: 'in_progress',
completedAt: null,
completionSource: null,
}),
);
completed = true;
return refreshWithState(state);
}
return refreshWithState(writeState({ ...state, status: 'in_progress' }));
},
@@ -396,34 +379,15 @@ export function createFirstRunSetupService(deps: {
}),
);
},
removeLegacyMpvPlugin: async () => {
const candidates = (await deps.detectLegacyMpvPluginCandidates?.()) ?? [];
if (candidates.length === 0) {
return refreshWithState(readState(), 'No legacy mpv plugin files were found.');
}
if (!deps.removeLegacyMpvPlugins) {
return refreshWithState(
readState(),
'Legacy mpv plugin removal is unavailable in this runtime.',
);
}
const result = await deps.removeLegacyMpvPlugins(candidates);
if (result.ok) {
return refreshWithState(
readState(),
'Legacy mpv plugin removed. Regular mpv will no longer load SubMiner. SubMiner-managed playback will use the bundled runtime plugin.',
);
}
const removedCount = result.removedPaths.length;
const removedText = `${removedCount} legacy mpv plugin path${removedCount === 1 ? '' : 's'}`;
const failedText = result.failedPaths
.map((failure) => `${failure.path} (${failure.message})`)
.join(', ');
installMpvPlugin: async () => {
const result = await deps.installPlugin();
return refreshWithState(
readState(),
`Removed ${removedText}, but failed to remove: ${failedText}. Delete the failed paths manually from mpv scripts.`,
writeState({
...readState(),
pluginInstallStatus: result.pluginInstallStatus,
pluginInstallPathSummary: result.pluginInstallPathSummary,
}),
result.message,
);
},
configureWindowsMpvShortcuts: async (preferences) => {
@@ -30,11 +30,8 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
});
assert.match(html, /SubMiner setup/);
assert.doesNotMatch(html, /Install legacy mpv plugin/);
assert.doesNotMatch(html, /action=install-plugin/);
assert.match(html, /Ready/);
assert.doesNotMatch(html, /Bundled ready/);
assert.match(html, /Managed mpv launches use the bundled runtime plugin\./);
assert.match(html, /Install mpv plugin/);
assert.match(html, /Required before SubMiner setup can finish/);
assert.match(html, /Open Yomitan Settings/);
assert.match(html, /Finish setup/);
assert.match(html, /disabled/);
@@ -61,49 +58,14 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
message: null,
});
assert.doesNotMatch(html, /Reinstall mpv plugin/);
assert.doesNotMatch(html, /action=install-plugin/);
assert.match(html, /Reinstall mpv plugin/);
assert.match(html, /mpv executable path/);
assert.match(html, /Leave blank to auto-discover mpv\.exe from PATH\./);
assert.match(html, /aria-label="Path to mpv\.exe"/);
assert.match(html, /SubMiner-managed mpv launches use the bundled runtime plugin\./);
});
test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirmation', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 1,
canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
legacyMpvPluginPaths: ['/tmp/mpv/scripts/subminer', '/tmp/mpv/scripts/subminer.lua'],
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
message: null,
});
assert.match(html, /Legacy mpv plugin/);
assert.match(html, /Legacy detected/);
assert.match(html, /\/tmp\/mpv\/scripts\/subminer/);
assert.match(html, /\/tmp\/mpv\/scripts\/subminer\.lua/);
assert.match(html, /Remove legacy mpv plugin/);
assert.match(html, /class="legacy-remove"/);
assert.match(html, /\.legacy-remove/);
assert.match(html, /Continue without removing/);
assert.match(
html,
/Remove these SubMiner mpv plugin files from mpv.s scripts directory\? This stops regular mpv from loading SubMiner\./,
/Finish stays unlocked once the mpv plugin is installed and Yomitan reports at least one installed dictionary\./,
);
assert.match(html, /action=remove-legacy-plugin/);
});
test('buildFirstRunSetupHtml marks an invalid configured mpv path as invalid', () => {
@@ -196,12 +158,6 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
action: 'refresh',
});
assert.deepEqual(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=remove-legacy-plugin'),
{
action: 'remove-legacy-plugin',
},
);
assert.equal(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'),
null,
@@ -221,7 +177,7 @@ test('first-run setup window handler focuses existing window', () => {
assert.deepEqual(calls, ['focus']);
});
test('first-run setup navigation handler prevents default and dispatches supported action', async () => {
test('first-run setup navigation handler prevents default and dispatches action', async () => {
const calls: string[] = [];
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
@@ -232,20 +188,13 @@ test('first-run setup navigation handler prevents default and dispatches support
});
const prevented = handleNavigation({
url: 'subminer://first-run-setup?action=refresh',
url: 'subminer://first-run-setup?action=install-plugin',
preventDefault: () => calls.push('preventDefault'),
});
assert.equal(prevented, true);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['preventDefault', 'refresh']);
});
test('first-run setup parser rejects legacy global plugin install action', () => {
assert.equal(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=install-plugin'),
null,
);
assert.deepEqual(calls, ['preventDefault', 'install-plugin']);
});
test('first-run setup navigation handler swallows stale custom-scheme actions', () => {
+18 -54
View File
@@ -18,7 +18,7 @@ type FirstRunSetupWindowLike = FocusableWindowLike & {
export type FirstRunSetupAction =
| 'configure-mpv-executable-path'
| 'remove-legacy-plugin'
| 'install-plugin'
| 'configure-windows-mpv-shortcuts'
| 'open-yomitan-settings'
| 'refresh'
@@ -38,7 +38,6 @@ export interface FirstRunSetupHtmlModel {
externalYomitanConfigured: boolean;
pluginStatus: 'installed' | 'required' | 'failed';
pluginInstallPathSummary: string | null;
legacyMpvPluginPaths?: string[];
mpvExecutablePath: string;
mpvExecutablePathStatus: 'blank' | 'configured' | 'invalid';
windowsMpvShortcuts: {
@@ -65,19 +64,20 @@ function renderStatusBadge(value: string, tone: 'ready' | 'warn' | 'muted' | 'da
}
export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
const legacyMpvPluginPaths = model.legacyMpvPluginPaths ?? [];
const finishButtonLabel =
legacyMpvPluginPaths.length > 0 && model.canFinish
? 'Continue without removing'
: 'Finish setup';
const pluginActionLabel =
model.pluginStatus === 'installed' ? 'Reinstall mpv plugin' : 'Install mpv plugin';
const pluginLabel =
legacyMpvPluginPaths.length > 0
? 'Legacy detected'
model.pluginStatus === 'installed'
? 'Installed'
: model.pluginStatus === 'failed'
? 'Failed'
: 'Ready';
: 'Required';
const pluginTone =
legacyMpvPluginPaths.length > 0 ? 'warn' : model.pluginStatus === 'failed' ? 'danger' : 'ready';
model.pluginStatus === 'installed'
? 'ready'
: model.pluginStatus === 'failed'
? 'danger'
: 'warn';
const windowsShortcutLabel =
model.windowsMpvShortcuts.status === 'installed'
? 'Installed'
@@ -159,23 +159,6 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</form>
</div>`
: '';
const legacyPluginCard =
legacyMpvPluginPaths.length > 0
? `
<div class="card block">
<div class="card-head">
<div>
<strong>Legacy mpv plugin</strong>
<div class="meta">Regular mpv still loads SubMiner from these mpv scripts paths.</div>
</div>
${renderStatusBadge('Found', 'warn')}
</div>
<ul class="legacy-paths">
${legacyMpvPluginPaths.map((pluginPath) => `<li>${escapeHtml(pluginPath)}</li>`).join('')}
</ul>
<button class="legacy-remove" onclick="if (confirm(&quot;Remove these SubMiner mpv plugin files from mpv's scripts directory? This stops regular mpv from loading SubMiner. SubMiner-managed playback will keep working with the bundled runtime plugin.&quot;)) window.location.href='subminer://first-run-setup?action=remove-legacy-plugin'">Remove legacy mpv plugin</button>
</div>`
: '';
const yomitanMeta = model.externalYomitanConfigured
? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.'
@@ -196,8 +179,8 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
: model.canFinish
? model.externalYomitanConfigured
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
: 'Finish stays unlocked once Yomitan reports at least one installed dictionary. SubMiner-managed mpv launches use the bundled runtime plugin.'
: 'Finish stays locked until Yomitan reports at least one installed dictionary.';
: 'Finish stays unlocked once the mpv plugin is installed and Yomitan reports at least one installed dictionary.'
: 'Finish stays locked until the mpv plugin is installed and Yomitan reports at least one installed dictionary.';
return `<!doctype html>
<html>
@@ -324,18 +307,6 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
background: transparent;
border: 1px solid rgba(202, 211, 245, 0.12);
}
button.legacy-remove {
display: inline-flex;
justify-content: center;
align-items: center;
min-width: 220px;
border: 1px solid rgba(237, 135, 150, 0.38);
background: rgba(237, 135, 150, 0.14);
color: #f5b1ba;
}
button.legacy-remove:hover {
background: rgba(237, 135, 150, 0.22);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
@@ -350,13 +321,6 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
color: var(--muted);
font-size: 12px;
}
.legacy-paths {
margin: 10px 0 12px;
padding-left: 18px;
color: var(--muted);
font-size: 12px;
overflow-wrap: anywhere;
}
</style>
</head>
<body>
@@ -371,9 +335,9 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</div>
<div class="card">
<div>
<strong>mpv runtime plugin</strong>
<strong>mpv plugin</strong>
<div class="meta">${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}</div>
<div class="meta">Managed mpv launches use the bundled runtime plugin.</div>
<div class="meta">Required before SubMiner setup can finish.</div>
</div>
${renderStatusBadge(pluginLabel, pluginTone)}
</div>
@@ -386,11 +350,11 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</div>
${mpvExecutablePathCard}
${windowsShortcutCard}
${legacyPluginCard}
<div class="actions">
<button onclick="window.location.href='subminer://first-run-setup?action=install-plugin'">${pluginActionLabel}</button>
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">${finishButtonLabel}</button>
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">Finish setup</button>
</div>
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
<div class="footer">${escapeHtml(footerMessage)}</div>
@@ -407,7 +371,7 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
const action = parsed.searchParams.get('action');
if (
action !== 'configure-mpv-executable-path' &&
action !== 'remove-legacy-plugin' &&
action !== 'install-plugin' &&
action !== 'configure-windows-mpv-shortcuts' &&
action !== 'open-yomitan-settings' &&
action !== 'refresh' &&
@@ -17,8 +17,8 @@ test('createCreateFirstRunSetupWindowHandler builds first-run setup window', ()
assert.deepEqual(createSetupWindow(), { id: 'first-run' });
assert.deepEqual(options, {
width: 560,
height: 640,
width: 480,
height: 460,
title: 'SubMiner Setup',
show: true,
autoHideMenuBar: true,
+2 -2
View File
@@ -32,8 +32,8 @@ export function createCreateFirstRunSetupWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
}) {
return createSetupWindowHandler(deps, {
width: 560,
height: 640,
width: 480,
height: 460,
title: 'SubMiner Setup',
resizable: false,
minimizable: false,
@@ -230,104 +230,6 @@ test('launchWindowsMpv spawns detached mpv with targets', async () => {
]);
});
test('launchWindowsMpv skips bundled script when installed plugin is detected', async () => {
const calls: string[] = [];
const notifications: string[] = [];
const result = await launchWindowsMpv(
['C:\\video.mkv'],
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
spawnDetached: async (command, args) => {
calls.push(command);
calls.push(args.join('|'));
},
}),
[],
'C:\\SubMiner\\SubMiner.exe',
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
'',
'normal',
{
detectInstalledMpvPlugin: () => ({
installed: true,
path: 'C:\\Users\\tester\\AppData\\Roaming\\mpv\\scripts\\subminer\\main.lua',
version: null,
source: 'default-config',
message: null,
}),
notifyInstalledPluginDetected: (detection) => {
notifications.push(detection.path ?? '');
},
},
);
assert.equal(result.ok, true);
assert.equal(calls[0], 'C:\\mpv\\mpv.exe');
assert.doesNotMatch(calls[1] ?? '', /--script=C:\\Program Files\\SubMiner/);
assert.match(calls[1] ?? '', /--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner\.exe/);
assert.deepEqual(notifications, [
'C:\\Users\\tester\\AppData\\Roaming\\mpv\\scripts\\subminer\\main.lua',
]);
});
test('launchWindowsMpv prompts before launch and injects bundled script after legacy plugin removal', async () => {
const calls: string[] = [];
const prompts: string[] = [];
let detectCalls = 0;
const result = await launchWindowsMpv(
['C:\\video.mkv'],
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
spawnDetached: async (command, args) => {
calls.push(command);
calls.push(args.join('|'));
},
}),
[],
'C:\\SubMiner\\SubMiner.exe',
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
'',
'normal',
{
detectInstalledMpvPlugin: () => {
detectCalls += 1;
return detectCalls === 1
? {
installed: true,
path: 'C:\\Users\\tester\\AppData\\Roaming\\mpv\\scripts\\subminer\\main.lua',
version: '0.12.0',
source: 'default-config',
message: null,
}
: {
installed: false,
path: null,
version: null,
source: null,
message: null,
};
},
resolveInstalledPluginBeforeLaunch: async (detection) => {
prompts.push(detection.path ?? '');
return 'removed' as const;
},
},
);
assert.equal(result.ok, true);
assert.equal(detectCalls, 2);
assert.deepEqual(prompts, [
'C:\\Users\\tester\\AppData\\Roaming\\mpv\\scripts\\subminer\\main.lua',
]);
assert.equal(calls[0], 'C:\\mpv\\mpv.exe');
assert.match(
calls[1] ?? '',
/--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main\.lua/,
);
});
test('launchWindowsMpv reports spawn failures with path context', async () => {
const errors: string[] = [];
const result = await launchWindowsMpv(
+3 -43
View File
@@ -2,7 +2,6 @@ import fs from 'node:fs';
import { spawn, spawnSync } from 'node:child_process';
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
import type { MpvLaunchMode } from '../../types/config';
import type { InstalledMpvPluginDetection } from './first-run-setup-plugin';
export interface WindowsMpvLaunchDeps {
getEnv: (name: string) => string | undefined;
@@ -14,15 +13,6 @@ export interface WindowsMpvLaunchDeps {
export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid';
export interface WindowsMpvRuntimePluginPolicy {
detectInstalledMpvPlugin?: (mpvPath: string) => InstalledMpvPluginDetection;
notifyInstalledPluginDetected?: (detection: InstalledMpvPluginDetection) => void;
resolveInstalledPluginBeforeLaunch?: (
detection: InstalledMpvPluginDetection,
mpvPath: string,
) => Promise<'removed' | 'continue' | 'cancel'> | 'removed' | 'continue' | 'cancel';
}
function normalizeCandidate(candidate: string | undefined): string {
return typeof candidate === 'string' ? candidate.trim() : '';
}
@@ -110,12 +100,10 @@ export function buildWindowsMpvLaunchArgs(
typeof pluginEntrypointPath === 'string' && pluginEntrypointPath.trim().length > 0
? `--script=${pluginEntrypointPath.trim()}`
: null;
const hasBinaryPath = typeof binaryPath === 'string' && binaryPath.trim().length > 0;
const shouldPassSubminerScriptOpts = scriptEntrypoint || hasBinaryPath;
const scriptOptPairs = shouldPassSubminerScriptOpts
const scriptOptPairs = scriptEntrypoint
? [`subminer-socket_path=${inputIpcServer.replace(/,/g, '\\,')}`]
: [];
if (hasBinaryPath) {
if (scriptEntrypoint && typeof binaryPath === 'string' && binaryPath.trim().length > 0) {
scriptOptPairs.unshift(`subminer-binary_path=${binaryPath.trim().replace(/,/g, '\\,')}`);
}
const scriptOpts = scriptOptPairs.length > 0 ? `--script-opts=${scriptOptPairs.join(',')}` : null;
@@ -148,7 +136,6 @@ export async function launchWindowsMpv(
pluginEntrypointPath?: string,
configuredMpvPath?: string,
launchMode: MpvLaunchMode = 'normal',
runtimePluginPolicy?: WindowsMpvRuntimePluginPolicy,
): Promise<{ ok: boolean; mpvPath: string }> {
const normalizedConfiguredPath = normalizeCandidate(configuredMpvPath);
const mpvPath = resolveWindowsMpvPath(deps, normalizedConfiguredPath);
@@ -163,36 +150,9 @@ export async function launchWindowsMpv(
}
try {
let installedPlugin = runtimePluginPolicy?.detectInstalledMpvPlugin?.(mpvPath);
let installedPluginPrompted = false;
if (installedPlugin?.installed) {
const resolution = await runtimePluginPolicy?.resolveInstalledPluginBeforeLaunch?.(
installedPlugin,
mpvPath,
);
installedPluginPrompted = resolution != null;
if (resolution === 'cancel') {
return { ok: false, mpvPath };
}
if (resolution === 'removed') {
installedPlugin = runtimePluginPolicy?.detectInstalledMpvPlugin?.(mpvPath);
}
}
const runtimePluginEntrypointPath = installedPlugin?.installed
? undefined
: pluginEntrypointPath;
if (installedPlugin?.installed && !installedPluginPrompted) {
runtimePluginPolicy?.notifyInstalledPluginDetected?.(installedPlugin);
}
await deps.spawnDetached(
mpvPath,
buildWindowsMpvLaunchArgs(
targets,
extraArgs,
binaryPath,
runtimePluginEntrypointPath,
launchMode,
),
buildWindowsMpvLaunchArgs(targets, extraArgs, binaryPath, pluginEntrypointPath, launchMode),
);
return { ok: true, mpvPath };
} catch (error) {
+2 -11
View File
@@ -178,19 +178,10 @@ test('release workflow skips empty AUR sync commits', () => {
assert.match(releaseWorkflow, /if git diff --quiet -- PKGBUILD \.SRCINFO; then/);
});
test('Makefile does not expose the legacy global mpv plugin installer', () => {
test('Makefile routes Windows install-plugin setup through bun and documents Windows builds', () => {
assert.match(
makefile,
/windows\) printf '%s\\n' "\[INFO\] Windows builds run via: bun run build:win" ;;/,
);
assert.doesNotMatch(makefile, /^\s*install-plugin:/m);
assert.doesNotMatch(makefile, /\binstall-plugin\b/);
assert.doesNotMatch(makefile, /configure-plugin-binary-path\.mjs/);
});
test('Makefile uninstall targets remove bundled runtime plugin app-data copies', () => {
assert.match(makefile, /uninstall-linux:[\s\S]*@rm -rf "\$\(LINUX_DATA_DIR\)\/plugin\/subminer"/);
assert.match(makefile, /uninstall-macos:[\s\S]*@rm -rf "\$\(MACOS_DATA_DIR\)\/plugin\/subminer"/);
assert.match(makefile, /Removed:[\s\S]*\$\(LINUX_DATA_DIR\)\/plugin\/subminer/);
assert.match(makefile, /Removed:[\s\S]*\$\(MACOS_DATA_DIR\)\/plugin\/subminer/);
assert.match(makefile, /bun \.\/scripts\/configure-plugin-binary-path\.mjs/);
});
+101
View File
@@ -4,6 +4,9 @@ import test from 'node:test';
import { createKeyboardHandlers } from './keyboard.js';
import { createRendererState } from '../state.js';
import type { CompiledSessionBinding } from '../../types';
import { DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from '../../config/definitions';
import { compileSessionBindings } from '../../core/services/session-bindings';
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
import { YOMITAN_POPUP_COMMAND_EVENT, YOMITAN_POPUP_HIDDEN_EVENT } from '../yomitan-popup.js';
type CommandEventDetail = {
@@ -40,6 +43,58 @@ function wait(ms: number): Promise<void> {
});
}
function eventFromKeyString(keyString: string): {
key: string;
code: string;
ctrlKey?: boolean;
metaKey?: boolean;
altKey?: boolean;
shiftKey?: boolean;
} {
const parts = keyString.split('+');
const code = parts.pop() ?? '';
return {
key: code === 'Space' ? ' ' : code,
code,
ctrlKey: parts.includes('Ctrl'),
metaKey: parts.includes('Meta'),
altKey: parts.includes('Alt'),
shiftKey: parts.includes('Shift'),
};
}
function countedJsonValues(values: unknown[]): Array<[string, number]> {
const counts = new Map<string, number>();
for (const value of values) {
const key = JSON.stringify(value);
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return [...counts.entries()].sort(([left], [right]) => left.localeCompare(right));
}
function createEmptyShortcuts(): ConfiguredShortcuts {
return {
toggleVisibleOverlayGlobal: null,
copySubtitle: null,
copySubtitleMultiple: null,
updateLastCardFromClipboard: null,
triggerFieldGrouping: null,
triggerSubsync: null,
mineSentence: null,
mineSentenceMultiple: null,
multiCopyTimeoutMs: 3000,
toggleSecondarySub: null,
markAudioCard: null,
openCharacterDictionary: null,
openRuntimeOptions: null,
openJimaku: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
};
}
function installKeyboardTestGlobals() {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousDocument = (globalThis as { document?: unknown }).document;
@@ -709,6 +764,52 @@ test('popup-visible mpv keybindings still fire for bound keys', async () => {
}
});
test('default keybindings dispatch through overlay keyboard handling', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
const specialActionIds: Record<string, string> = {
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]: 'shiftSubDelayPrevLine',
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START]: 'shiftSubDelayNextLine',
[SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
[SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser',
[SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle',
[SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE]: 'playNextSubtitle',
};
const compiled = compileSessionBindings({
shortcuts: createEmptyShortcuts(),
keybindings: DEFAULT_KEYBINDINGS,
platform: 'linux',
});
try {
assert.deepEqual(compiled.warnings, []);
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings(compiled.bindings);
for (const binding of DEFAULT_KEYBINDINGS) {
testGlobals.dispatchKeydown(eventFromKeyString(binding.key));
}
await wait(0);
const expectedMpvCommands = DEFAULT_KEYBINDINGS.filter(
(binding) => !specialActionIds[String(binding.command?.[0])],
).map((binding) => binding.command);
const expectedSessionActions = DEFAULT_KEYBINDINGS.map(
(binding) => specialActionIds[String(binding.command?.[0])],
).filter(Boolean);
assert.deepEqual(
countedJsonValues(testGlobals.mpvCommands),
countedJsonValues(expectedMpvCommands),
);
assert.deepEqual(
testGlobals.sessionActions.map((action) => action.actionId).sort(),
expectedSessionActions.sort(),
);
} finally {
testGlobals.restore();
}
});
test('paused configured subtitle-jump keybinding re-applies pause after backward seek', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();