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
- 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
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
|
APP_NAME := subminer
|
||||||
THEME_SOURCE := assets/themes/subminer.rasi
|
THEME_SOURCE := assets/themes/subminer.rasi
|
||||||
LAUNCHER_OUT := dist/launcher/$(APP_NAME)
|
LAUNCHER_OUT := dist/launcher/$(APP_NAME)
|
||||||
THEME_FILE := subminer.rasi
|
THEME_FILE := subminer.rasi
|
||||||
PLUGIN_CONF := plugin/subminer.conf
|
|
||||||
|
|
||||||
# Default install prefix for the wrapper script.
|
# Default install prefix for the wrapper script.
|
||||||
PREFIX ?= $(HOME)/.local
|
PREFIX ?= $(HOME)/.local
|
||||||
@@ -64,8 +63,7 @@ help:
|
|||||||
" dev-stop Stop a running local Electron app" \
|
" dev-stop Stop a running local Electron app" \
|
||||||
" install-linux Install Linux wrapper/theme/app artifacts" \
|
" install-linux Install Linux wrapper/theme/app artifacts" \
|
||||||
" install-macos Install macOS wrapper/theme/app artifacts" \
|
" install-macos Install macOS wrapper/theme/app artifacts" \
|
||||||
" install-windows Install Windows mpv plugin artifacts" \
|
" install-windows Print Windows packaging/install guidance" \
|
||||||
" install-plugin Install mpv Lua plugin and plugin config" \
|
|
||||||
" generate-config Generate ~/.config/SubMiner/config.jsonc from centralized defaults" \
|
" generate-config Generate ~/.config/SubMiner/config.jsonc from centralized defaults" \
|
||||||
"" \
|
"" \
|
||||||
"Other targets:" \
|
"Other targets:" \
|
||||||
@@ -200,6 +198,8 @@ install-linux: build-launcher
|
|||||||
@install -m 0755 "$(LAUNCHER_OUT)" "$(BINDIR)/$(APP_NAME)"
|
@install -m 0755 "$(LAUNCHER_OUT)" "$(BINDIR)/$(APP_NAME)"
|
||||||
@install -d "$(LINUX_DATA_DIR)/themes"
|
@install -d "$(LINUX_DATA_DIR)/themes"
|
||||||
@install -m 0644 "./$(THEME_SOURCE)" "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
|
@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 \
|
@if [ -n "$(APPIMAGE_SRC)" ]; then \
|
||||||
install -m 0755 "$(APPIMAGE_SRC)" "$(BINDIR)/SubMiner.AppImage"; \
|
install -m 0755 "$(APPIMAGE_SRC)" "$(BINDIR)/SubMiner.AppImage"; \
|
||||||
else \
|
else \
|
||||||
@@ -214,6 +214,8 @@ install-macos: build-launcher
|
|||||||
@install -m 0755 "$(LAUNCHER_OUT)" "$(BINDIR)/$(APP_NAME)"
|
@install -m 0755 "$(LAUNCHER_OUT)" "$(BINDIR)/$(APP_NAME)"
|
||||||
@install -d "$(MACOS_DATA_DIR)/themes"
|
@install -d "$(MACOS_DATA_DIR)/themes"
|
||||||
@install -m 0644 "./$(THEME_SOURCE)" "$(MACOS_DATA_DIR)/themes/$(THEME_FILE)"
|
@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)"
|
@install -d "$(MACOS_APP_DIR)"
|
||||||
@if [ -n "$(MACOS_APP_SRC)" ]; then \
|
@if [ -n "$(MACOS_APP_SRC)" ]; then \
|
||||||
rm -rf "$(MACOS_APP_DEST)"; \
|
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)"
|
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_APP_DEST)"
|
||||||
|
|
||||||
install-windows:
|
install-windows:
|
||||||
@printf '%s\n' "[INFO] Installing Windows mpv plugin artifacts"
|
@printf '%s\n' "[INFO] Windows builds run via: bun run build:win"
|
||||||
@$(MAKE) --no-print-directory install-plugin
|
@printf '%s\n' "[INFO] SubMiner-managed mpv launches inject the bundled runtime plugin; no global mpv plugin install is needed."
|
||||||
|
|
||||||
install-plugin:
|
|
||||||
@printf '%s\n' "[INFO] Installing mpv plugin artifacts"
|
|
||||||
@install -d "$(MPV_SCRIPTS_DIR)"
|
|
||||||
@rm -f "$(MPV_SCRIPTS_DIR)/subminer.lua" "$(MPV_SCRIPTS_DIR)/subminer-loader.lua"
|
|
||||||
@install -d "$(MPV_SCRIPTS_DIR)/subminer"
|
|
||||||
@install -d "$(MPV_SCRIPT_OPTS_DIR)"
|
|
||||||
@cp -R ./plugin/subminer/. "$(MPV_SCRIPTS_DIR)/subminer/"
|
|
||||||
@install -m 0644 "./$(PLUGIN_CONF)" "$(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
|
|
||||||
@if [ "$(PLATFORM)" = "windows" ]; then \
|
|
||||||
bun ./scripts/configure-plugin-binary-path.mjs "$(MPV_SCRIPT_OPTS_DIR)/subminer.conf" "$(CURDIR)" win32; \
|
|
||||||
fi
|
|
||||||
@printf '%s\n' "Installed to:" " $(MPV_SCRIPTS_DIR)/subminer/main.lua" " $(MPV_SCRIPTS_DIR)/subminer/" " $(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
|
|
||||||
|
|
||||||
uninstall:
|
uninstall:
|
||||||
@printf '%s\n' "[INFO] Detected platform: $(PLATFORM)"
|
@printf '%s\n' "[INFO] Detected platform: $(PLATFORM)"
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
# App-Managed mpv Runtime Plugin Plan
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Shift SubMiner’s default mpv integration from “install and maintain a plugin in the user’s mpv scripts directory” to “inject the bundled plugin at launch time when SubMiner controls mpv.” Existing user-installed plugins remain supported and take precedence. If SubMiner detects an installed plugin, it will not inject the bundled runtime plugin and will show/log a helpful message with the detected path.
|
||||||
|
|
||||||
|
This keeps existing users working, avoids double-loading, and removes the need to update user plugin files for new installs.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- New users should not need to install the mpv plugin manually for SubMiner-managed mpv launches.
|
||||||
|
- Existing users with an installed mpv plugin should keep working without behavior changes.
|
||||||
|
- SubMiner should avoid loading both the installed plugin and bundled runtime plugin in the same mpv session.
|
||||||
|
- SubMiner should tell users exactly where an installed plugin was detected so they can remove it if they want app-managed runtime loading.
|
||||||
|
- Windows app-managed mpv launch should support this.
|
||||||
|
- macOS/Linux launcher-managed mpv launch should support this.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Do not remove the current first-run plugin installer yet.
|
||||||
|
- Do not auto-delete, rename, or overwrite user-installed mpv plugin files.
|
||||||
|
- Do not force `--load-scripts=no` by default.
|
||||||
|
- Do not make the runtime plugin override an installed user plugin.
|
||||||
|
- Do not solve arbitrary already-running mpv sessions without IPC or prior SubMiner launch control.
|
||||||
|
|
||||||
|
## Runtime Policy
|
||||||
|
|
||||||
|
When SubMiner is about to launch mpv:
|
||||||
|
|
||||||
|
1. Detect whether a SubMiner plugin is already installed in mpv’s user scripts directory.
|
||||||
|
2. If an installed plugin is found:
|
||||||
|
- Do not pass `--script=<bundled plugin path>`.
|
||||||
|
- Continue passing SubMiner script options needed by the installed plugin.
|
||||||
|
- Show/log a warning with the detected installed plugin path and detected version if available.
|
||||||
|
- Defer to the installed plugin for that mpv session.
|
||||||
|
3. If no installed plugin is found:
|
||||||
|
- Pass `--script=<bundled runtime plugin path>`.
|
||||||
|
- Pass runtime script options for binary path, socket path, log level, and any existing launch metadata.
|
||||||
|
- Use the plugin bundled with the currently running SubMiner app.
|
||||||
|
|
||||||
|
## Plugin Detection
|
||||||
|
|
||||||
|
Add a shared detector module, likely under `src/shared/` or `src/core/services/`, usable by both Electron main runtime and launcher code.
|
||||||
|
|
||||||
|
Suggested type:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Detection candidates:
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
```text
|
||||||
|
%APPDATA%\mpv\scripts\subminer\main.lua
|
||||||
|
%APPDATA%\mpv\scripts\subminer.lua
|
||||||
|
%APPDATA%\mpv\scripts\subminer-loader.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
If an mpv executable path is known, also check:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<mpv.exe directory>\portable_config\scripts\subminer\main.lua
|
||||||
|
<mpv.exe directory>\portable_config\scripts\subminer.lua
|
||||||
|
<mpv.exe directory>\portable_config\scripts\subminer-loader.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
macOS/Linux:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$XDG_CONFIG_HOME/mpv/scripts/subminer/main.lua
|
||||||
|
$XDG_CONFIG_HOME/mpv/scripts/subminer.lua
|
||||||
|
$XDG_CONFIG_HOME/mpv/scripts/subminer-loader.lua
|
||||||
|
~/.config/mpv/scripts/subminer/main.lua
|
||||||
|
~/.config/mpv/scripts/subminer.lua
|
||||||
|
~/.config/mpv/scripts/subminer-loader.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Return the first existing candidate using the same precedence mpv would most likely use.
|
||||||
|
- Prefer `subminer/main.lua` over legacy single-file loaders within the same config root.
|
||||||
|
- Include the absolute detected path in the warning message.
|
||||||
|
- If multiple plugin candidates are found, log all candidates at debug level, but use the first one as the active detection result.
|
||||||
|
|
||||||
|
## Plugin Versioning
|
||||||
|
|
||||||
|
Add version metadata to the bundled Lua plugin.
|
||||||
|
|
||||||
|
New file:
|
||||||
|
|
||||||
|
```text
|
||||||
|
plugin/subminer/version.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
Contents shape:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
return {
|
||||||
|
name = "SubMiner mpv plugin",
|
||||||
|
version = "0.12.0",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `plugin/subminer/init.lua` or `bootstrap.lua` to load this version and expose it in logs.
|
||||||
|
|
||||||
|
Version detection should try:
|
||||||
|
|
||||||
|
1. Read sibling `version.lua` next to detected `main.lua`.
|
||||||
|
2. Parse a simple `version = "..."` value.
|
||||||
|
3. If unavailable, return `version: null`.
|
||||||
|
|
||||||
|
For legacy single-file installs, return `version: null` unless a parseable marker is present.
|
||||||
|
|
||||||
|
Important: old installed plugins will not have version metadata, so `null` is expected and should be messaged as “unknown or legacy version,” not treated as an error.
|
||||||
|
|
||||||
|
## Bundled Runtime Plugin Path Resolution
|
||||||
|
|
||||||
|
Add a helper for resolving the packaged plugin entrypoint/directory.
|
||||||
|
|
||||||
|
For Electron main process:
|
||||||
|
|
||||||
|
- Reuse `resolvePackagedFirstRunPluginAssets()` or extract a narrower helper from `src/main/runtime/first-run-setup-plugin.ts`.
|
||||||
|
- Preferred runtime script path should be the plugin directory if mpv supports directory script loading:
|
||||||
|
```text
|
||||||
|
<resourcesPath>/plugin/subminer
|
||||||
|
```
|
||||||
|
- Fallback:
|
||||||
|
```text
|
||||||
|
<resourcesPath>/plugin/subminer/main.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
For launcher:
|
||||||
|
|
||||||
|
- Accept a resolved runtime plugin path where possible.
|
||||||
|
- If only `appPath` is available, resolve relative packaged resources using existing app path conventions.
|
||||||
|
- If runtime plugin path cannot be resolved and no installed plugin exists, fail with a clear message telling the user the packaged mpv plugin assets were not found.
|
||||||
|
|
||||||
|
## Windows Implementation
|
||||||
|
|
||||||
|
Update `src/main/runtime/windows-mpv-launch.ts`.
|
||||||
|
|
||||||
|
Current behavior already supports `pluginEntrypointPath`.
|
||||||
|
|
||||||
|
Change launch preparation so the caller passes either:
|
||||||
|
|
||||||
|
- `pluginEntrypointPath` when no installed plugin is detected.
|
||||||
|
- `undefined` when an installed plugin is detected.
|
||||||
|
|
||||||
|
Add or update a dependency boundary so detection is testable:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
detectInstalledMpvPlugin?: () => InstalledMpvPluginDetection;
|
||||||
|
notifyInstalledPluginDetected?: (detection: InstalledMpvPluginDetection) => void;
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- Resolve `mpv.exe` first.
|
||||||
|
- Run installed plugin detection, including portable config checks when `mpv.exe` path is known.
|
||||||
|
- If installed plugin exists:
|
||||||
|
- show a non-fatal notification or warning dialog/log entry:
|
||||||
|
```text
|
||||||
|
SubMiner detected an installed mpv plugin at:
|
||||||
|
<path>
|
||||||
|
|
||||||
|
This mpv session will use the installed plugin. Remove it to use SubMiner's bundled runtime plugin automatically.
|
||||||
|
```
|
||||||
|
- launch without `--script=<runtime plugin>`.
|
||||||
|
- If no installed plugin exists:
|
||||||
|
- launch with `--script=<bundled runtime plugin path>`.
|
||||||
|
|
||||||
|
Keep current script options:
|
||||||
|
|
||||||
|
```text
|
||||||
|
subminer-binary_path=<process.execPath>
|
||||||
|
subminer-socket_path=<socket>
|
||||||
|
```
|
||||||
|
|
||||||
|
## macOS/Linux Launcher Implementation
|
||||||
|
|
||||||
|
Update `launcher/mpv.ts`.
|
||||||
|
|
||||||
|
Affected functions:
|
||||||
|
|
||||||
|
- `startMpv()`
|
||||||
|
- `launchMpvIdleDetached()`
|
||||||
|
|
||||||
|
Current launcher passes `--script-opts=...` but does not explicitly pass `--script=...`.
|
||||||
|
|
||||||
|
New behavior:
|
||||||
|
|
||||||
|
- Detect installed plugin before building mpv args.
|
||||||
|
- If installed plugin exists:
|
||||||
|
- do not add `--script=<bundled runtime plugin path>`.
|
||||||
|
- keep passing SubMiner script opts.
|
||||||
|
- log warning with detected path/version.
|
||||||
|
- If no installed plugin exists:
|
||||||
|
- add `--script=<bundled runtime plugin path>`.
|
||||||
|
- keep passing SubMiner script opts.
|
||||||
|
|
||||||
|
Prefer repeated `--script-opt=key=value` in new code where practical, but do not require a full conversion if it risks broad parser churn. If keeping the existing `--script-opts=...` helper, preserve current escaping behavior.
|
||||||
|
|
||||||
|
## User Messaging
|
||||||
|
|
||||||
|
Add user-facing copy in two places:
|
||||||
|
|
||||||
|
1. Runtime warning/log when launching mpv and installed plugin is detected.
|
||||||
|
2. First-run/setup UI copy update, if applicable, to explain that plugin installation is now optional for normal SubMiner-managed launch.
|
||||||
|
|
||||||
|
Suggested warning:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SubMiner detected an installed mpv plugin at:
|
||||||
|
<path>
|
||||||
|
|
||||||
|
This session will use the installed plugin. Remove that plugin to use the bundled runtime plugin that ships with this SubMiner version.
|
||||||
|
```
|
||||||
|
|
||||||
|
If version is known:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Detected plugin version: <version>
|
||||||
|
Bundled plugin version: <version>
|
||||||
|
```
|
||||||
|
|
||||||
|
If version is unknown:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Detected plugin version: unknown or legacy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compatibility Behavior
|
||||||
|
|
||||||
|
Existing installed plugin:
|
||||||
|
|
||||||
|
- Used as-is.
|
||||||
|
- Receives script options from the launch command.
|
||||||
|
- Not modified.
|
||||||
|
|
||||||
|
No installed plugin:
|
||||||
|
|
||||||
|
- Bundled runtime plugin is injected.
|
||||||
|
- No files are written to mpv config.
|
||||||
|
|
||||||
|
Old installed plugin with no version:
|
||||||
|
|
||||||
|
- Used as-is.
|
||||||
|
- Warning says version is unknown or legacy.
|
||||||
|
- User can remove it to switch to runtime injection.
|
||||||
|
|
||||||
|
Portable Windows mpv:
|
||||||
|
|
||||||
|
- If `mpv.exe` path is known and `portable_config` exists beside it, detect installed plugin there before checking `%APPDATA%`.
|
||||||
|
- If `mpv.exe` path is unknown, only `%APPDATA%` detection is possible.
|
||||||
|
|
||||||
|
## Public Interface / Type Changes
|
||||||
|
|
||||||
|
Add shared detection exports:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface InstalledMpvPluginDetection {
|
||||||
|
installed: boolean;
|
||||||
|
path: string | null;
|
||||||
|
version: string | null;
|
||||||
|
source: InstalledMpvPluginSource | null;
|
||||||
|
message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectInstalledMpvPlugin(options: {
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
homeDir: string;
|
||||||
|
xdgConfigHome?: string;
|
||||||
|
appDataDir?: string;
|
||||||
|
mpvExecutablePath?: string;
|
||||||
|
existsSync?: (path: string) => boolean;
|
||||||
|
readFileSync?: (path: string, encoding: BufferEncoding) => string;
|
||||||
|
}): InstalledMpvPluginDetection;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add runtime plugin path helper:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function resolvePackagedRuntimePluginPath(options: {
|
||||||
|
dirname: string;
|
||||||
|
appPath: string;
|
||||||
|
resourcesPath: string;
|
||||||
|
existsSync?: (path: string) => boolean;
|
||||||
|
joinPath?: (...parts: string[]) => string;
|
||||||
|
}): string | null;
|
||||||
|
```
|
||||||
|
|
||||||
|
Lua plugin version module:
|
||||||
|
|
||||||
|
```text
|
||||||
|
plugin/subminer/version.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Add detector unit tests covering:
|
||||||
|
|
||||||
|
- Windows `%APPDATA%\mpv\scripts\subminer\main.lua`.
|
||||||
|
- Windows legacy `subminer.lua`.
|
||||||
|
- Windows `portable_config` next to known `mpv.exe`.
|
||||||
|
- Windows portable config precedence over `%APPDATA%`.
|
||||||
|
- Linux `$XDG_CONFIG_HOME/mpv/scripts/subminer/main.lua`.
|
||||||
|
- Linux fallback to `~/.config/mpv/scripts/subminer/main.lua`.
|
||||||
|
- macOS `~/.config/mpv/scripts/subminer/main.lua`.
|
||||||
|
- No installed plugin.
|
||||||
|
- Version parsed from `version.lua`.
|
||||||
|
- Missing version returns `null`.
|
||||||
|
|
||||||
|
Update Windows launch tests:
|
||||||
|
|
||||||
|
- Installed plugin detected means no `--script=<runtime plugin>`.
|
||||||
|
- Installed plugin detected still includes `--script-opts` or equivalent script options.
|
||||||
|
- No installed plugin means `--script=<runtime plugin>`.
|
||||||
|
- Warning callback receives detected path.
|
||||||
|
|
||||||
|
Update launcher tests:
|
||||||
|
|
||||||
|
- `startMpv()` includes `--script=<runtime plugin>` when no installed plugin exists.
|
||||||
|
- `startMpv()` omits `--script=<runtime plugin>` when installed plugin exists.
|
||||||
|
- `launchMpvIdleDetached()` follows the same policy.
|
||||||
|
- Warning/log path includes detected installed plugin path.
|
||||||
|
|
||||||
|
Lua/plugin tests:
|
||||||
|
|
||||||
|
- Version module loads successfully.
|
||||||
|
- Bootstrap logs or exposes version without breaking existing plugin startup.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Minimum targeted verification:
|
||||||
|
|
||||||
|
```text
|
||||||
|
bun test src/shared/<new-detector>.test.ts
|
||||||
|
bun test src/main/runtime/windows-mpv-launch.test.ts
|
||||||
|
bun test launcher/mpv.test.ts launcher/main.test.ts
|
||||||
|
lua scripts/test-plugin-lua-compat.lua
|
||||||
|
bun run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
Broader handoff verification if implementation touches setup or launch wiring substantially:
|
||||||
|
|
||||||
|
```text
|
||||||
|
bun run test:launcher
|
||||||
|
bun run test:fast
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rollout Notes
|
||||||
|
|
||||||
|
- Keep first-run plugin install available for one release as an optional compatibility path.
|
||||||
|
- Update docs/release notes to explain:
|
||||||
|
- SubMiner-managed mpv launch no longer requires plugin installation.
|
||||||
|
- Existing installed plugin takes precedence.
|
||||||
|
- Remove the installed plugin to use the bundled runtime plugin.
|
||||||
|
- Add a `changes/*.md` fragment because this is user-visible behavior.
|
||||||
|
|
||||||
|
## Assumptions and Defaults
|
||||||
|
|
||||||
|
- Default behavior preserves user-installed plugins and does not override them.
|
||||||
|
- Runtime injection is only guaranteed when SubMiner launches mpv or has IPC access to an existing mpv.
|
||||||
|
- No automatic deletion or migration of installed plugin files.
|
||||||
|
- Windows portable mpv detection is implemented only when SubMiner knows the `mpv.exe` path.
|
||||||
|
- macOS/Linux use default mpv config paths unless future work adds custom config-dir discovery.
|
||||||
|
- Use the bundled plugin from `process.resourcesPath/plugin/subminer` as the runtime source of truth.
|
||||||
@@ -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` | Build platform package for detected OS |
|
||||||
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
|
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
|
||||||
| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) |
|
| `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 deps` | Install JS dependencies (root + stats + texthooker-ui) |
|
||||||
| `make pretty` | Run scoped Prettier formatting for maintained source/config files |
|
| `make pretty` | Run scoped Prettier formatting for maintained source/config files |
|
||||||
| `make generate-config` | Generate default config from centralized registry |
|
| `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)
|
# Download and install the subminer launcher (recommended)
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer
|
||||||
chmod +x ~/.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
|
### From Source
|
||||||
|
|
||||||
@@ -315,7 +321,7 @@ Download the latest Windows installer from [GitHub Releases](https://github.com/
|
|||||||
|
|
||||||
### Getting Started on Windows
|
### 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.
|
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:
|
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"
|
& "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
|
### 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.
|
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.
|
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
|
::: 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`.
|
See [MPV Plugin](/mpv-plugin) for the keybindings, script messages, and runtime configuration reference.
|
||||||
First-run setup also pins `binary_path` to the current app binary so mpv launches the same SubMiner build that installed the plugin.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Option 1: install from release assets bundle
|
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
|
||||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
|
||||||
mkdir -p ~/.config/SubMiner
|
|
||||||
cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
|
|
||||||
mkdir -p ~/.config/mpv/scripts/subminer
|
|
||||||
mkdir -p ~/.config/mpv/script-opts
|
|
||||||
cp -R /tmp/plugin/subminer/. ~/.config/mpv/scripts/subminer/
|
|
||||||
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
|
|
||||||
|
|
||||||
# Option 2: from source checkout
|
|
||||||
# make install-plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
See [MPV Plugin](/mpv-plugin) for the full configuration reference, keybindings, script messages, and binary auto-detection details.
|
|
||||||
|
|
||||||
## Anki Setup (Recommended)
|
## Anki Setup (Recommended)
|
||||||
|
|
||||||
|
|||||||
+5
-15
@@ -1,22 +1,12 @@
|
|||||||
# MPV Plugin
|
# 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
|
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.
|
||||||
# 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/
|
|
||||||
|
|
||||||
# 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:
|
mpv must have IPC enabled for SubMiner to connect:
|
||||||
|
|
||||||
@@ -67,7 +57,7 @@ Select an item by pressing its number.
|
|||||||
|
|
||||||
## Configuration
|
## 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
|
```ini
|
||||||
# Path to SubMiner binary. Leave empty for auto-detection.
|
# 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
|
### 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.
|
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.
|
After setup completes, the shortcut is the normal Windows playback entry point.
|
||||||
@@ -195,13 +195,14 @@ SubMiner.AppImage --setup
|
|||||||
Setup flow:
|
Setup flow:
|
||||||
|
|
||||||
- config file: create the default config directory and prefer `config.jsonc`
|
- config file: create the default config directory and prefer `config.jsonc`
|
||||||
- plugin status: install 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
|
- Yomitan shortcut: open bundled Yomitan settings directly from the setup window
|
||||||
- dictionary check: ensure at least one bundled Yomitan dictionary is available, unless an external Yomitan profile is configured
|
- dictionary check: ensure at least one bundled Yomitan dictionary is available, unless an external Yomitan profile is configured
|
||||||
- Windows: optionally create or remove `SubMiner mpv` Start Menu/Desktop shortcuts (`SubMiner.exe --launch-mpv`)
|
- Windows: optionally create or remove `SubMiner mpv` Start Menu/Desktop shortcuts (`SubMiner.exe --launch-mpv`)
|
||||||
- Windows: optionally set `mpv.executablePath` if `mpv.exe` is not on `PATH`
|
- Windows: optionally set `mpv.executablePath` if `mpv.exe` is not on `PATH`
|
||||||
- refresh: re-check plugin + dictionary state without restarting
|
- refresh: re-check plugin + dictionary state without restarting
|
||||||
- `Finish setup` stays disabled until the config, plugin, and dictionary gates are satisfied
|
- `Finish setup` stays disabled until the config and dictionary gates are satisfied
|
||||||
- finish action writes setup completion state and suppresses future auto-open prompts
|
- finish action writes setup completion state and suppresses future auto-open prompts
|
||||||
|
|
||||||
AniList character dictionary auto-sync (optional):
|
AniList character dictionary auto-sync (optional):
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { fail, log } from '../log.js';
|
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';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
interface MpvCommandDeps {
|
interface MpvCommandDeps {
|
||||||
@@ -8,6 +12,7 @@ interface MpvCommandDeps {
|
|||||||
socketPath: string,
|
socketPath: string,
|
||||||
appPath: string,
|
appPath: string,
|
||||||
args: LauncherCommandContext['args'],
|
args: LauncherCommandContext['args'],
|
||||||
|
runtimePluginPath?: string | null,
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +49,7 @@ export async function runMpvPostAppCommand(
|
|||||||
context: LauncherCommandContext,
|
context: LauncherCommandContext,
|
||||||
deps: MpvCommandDeps = defaultDeps,
|
deps: MpvCommandDeps = defaultDeps,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const { args, appPath, mpvSocketPath } = context;
|
const { args, appPath, scriptPath, mpvSocketPath } = context;
|
||||||
if (!args.mpvIdle) {
|
if (!args.mpvIdle) {
|
||||||
return false;
|
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.');
|
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);
|
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
fail(`MPV IPC socket not ready after idle launch: ${mpvSocketPath}`);
|
fail(`MPV IPC socket not ready after idle launch: ${mpvSocketPath}`);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
import { runPlaybackCommandWithDeps } from './playback-command.js';
|
import { runPlaybackCommandWithDeps } from './playback-command.js';
|
||||||
|
import { state } from '../mpv.js';
|
||||||
|
|
||||||
function createContext(): LauncherCommandContext {
|
function createContext(): LauncherCommandContext {
|
||||||
return {
|
return {
|
||||||
@@ -95,7 +97,7 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
|
|||||||
autoStartVisibleOverlay: false,
|
autoStartVisibleOverlay: false,
|
||||||
autoStartPauseUntilReady: false,
|
autoStartPauseUntilReady: false,
|
||||||
};
|
};
|
||||||
let receivedStartMpvOptions: Record<string, unknown> | null = null;
|
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
await runPlaybackCommandWithDeps(context, {
|
await runPlaybackCommandWithDeps(context, {
|
||||||
ensurePlaybackSetupReady: async () => {},
|
ensurePlaybackSetupReady: async () => {},
|
||||||
@@ -111,7 +113,9 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
|
|||||||
_preloadedSubtitles,
|
_preloadedSubtitles,
|
||||||
options,
|
options,
|
||||||
) => {
|
) => {
|
||||||
receivedStartMpvOptions = options ?? null;
|
if (options) {
|
||||||
|
receivedStartMpvOptions.push(options as Record<string, unknown>);
|
||||||
|
}
|
||||||
calls.push('startMpv');
|
calls.push('startMpv');
|
||||||
},
|
},
|
||||||
waitForUnixSocketReady: async () => true,
|
waitForUnixSocketReady: async () => true,
|
||||||
@@ -130,8 +134,63 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
|
|||||||
'startMpv',
|
'startMpv',
|
||||||
'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw --youtube-mode download',
|
'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw --youtube-mode download',
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(receivedStartMpvOptions, {
|
assert.equal(receivedStartMpvOptions[0]?.startPaused, true);
|
||||||
startPaused: true,
|
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
|
||||||
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 {
|
import {
|
||||||
cleanupPlaybackSession,
|
cleanupPlaybackSession,
|
||||||
launchAppCommandDetached,
|
launchAppCommandDetached,
|
||||||
|
markOverlayManagedByLauncher,
|
||||||
|
resolveLauncherRuntimePluginPath,
|
||||||
startMpv,
|
startMpv,
|
||||||
startOverlay,
|
startOverlay,
|
||||||
state,
|
state,
|
||||||
@@ -21,9 +23,8 @@ import {
|
|||||||
getDefaultConfigDir,
|
getDefaultConfigDir,
|
||||||
getSetupStatePath,
|
getSetupStatePath,
|
||||||
readSetupState,
|
readSetupState,
|
||||||
resolveDefaultMpvInstallPaths,
|
|
||||||
} from '../../src/shared/setup-state.js';
|
} from '../../src/shared/setup-state.js';
|
||||||
import { detectInstalledFirstRunPlugin } from '../../src/main/runtime/first-run-setup-plugin.js';
|
import { detectInstalledFirstRunPluginCandidates } from '../../src/main/runtime/first-run-setup-plugin.js';
|
||||||
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
||||||
|
|
||||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
@@ -107,14 +108,13 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
|||||||
const ready = await ensureLauncherSetupReady({
|
const ready = await ensureLauncherSetupReady({
|
||||||
readSetupState: () => readSetupState(statePath),
|
readSetupState: () => readSetupState(statePath),
|
||||||
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
|
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
|
||||||
isPluginInstalled: () => {
|
hasLegacyMpvPlugin: () =>
|
||||||
const installPaths = resolveDefaultMpvInstallPaths(
|
detectInstalledFirstRunPluginCandidates({
|
||||||
process.platform,
|
platform: process.platform,
|
||||||
os.homedir(),
|
homeDir: os.homedir(),
|
||||||
process.env.XDG_CONFIG_HOME,
|
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||||
);
|
appDataDir: process.env.APPDATA,
|
||||||
return detectInstalledFirstRunPlugin(installPaths);
|
}).length > 0,
|
||||||
},
|
|
||||||
launchSetupApp: () => {
|
launchSetupApp: () => {
|
||||||
const setupArgs = ['--background', '--setup'];
|
const setupArgs = ['--background', '--setup'];
|
||||||
if (args.logLevel) {
|
if (args.logLevel) {
|
||||||
@@ -237,6 +237,7 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
{
|
{
|
||||||
startPaused: shouldPauseUntilOverlayReady || isAppOwnedYoutubeFlow,
|
startPaused: shouldPauseUntilOverlayReady || isAppOwnedYoutubeFlow,
|
||||||
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
|
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
|
||||||
|
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -262,6 +263,7 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
} else if (pluginAutoStartEnabled) {
|
} else if (pluginAutoStartEnabled) {
|
||||||
|
markOverlayManagedByLauncher(appPath);
|
||||||
if (ready) {
|
if (ready) {
|
||||||
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
runAppCommandCaptureOutput,
|
runAppCommandCaptureOutput,
|
||||||
launchAppStartDetached,
|
launchAppStartDetached,
|
||||||
launchMpvIdleDetached,
|
launchMpvIdleDetached,
|
||||||
|
resolveLauncherRuntimePluginPath,
|
||||||
waitForUnixSocketReady,
|
waitForUnixSocketReady,
|
||||||
} from './mpv.js';
|
} from './mpv.js';
|
||||||
|
|
||||||
@@ -1014,7 +1015,12 @@ export async function runJellyfinPlayMenu(
|
|||||||
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 250);
|
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 250);
|
||||||
}
|
}
|
||||||
if (!mpvReady) {
|
if (!mpvReady) {
|
||||||
await launchMpvIdleDetached(mpvSocketPath, appPath, args);
|
await launchMpvIdleDetached(
|
||||||
|
mpvSocketPath,
|
||||||
|
appPath,
|
||||||
|
args,
|
||||||
|
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||||
|
);
|
||||||
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000);
|
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000);
|
||||||
}
|
}
|
||||||
log('debug', args.logLevel, `MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`);
|
log('debug', args.logLevel, `MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`);
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
launchTexthookerOnly,
|
launchTexthookerOnly,
|
||||||
parseMpvArgString,
|
parseMpvArgString,
|
||||||
runAppCommandCaptureOutput,
|
runAppCommandCaptureOutput,
|
||||||
|
resolveLauncherRuntimePluginPath,
|
||||||
|
resolveLauncherRuntimePluginPlan,
|
||||||
shouldResolveAniSkipMetadata,
|
shouldResolveAniSkipMetadata,
|
||||||
stopOverlay,
|
stopOverlay,
|
||||||
startOverlay,
|
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', () => {
|
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
|
||||||
const error = withProcessExitIntercept(() => {
|
const error = withProcessExitIntercept(() => {
|
||||||
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
|
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
|
||||||
|
|||||||
+216
-2
@@ -4,6 +4,10 @@ import os from 'node:os';
|
|||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import { spawn, spawnSync } from 'node:child_process';
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
|
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 type { LogLevel, Backend, Args, MpvTrack } from './types.js';
|
||||||
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
|
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
|
||||||
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.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_SOCKET_READY_TIMEOUT_MS = 900;
|
||||||
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700;
|
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[] {
|
export function parseMpvArgString(input: string): string[] {
|
||||||
const chars = input;
|
const chars = input;
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
@@ -226,6 +237,182 @@ export function makeTempDir(prefix: string): string {
|
|||||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
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(
|
export function detectBackend(
|
||||||
backend: Backend,
|
backend: Backend,
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
@@ -658,7 +845,11 @@ export async function startMpv(
|
|||||||
socketPath: string,
|
socketPath: string,
|
||||||
appPath: string,
|
appPath: string,
|
||||||
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
|
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
|
||||||
options?: { startPaused?: boolean; disableYoutubeSubtitleAutoLoad?: boolean },
|
options?: {
|
||||||
|
startPaused?: boolean;
|
||||||
|
disableYoutubeSubtitleAutoLoad?: boolean;
|
||||||
|
runtimePluginPath?: string | null;
|
||||||
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
|
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
|
||||||
fail(`Video file not found: ${target}`);
|
fail(`Video file not found: ${target}`);
|
||||||
@@ -672,6 +863,14 @@ export async function startMpv(
|
|||||||
|
|
||||||
const mpvArgs: string[] = [];
|
const mpvArgs: string[] = [];
|
||||||
mpvArgs.push(...buildConfiguredMpvDefaultArgs(args));
|
mpvArgs.push(...buildConfiguredMpvDefaultArgs(args));
|
||||||
|
appendRuntimePluginLaunchArgs(
|
||||||
|
mpvArgs,
|
||||||
|
resolveLauncherRuntimePluginPlan({
|
||||||
|
runtimePluginPath:
|
||||||
|
options?.runtimePluginPath ?? resolveLauncherRuntimePluginPath({ appPath }),
|
||||||
|
}),
|
||||||
|
args.logLevel,
|
||||||
|
);
|
||||||
if (targetKind === 'url' && isYoutubeTarget(target)) {
|
if (targetKind === 'url' && isYoutubeTarget(target)) {
|
||||||
log('info', args.logLevel, 'Applying URL playback options');
|
log('info', args.logLevel, 'Applying URL playback options');
|
||||||
mpvArgs.push('--ytdl=yes');
|
mpvArgs.push('--ytdl=yes');
|
||||||
@@ -811,7 +1010,7 @@ export async function startOverlay(
|
|||||||
env: buildAppEnv(),
|
env: buildAppEnv(),
|
||||||
});
|
});
|
||||||
attachAppProcessLogging(state.overlayProc);
|
attachAppProcessLogging(state.overlayProc);
|
||||||
state.overlayManagedByLauncher = true;
|
markOverlayManagedByLauncher(appPath);
|
||||||
|
|
||||||
const [socketReady] = await Promise.all([
|
const [socketReady] = await Promise.all([
|
||||||
waitForUnixSocketReady(socketPath, OVERLAY_START_SOCKET_READY_TIMEOUT_MS),
|
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 {
|
export function openUrlInDefaultBrowser(url: string, logLevel: LogLevel): void {
|
||||||
const target =
|
const target =
|
||||||
process.platform === 'darwin'
|
process.platform === 'darwin'
|
||||||
@@ -1236,6 +1442,7 @@ export function launchMpvIdleDetached(
|
|||||||
socketPath: string,
|
socketPath: string,
|
||||||
appPath: string,
|
appPath: string,
|
||||||
args: Args,
|
args: Args,
|
||||||
|
runtimePluginPath?: string | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return (async () => {
|
return (async () => {
|
||||||
await terminateTrackedDetachedMpv(args.logLevel);
|
await terminateTrackedDetachedMpv(args.logLevel);
|
||||||
@@ -1246,6 +1453,13 @@ export function launchMpvIdleDetached(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mpvArgs: string[] = buildConfiguredMpvDefaultArgs(args);
|
const mpvArgs: string[] = buildConfiguredMpvDefaultArgs(args);
|
||||||
|
appendRuntimePluginLaunchArgs(
|
||||||
|
mpvArgs,
|
||||||
|
resolveLauncherRuntimePluginPlan({
|
||||||
|
runtimePluginPath: runtimePluginPath ?? resolveLauncherRuntimePluginPath({ appPath }),
|
||||||
|
}),
|
||||||
|
args.logLevel,
|
||||||
|
);
|
||||||
if (args.mpvArgs) {
|
if (args.mpvArgs) {
|
||||||
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
|
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-16
@@ -116,34 +116,81 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf
|
|||||||
assert.deepEqual(calls, []);
|
assert.deepEqual(calls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ensureLauncherSetupReady bypasses setup gate when plugin is already installed', async () => {
|
test('ensureLauncherSetupReady waits for finish after legacy mpv plugin removal', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
let legacyPluginInstalled = true;
|
||||||
|
let reads = 0;
|
||||||
|
|
||||||
const ready = await ensureLauncherSetupReady({
|
const ready = await ensureLauncherSetupReady({
|
||||||
readSetupState: () => ({
|
readSetupState: () => {
|
||||||
version: 3,
|
reads += 1;
|
||||||
status: 'cancelled',
|
return {
|
||||||
completedAt: null,
|
version: 3,
|
||||||
completionSource: null,
|
status: 'completed',
|
||||||
yomitanSetupMode: null,
|
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:40:00.000Z',
|
||||||
lastSeenYomitanDictionaryCount: 0,
|
completionSource: 'user',
|
||||||
pluginInstallStatus: 'unknown',
|
yomitanSetupMode: null,
|
||||||
pluginInstallPathSummary: null,
|
lastSeenYomitanDictionaryCount: 0,
|
||||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
pluginInstallStatus: 'unknown',
|
||||||
windowsMpvShortcutLastStatus: 'unknown',
|
pluginInstallPathSummary: null,
|
||||||
}),
|
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||||
isPluginInstalled: () => true,
|
windowsMpvShortcutLastStatus: 'unknown',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
hasLegacyMpvPlugin: () => legacyPluginInstalled,
|
||||||
launchSetupApp: () => {
|
launchSetupApp: () => {
|
||||||
calls.push('launch');
|
calls.push('launch');
|
||||||
|
legacyPluginInstalled = false;
|
||||||
},
|
},
|
||||||
sleep: async () => undefined,
|
sleep: async () => undefined,
|
||||||
now: () => 0,
|
now: (() => {
|
||||||
|
let value = 0;
|
||||||
|
return () => (value += 100);
|
||||||
|
})(),
|
||||||
timeoutMs: 5_000,
|
timeoutMs: 5_000,
|
||||||
pollIntervalMs: 100,
|
pollIntervalMs: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(ready, true);
|
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 () => {
|
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
||||||
|
|||||||
+58
-8
@@ -32,31 +32,81 @@ export async function waitForSetupCompletion(deps: {
|
|||||||
return 'timeout';
|
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: {
|
export async function ensureLauncherSetupReady(deps: {
|
||||||
readSetupState: () => SetupState | null;
|
readSetupState: () => SetupState | null;
|
||||||
isExternalYomitanConfigured?: () => boolean;
|
isExternalYomitanConfigured?: () => boolean;
|
||||||
isPluginInstalled?: () => boolean;
|
hasLegacyMpvPlugin?: () => boolean;
|
||||||
launchSetupApp: () => void;
|
launchSetupApp: () => void;
|
||||||
sleep: (ms: number) => Promise<void>;
|
sleep: (ms: number) => Promise<void>;
|
||||||
now: () => number;
|
now: () => number;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
pollIntervalMs: number;
|
pollIntervalMs: number;
|
||||||
}): Promise<boolean> {
|
}): 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?.()) {
|
if (deps.isExternalYomitanConfigured?.()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (deps.isPluginInstalled?.()) {
|
const stateAfterLegacyPrompt = deps.readSetupState();
|
||||||
return true;
|
if (isSetupCompleted(stateAfterLegacyPrompt)) {
|
||||||
}
|
|
||||||
const initialState = deps.readSetupState();
|
|
||||||
if (isSetupCompleted(initialState)) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
deps.launchSetupApp();
|
launchSetupApp();
|
||||||
const result = await waitForSetupCompletion({
|
const result = await waitForSetupCompletion({
|
||||||
...deps,
|
...deps,
|
||||||
ignoreInitialCancelledState: initialState?.status === 'cancelled',
|
ignoreInitialCancelledState: stateAfterLegacyPrompt?.status === 'cancelled',
|
||||||
});
|
});
|
||||||
return result === 'completed';
|
return result === 'completed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ function M.init()
|
|||||||
local utils = require("mp.utils")
|
local utils = require("mp.utils")
|
||||||
|
|
||||||
local options_helper = require("options")
|
local options_helper = require("options")
|
||||||
|
local version = require("version")
|
||||||
local environment = require("environment").create({ mp = mp, utils = utils })
|
local environment = require("environment").create({ mp = mp, utils = utils })
|
||||||
local opts = options_helper.load(options_lib, environment.default_socket_path())
|
local opts = options_helper.load(options_lib, environment.default_socket_path())
|
||||||
local state = require("state").new()
|
local state = require("state").new()
|
||||||
@@ -78,7 +79,7 @@ function M.init()
|
|||||||
ctx.session_bindings.register_bindings()
|
ctx.session_bindings.register_bindings()
|
||||||
ctx.messages.register_script_messages()
|
ctx.messages.register_script_messages()
|
||||||
ctx.lifecycle.register_lifecycle_hooks()
|
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
|
end
|
||||||
|
|
||||||
return M
|
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,7 @@
|
|||||||
local MODULE_PATHS = {
|
local MODULE_PATHS = {
|
||||||
"plugin/subminer/hover.lua",
|
"plugin/subminer/hover.lua",
|
||||||
"plugin/subminer/environment.lua",
|
"plugin/subminer/environment.lua",
|
||||||
|
"plugin/subminer/version.lua",
|
||||||
}
|
}
|
||||||
|
|
||||||
local LEGACY_PARSER_CANDIDATES = {
|
local LEGACY_PARSER_CANDIDATES = {
|
||||||
|
|||||||
@@ -1025,6 +1025,46 @@ describe('stats server API routes', () => {
|
|||||||
assert.equal(res.status, 400);
|
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 () => {
|
it('POST /api/stats/anki/notesInfo resolves stale note ids through the configured alias resolver', async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const requests: unknown[] = [];
|
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 () => {
|
test('updateAnilistPostWatchProgress skips when progress already reached', async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
let call = 0;
|
let call = 0;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as childProcess from 'child_process';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { parseMediaInfo } from '../../../jimaku/utils';
|
import { parseMediaInfo } from '../../../jimaku/utils';
|
||||||
|
import type { AnilistRateLimiter } from './rate-limiter';
|
||||||
|
|
||||||
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||||
|
|
||||||
@@ -19,6 +20,10 @@ export interface AnilistPostWatchUpdateResult {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AnilistPostWatchUpdateOptions {
|
||||||
|
rateLimiter?: AnilistRateLimiter;
|
||||||
|
}
|
||||||
|
|
||||||
interface AnilistGraphQlError {
|
interface AnilistGraphQlError {
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
@@ -155,8 +160,10 @@ async function anilistGraphQl<T>(
|
|||||||
accessToken: string,
|
accessToken: string,
|
||||||
query: string,
|
query: string,
|
||||||
variables: Record<string, unknown>,
|
variables: Record<string, unknown>,
|
||||||
|
options: AnilistPostWatchUpdateOptions = {},
|
||||||
): Promise<AnilistGraphQlResponse<T>> {
|
): Promise<AnilistGraphQlResponse<T>> {
|
||||||
try {
|
try {
|
||||||
|
await options.rateLimiter?.acquire();
|
||||||
const response = await fetch(ANILIST_GRAPHQL_URL, {
|
const response = await fetch(ANILIST_GRAPHQL_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -166,6 +173,7 @@ async function anilistGraphQl<T>(
|
|||||||
body: JSON.stringify({ query, variables }),
|
body: JSON.stringify({ query, variables }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
options.rateLimiter?.recordResponse(response.headers);
|
||||||
const payload = (await response.json()) as AnilistGraphQlResponse<T>;
|
const payload = (await response.json()) as AnilistGraphQlResponse<T>;
|
||||||
return payload;
|
return payload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -269,6 +277,7 @@ export async function updateAnilistPostWatchProgress(
|
|||||||
accessToken: string,
|
accessToken: string,
|
||||||
title: string,
|
title: string,
|
||||||
episode: number,
|
episode: number,
|
||||||
|
options: AnilistPostWatchUpdateOptions = {},
|
||||||
): Promise<AnilistPostWatchUpdateResult> {
|
): Promise<AnilistPostWatchUpdateResult> {
|
||||||
const searchResponse = await anilistGraphQl<AnilistSearchData>(
|
const searchResponse = await anilistGraphQl<AnilistSearchData>(
|
||||||
accessToken,
|
accessToken,
|
||||||
@@ -288,6 +297,7 @@ export async function updateAnilistPostWatchProgress(
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
{ search: title },
|
{ search: title },
|
||||||
|
options,
|
||||||
);
|
);
|
||||||
const searchError = firstErrorMessage(searchResponse);
|
const searchError = firstErrorMessage(searchResponse);
|
||||||
if (searchError) {
|
if (searchError) {
|
||||||
@@ -317,6 +327,7 @@ export async function updateAnilistPostWatchProgress(
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
{ mediaId: picked.id },
|
{ mediaId: picked.id },
|
||||||
|
options,
|
||||||
);
|
);
|
||||||
const entryError = firstErrorMessage(entryResponse);
|
const entryError = firstErrorMessage(entryResponse);
|
||||||
if (entryError) {
|
if (entryError) {
|
||||||
@@ -345,6 +356,7 @@ export async function updateAnilistPostWatchProgress(
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
{ mediaId: picked.id, progress: episode },
|
{ mediaId: picked.id, progress: episode },
|
||||||
|
options,
|
||||||
);
|
);
|
||||||
const saveError = firstErrorMessage(saveResponse);
|
const saveError = firstErrorMessage(saveResponse);
|
||||||
if (saveError) {
|
if (saveError) {
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import path from 'node:path';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { createCoverArtFetcher, stripFilenameTags } from './cover-art-fetcher.js';
|
import { createCoverArtFetcher, stripFilenameTags } from './cover-art-fetcher.js';
|
||||||
import { Database } from '../immersion-tracker/sqlite.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 { getCoverArt } from '../immersion-tracker/query-library.js';
|
||||||
import { upsertCoverArt } from '../immersion-tracker/query-maintenance.js';
|
import { upsertCoverArt } from '../immersion-tracker/query-maintenance.js';
|
||||||
import { SOURCE_TYPE_LOCAL } from '../immersion-tracker/types.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 {
|
function createJsonResponse(payload: unknown): Response {
|
||||||
return new Response(JSON.stringify(payload), {
|
return new Response(JSON.stringify(payload), {
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { AnilistRateLimiter } from './rate-limiter';
|
import type { AnilistRateLimiter } from './rate-limiter';
|
||||||
import type { DatabaseSync } from '../immersion-tracker/sqlite';
|
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 {
|
import {
|
||||||
guessAnilistMediaInfo,
|
guessAnilistMediaInfo,
|
||||||
runGuessit,
|
runGuessit,
|
||||||
@@ -257,6 +262,30 @@ export function createCoverArtFetcher(
|
|||||||
logger: Logger,
|
logger: Logger,
|
||||||
options: CoverArtFetcherOptions = {},
|
options: CoverArtFetcherOptions = {},
|
||||||
): CoverArtFetcher {
|
): 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 = (
|
const resolveCanonicalTitle = (
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
videoId: number,
|
videoId: number,
|
||||||
@@ -317,6 +346,10 @@ export function createCoverArtFetcher(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (reuseAnimeCoverArt(db, videoId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
existing &&
|
existing &&
|
||||||
existing.coverUrl === null &&
|
existing.coverUrl === null &&
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
getPreferredNoteFieldValue,
|
getPreferredNoteFieldValue,
|
||||||
} from '../../anki-field-config.js';
|
} from '../../anki-field-config.js';
|
||||||
import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js';
|
import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js';
|
||||||
|
import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
|
||||||
|
|
||||||
type StatsServerNoteInfo = {
|
type StatsServerNoteInfo = {
|
||||||
noteId: number;
|
noteId: number;
|
||||||
@@ -255,6 +256,7 @@ export interface StatsServerConfig {
|
|||||||
knownWordCachePath?: string;
|
knownWordCachePath?: string;
|
||||||
mpvSocketPath?: string;
|
mpvSocketPath?: string;
|
||||||
ankiConnectConfig?: AnkiConnectConfig;
|
ankiConnectConfig?: AnkiConnectConfig;
|
||||||
|
anilistRateLimiter?: AnilistRateLimiter;
|
||||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||||
resolveAnkiNoteId?: (noteId: number) => number;
|
resolveAnkiNoteId?: (noteId: number) => number;
|
||||||
}
|
}
|
||||||
@@ -338,6 +340,7 @@ export function createStatsApp(
|
|||||||
knownWordCachePath?: string;
|
knownWordCachePath?: string;
|
||||||
mpvSocketPath?: string;
|
mpvSocketPath?: string;
|
||||||
ankiConnectConfig?: AnkiConnectConfig;
|
ankiConnectConfig?: AnkiConnectConfig;
|
||||||
|
anilistRateLimiter?: AnilistRateLimiter;
|
||||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||||
resolveAnkiNoteId?: (noteId: number) => number;
|
resolveAnkiNoteId?: (noteId: number) => number;
|
||||||
},
|
},
|
||||||
@@ -632,6 +635,7 @@ export function createStatsApp(
|
|||||||
const query = (c.req.query('q') ?? '').trim();
|
const query = (c.req.query('q') ?? '').trim();
|
||||||
if (!query) return c.json([]);
|
if (!query) return c.json([]);
|
||||||
try {
|
try {
|
||||||
|
await options?.anilistRateLimiter?.acquire();
|
||||||
const res = await fetch('https://graphql.anilist.co', {
|
const res = await fetch('https://graphql.anilist.co', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -652,6 +656,10 @@ export function createStatsApp(
|
|||||||
variables: { search: query },
|
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[] } } };
|
const json = (await res.json()) as { data?: { Page?: { media?: unknown[] } } };
|
||||||
return c.json(json.data?.Page?.media ?? []);
|
return c.json(json.data?.Page?.media ?? []);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1131,6 +1139,7 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
|
|||||||
knownWordCachePath: config.knownWordCachePath,
|
knownWordCachePath: config.knownWordCachePath,
|
||||||
mpvSocketPath: config.mpvSocketPath,
|
mpvSocketPath: config.mpvSocketPath,
|
||||||
ankiConnectConfig: config.ankiConnectConfig,
|
ankiConnectConfig: config.ankiConnectConfig,
|
||||||
|
anilistRateLimiter: config.anilistRateLimiter,
|
||||||
addYomitanNote: config.addYomitanNote,
|
addYomitanNote: config.addYomitanNote,
|
||||||
resolveAnkiNoteId: config.resolveAnkiNoteId,
|
resolveAnkiNoteId: config.resolveAnkiNoteId,
|
||||||
});
|
});
|
||||||
|
|||||||
+105
-9
@@ -1,6 +1,7 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { app, dialog } from 'electron';
|
import { app, dialog, shell } from 'electron';
|
||||||
import { printHelp } from './cli/help';
|
import { printHelp } from './cli/help';
|
||||||
import { loadRawConfigStrict } from './config/load';
|
import { loadRawConfigStrict } from './config/load';
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +19,12 @@ import {
|
|||||||
shouldHandleStatsDaemonCommandAtEntry,
|
shouldHandleStatsDaemonCommandAtEntry,
|
||||||
} from './main-entry-runtime';
|
} from './main-entry-runtime';
|
||||||
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
||||||
import { resolvePackagedFirstRunPluginAssets } from './main/runtime/first-run-setup-plugin';
|
import {
|
||||||
|
detectInstalledFirstRunPluginCandidates,
|
||||||
|
detectInstalledMpvPlugin,
|
||||||
|
removeLegacyMpvPluginCandidates,
|
||||||
|
resolvePackagedRuntimePluginPath,
|
||||||
|
} from './main/runtime/first-run-setup-plugin';
|
||||||
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
||||||
import { parseMpvLaunchMode } from './shared/mpv-launch-mode';
|
import { parseMpvLaunchMode } from './shared/mpv-launch-mode';
|
||||||
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
|
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
|
||||||
@@ -38,16 +44,105 @@ function applySanitizedEnv(sanitizedEnv: NodeJS.ProcessEnv): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined {
|
function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined {
|
||||||
const assets = resolvePackagedFirstRunPluginAssets({
|
return (
|
||||||
dirname: __dirname,
|
resolvePackagedRuntimePluginPath({
|
||||||
appPath: app.getAppPath(),
|
dirname: __dirname,
|
||||||
resourcesPath: process.resourcesPath,
|
appPath: app.getAppPath(),
|
||||||
|
resourcesPath: process.resourcesPath,
|
||||||
|
}) ?? undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInstalledWindowsMpvPluginMessage(pathValue: string, version: string | null): string {
|
||||||
|
return [
|
||||||
|
'SubMiner detected an installed mpv plugin at:',
|
||||||
|
pathValue,
|
||||||
|
'',
|
||||||
|
"This mpv session will use the installed plugin. Remove it to use SubMiner's bundled runtime plugin automatically.",
|
||||||
|
`Detected plugin version: ${version ?? 'unknown or legacy'}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptForWindowsLegacyMpvPluginRemoval(
|
||||||
|
mpvPath: string,
|
||||||
|
detection: { path: string | null; version: string | null },
|
||||||
|
): Promise<'removed' | 'continue' | 'cancel'> {
|
||||||
|
const response = await dialog.showMessageBox({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'SubMiner mpv plugin detected',
|
||||||
|
message: buildInstalledWindowsMpvPluginMessage(
|
||||||
|
detection.path ?? 'unknown path',
|
||||||
|
detection.version,
|
||||||
|
),
|
||||||
|
detail:
|
||||||
|
'Remove the legacy SubMiner mpv plugin files from mpv before launching this video? This moves the files to the OS trash. SubMiner-managed playback will then use the bundled runtime plugin.',
|
||||||
|
buttons: ['Remove legacy plugin', 'Continue with installed plugin', 'Cancel'],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 2,
|
||||||
});
|
});
|
||||||
if (!assets) {
|
|
||||||
return undefined;
|
if (response.response === 2) {
|
||||||
|
return 'cancel';
|
||||||
|
}
|
||||||
|
if (response.response === 1) {
|
||||||
|
return 'continue';
|
||||||
}
|
}
|
||||||
|
|
||||||
return path.join(assets.pluginDirSource, 'main.lua');
|
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): {
|
function readConfiguredWindowsMpvLaunch(configDir: string): {
|
||||||
@@ -117,6 +212,7 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
|
|||||||
resolveBundledWindowsMpvPluginEntrypoint(),
|
resolveBundledWindowsMpvPluginEntrypoint(),
|
||||||
configuredMpvLaunch.executablePath,
|
configuredMpvLaunch.executablePath,
|
||||||
configuredMpvLaunch.launchMode,
|
configuredMpvLaunch.launchMode,
|
||||||
|
createWindowsRuntimePluginPolicy(),
|
||||||
);
|
);
|
||||||
app.exit(result.ok ? 0 : 1);
|
app.exit(result.ok ? 0 : 1);
|
||||||
});
|
});
|
||||||
|
|||||||
+126
-14
@@ -382,7 +382,10 @@ import {
|
|||||||
} from './main/runtime/first-run-setup-window';
|
} from './main/runtime/first-run-setup-window';
|
||||||
import {
|
import {
|
||||||
detectInstalledFirstRunPlugin,
|
detectInstalledFirstRunPlugin,
|
||||||
installFirstRunPluginToDefaultLocation,
|
detectInstalledFirstRunPluginCandidates,
|
||||||
|
detectInstalledMpvPlugin,
|
||||||
|
removeLegacyMpvPluginCandidates,
|
||||||
|
resolvePackagedRuntimePluginPath,
|
||||||
syncInstalledFirstRunPluginBinaryPath,
|
syncInstalledFirstRunPluginBinaryPath,
|
||||||
} from './main/runtime/first-run-setup-plugin';
|
} from './main/runtime/first-run-setup-plugin';
|
||||||
import {
|
import {
|
||||||
@@ -1063,6 +1066,89 @@ const managedLocalSubtitleSelectionRuntime = createManagedLocalSubtitleSelection
|
|||||||
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||||
clearScheduled: (timer) => clearTimeout(timer),
|
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({
|
const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
directPlaybackFormat: YOUTUBE_DIRECT_PLAYBACK_FORMAT,
|
directPlaybackFormat: YOUTUBE_DIRECT_PLAYBACK_FORMAT,
|
||||||
@@ -1087,10 +1173,16 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
|
|||||||
showError: (title, content) => dialog.showErrorBox(title, content),
|
showError: (title, content) => dialog.showErrorBox(title, content),
|
||||||
}),
|
}),
|
||||||
[...args, `--log-file=${DEFAULT_MPV_LOG_PATH}`],
|
[...args, `--log-file=${DEFAULT_MPV_LOG_PATH}`],
|
||||||
undefined,
|
process.execPath,
|
||||||
undefined,
|
resolveBundledMpvRuntimePluginEntrypoint(),
|
||||||
getResolvedConfig().mpv.executablePath,
|
getResolvedConfig().mpv.executablePath,
|
||||||
getResolvedConfig().mpv.launchMode,
|
getResolvedConfig().mpv.launchMode,
|
||||||
|
{
|
||||||
|
detectInstalledMpvPlugin: detectWindowsInstalledMpvPlugin,
|
||||||
|
notifyInstalledPluginDetected: logInstalledMpvPluginDetected,
|
||||||
|
resolveInstalledPluginBeforeLaunch: (detection, mpvPath) =>
|
||||||
|
promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(mpvPath, detection),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
|
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
|
||||||
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
|
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
|
||||||
@@ -1127,6 +1219,16 @@ const firstRunSetupService = createFirstRunSetupService({
|
|||||||
isExternalYomitanConfigured: () =>
|
isExternalYomitanConfigured: () =>
|
||||||
getResolvedConfig().yomitan.externalProfilePath.trim().length > 0,
|
getResolvedConfig().yomitan.externalProfilePath.trim().length > 0,
|
||||||
detectPluginInstalled: () => {
|
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(
|
const installPaths = resolveDefaultMpvInstallPaths(
|
||||||
process.platform,
|
process.platform,
|
||||||
os.homedir(),
|
os.homedir(),
|
||||||
@@ -1134,15 +1236,18 @@ const firstRunSetupService = createFirstRunSetupService({
|
|||||||
);
|
);
|
||||||
return detectInstalledFirstRunPlugin(installPaths);
|
return detectInstalledFirstRunPlugin(installPaths);
|
||||||
},
|
},
|
||||||
installPlugin: async () =>
|
detectLegacyMpvPluginCandidates: () =>
|
||||||
installFirstRunPluginToDefaultLocation({
|
detectInstalledFirstRunPluginCandidates({
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
homeDir: os.homedir(),
|
homeDir: os.homedir(),
|
||||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||||
dirname: __dirname,
|
appDataDir: app.getPath('appData'),
|
||||||
appPath: app.getAppPath(),
|
mpvExecutablePath: getResolvedConfig().mpv.executablePath,
|
||||||
resourcesPath: process.resourcesPath,
|
}),
|
||||||
binaryPath: process.execPath,
|
removeLegacyMpvPlugins: (candidates) =>
|
||||||
|
removeLegacyMpvPluginCandidates({
|
||||||
|
candidates,
|
||||||
|
trashItem: (candidatePath) => shell.trashItem(candidatePath),
|
||||||
}),
|
}),
|
||||||
detectWindowsMpvShortcuts: () => {
|
detectWindowsMpvShortcuts: () => {
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
@@ -1309,8 +1414,9 @@ const buildMainSubsyncRuntimeMainDepsHandler = createBuildMainSubsyncRuntimeMain
|
|||||||
const immersionMediaRuntime = createImmersionMediaRuntime(
|
const immersionMediaRuntime = createImmersionMediaRuntime(
|
||||||
buildImmersionMediaRuntimeMainDepsHandler(),
|
buildImmersionMediaRuntimeMainDepsHandler(),
|
||||||
);
|
);
|
||||||
|
const anilistRateLimiter = createAnilistRateLimiter();
|
||||||
const statsCoverArtFetcher = createCoverArtFetcher(
|
const statsCoverArtFetcher = createCoverArtFetcher(
|
||||||
createAnilistRateLimiter(),
|
anilistRateLimiter,
|
||||||
createLogger('main:stats-cover-art'),
|
createLogger('main:stats-cover-art'),
|
||||||
);
|
);
|
||||||
const anilistStateRuntime = createAnilistStateRuntime(buildAnilistStateRuntimeMainDepsHandler());
|
const anilistStateRuntime = createAnilistStateRuntime(buildAnilistStateRuntimeMainDepsHandler());
|
||||||
@@ -2639,6 +2745,7 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
|||||||
externalYomitanConfigured: snapshot.externalYomitanConfigured,
|
externalYomitanConfigured: snapshot.externalYomitanConfigured,
|
||||||
pluginStatus: snapshot.pluginStatus,
|
pluginStatus: snapshot.pluginStatus,
|
||||||
pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
|
pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
|
||||||
|
legacyMpvPluginPaths: snapshot.legacyMpvPluginPaths,
|
||||||
mpvExecutablePath,
|
mpvExecutablePath,
|
||||||
mpvExecutablePathStatus: getConfiguredWindowsMpvPathStatus(mpvExecutablePath),
|
mpvExecutablePathStatus: getConfiguredWindowsMpvPathStatus(mpvExecutablePath),
|
||||||
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
|
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
|
||||||
@@ -2648,8 +2755,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
|||||||
buildSetupHtml: (model) => buildFirstRunSetupHtml(model),
|
buildSetupHtml: (model) => buildFirstRunSetupHtml(model),
|
||||||
parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl),
|
parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl),
|
||||||
handleAction: async (submission: FirstRunSetupSubmission) => {
|
handleAction: async (submission: FirstRunSetupSubmission) => {
|
||||||
if (submission.action === 'install-plugin') {
|
if (submission.action === 'remove-legacy-plugin') {
|
||||||
const snapshot = await firstRunSetupService.installMpvPlugin();
|
const snapshot = await firstRunSetupService.removeLegacyMpvPlugin();
|
||||||
firstRunSetupMessage = snapshot.message;
|
firstRunSetupMessage = snapshot.message;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2998,7 +3105,9 @@ const {
|
|||||||
},
|
},
|
||||||
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
|
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
|
||||||
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
|
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
|
||||||
updateAnilistPostWatchProgress(accessToken, title, episode),
|
updateAnilistPostWatchProgress(accessToken, title, episode, {
|
||||||
|
rateLimiter: anilistRateLimiter,
|
||||||
|
}),
|
||||||
markSuccess: (key) => {
|
markSuccess: (key) => {
|
||||||
anilistUpdateQueue.markSuccess(key);
|
anilistUpdateQueue.markSuccess(key);
|
||||||
},
|
},
|
||||||
@@ -3044,7 +3153,9 @@ const {
|
|||||||
},
|
},
|
||||||
refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(),
|
refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(),
|
||||||
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
|
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
|
||||||
updateAnilistPostWatchProgress(accessToken, title, episode),
|
updateAnilistPostWatchProgress(accessToken, title, episode, {
|
||||||
|
rateLimiter: anilistRateLimiter,
|
||||||
|
}),
|
||||||
rememberAttemptedUpdateKey: (key) => {
|
rememberAttemptedUpdateKey: (key) => {
|
||||||
rememberAnilistAttemptedUpdate(key);
|
rememberAnilistAttemptedUpdate(key);
|
||||||
},
|
},
|
||||||
@@ -3251,6 +3362,7 @@ const startLocalStatsServer = (): void => {
|
|||||||
knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||||
mpvSocketPath: appState.mpvSocketPath,
|
mpvSocketPath: appState.mpvSocketPath,
|
||||||
ankiConnectConfig: getResolvedConfig().ankiConnect,
|
ankiConnectConfig: getResolvedConfig().ankiConnect,
|
||||||
|
anilistRateLimiter,
|
||||||
resolveAnkiNoteId: (noteId: number) =>
|
resolveAnkiNoteId: (noteId: number) =>
|
||||||
appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
|
appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
|
||||||
addYomitanNote: async (word: string) => {
|
addYomitanNote: async (word: string) => {
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import {
|
import {
|
||||||
detectInstalledFirstRunPlugin,
|
detectInstalledFirstRunPlugin,
|
||||||
installFirstRunPluginToDefaultLocation,
|
detectInstalledFirstRunPluginCandidates,
|
||||||
|
detectInstalledMpvPlugin,
|
||||||
|
removeLegacyMpvPluginCandidates,
|
||||||
resolvePackagedFirstRunPluginAssets,
|
resolvePackagedFirstRunPluginAssets,
|
||||||
|
resolvePackagedRuntimePluginPath,
|
||||||
syncInstalledFirstRunPluginBinaryPath,
|
syncInstalledFirstRunPluginBinaryPath,
|
||||||
} from './first-run-setup-plugin';
|
} from './first-run-setup-plugin';
|
||||||
import { resolveDefaultMpvInstallPaths } from '../../shared/setup-state';
|
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) => {
|
withTempDir((root) => {
|
||||||
const resourcesPath = path.join(root, 'resources');
|
const resourcesPath = path.join(root, 'resources');
|
||||||
const pluginRoot = path.join(resourcesPath, 'plugin');
|
const pluginRoot = path.join(resourcesPath, 'plugin');
|
||||||
const homeDir = path.join(root, 'home');
|
const entrypoint = path.join(pluginRoot, 'subminer', 'main.lua');
|
||||||
const xdgConfigHome = path.join(root, 'xdg');
|
fs.mkdirSync(path.dirname(entrypoint), { recursive: true });
|
||||||
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
|
fs.writeFileSync(entrypoint, '-- plugin');
|
||||||
|
|
||||||
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
|
|
||||||
fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n');
|
fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n');
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(installPaths.pluginEntrypointPath), { recursive: true });
|
|
||||||
fs.mkdirSync(installPaths.pluginDir, { recursive: true });
|
|
||||||
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(installPaths.scriptsDir, 'subminer-loader.lua'), '-- old loader');
|
|
||||||
fs.writeFileSync(path.join(installPaths.pluginDir, 'old.lua'), '-- old plugin');
|
|
||||||
fs.writeFileSync(installPaths.pluginConfigPath, 'old=true\n');
|
|
||||||
|
|
||||||
const result = installFirstRunPluginToDefaultLocation({
|
|
||||||
platform: 'linux',
|
|
||||||
homeDir,
|
|
||||||
xdgConfigHome,
|
|
||||||
dirname: path.join(root, 'dist', 'main', 'runtime'),
|
|
||||||
appPath: path.join(root, 'app'),
|
|
||||||
resourcesPath,
|
|
||||||
binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.ok, true);
|
|
||||||
assert.equal(result.pluginInstallStatus, 'installed');
|
|
||||||
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
|
|
||||||
assert.equal(fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'), '-- packaged plugin');
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
|
resolvePackagedRuntimePluginPath({
|
||||||
'configured=true\nbinary_path=/Applications/SubMiner.app/Contents/MacOS/SubMiner\n',
|
dirname: path.join(root, 'dist', 'main', 'runtime'),
|
||||||
);
|
appPath: path.join(root, 'app'),
|
||||||
|
resourcesPath,
|
||||||
const scriptsDirEntries = fs.readdirSync(installPaths.scriptsDir);
|
}),
|
||||||
const scriptOptsEntries = fs.readdirSync(installPaths.scriptOptsDir);
|
entrypoint,
|
||||||
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',
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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', () => {
|
test('detectInstalledFirstRunPlugin requires main.lua in subminer directory', () => {
|
||||||
withTempDir((root) => {
|
withTempDir((root) => {
|
||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { resolveDefaultMpvInstallPaths, type MpvInstallPaths } from '../../shared/setup-state';
|
import { resolveDefaultMpvInstallPaths, type MpvInstallPaths } from '../../shared/setup-state';
|
||||||
import type { PluginInstallResult } from './first-run-setup-service';
|
|
||||||
|
|
||||||
function timestamp(): string {
|
export interface InstalledFirstRunPluginCandidate {
|
||||||
return new Date().toISOString().replaceAll(':', '-');
|
path: string;
|
||||||
|
kind: 'directory' | 'file';
|
||||||
}
|
}
|
||||||
|
|
||||||
function backupExistingPath(targetPath: string): void {
|
export type InstalledMpvPluginSource =
|
||||||
if (!fs.existsSync(targetPath)) return;
|
| 'default-config'
|
||||||
fs.renameSync(targetPath, `${targetPath}.bak.${timestamp()}`);
|
| '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 {
|
function rewriteInstalledWindowsPluginConfig(configPath: string): void {
|
||||||
@@ -89,6 +104,30 @@ export function resolvePackagedFirstRunPluginAssets(deps: {
|
|||||||
return null;
|
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(
|
export function detectInstalledFirstRunPlugin(
|
||||||
installPaths: MpvInstallPaths,
|
installPaths: MpvInstallPaths,
|
||||||
deps?: {
|
deps?: {
|
||||||
@@ -100,61 +139,203 @@ export function detectInstalledFirstRunPlugin(
|
|||||||
return existsSync(pluginEntrypointPath);
|
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;
|
platform: NodeJS.Platform;
|
||||||
homeDir: string;
|
homeDir: string;
|
||||||
xdgConfigHome?: string;
|
xdgConfigHome?: string;
|
||||||
dirname: string;
|
appDataDir?: string;
|
||||||
appPath: string;
|
mpvExecutablePath?: string;
|
||||||
resourcesPath: string;
|
}): MpvConfigRootCandidate[] {
|
||||||
binaryPath: string;
|
const platformPath = getPlatformPath(options.platform);
|
||||||
}): 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);
|
|
||||||
if (options.platform === 'win32') {
|
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 {
|
return {
|
||||||
ok: true,
|
installed: false,
|
||||||
pluginInstallStatus: 'installed',
|
path: null,
|
||||||
pluginInstallPathSummary: installPaths.mpvConfigDir,
|
version: null,
|
||||||
message: `Installed mpv plugin to ${installPaths.mpvConfigDir}.`,
|
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) => {
|
await withTempDir(async (root) => {
|
||||||
const configDir = path.join(root, 'SubMiner');
|
const configDir = path.join(root, 'SubMiner');
|
||||||
fs.mkdirSync(configDir, { recursive: true });
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||||
let dictionaryCount = 0;
|
let dictionaryCount = 0;
|
||||||
let pluginInstalled = false;
|
|
||||||
|
|
||||||
const service = createFirstRunSetupService({
|
const service = createFirstRunSetupService({
|
||||||
configDir,
|
configDir,
|
||||||
getYomitanDictionaryCount: async () => dictionaryCount,
|
getYomitanDictionaryCount: async () => dictionaryCount,
|
||||||
detectPluginInstalled: () => pluginInstalled,
|
detectPluginInstalled: () => false,
|
||||||
installPlugin: async () => ({
|
installPlugin: async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
pluginInstallStatus: 'installed',
|
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.state.status, 'incomplete');
|
||||||
assert.equal(initial.canFinish, false);
|
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;
|
dictionaryCount = 1;
|
||||||
const refreshed = await service.refreshStatus();
|
const refreshed = await service.refreshStatus();
|
||||||
assert.equal(refreshed.canFinish, true);
|
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) => {
|
await withTempDir(async (root) => {
|
||||||
const configDir = path.join(root, 'SubMiner');
|
const configDir = path.join(root, 'SubMiner');
|
||||||
fs.mkdirSync(configDir, { recursive: true });
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
@@ -340,12 +334,41 @@ test('setup service reopens when a completed setup no longer has the mpv plugin
|
|||||||
});
|
});
|
||||||
|
|
||||||
const snapshot = await service.ensureSetupStateInitialized();
|
const snapshot = await service.ensureSetupStateInitialized();
|
||||||
assert.equal(snapshot.state.status, 'incomplete');
|
assert.equal(snapshot.state.status, 'completed');
|
||||||
assert.equal(snapshot.canFinish, false);
|
assert.equal(snapshot.canFinish, true);
|
||||||
assert.equal(snapshot.pluginStatus, 'required');
|
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 () => {
|
test('setup service keeps completed when external-yomitan completion later has internal dictionaries available', async () => {
|
||||||
await withTempDir(async (root) => {
|
await withTempDir(async (root) => {
|
||||||
const configDir = path.join(root, 'SubMiner');
|
const configDir = path.join(root, 'SubMiner');
|
||||||
@@ -490,3 +513,86 @@ test('setup service persists Windows mpv shortcut preferences and status with on
|
|||||||
assert.deepEqual(stateChanges, ['installed']);
|
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,
|
type SetupState,
|
||||||
} from '../../shared/setup-state';
|
} from '../../shared/setup-state';
|
||||||
import type { CliArgs } from '../../cli/args';
|
import type { CliArgs } from '../../cli/args';
|
||||||
|
import type {
|
||||||
|
InstalledFirstRunPluginCandidate,
|
||||||
|
LegacyMpvPluginRemovalResult,
|
||||||
|
} from './first-run-setup-plugin';
|
||||||
|
|
||||||
export interface SetupWindowsMpvShortcutSnapshot {
|
export interface SetupWindowsMpvShortcutSnapshot {
|
||||||
supported: boolean;
|
supported: boolean;
|
||||||
@@ -29,6 +33,7 @@ export interface SetupStatusSnapshot {
|
|||||||
externalYomitanConfigured: boolean;
|
externalYomitanConfigured: boolean;
|
||||||
pluginStatus: 'installed' | 'required' | 'failed';
|
pluginStatus: 'installed' | 'required' | 'failed';
|
||||||
pluginInstallPathSummary: string | null;
|
pluginInstallPathSummary: string | null;
|
||||||
|
legacyMpvPluginPaths: string[];
|
||||||
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
|
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
|
||||||
message: string | null;
|
message: string | null;
|
||||||
state: SetupState;
|
state: SetupState;
|
||||||
@@ -48,7 +53,7 @@ export interface FirstRunSetupService {
|
|||||||
markSetupInProgress: () => Promise<SetupStatusSnapshot>;
|
markSetupInProgress: () => Promise<SetupStatusSnapshot>;
|
||||||
markSetupCancelled: () => Promise<SetupStatusSnapshot>;
|
markSetupCancelled: () => Promise<SetupStatusSnapshot>;
|
||||||
markSetupCompleted: () => Promise<SetupStatusSnapshot>;
|
markSetupCompleted: () => Promise<SetupStatusSnapshot>;
|
||||||
installMpvPlugin: () => Promise<SetupStatusSnapshot>;
|
removeLegacyMpvPlugin: () => Promise<SetupStatusSnapshot>;
|
||||||
configureWindowsMpvShortcuts: (preferences: {
|
configureWindowsMpvShortcuts: (preferences: {
|
||||||
startMenuEnabled: boolean;
|
startMenuEnabled: boolean;
|
||||||
desktopEnabled: boolean;
|
desktopEnabled: boolean;
|
||||||
@@ -176,9 +181,6 @@ export function getFirstRunSetupCompletionMessage(snapshot: {
|
|||||||
if (!snapshot.configReady) {
|
if (!snapshot.configReady) {
|
||||||
return 'Create or provide the config file before finishing setup.';
|
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) {
|
if (!snapshot.externalYomitanConfigured && snapshot.dictionaryCount < 1) {
|
||||||
return 'Install at least one Yomitan dictionary before finishing setup.';
|
return 'Install at least one Yomitan dictionary before finishing setup.';
|
||||||
}
|
}
|
||||||
@@ -219,7 +221,13 @@ export function createFirstRunSetupService(deps: {
|
|||||||
getYomitanDictionaryCount: () => Promise<number>;
|
getYomitanDictionaryCount: () => Promise<number>;
|
||||||
isExternalYomitanConfigured?: () => boolean;
|
isExternalYomitanConfigured?: () => boolean;
|
||||||
detectPluginInstalled: () => boolean | Promise<boolean>;
|
detectPluginInstalled: () => boolean | Promise<boolean>;
|
||||||
installPlugin: () => Promise<PluginInstallResult>;
|
detectLegacyMpvPluginCandidates?: () =>
|
||||||
|
| InstalledFirstRunPluginCandidate[]
|
||||||
|
| Promise<InstalledFirstRunPluginCandidate[]>;
|
||||||
|
installPlugin?: () => Promise<PluginInstallResult>;
|
||||||
|
removeLegacyMpvPlugins?: (
|
||||||
|
candidates: InstalledFirstRunPluginCandidate[],
|
||||||
|
) => Promise<LegacyMpvPluginRemovalResult>;
|
||||||
detectWindowsMpvShortcuts?: () =>
|
detectWindowsMpvShortcuts?: () =>
|
||||||
| { startMenuInstalled: boolean; desktopInstalled: boolean }
|
| { startMenuInstalled: boolean; desktopInstalled: boolean }
|
||||||
| Promise<{ startMenuInstalled: boolean; desktopInstalled: boolean }>;
|
| Promise<{ startMenuInstalled: boolean; desktopInstalled: boolean }>;
|
||||||
@@ -250,6 +258,7 @@ export function createFirstRunSetupService(deps: {
|
|||||||
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
|
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
|
||||||
});
|
});
|
||||||
const pluginInstalled = await deps.detectPluginInstalled();
|
const pluginInstalled = await deps.detectPluginInstalled();
|
||||||
|
const legacyMpvPluginCandidates = (await deps.detectLegacyMpvPluginCandidates?.()) ?? [];
|
||||||
const detectedWindowsMpvShortcuts = isWindows
|
const detectedWindowsMpvShortcuts = isWindows
|
||||||
? await deps.detectWindowsMpvShortcuts?.()
|
? await deps.detectWindowsMpvShortcuts?.()
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -264,16 +273,15 @@ export function createFirstRunSetupService(deps: {
|
|||||||
return {
|
return {
|
||||||
configReady,
|
configReady,
|
||||||
dictionaryCount,
|
dictionaryCount,
|
||||||
canFinish:
|
canFinish: isYomitanSetupSatisfied({
|
||||||
pluginInstalled &&
|
configReady,
|
||||||
isYomitanSetupSatisfied({
|
dictionaryCount,
|
||||||
configReady,
|
externalYomitanConfigured,
|
||||||
dictionaryCount,
|
}),
|
||||||
externalYomitanConfigured,
|
|
||||||
}),
|
|
||||||
externalYomitanConfigured,
|
externalYomitanConfigured,
|
||||||
pluginStatus: getPluginStatus(state, pluginInstalled),
|
pluginStatus: getPluginStatus(state, pluginInstalled),
|
||||||
pluginInstallPathSummary: state.pluginInstallPathSummary,
|
pluginInstallPathSummary: state.pluginInstallPathSummary,
|
||||||
|
legacyMpvPluginPaths: legacyMpvPluginCandidates.map((candidate) => candidate.path),
|
||||||
windowsMpvShortcuts: {
|
windowsMpvShortcuts: {
|
||||||
supported: isWindows,
|
supported: isWindows,
|
||||||
startMenuEnabled: effectiveWindowsMpvShortcutPreferences.startMenuEnabled,
|
startMenuEnabled: effectiveWindowsMpvShortcutPreferences.startMenuEnabled,
|
||||||
@@ -308,14 +316,11 @@ export function createFirstRunSetupService(deps: {
|
|||||||
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
|
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
|
||||||
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
|
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
|
||||||
});
|
});
|
||||||
const pluginInstalled = await deps.detectPluginInstalled();
|
const canFinish = isYomitanSetupSatisfied({
|
||||||
const canFinish =
|
configReady,
|
||||||
pluginInstalled &&
|
dictionaryCount,
|
||||||
isYomitanSetupSatisfied({
|
externalYomitanConfigured,
|
||||||
configReady,
|
});
|
||||||
dictionaryCount,
|
|
||||||
externalYomitanConfigured,
|
|
||||||
});
|
|
||||||
if (isSetupCompleted(state) && canFinish) {
|
if (isSetupCompleted(state) && canFinish) {
|
||||||
completed = true;
|
completed = true;
|
||||||
return refreshWithState(state);
|
return refreshWithState(state);
|
||||||
@@ -349,8 +354,20 @@ export function createFirstRunSetupService(deps: {
|
|||||||
markSetupInProgress: async () => {
|
markSetupInProgress: async () => {
|
||||||
const state = readState();
|
const state = readState();
|
||||||
if (state.status === 'completed') {
|
if (state.status === 'completed') {
|
||||||
completed = true;
|
const legacyMpvPluginCandidates = (await deps.detectLegacyMpvPluginCandidates?.()) ?? [];
|
||||||
return refreshWithState(state);
|
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' }));
|
return refreshWithState(writeState({ ...state, status: 'in_progress' }));
|
||||||
},
|
},
|
||||||
@@ -379,15 +396,34 @@ export function createFirstRunSetupService(deps: {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
installMpvPlugin: async () => {
|
removeLegacyMpvPlugin: async () => {
|
||||||
const result = await deps.installPlugin();
|
const candidates = (await deps.detectLegacyMpvPluginCandidates?.()) ?? [];
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return refreshWithState(readState(), 'No legacy mpv plugin files were found.');
|
||||||
|
}
|
||||||
|
if (!deps.removeLegacyMpvPlugins) {
|
||||||
|
return refreshWithState(
|
||||||
|
readState(),
|
||||||
|
'Legacy mpv plugin removal is unavailable in this runtime.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await deps.removeLegacyMpvPlugins(candidates);
|
||||||
|
if (result.ok) {
|
||||||
|
return refreshWithState(
|
||||||
|
readState(),
|
||||||
|
'Legacy mpv plugin removed. Regular mpv will no longer load SubMiner. SubMiner-managed playback will use the bundled runtime plugin.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedCount = result.removedPaths.length;
|
||||||
|
const removedText = `${removedCount} legacy mpv plugin path${removedCount === 1 ? '' : 's'}`;
|
||||||
|
const failedText = result.failedPaths
|
||||||
|
.map((failure) => `${failure.path} (${failure.message})`)
|
||||||
|
.join(', ');
|
||||||
return refreshWithState(
|
return refreshWithState(
|
||||||
writeState({
|
readState(),
|
||||||
...readState(),
|
`Removed ${removedText}, but failed to remove: ${failedText}. Delete the failed paths manually from mpv scripts.`,
|
||||||
pluginInstallStatus: result.pluginInstallStatus,
|
|
||||||
pluginInstallPathSummary: result.pluginInstallPathSummary,
|
|
||||||
}),
|
|
||||||
result.message,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
configureWindowsMpvShortcuts: async (preferences) => {
|
configureWindowsMpvShortcuts: async (preferences) => {
|
||||||
|
|||||||
@@ -30,8 +30,11 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.match(html, /SubMiner setup/);
|
assert.match(html, /SubMiner setup/);
|
||||||
assert.match(html, /Install mpv plugin/);
|
assert.doesNotMatch(html, /Install legacy mpv plugin/);
|
||||||
assert.match(html, /Required before SubMiner setup can finish/);
|
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, /Open Yomitan Settings/);
|
||||||
assert.match(html, /Finish setup/);
|
assert.match(html, /Finish setup/);
|
||||||
assert.match(html, /disabled/);
|
assert.match(html, /disabled/);
|
||||||
@@ -58,14 +61,49 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
|
|||||||
message: null,
|
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, /mpv executable path/);
|
||||||
assert.match(html, /Leave blank to auto-discover mpv\.exe from 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, /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(
|
assert.match(
|
||||||
html,
|
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', () => {
|
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'), {
|
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
|
||||||
action: 'refresh',
|
action: 'refresh',
|
||||||
});
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=remove-legacy-plugin'),
|
||||||
|
{
|
||||||
|
action: 'remove-legacy-plugin',
|
||||||
|
},
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'),
|
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'),
|
||||||
null,
|
null,
|
||||||
@@ -177,7 +221,7 @@ test('first-run setup window handler focuses existing window', () => {
|
|||||||
assert.deepEqual(calls, ['focus']);
|
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 calls: string[] = [];
|
||||||
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
|
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
|
||||||
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
|
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
|
||||||
@@ -188,13 +232,20 @@ test('first-run setup navigation handler prevents default and dispatches action'
|
|||||||
});
|
});
|
||||||
|
|
||||||
const prevented = handleNavigation({
|
const prevented = handleNavigation({
|
||||||
url: 'subminer://first-run-setup?action=install-plugin',
|
url: 'subminer://first-run-setup?action=refresh',
|
||||||
preventDefault: () => calls.push('preventDefault'),
|
preventDefault: () => calls.push('preventDefault'),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(prevented, true);
|
assert.equal(prevented, true);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
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', () => {
|
test('first-run setup navigation handler swallows stale custom-scheme actions', () => {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type FirstRunSetupWindowLike = FocusableWindowLike & {
|
|||||||
|
|
||||||
export type FirstRunSetupAction =
|
export type FirstRunSetupAction =
|
||||||
| 'configure-mpv-executable-path'
|
| 'configure-mpv-executable-path'
|
||||||
| 'install-plugin'
|
| 'remove-legacy-plugin'
|
||||||
| 'configure-windows-mpv-shortcuts'
|
| 'configure-windows-mpv-shortcuts'
|
||||||
| 'open-yomitan-settings'
|
| 'open-yomitan-settings'
|
||||||
| 'refresh'
|
| 'refresh'
|
||||||
@@ -38,6 +38,7 @@ export interface FirstRunSetupHtmlModel {
|
|||||||
externalYomitanConfigured: boolean;
|
externalYomitanConfigured: boolean;
|
||||||
pluginStatus: 'installed' | 'required' | 'failed';
|
pluginStatus: 'installed' | 'required' | 'failed';
|
||||||
pluginInstallPathSummary: string | null;
|
pluginInstallPathSummary: string | null;
|
||||||
|
legacyMpvPluginPaths?: string[];
|
||||||
mpvExecutablePath: string;
|
mpvExecutablePath: string;
|
||||||
mpvExecutablePathStatus: 'blank' | 'configured' | 'invalid';
|
mpvExecutablePathStatus: 'blank' | 'configured' | 'invalid';
|
||||||
windowsMpvShortcuts: {
|
windowsMpvShortcuts: {
|
||||||
@@ -64,20 +65,19 @@ function renderStatusBadge(value: string, tone: 'ready' | 'warn' | 'muted' | 'da
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||||
const pluginActionLabel =
|
const legacyMpvPluginPaths = model.legacyMpvPluginPaths ?? [];
|
||||||
model.pluginStatus === 'installed' ? 'Reinstall mpv plugin' : 'Install mpv plugin';
|
const finishButtonLabel =
|
||||||
|
legacyMpvPluginPaths.length > 0 && model.canFinish
|
||||||
|
? 'Continue without removing'
|
||||||
|
: 'Finish setup';
|
||||||
const pluginLabel =
|
const pluginLabel =
|
||||||
model.pluginStatus === 'installed'
|
legacyMpvPluginPaths.length > 0
|
||||||
? 'Installed'
|
? 'Legacy detected'
|
||||||
: model.pluginStatus === 'failed'
|
: model.pluginStatus === 'failed'
|
||||||
? 'Failed'
|
? 'Failed'
|
||||||
: 'Required';
|
: 'Ready';
|
||||||
const pluginTone =
|
const pluginTone =
|
||||||
model.pluginStatus === 'installed'
|
legacyMpvPluginPaths.length > 0 ? 'warn' : model.pluginStatus === 'failed' ? 'danger' : 'ready';
|
||||||
? 'ready'
|
|
||||||
: model.pluginStatus === 'failed'
|
|
||||||
? 'danger'
|
|
||||||
: 'warn';
|
|
||||||
const windowsShortcutLabel =
|
const windowsShortcutLabel =
|
||||||
model.windowsMpvShortcuts.status === 'installed'
|
model.windowsMpvShortcuts.status === 'installed'
|
||||||
? 'Installed'
|
? 'Installed'
|
||||||
@@ -159,6 +159,23 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
|||||||
</form>
|
</form>
|
||||||
</div>`
|
</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
|
const yomitanMeta = model.externalYomitanConfigured
|
||||||
? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.'
|
? '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.canFinish
|
||||||
? model.externalYomitanConfigured
|
? 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 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 unlocked once Yomitan reports at least one installed dictionary. SubMiner-managed mpv launches use the bundled runtime plugin.'
|
||||||
: 'Finish stays locked until the mpv plugin is installed and Yomitan reports at least one installed dictionary.';
|
: 'Finish stays locked until Yomitan reports at least one installed dictionary.';
|
||||||
|
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
@@ -307,6 +324,18 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid rgba(202, 211, 245, 0.12);
|
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 {
|
button:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
@@ -321,6 +350,13 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
.legacy-paths {
|
||||||
|
margin: 10px 0 12px;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -335,9 +371,9 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
|||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div>
|
<div>
|
||||||
<strong>mpv plugin</strong>
|
<strong>mpv runtime plugin</strong>
|
||||||
<div class="meta">${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}</div>
|
<div class="meta">${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}</div>
|
||||||
<div class="meta">Required before SubMiner setup can finish.</div>
|
<div class="meta">Managed mpv launches use the bundled runtime plugin.</div>
|
||||||
</div>
|
</div>
|
||||||
${renderStatusBadge(pluginLabel, pluginTone)}
|
${renderStatusBadge(pluginLabel, pluginTone)}
|
||||||
</div>
|
</div>
|
||||||
@@ -350,11 +386,11 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
|||||||
</div>
|
</div>
|
||||||
${mpvExecutablePathCard}
|
${mpvExecutablePathCard}
|
||||||
${windowsShortcutCard}
|
${windowsShortcutCard}
|
||||||
|
${legacyPluginCard}
|
||||||
<div class="actions">
|
<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 onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
|
||||||
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
|
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
|
||||||
<button class="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>
|
||||||
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
|
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
|
||||||
<div class="footer">${escapeHtml(footerMessage)}</div>
|
<div class="footer">${escapeHtml(footerMessage)}</div>
|
||||||
@@ -371,7 +407,7 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
|
|||||||
const action = parsed.searchParams.get('action');
|
const action = parsed.searchParams.get('action');
|
||||||
if (
|
if (
|
||||||
action !== 'configure-mpv-executable-path' &&
|
action !== 'configure-mpv-executable-path' &&
|
||||||
action !== 'install-plugin' &&
|
action !== 'remove-legacy-plugin' &&
|
||||||
action !== 'configure-windows-mpv-shortcuts' &&
|
action !== 'configure-windows-mpv-shortcuts' &&
|
||||||
action !== 'open-yomitan-settings' &&
|
action !== 'open-yomitan-settings' &&
|
||||||
action !== 'refresh' &&
|
action !== 'refresh' &&
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ test('createCreateFirstRunSetupWindowHandler builds first-run setup window', ()
|
|||||||
|
|
||||||
assert.deepEqual(createSetupWindow(), { id: 'first-run' });
|
assert.deepEqual(createSetupWindow(), { id: 'first-run' });
|
||||||
assert.deepEqual(options, {
|
assert.deepEqual(options, {
|
||||||
width: 480,
|
width: 560,
|
||||||
height: 460,
|
height: 640,
|
||||||
title: 'SubMiner Setup',
|
title: 'SubMiner Setup',
|
||||||
show: true,
|
show: true,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ export function createCreateFirstRunSetupWindowHandler<TWindow>(deps: {
|
|||||||
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
|
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
|
||||||
}) {
|
}) {
|
||||||
return createSetupWindowHandler(deps, {
|
return createSetupWindowHandler(deps, {
|
||||||
width: 480,
|
width: 560,
|
||||||
height: 460,
|
height: 640,
|
||||||
title: 'SubMiner Setup',
|
title: 'SubMiner Setup',
|
||||||
resizable: false,
|
resizable: false,
|
||||||
minimizable: 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 () => {
|
test('launchWindowsMpv reports spawn failures with path context', async () => {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const result = await launchWindowsMpv(
|
const result = await launchWindowsMpv(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|||||||
import { spawn, spawnSync } from 'node:child_process';
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
|
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
|
||||||
import type { MpvLaunchMode } from '../../types/config';
|
import type { MpvLaunchMode } from '../../types/config';
|
||||||
|
import type { InstalledMpvPluginDetection } from './first-run-setup-plugin';
|
||||||
|
|
||||||
export interface WindowsMpvLaunchDeps {
|
export interface WindowsMpvLaunchDeps {
|
||||||
getEnv: (name: string) => string | undefined;
|
getEnv: (name: string) => string | undefined;
|
||||||
@@ -13,6 +14,15 @@ export interface WindowsMpvLaunchDeps {
|
|||||||
|
|
||||||
export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid';
|
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 {
|
function normalizeCandidate(candidate: string | undefined): string {
|
||||||
return typeof candidate === 'string' ? candidate.trim() : '';
|
return typeof candidate === 'string' ? candidate.trim() : '';
|
||||||
}
|
}
|
||||||
@@ -100,10 +110,12 @@ export function buildWindowsMpvLaunchArgs(
|
|||||||
typeof pluginEntrypointPath === 'string' && pluginEntrypointPath.trim().length > 0
|
typeof pluginEntrypointPath === 'string' && pluginEntrypointPath.trim().length > 0
|
||||||
? `--script=${pluginEntrypointPath.trim()}`
|
? `--script=${pluginEntrypointPath.trim()}`
|
||||||
: null;
|
: 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, '\\,')}`]
|
? [`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, '\\,')}`);
|
scriptOptPairs.unshift(`subminer-binary_path=${binaryPath.trim().replace(/,/g, '\\,')}`);
|
||||||
}
|
}
|
||||||
const scriptOpts = scriptOptPairs.length > 0 ? `--script-opts=${scriptOptPairs.join(',')}` : null;
|
const scriptOpts = scriptOptPairs.length > 0 ? `--script-opts=${scriptOptPairs.join(',')}` : null;
|
||||||
@@ -136,6 +148,7 @@ export async function launchWindowsMpv(
|
|||||||
pluginEntrypointPath?: string,
|
pluginEntrypointPath?: string,
|
||||||
configuredMpvPath?: string,
|
configuredMpvPath?: string,
|
||||||
launchMode: MpvLaunchMode = 'normal',
|
launchMode: MpvLaunchMode = 'normal',
|
||||||
|
runtimePluginPolicy?: WindowsMpvRuntimePluginPolicy,
|
||||||
): Promise<{ ok: boolean; mpvPath: string }> {
|
): Promise<{ ok: boolean; mpvPath: string }> {
|
||||||
const normalizedConfiguredPath = normalizeCandidate(configuredMpvPath);
|
const normalizedConfiguredPath = normalizeCandidate(configuredMpvPath);
|
||||||
const mpvPath = resolveWindowsMpvPath(deps, normalizedConfiguredPath);
|
const mpvPath = resolveWindowsMpvPath(deps, normalizedConfiguredPath);
|
||||||
@@ -150,9 +163,36 @@ export async function launchWindowsMpv(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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(
|
await deps.spawnDetached(
|
||||||
mpvPath,
|
mpvPath,
|
||||||
buildWindowsMpvLaunchArgs(targets, extraArgs, binaryPath, pluginEntrypointPath, launchMode),
|
buildWindowsMpvLaunchArgs(
|
||||||
|
targets,
|
||||||
|
extraArgs,
|
||||||
|
binaryPath,
|
||||||
|
runtimePluginEntrypointPath,
|
||||||
|
launchMode,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return { ok: true, mpvPath };
|
return { ok: true, mpvPath };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -178,10 +178,12 @@ test('release workflow skips empty AUR sync commits', () => {
|
|||||||
assert.match(releaseWorkflow, /if git diff --quiet -- PKGBUILD \.SRCINFO; then/);
|
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(
|
assert.match(
|
||||||
makefile,
|
makefile,
|
||||||
/windows\) printf '%s\\n' "\[INFO\] Windows builds run via: bun run build:win" ;;/,
|
/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/);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user