mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-13 08:12:54 -07:00
feat: inject bundled mpv plugin for managed launches, remove legacy glob (#62)
* feat: inject bundled mpv plugin for managed launches, remove legacy glob - SubMiner-managed launcher and Windows shortcut launches inject the bundled plugin when no global plugin is detected - First-run setup detects and removes legacy global plugin files via OS trash before managed playback starts - Makefile `install-plugin` target and Windows config-rewrite script removed; Linux/macOS install now copies plugin to app data dir - AniList stats search and post-watch tracking now go through the shared rate limiter - Stats cover-art lookup reuses cached AniList data before issuing a new request - Closing mpv in a launcher-managed session now terminates the background Electron app * harden bootstrap version load and clean plugin on uninstall - Use pcall for version.lua in bootstrap.lua so missing version module does not crash plugin startup - Remove plugin/subminer from app-data dirs in uninstall-linux and uninstall-macos targets - Add Lua compat test asserting bootstrap uses defensive pcall for version load - Add release-workflow test asserting uninstall targets clean bundled plugin dirs - Delete completed planning document
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
.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
|
||||
.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
|
||||
|
||||
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
|
||||
@@ -64,8 +63,7 @@ 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 Install Windows mpv plugin artifacts" \
|
||||
" install-plugin Install mpv Lua plugin and plugin config" \
|
||||
" install-windows Print Windows packaging/install guidance" \
|
||||
" generate-config Generate ~/.config/SubMiner/config.jsonc from centralized defaults" \
|
||||
"" \
|
||||
"Other targets:" \
|
||||
@@ -200,6 +198,8 @@ 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,6 +214,8 @@ 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)"; \
|
||||
@@ -230,21 +232,8 @@ 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] 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"
|
||||
@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."
|
||||
|
||||
uninstall:
|
||||
@printf '%s\n' "[INFO] Detected platform: $(PLATFORM)"
|
||||
@@ -258,13 +247,15 @@ uninstall:
|
||||
uninstall-linux:
|
||||
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
|
||||
@rm -f "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
|
||||
@printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(BINDIR)/SubMiner.AppImage" " $(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"
|
||||
|
||||
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_APP_DEST)"
|
||||
@printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_DATA_DIR)/plugin/subminer" " $(MACOS_APP_DEST)"
|
||||
|
||||
uninstall-windows:
|
||||
@rm -rf "$(MPV_SCRIPTS_DIR)/subminer"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
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 -->
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
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 -->
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
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 -->
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
---
|
||||
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 -->
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
---
|
||||
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 -->
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
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,5 @@
|
||||
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.
|
||||
@@ -202,7 +202,6 @@ 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 |
|
||||
|
||||
+13
-25
@@ -154,9 +154,15 @@ 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 and SubMiner defaults so you don't need to configure `mpv.conf` manually.
|
||||
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.
|
||||
|
||||
### From Source
|
||||
|
||||
@@ -315,7 +321,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`, installs the mpv plugin, and opens Yomitan settings for dictionary import.
|
||||
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.
|
||||
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:
|
||||
|
||||
@@ -323,7 +329,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 and subtitle args directly — no `mpv.conf` profile is needed.
|
||||
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.
|
||||
|
||||
### Windows-Specific Notes
|
||||
|
||||
@@ -352,33 +358,15 @@ 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 (Recommended)
|
||||
## MPV Plugin
|
||||
|
||||
The Lua plugin provides in-player keybindings to control the overlay from mpv. It communicates with SubMiner by invoking the binary with CLI flags.
|
||||
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.
|
||||
|
||||
::: warning Important
|
||||
mpv must be launched with `--input-ipc-server=/tmp/subminer-socket` for SubMiner to connect.
|
||||
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.
|
||||
:::
|
||||
|
||||
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.
|
||||
See [MPV Plugin](/mpv-plugin) for the keybindings, script messages, and runtime configuration reference.
|
||||
|
||||
## Anki Setup (Recommended)
|
||||
|
||||
|
||||
+5
-15
@@ -1,22 +1,12 @@
|
||||
# MPV Plugin
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Installation
|
||||
## Runtime Loading
|
||||
|
||||
```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/
|
||||
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.
|
||||
|
||||
# Or from source checkout: make install-plugin
|
||||
```
|
||||
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.
|
||||
|
||||
mpv must have IPC enabled for SubMiner to connect:
|
||||
|
||||
@@ -67,7 +57,7 @@ Select an item by pressing its number.
|
||||
|
||||
## Configuration
|
||||
|
||||
Create or edit `~/.config/mpv/script-opts/subminer.conf`:
|
||||
For advanced/manual runtime use, create or edit `~/.config/mpv/script-opts/subminer.conf`:
|
||||
|
||||
```ini
|
||||
# Path to SubMiner binary. Leave empty for auto-detection.
|
||||
|
||||
+4
-3
@@ -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 the mpv plugin and Yomitan dictionaries before it can finish.
|
||||
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.
|
||||
|
||||
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,13 +195,14 @@ SubMiner.AppImage --setup
|
||||
Setup flow:
|
||||
|
||||
- config file: create the default config directory and prefer `config.jsonc`
|
||||
- plugin status: install the bundled mpv plugin before finishing setup
|
||||
- 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
|
||||
- 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, plugin, and dictionary gates are satisfied
|
||||
- `Finish setup` stays disabled until the config and dictionary gates are satisfied
|
||||
- finish action writes setup completion state and suppresses future auto-open prompts
|
||||
|
||||
AniList character dictionary auto-sync (optional):
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { fail, log } from '../log.js';
|
||||
import { waitForUnixSocketReady, launchMpvIdleDetached } from '../mpv.js';
|
||||
import {
|
||||
waitForUnixSocketReady,
|
||||
launchMpvIdleDetached,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
} from '../mpv.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
interface MpvCommandDeps {
|
||||
@@ -8,6 +12,7 @@ interface MpvCommandDeps {
|
||||
socketPath: string,
|
||||
appPath: string,
|
||||
args: LauncherCommandContext['args'],
|
||||
runtimePluginPath?: string | null,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -44,7 +49,7 @@ export async function runMpvPostAppCommand(
|
||||
context: LauncherCommandContext,
|
||||
deps: MpvCommandDeps = defaultDeps,
|
||||
): Promise<boolean> {
|
||||
const { args, appPath, mpvSocketPath } = context;
|
||||
const { args, appPath, scriptPath, mpvSocketPath } = context;
|
||||
if (!args.mpvIdle) {
|
||||
return false;
|
||||
}
|
||||
@@ -52,7 +57,12 @@ 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);
|
||||
await deps.launchMpvIdleDetached(
|
||||
mpvSocketPath,
|
||||
appPath,
|
||||
args,
|
||||
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||
);
|
||||
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
|
||||
if (!ready) {
|
||||
fail(`MPV IPC socket not ready after idle launch: ${mpvSocketPath}`);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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 {
|
||||
@@ -95,7 +97,7 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
};
|
||||
let receivedStartMpvOptions: Record<string, unknown> | null = null;
|
||||
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
@@ -111,7 +113,9 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
|
||||
_preloadedSubtitles,
|
||||
options,
|
||||
) => {
|
||||
receivedStartMpvOptions = options ?? null;
|
||||
if (options) {
|
||||
receivedStartMpvOptions.push(options as Record<string, unknown>);
|
||||
}
|
||||
calls.push('startMpv');
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
@@ -130,8 +134,63 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
|
||||
'startMpv',
|
||||
'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw --youtube-mode download',
|
||||
]);
|
||||
assert.deepEqual(receivedStartMpvOptions, {
|
||||
startPaused: true,
|
||||
disableYoutubeSubtitleAutoLoad: true,
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js';
|
||||
import {
|
||||
cleanupPlaybackSession,
|
||||
launchAppCommandDetached,
|
||||
markOverlayManagedByLauncher,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
startMpv,
|
||||
startOverlay,
|
||||
state,
|
||||
@@ -21,9 +23,8 @@ import {
|
||||
getDefaultConfigDir,
|
||||
getSetupStatePath,
|
||||
readSetupState,
|
||||
resolveDefaultMpvInstallPaths,
|
||||
} from '../../src/shared/setup-state.js';
|
||||
import { detectInstalledFirstRunPlugin } from '../../src/main/runtime/first-run-setup-plugin.js';
|
||||
import { detectInstalledFirstRunPluginCandidates } from '../../src/main/runtime/first-run-setup-plugin.js';
|
||||
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
||||
|
||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
@@ -107,14 +108,13 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
readSetupState: () => readSetupState(statePath),
|
||||
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
|
||||
isPluginInstalled: () => {
|
||||
const installPaths = resolveDefaultMpvInstallPaths(
|
||||
process.platform,
|
||||
os.homedir(),
|
||||
process.env.XDG_CONFIG_HOME,
|
||||
);
|
||||
return detectInstalledFirstRunPlugin(installPaths);
|
||||
},
|
||||
hasLegacyMpvPlugin: () =>
|
||||
detectInstalledFirstRunPluginCandidates({
|
||||
platform: process.platform,
|
||||
homeDir: os.homedir(),
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
appDataDir: process.env.APPDATA,
|
||||
}).length > 0,
|
||||
launchSetupApp: () => {
|
||||
const setupArgs = ['--background', '--setup'];
|
||||
if (args.logLevel) {
|
||||
@@ -237,6 +237,7 @@ export async function runPlaybackCommandWithDeps(
|
||||
{
|
||||
startPaused: shouldPauseUntilOverlayReady || isAppOwnedYoutubeFlow,
|
||||
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
|
||||
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -262,6 +263,7 @@ 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 {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
runAppCommandCaptureOutput,
|
||||
launchAppStartDetached,
|
||||
launchMpvIdleDetached,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
waitForUnixSocketReady,
|
||||
} from './mpv.js';
|
||||
|
||||
@@ -1014,7 +1015,12 @@ export async function runJellyfinPlayMenu(
|
||||
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 250);
|
||||
}
|
||||
if (!mpvReady) {
|
||||
await launchMpvIdleDetached(mpvSocketPath, appPath, args);
|
||||
await launchMpvIdleDetached(
|
||||
mpvSocketPath,
|
||||
appPath,
|
||||
args,
|
||||
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||
);
|
||||
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000);
|
||||
}
|
||||
log('debug', args.logLevel, `MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`);
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
launchTexthookerOnly,
|
||||
parseMpvArgString,
|
||||
runAppCommandCaptureOutput,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
resolveLauncherRuntimePluginPlan,
|
||||
shouldResolveAniSkipMetadata,
|
||||
stopOverlay,
|
||||
startOverlay,
|
||||
@@ -262,6 +264,89 @@ 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());
|
||||
|
||||
+216
-2
@@ -4,6 +4,10 @@ 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';
|
||||
@@ -42,6 +46,13 @@ 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[] = [];
|
||||
@@ -226,6 +237,182 @@ 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,
|
||||
@@ -658,7 +845,11 @@ export async function startMpv(
|
||||
socketPath: string,
|
||||
appPath: string,
|
||||
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
|
||||
options?: { startPaused?: boolean; disableYoutubeSubtitleAutoLoad?: boolean },
|
||||
options?: {
|
||||
startPaused?: boolean;
|
||||
disableYoutubeSubtitleAutoLoad?: boolean;
|
||||
runtimePluginPath?: string | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
|
||||
fail(`Video file not found: ${target}`);
|
||||
@@ -672,6 +863,14 @@ 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');
|
||||
@@ -811,7 +1010,7 @@ export async function startOverlay(
|
||||
env: buildAppEnv(),
|
||||
});
|
||||
attachAppProcessLogging(state.overlayProc);
|
||||
state.overlayManagedByLauncher = true;
|
||||
markOverlayManagedByLauncher(appPath);
|
||||
|
||||
const [socketReady] = await Promise.all([
|
||||
waitForUnixSocketReady(socketPath, OVERLAY_START_SOCKET_READY_TIMEOUT_MS),
|
||||
@@ -831,6 +1030,13 @@ 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'
|
||||
@@ -1236,6 +1442,7 @@ export function launchMpvIdleDetached(
|
||||
socketPath: string,
|
||||
appPath: string,
|
||||
args: Args,
|
||||
runtimePluginPath?: string | null,
|
||||
): Promise<void> {
|
||||
return (async () => {
|
||||
await terminateTrackedDetachedMpv(args.logLevel);
|
||||
@@ -1246,6 +1453,13 @@ 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));
|
||||
}
|
||||
|
||||
@@ -116,34 +116,81 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('ensureLauncherSetupReady bypasses setup gate when plugin is already installed', async () => {
|
||||
test('ensureLauncherSetupReady waits for finish after legacy mpv plugin removal', async () => {
|
||||
const calls: string[] = [];
|
||||
let legacyPluginInstalled = true;
|
||||
let reads = 0;
|
||||
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
readSetupState: () => ({
|
||||
readSetupState: () => {
|
||||
reads += 1;
|
||||
return {
|
||||
version: 3,
|
||||
status: 'cancelled',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
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',
|
||||
}),
|
||||
isPluginInstalled: () => true,
|
||||
};
|
||||
},
|
||||
hasLegacyMpvPlugin: () => legacyPluginInstalled,
|
||||
launchSetupApp: () => {
|
||||
calls.push('launch');
|
||||
legacyPluginInstalled = false;
|
||||
},
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
now: (() => {
|
||||
let value = 0;
|
||||
return () => (value += 100);
|
||||
})(),
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
});
|
||||
|
||||
assert.equal(ready, true);
|
||||
assert.deepEqual(calls, []);
|
||||
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']);
|
||||
});
|
||||
|
||||
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
||||
|
||||
+58
-8
@@ -32,31 +32,81 @@ 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;
|
||||
isPluginInstalled?: () => boolean;
|
||||
hasLegacyMpvPlugin?: () => 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;
|
||||
}
|
||||
if (deps.isPluginInstalled?.()) {
|
||||
return true;
|
||||
}
|
||||
const initialState = deps.readSetupState();
|
||||
if (isSetupCompleted(initialState)) {
|
||||
const stateAfterLegacyPrompt = deps.readSetupState();
|
||||
if (isSetupCompleted(stateAfterLegacyPrompt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
deps.launchSetupApp();
|
||||
launchSetupApp();
|
||||
const result = await waitForSetupCompletion({
|
||||
...deps,
|
||||
ignoreInitialCancelledState: initialState?.status === 'cancelled',
|
||||
ignoreInitialCancelledState: stateAfterLegacyPrompt?.status === 'cancelled',
|
||||
});
|
||||
return result === 'completed';
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ 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()
|
||||
@@ -78,7 +82,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")
|
||||
ctx.log.subminer_log("info", "lifecycle", "SubMiner plugin loaded " .. tostring(version.version or "unknown"))
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
return {
|
||||
name = "SubMiner mpv plugin",
|
||||
version = "0.12.0",
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
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}`);
|
||||
@@ -1,6 +1,8 @@
|
||||
local MODULE_PATHS = {
|
||||
"plugin/subminer/bootstrap.lua",
|
||||
"plugin/subminer/hover.lua",
|
||||
"plugin/subminer/environment.lua",
|
||||
"plugin/subminer/version.lua",
|
||||
}
|
||||
|
||||
local LEGACY_PARSER_CANDIDATES = {
|
||||
@@ -48,6 +50,12 @@ 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
|
||||
@@ -128,6 +136,7 @@ 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
|
||||
|
||||
@@ -1025,6 +1025,46 @@ 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,6 +184,57 @@ 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,6 +2,7 @@ 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';
|
||||
|
||||
@@ -19,6 +20,10 @@ export interface AnilistPostWatchUpdateResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AnilistPostWatchUpdateOptions {
|
||||
rateLimiter?: AnilistRateLimiter;
|
||||
}
|
||||
|
||||
interface AnilistGraphQlError {
|
||||
message?: string;
|
||||
}
|
||||
@@ -155,8 +160,10 @@ 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: {
|
||||
@@ -166,6 +173,7 @@ 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) {
|
||||
@@ -269,6 +277,7 @@ export async function updateAnilistPostWatchProgress(
|
||||
accessToken: string,
|
||||
title: string,
|
||||
episode: number,
|
||||
options: AnilistPostWatchUpdateOptions = {},
|
||||
): Promise<AnilistPostWatchUpdateResult> {
|
||||
const searchResponse = await anilistGraphQl<AnilistSearchData>(
|
||||
accessToken,
|
||||
@@ -288,6 +297,7 @@ export async function updateAnilistPostWatchProgress(
|
||||
}
|
||||
`,
|
||||
{ search: title },
|
||||
options,
|
||||
);
|
||||
const searchError = firstErrorMessage(searchResponse);
|
||||
if (searchError) {
|
||||
@@ -317,6 +327,7 @@ export async function updateAnilistPostWatchProgress(
|
||||
}
|
||||
`,
|
||||
{ mediaId: picked.id },
|
||||
options,
|
||||
);
|
||||
const entryError = firstErrorMessage(entryResponse);
|
||||
if (entryError) {
|
||||
@@ -345,6 +356,7 @@ export async function updateAnilistPostWatchProgress(
|
||||
}
|
||||
`,
|
||||
{ mediaId: picked.id, progress: episode },
|
||||
options,
|
||||
);
|
||||
const saveError = firstErrorMessage(saveResponse);
|
||||
if (saveError) {
|
||||
|
||||
@@ -5,7 +5,12 @@ 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, getOrCreateVideoRecord } from '../immersion-tracker/storage.js';
|
||||
import {
|
||||
ensureSchema,
|
||||
getOrCreateAnimeRecord,
|
||||
getOrCreateVideoRecord,
|
||||
linkVideoToAnimeRecord,
|
||||
} 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';
|
||||
@@ -100,6 +105,82 @@ 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,6 +1,11 @@
|
||||
import type { AnilistRateLimiter } from './rate-limiter';
|
||||
import type { DatabaseSync } from '../immersion-tracker/sqlite';
|
||||
import { getCoverArt, upsertCoverArt, updateAnimeAnilistInfo } from '../immersion-tracker/query';
|
||||
import {
|
||||
getAnimeCoverArt,
|
||||
getCoverArt,
|
||||
upsertCoverArt,
|
||||
updateAnimeAnilistInfo,
|
||||
} from '../immersion-tracker/query';
|
||||
import {
|
||||
guessAnilistMediaInfo,
|
||||
runGuessit,
|
||||
@@ -257,6 +262,30 @@ 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,
|
||||
@@ -317,6 +346,10 @@ export function createCoverArtFetcher(
|
||||
}
|
||||
}
|
||||
|
||||
if (reuseAnimeCoverArt(db, videoId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
existing &&
|
||||
existing.coverUrl === null &&
|
||||
|
||||
@@ -14,6 +14,7 @@ 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;
|
||||
@@ -255,6 +256,7 @@ export interface StatsServerConfig {
|
||||
knownWordCachePath?: string;
|
||||
mpvSocketPath?: string;
|
||||
ankiConnectConfig?: AnkiConnectConfig;
|
||||
anilistRateLimiter?: AnilistRateLimiter;
|
||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||
resolveAnkiNoteId?: (noteId: number) => number;
|
||||
}
|
||||
@@ -338,6 +340,7 @@ export function createStatsApp(
|
||||
knownWordCachePath?: string;
|
||||
mpvSocketPath?: string;
|
||||
ankiConnectConfig?: AnkiConnectConfig;
|
||||
anilistRateLimiter?: AnilistRateLimiter;
|
||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||
resolveAnkiNoteId?: (noteId: number) => number;
|
||||
},
|
||||
@@ -632,6 +635,7 @@ 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' },
|
||||
@@ -652,6 +656,10 @@ 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 {
|
||||
@@ -1131,6 +1139,7 @@ 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,
|
||||
});
|
||||
|
||||
+103
-7
@@ -1,6 +1,7 @@
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { app, dialog } from 'electron';
|
||||
import { app, dialog, shell } from 'electron';
|
||||
import { printHelp } from './cli/help';
|
||||
import { loadRawConfigStrict } from './config/load';
|
||||
import {
|
||||
@@ -18,7 +19,12 @@ import {
|
||||
shouldHandleStatsDaemonCommandAtEntry,
|
||||
} from './main-entry-runtime';
|
||||
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
||||
import { resolvePackagedFirstRunPluginAssets } from './main/runtime/first-run-setup-plugin';
|
||||
import {
|
||||
detectInstalledFirstRunPluginCandidates,
|
||||
detectInstalledMpvPlugin,
|
||||
removeLegacyMpvPluginCandidates,
|
||||
resolvePackagedRuntimePluginPath,
|
||||
} 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';
|
||||
@@ -38,16 +44,105 @@ function applySanitizedEnv(sanitizedEnv: NodeJS.ProcessEnv): void {
|
||||
}
|
||||
|
||||
function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined {
|
||||
const assets = resolvePackagedFirstRunPluginAssets({
|
||||
return (
|
||||
resolvePackagedRuntimePluginPath({
|
||||
dirname: __dirname,
|
||||
appPath: app.getAppPath(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
});
|
||||
if (!assets) {
|
||||
return undefined;
|
||||
}) ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
return path.join(assets.pluginDirSource, 'main.lua');
|
||||
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,
|
||||
});
|
||||
|
||||
if (response.response === 2) {
|
||||
return 'cancel';
|
||||
}
|
||||
if (response.response === 1) {
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
function readConfiguredWindowsMpvLaunch(configDir: string): {
|
||||
@@ -117,6 +212,7 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
|
||||
resolveBundledWindowsMpvPluginEntrypoint(),
|
||||
configuredMpvLaunch.executablePath,
|
||||
configuredMpvLaunch.launchMode,
|
||||
createWindowsRuntimePluginPolicy(),
|
||||
);
|
||||
app.exit(result.ok ? 0 : 1);
|
||||
});
|
||||
|
||||
+126
-14
@@ -382,7 +382,10 @@ import {
|
||||
} from './main/runtime/first-run-setup-window';
|
||||
import {
|
||||
detectInstalledFirstRunPlugin,
|
||||
installFirstRunPluginToDefaultLocation,
|
||||
detectInstalledFirstRunPluginCandidates,
|
||||
detectInstalledMpvPlugin,
|
||||
removeLegacyMpvPluginCandidates,
|
||||
resolvePackagedRuntimePluginPath,
|
||||
syncInstalledFirstRunPluginBinaryPath,
|
||||
} from './main/runtime/first-run-setup-plugin';
|
||||
import {
|
||||
@@ -1063,6 +1066,89 @@ 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,
|
||||
@@ -1087,10 +1173,16 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
|
||||
showError: (title, content) => dialog.showErrorBox(title, content),
|
||||
}),
|
||||
[...args, `--log-file=${DEFAULT_MPV_LOG_PATH}`],
|
||||
undefined,
|
||||
undefined,
|
||||
process.execPath,
|
||||
resolveBundledMpvRuntimePluginEntrypoint(),
|
||||
getResolvedConfig().mpv.executablePath,
|
||||
getResolvedConfig().mpv.launchMode,
|
||||
{
|
||||
detectInstalledMpvPlugin: detectWindowsInstalledMpvPlugin,
|
||||
notifyInstalledPluginDetected: logInstalledMpvPluginDetected,
|
||||
resolveInstalledPluginBeforeLaunch: (detection, mpvPath) =>
|
||||
promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(mpvPath, detection),
|
||||
},
|
||||
),
|
||||
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
|
||||
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
|
||||
@@ -1127,6 +1219,16 @@ 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(),
|
||||
@@ -1134,15 +1236,18 @@ const firstRunSetupService = createFirstRunSetupService({
|
||||
);
|
||||
return detectInstalledFirstRunPlugin(installPaths);
|
||||
},
|
||||
installPlugin: async () =>
|
||||
installFirstRunPluginToDefaultLocation({
|
||||
detectLegacyMpvPluginCandidates: () =>
|
||||
detectInstalledFirstRunPluginCandidates({
|
||||
platform: process.platform,
|
||||
homeDir: os.homedir(),
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
dirname: __dirname,
|
||||
appPath: app.getAppPath(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
binaryPath: process.execPath,
|
||||
appDataDir: app.getPath('appData'),
|
||||
mpvExecutablePath: getResolvedConfig().mpv.executablePath,
|
||||
}),
|
||||
removeLegacyMpvPlugins: (candidates) =>
|
||||
removeLegacyMpvPluginCandidates({
|
||||
candidates,
|
||||
trashItem: (candidatePath) => shell.trashItem(candidatePath),
|
||||
}),
|
||||
detectWindowsMpvShortcuts: () => {
|
||||
if (process.platform !== 'win32') {
|
||||
@@ -1309,8 +1414,9 @@ const buildMainSubsyncRuntimeMainDepsHandler = createBuildMainSubsyncRuntimeMain
|
||||
const immersionMediaRuntime = createImmersionMediaRuntime(
|
||||
buildImmersionMediaRuntimeMainDepsHandler(),
|
||||
);
|
||||
const anilistRateLimiter = createAnilistRateLimiter();
|
||||
const statsCoverArtFetcher = createCoverArtFetcher(
|
||||
createAnilistRateLimiter(),
|
||||
anilistRateLimiter,
|
||||
createLogger('main:stats-cover-art'),
|
||||
);
|
||||
const anilistStateRuntime = createAnilistStateRuntime(buildAnilistStateRuntimeMainDepsHandler());
|
||||
@@ -2639,6 +2745,7 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
externalYomitanConfigured: snapshot.externalYomitanConfigured,
|
||||
pluginStatus: snapshot.pluginStatus,
|
||||
pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
|
||||
legacyMpvPluginPaths: snapshot.legacyMpvPluginPaths,
|
||||
mpvExecutablePath,
|
||||
mpvExecutablePathStatus: getConfiguredWindowsMpvPathStatus(mpvExecutablePath),
|
||||
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
|
||||
@@ -2648,8 +2755,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
buildSetupHtml: (model) => buildFirstRunSetupHtml(model),
|
||||
parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl),
|
||||
handleAction: async (submission: FirstRunSetupSubmission) => {
|
||||
if (submission.action === 'install-plugin') {
|
||||
const snapshot = await firstRunSetupService.installMpvPlugin();
|
||||
if (submission.action === 'remove-legacy-plugin') {
|
||||
const snapshot = await firstRunSetupService.removeLegacyMpvPlugin();
|
||||
firstRunSetupMessage = snapshot.message;
|
||||
return;
|
||||
}
|
||||
@@ -2998,7 +3105,9 @@ const {
|
||||
},
|
||||
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
|
||||
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
|
||||
updateAnilistPostWatchProgress(accessToken, title, episode),
|
||||
updateAnilistPostWatchProgress(accessToken, title, episode, {
|
||||
rateLimiter: anilistRateLimiter,
|
||||
}),
|
||||
markSuccess: (key) => {
|
||||
anilistUpdateQueue.markSuccess(key);
|
||||
},
|
||||
@@ -3044,7 +3153,9 @@ const {
|
||||
},
|
||||
refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(),
|
||||
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
|
||||
updateAnilistPostWatchProgress(accessToken, title, episode),
|
||||
updateAnilistPostWatchProgress(accessToken, title, episode, {
|
||||
rateLimiter: anilistRateLimiter,
|
||||
}),
|
||||
rememberAttemptedUpdateKey: (key) => {
|
||||
rememberAnilistAttemptedUpdate(key);
|
||||
},
|
||||
@@ -3251,6 +3362,7 @@ 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) => {
|
||||
|
||||
@@ -5,8 +5,11 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
detectInstalledFirstRunPlugin,
|
||||
installFirstRunPluginToDefaultLocation,
|
||||
detectInstalledFirstRunPluginCandidates,
|
||||
detectInstalledMpvPlugin,
|
||||
removeLegacyMpvPluginCandidates,
|
||||
resolvePackagedFirstRunPluginAssets,
|
||||
resolvePackagedRuntimePluginPath,
|
||||
syncInstalledFirstRunPluginBinaryPath,
|
||||
} from './first-run-setup-plugin';
|
||||
import { resolveDefaultMpvInstallPaths } from '../../shared/setup-state';
|
||||
@@ -43,125 +46,22 @@ test('resolvePackagedFirstRunPluginAssets finds packaged plugin assets', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('installFirstRunPluginToDefaultLocation installs plugin and backs up existing files', () => {
|
||||
test('resolvePackagedRuntimePluginPath returns packaged plugin entrypoint', () => {
|
||||
withTempDir((root) => {
|
||||
const resourcesPath = path.join(root, 'resources');
|
||||
const pluginRoot = path.join(resourcesPath, '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');
|
||||
const entrypoint = path.join(pluginRoot, 'subminer', 'main.lua');
|
||||
fs.mkdirSync(path.dirname(entrypoint), { recursive: true });
|
||||
fs.writeFileSync(entrypoint, '-- 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,
|
||||
assert.equal(
|
||||
resolvePackagedRuntimePluginPath({
|
||||
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(
|
||||
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',
|
||||
}),
|
||||
entrypoint,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -270,6 +170,140 @@ 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');
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
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';
|
||||
|
||||
function timestamp(): string {
|
||||
return new Date().toISOString().replaceAll(':', '-');
|
||||
export interface InstalledFirstRunPluginCandidate {
|
||||
path: string;
|
||||
kind: 'directory' | 'file';
|
||||
}
|
||||
|
||||
function backupExistingPath(targetPath: string): void {
|
||||
if (!fs.existsSync(targetPath)) return;
|
||||
fs.renameSync(targetPath, `${targetPath}.bak.${timestamp()}`);
|
||||
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 rewriteInstalledWindowsPluginConfig(configPath: string): void {
|
||||
@@ -89,6 +104,30 @@ 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?: {
|
||||
@@ -100,61 +139,203 @@ export function detectInstalledFirstRunPlugin(
|
||||
return existsSync(pluginEntrypointPath);
|
||||
}
|
||||
|
||||
export function installFirstRunPluginToDefaultLocation(options: {
|
||||
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: {
|
||||
platform: NodeJS.Platform;
|
||||
homeDir: string;
|
||||
xdgConfigHome?: string;
|
||||
dirname: string;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
binaryPath: string;
|
||||
}): PluginInstallResult {
|
||||
const installPaths = resolveDefaultMpvInstallPaths(
|
||||
options.platform,
|
||||
options.homeDir,
|
||||
options.xdgConfigHome,
|
||||
);
|
||||
if (!installPaths.supported) {
|
||||
return {
|
||||
ok: false,
|
||||
pluginInstallStatus: 'failed',
|
||||
pluginInstallPathSummary: installPaths.mpvConfigDir,
|
||||
message: 'Automatic mpv plugin install is not supported on this platform yet.',
|
||||
};
|
||||
}
|
||||
|
||||
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.',
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
appDataDir?: string;
|
||||
mpvExecutablePath?: string;
|
||||
}): MpvConfigRootCandidate[] {
|
||||
const platformPath = getPlatformPath(options.platform);
|
||||
if (options.platform === 'win32') {
|
||||
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
|
||||
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',
|
||||
);
|
||||
if (!options.existsSync(versionPath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return parseInstalledPluginVersion(options.readFileSync(versionPath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: installPaths.mpvConfigDir,
|
||||
message: `Installed mpv plugin to ${installPaths.mpvConfigDir}.`,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -159,18 +159,17 @@ test('setup service auto-completes legacy installs with config and dictionaries'
|
||||
});
|
||||
});
|
||||
|
||||
test('setup service requires mpv plugin install before finish', async () => {
|
||||
test('setup service allows finish without global mpv plugin once dictionaries are ready', 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: () => pluginInstalled,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
@@ -184,11 +183,6 @@ test('setup service requires mpv plugin install before finish', async () => {
|
||||
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);
|
||||
@@ -304,7 +298,7 @@ test('setup service reopens when external-yomitan completion later has no extern
|
||||
});
|
||||
});
|
||||
|
||||
test('setup service reopens when a completed setup no longer has the mpv plugin installed', async () => {
|
||||
test('setup service keeps completed when a global mpv plugin is removed later', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
@@ -340,12 +334,41 @@ test('setup service reopens when a completed setup no longer has the mpv plugin
|
||||
});
|
||||
|
||||
const snapshot = await service.ensureSetupStateInitialized();
|
||||
assert.equal(snapshot.state.status, 'incomplete');
|
||||
assert.equal(snapshot.canFinish, false);
|
||||
assert.equal(snapshot.state.status, 'completed');
|
||||
assert.equal(snapshot.canFinish, true);
|
||||
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');
|
||||
@@ -490,3 +513,86 @@ 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,10 @@ 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;
|
||||
@@ -29,6 +33,7 @@ export interface SetupStatusSnapshot {
|
||||
externalYomitanConfigured: boolean;
|
||||
pluginStatus: 'installed' | 'required' | 'failed';
|
||||
pluginInstallPathSummary: string | null;
|
||||
legacyMpvPluginPaths: string[];
|
||||
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
|
||||
message: string | null;
|
||||
state: SetupState;
|
||||
@@ -48,7 +53,7 @@ export interface FirstRunSetupService {
|
||||
markSetupInProgress: () => Promise<SetupStatusSnapshot>;
|
||||
markSetupCancelled: () => Promise<SetupStatusSnapshot>;
|
||||
markSetupCompleted: () => Promise<SetupStatusSnapshot>;
|
||||
installMpvPlugin: () => Promise<SetupStatusSnapshot>;
|
||||
removeLegacyMpvPlugin: () => Promise<SetupStatusSnapshot>;
|
||||
configureWindowsMpvShortcuts: (preferences: {
|
||||
startMenuEnabled: boolean;
|
||||
desktopEnabled: boolean;
|
||||
@@ -176,9 +181,6 @@ 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.';
|
||||
}
|
||||
@@ -219,7 +221,13 @@ export function createFirstRunSetupService(deps: {
|
||||
getYomitanDictionaryCount: () => Promise<number>;
|
||||
isExternalYomitanConfigured?: () => boolean;
|
||||
detectPluginInstalled: () => boolean | Promise<boolean>;
|
||||
installPlugin: () => Promise<PluginInstallResult>;
|
||||
detectLegacyMpvPluginCandidates?: () =>
|
||||
| InstalledFirstRunPluginCandidate[]
|
||||
| Promise<InstalledFirstRunPluginCandidate[]>;
|
||||
installPlugin?: () => Promise<PluginInstallResult>;
|
||||
removeLegacyMpvPlugins?: (
|
||||
candidates: InstalledFirstRunPluginCandidate[],
|
||||
) => Promise<LegacyMpvPluginRemovalResult>;
|
||||
detectWindowsMpvShortcuts?: () =>
|
||||
| { startMenuInstalled: boolean; desktopInstalled: boolean }
|
||||
| Promise<{ startMenuInstalled: boolean; desktopInstalled: boolean }>;
|
||||
@@ -250,6 +258,7 @@ export function createFirstRunSetupService(deps: {
|
||||
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
|
||||
});
|
||||
const pluginInstalled = await deps.detectPluginInstalled();
|
||||
const legacyMpvPluginCandidates = (await deps.detectLegacyMpvPluginCandidates?.()) ?? [];
|
||||
const detectedWindowsMpvShortcuts = isWindows
|
||||
? await deps.detectWindowsMpvShortcuts?.()
|
||||
: undefined;
|
||||
@@ -264,9 +273,7 @@ export function createFirstRunSetupService(deps: {
|
||||
return {
|
||||
configReady,
|
||||
dictionaryCount,
|
||||
canFinish:
|
||||
pluginInstalled &&
|
||||
isYomitanSetupSatisfied({
|
||||
canFinish: isYomitanSetupSatisfied({
|
||||
configReady,
|
||||
dictionaryCount,
|
||||
externalYomitanConfigured,
|
||||
@@ -274,6 +281,7 @@ export function createFirstRunSetupService(deps: {
|
||||
externalYomitanConfigured,
|
||||
pluginStatus: getPluginStatus(state, pluginInstalled),
|
||||
pluginInstallPathSummary: state.pluginInstallPathSummary,
|
||||
legacyMpvPluginPaths: legacyMpvPluginCandidates.map((candidate) => candidate.path),
|
||||
windowsMpvShortcuts: {
|
||||
supported: isWindows,
|
||||
startMenuEnabled: effectiveWindowsMpvShortcutPreferences.startMenuEnabled,
|
||||
@@ -308,10 +316,7 @@ export function createFirstRunSetupService(deps: {
|
||||
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
|
||||
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
|
||||
});
|
||||
const pluginInstalled = await deps.detectPluginInstalled();
|
||||
const canFinish =
|
||||
pluginInstalled &&
|
||||
isYomitanSetupSatisfied({
|
||||
const canFinish = isYomitanSetupSatisfied({
|
||||
configReady,
|
||||
dictionaryCount,
|
||||
externalYomitanConfigured,
|
||||
@@ -349,9 +354,21 @@ 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return refreshWithState(writeState({ ...state, status: 'in_progress' }));
|
||||
},
|
||||
markSetupCancelled: async () => {
|
||||
@@ -379,15 +396,34 @@ export function createFirstRunSetupService(deps: {
|
||||
}),
|
||||
);
|
||||
},
|
||||
installMpvPlugin: async () => {
|
||||
const result = await deps.installPlugin();
|
||||
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(
|
||||
writeState({
|
||||
...readState(),
|
||||
pluginInstallStatus: result.pluginInstallStatus,
|
||||
pluginInstallPathSummary: result.pluginInstallPathSummary,
|
||||
}),
|
||||
result.message,
|
||||
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(', ');
|
||||
return refreshWithState(
|
||||
readState(),
|
||||
`Removed ${removedText}, but failed to remove: ${failedText}. Delete the failed paths manually from mpv scripts.`,
|
||||
);
|
||||
},
|
||||
configureWindowsMpvShortcuts: async (preferences) => {
|
||||
|
||||
@@ -30,8 +30,11 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
|
||||
});
|
||||
|
||||
assert.match(html, /SubMiner setup/);
|
||||
assert.match(html, /Install mpv plugin/);
|
||||
assert.match(html, /Required before SubMiner setup can finish/);
|
||||
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, /Open Yomitan Settings/);
|
||||
assert.match(html, /Finish setup/);
|
||||
assert.match(html, /disabled/);
|
||||
@@ -58,14 +61,49 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
|
||||
message: null,
|
||||
});
|
||||
|
||||
assert.match(html, /Reinstall mpv plugin/);
|
||||
assert.doesNotMatch(html, /Reinstall mpv plugin/);
|
||||
assert.doesNotMatch(html, /action=install-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,
|
||||
/Finish stays unlocked once the mpv plugin is installed and Yomitan reports at least one installed dictionary\./,
|
||||
/Remove these SubMiner mpv plugin files from mpv.s scripts directory\? This stops regular mpv from loading SubMiner\./,
|
||||
);
|
||||
assert.match(html, /action=remove-legacy-plugin/);
|
||||
});
|
||||
|
||||
test('buildFirstRunSetupHtml marks an invalid configured mpv path as invalid', () => {
|
||||
@@ -158,6 +196,12 @@ 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,
|
||||
@@ -177,7 +221,7 @@ test('first-run setup window handler focuses existing window', () => {
|
||||
assert.deepEqual(calls, ['focus']);
|
||||
});
|
||||
|
||||
test('first-run setup navigation handler prevents default and dispatches action', async () => {
|
||||
test('first-run setup navigation handler prevents default and dispatches supported action', async () => {
|
||||
const calls: string[] = [];
|
||||
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
|
||||
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
|
||||
@@ -188,13 +232,20 @@ test('first-run setup navigation handler prevents default and dispatches action'
|
||||
});
|
||||
|
||||
const prevented = handleNavigation({
|
||||
url: 'subminer://first-run-setup?action=install-plugin',
|
||||
url: 'subminer://first-run-setup?action=refresh',
|
||||
preventDefault: () => calls.push('preventDefault'),
|
||||
});
|
||||
|
||||
assert.equal(prevented, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, ['preventDefault', 'install-plugin']);
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
test('first-run setup navigation handler swallows stale custom-scheme actions', () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ type FirstRunSetupWindowLike = FocusableWindowLike & {
|
||||
|
||||
export type FirstRunSetupAction =
|
||||
| 'configure-mpv-executable-path'
|
||||
| 'install-plugin'
|
||||
| 'remove-legacy-plugin'
|
||||
| 'configure-windows-mpv-shortcuts'
|
||||
| 'open-yomitan-settings'
|
||||
| 'refresh'
|
||||
@@ -38,6 +38,7 @@ export interface FirstRunSetupHtmlModel {
|
||||
externalYomitanConfigured: boolean;
|
||||
pluginStatus: 'installed' | 'required' | 'failed';
|
||||
pluginInstallPathSummary: string | null;
|
||||
legacyMpvPluginPaths?: string[];
|
||||
mpvExecutablePath: string;
|
||||
mpvExecutablePathStatus: 'blank' | 'configured' | 'invalid';
|
||||
windowsMpvShortcuts: {
|
||||
@@ -64,20 +65,19 @@ function renderStatusBadge(value: string, tone: 'ready' | 'warn' | 'muted' | 'da
|
||||
}
|
||||
|
||||
export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
const pluginActionLabel =
|
||||
model.pluginStatus === 'installed' ? 'Reinstall mpv plugin' : 'Install mpv plugin';
|
||||
const legacyMpvPluginPaths = model.legacyMpvPluginPaths ?? [];
|
||||
const finishButtonLabel =
|
||||
legacyMpvPluginPaths.length > 0 && model.canFinish
|
||||
? 'Continue without removing'
|
||||
: 'Finish setup';
|
||||
const pluginLabel =
|
||||
model.pluginStatus === 'installed'
|
||||
? 'Installed'
|
||||
legacyMpvPluginPaths.length > 0
|
||||
? 'Legacy detected'
|
||||
: model.pluginStatus === 'failed'
|
||||
? 'Failed'
|
||||
: 'Required';
|
||||
: 'Ready';
|
||||
const pluginTone =
|
||||
model.pluginStatus === 'installed'
|
||||
? 'ready'
|
||||
: model.pluginStatus === 'failed'
|
||||
? 'danger'
|
||||
: 'warn';
|
||||
legacyMpvPluginPaths.length > 0 ? 'warn' : model.pluginStatus === 'failed' ? 'danger' : 'ready';
|
||||
const windowsShortcutLabel =
|
||||
model.windowsMpvShortcuts.status === 'installed'
|
||||
? 'Installed'
|
||||
@@ -159,6 +159,23 @@ 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("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.")) 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.'
|
||||
@@ -179,8 +196,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 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.';
|
||||
: '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.';
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
@@ -307,6 +324,18 @@ 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;
|
||||
@@ -321,6 +350,13 @@ 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>
|
||||
@@ -335,9 +371,9 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
</div>
|
||||
<div class="card">
|
||||
<div>
|
||||
<strong>mpv plugin</strong>
|
||||
<strong>mpv runtime plugin</strong>
|
||||
<div class="meta">${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}</div>
|
||||
<div class="meta">Required before SubMiner setup can finish.</div>
|
||||
<div class="meta">Managed mpv launches use the bundled runtime plugin.</div>
|
||||
</div>
|
||||
${renderStatusBadge(pluginLabel, pluginTone)}
|
||||
</div>
|
||||
@@ -350,11 +386,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'">Finish setup</button>
|
||||
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">${finishButtonLabel}</button>
|
||||
</div>
|
||||
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
|
||||
<div class="footer">${escapeHtml(footerMessage)}</div>
|
||||
@@ -371,7 +407,7 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
|
||||
const action = parsed.searchParams.get('action');
|
||||
if (
|
||||
action !== 'configure-mpv-executable-path' &&
|
||||
action !== 'install-plugin' &&
|
||||
action !== 'remove-legacy-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: 480,
|
||||
height: 460,
|
||||
width: 560,
|
||||
height: 640,
|
||||
title: 'SubMiner Setup',
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
|
||||
@@ -32,8 +32,8 @@ export function createCreateFirstRunSetupWindowHandler<TWindow>(deps: {
|
||||
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
|
||||
}) {
|
||||
return createSetupWindowHandler(deps, {
|
||||
width: 480,
|
||||
height: 460,
|
||||
width: 560,
|
||||
height: 640,
|
||||
title: 'SubMiner Setup',
|
||||
resizable: false,
|
||||
minimizable: false,
|
||||
|
||||
@@ -230,6 +230,104 @@ 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(
|
||||
|
||||
@@ -2,6 +2,7 @@ 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;
|
||||
@@ -13,6 +14,15 @@ 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() : '';
|
||||
}
|
||||
@@ -100,10 +110,12 @@ export function buildWindowsMpvLaunchArgs(
|
||||
typeof pluginEntrypointPath === 'string' && pluginEntrypointPath.trim().length > 0
|
||||
? `--script=${pluginEntrypointPath.trim()}`
|
||||
: null;
|
||||
const scriptOptPairs = scriptEntrypoint
|
||||
const hasBinaryPath = typeof binaryPath === 'string' && binaryPath.trim().length > 0;
|
||||
const shouldPassSubminerScriptOpts = scriptEntrypoint || hasBinaryPath;
|
||||
const scriptOptPairs = shouldPassSubminerScriptOpts
|
||||
? [`subminer-socket_path=${inputIpcServer.replace(/,/g, '\\,')}`]
|
||||
: [];
|
||||
if (scriptEntrypoint && typeof binaryPath === 'string' && binaryPath.trim().length > 0) {
|
||||
if (hasBinaryPath) {
|
||||
scriptOptPairs.unshift(`subminer-binary_path=${binaryPath.trim().replace(/,/g, '\\,')}`);
|
||||
}
|
||||
const scriptOpts = scriptOptPairs.length > 0 ? `--script-opts=${scriptOptPairs.join(',')}` : null;
|
||||
@@ -136,6 +148,7 @@ 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);
|
||||
@@ -150,9 +163,36 @@ 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, pluginEntrypointPath, launchMode),
|
||||
buildWindowsMpvLaunchArgs(
|
||||
targets,
|
||||
extraArgs,
|
||||
binaryPath,
|
||||
runtimePluginEntrypointPath,
|
||||
launchMode,
|
||||
),
|
||||
);
|
||||
return { ok: true, mpvPath };
|
||||
} catch (error) {
|
||||
|
||||
@@ -178,10 +178,19 @@ test('release workflow skips empty AUR sync commits', () => {
|
||||
assert.match(releaseWorkflow, /if git diff --quiet -- PKGBUILD \.SRCINFO; then/);
|
||||
});
|
||||
|
||||
test('Makefile routes Windows install-plugin setup through bun and documents Windows builds', () => {
|
||||
test('Makefile does not expose the legacy global mpv plugin installer', () => {
|
||||
assert.match(
|
||||
makefile,
|
||||
/windows\) printf '%s\\n' "\[INFO\] Windows builds run via: bun run build:win" ;;/,
|
||||
);
|
||||
assert.match(makefile, /bun \.\/scripts\/configure-plugin-binary-path\.mjs/);
|
||||
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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user