Compare commits

..

42 Commits

Author SHA1 Message Date
sudacode e8f10fe8a9 fix: macOS visible-overlay blur no longer invokes Windows-only blur call
- Split win32/darwin branches in handleOverlayWindowBlurred so darwin visible blur returns early without calling onWindowsVisibleOverlayBlur
- Add regression test asserting Windows callback stays inactive on macOS visible overlay blur
- Close TASK-347
2026-05-12 02:50:05 -07:00
sudacode ca796bfe6a fix: macOS overlay z-order and Yomitan compound token known highlighting
- Release always-on-top when tracked mpv loses foreground on macOS
- Skip visible overlay blur restacking on macOS to avoid covering unrelated windows
- Prefer Yomitan internal parse tokens over fragmented scanner output for known-word decisions
- Add regression tests for both behaviors
2026-05-12 02:34:28 -07:00
sudacode 6bf905140c fix: address PR-57 CodeRabbit findings and CI failures
- use filtered word counts in media detail session token aggregation
- cancel fullscreen refresh burst on exit via updateLinuxMpvFullscreenOverlayRefreshBurst
- guard Hyprland JSON.parse in try/catch; exclude windowtitle from geometry events
- narrow focus suppression from :focus to :focus-visible
- apply JLPT lock selectors to word-name-match tokens (N1–N5)
2026-05-12 00:28:48 -07:00
sudacode 6e666d7ca5 fix: resolve media detail from sessions when lifetime summary is absent
- Change `getMediaDetail` JOIN to LEFT JOIN on `imm_lifetime_media` and fall back to aggregated session metrics when no lifetime row exists
- Add filter `AND (lm.video_id IS NOT NULL OR s.session_id IS NOT NULL)` to keep results valid
- Add regression test covering the session-visible / media-detail-missing mismatch
2026-05-11 23:52:56 -07:00
sudacode 27be0e6fd7 fix: address coderabbit subtitle follow-ups 2026-05-11 23:48:32 -07:00
sudacode eff33e2027 fix: keep macOS overlay interactive while mpv remains active
- Overlay no longer hides or becomes click-through during tracker refreshes when mpv is the focused window
- Preserve already-visible overlay when tracker is temporarily not ready but mpv target signal is active
- Add regression tests for active-mpv tracker refresh and transient tracker-not-ready paths
2026-05-11 02:35:05 -07:00
sudacode 47499eccff fix: map openCharacterDictionary session action to --open-character-dict
- Add missing Lua CLI dispatch entry for openCharacterDictionary
- Add regression test for Alt+Meta+A binding and CLI flag forwarding
2026-05-11 01:07:11 -07:00
sudacode 0b72fa108f fix: retain frequency rank for honorific prefix-noun tokens
- Add `shouldAllowHonorificPrefixNounFrequency` to exempt お/ご/御 + noun merged tokens from frequency exclusion
- Add regression test for `ご機嫌` asserting rank 5484 is preserved after MeCab enrichment and annotation
- Close TASK-341
2026-05-10 22:19:42 -07:00
sudacode 2b60c20711 fix: align Hyprland overlay windows to mpv and stop pinning them
- Force-apply exact Hyprland move/resize/setprop dispatches when bounds are provided
- Stop pinning overlay windows; toggle pin off when Hyprland reports pinned=true
- Compensate stats overlay outer placement for Electron/Wayland content insets
- Make stats overlay window and page opaque so mpv cannot show through transparent insets
- Constrain stats app to h-screen with internal scroll so content covers mpv from y=0
- Lock overlay/stats window titles against page-title-updated events
- Add regression coverage for placement dispatches, inset compensation, and CSS overlay mode
2026-05-10 22:19:42 -07:00
sudacode 8f43f8825d fix: restore subtitle playback keybindings 2026-05-10 22:19:42 -07:00
sudacode 5396b08972 fix: align Hyprland fullscreen overlays 2026-05-10 22:19:42 -07:00
sudacode 934a7281b0 fix: hide overlay focus ring 2026-05-10 22:19:42 -07:00
sudacode 4497d0a39f fix: retry transient AniList safeStorage failures 2026-05-10 22:19:42 -07:00
sudacode 2d1e51e7e1 fix: suppress known highlights for subtitle particles 2026-05-10 22:19:42 -07:00
sudacode c97888f811 fix: stop AniList setup reopening on Linux when keyring token exists
- Gate setup success on token persistence: `saveToken` now returns `boolean`; on failure, keeps the setup window open instead of reporting success
- Config reload passes `allowSetupPrompt: false` so playback reloads don't re-open the setup window
- Add regression test for persistence-failure path
2026-05-10 22:19:42 -07:00
sudacode 77f5a48f5d fix: address PR #57 CodeRabbit feedback
- Acquire AniList post-watch in-flight lock before async gating to prevent duplicate writes
- Isolate manual watched mark result from AniList post-watch callback failures
- Report known-word cache clears as mutations during immediate append when state existed
- Add regression tests for each fix
2026-05-10 22:19:42 -07:00
sudacode f2fb9fa1b9 fix: preserve known highlighting for filtered tokens 2026-05-10 22:19:42 -07:00
sudacode 3284c40ab5 fix: preserve ordinal frequency annotations 2026-05-10 22:19:42 -07:00
sudacode 4bd8fc3db4 fix: sync AniList after seeked completion 2026-05-10 22:19:42 -07:00
sudacode 837f21b346 fix: address coderabbit feedback 2026-05-10 22:19:42 -07:00
sudacode 12e1e783c9 Fix JLPT underline color drift and AniList skipped-threshold sync
- Replace JLPT `text-decoration` underlines with `border-bottom` so Chromium selection/hover cannot repaint them to another annotation's color
- Lock JLPT underline color for combined annotation selectors (known, n+1, frequency) and character hover/selection states
- Trigger AniList post-watch check on every mpv time-position update to catch skipped completion thresholds
- Fall back to filename-parser season/episode when guessit omits them
2026-05-10 22:19:42 -07:00
sudacode 42576d99b1 fix: keep subtitle prefetch alive after cache hits 2026-05-10 22:19:42 -07:00
sudacode a2fd3cd194 fix: restore stats daemon deferral 2026-05-10 22:19:42 -07:00
sudacode 805b68dd92 Preserve overlay across macOS flaps and mpv playlist changes
- keep visible overlays alive during transient macOS tracker loss
- reuse the running mpv overlay path on playlist navigation
- update regression coverage and changelog fragments
2026-05-10 22:19:42 -07:00
sudacode dacae39544 fix: CI changelog, annotation options threading, and Jellyfin quit
- Add `type: fixed` / `area:` frontmatter to `changes/319` to pass `changelog:lint`
- Thread `TokenizerAnnotationOptions` through `stripSubtitleAnnotationMetadata` so `sourceText` is honored
- Include `jellyfinPlay` in `shouldQuitOnDisconnectWhenOverlayRuntimeInitialized` predicate
- Make mouse test `elementFromPoint` stubs coordinate-sensitive
- Make Lua test `.tmp` mkdir portable on Windows
2026-05-10 22:19:42 -07:00
sudacode 41b2c7eccf Replace grammar-ending permutations with shared matcher; preserve word a
- Extract `grammar-ending.ts` with `isStandaloneGrammarEndingText` / `isSubtitleGrammarEndingText` pattern matchers
- Replace `STANDALONE_GRAMMAR_ENDINGS` set in parser-selection-stage with shared matcher
- Replace generated phrase sets in subtitle-annotation-filter with shared matcher
- Remove stale duplicate subtitle-exclusion constants and helpers from annotation-stage
- Manual clipboard card updates now write only to the sentence audio field, leaving word/expression audio untouched
2026-05-10 22:19:42 -07:00
sudacode 9c8784672c fix: preserve jlpt underline color after lookup 2026-05-10 22:19:42 -07:00
sudacode cb1650d366 fix: suppress sigh interjection annotations 2026-05-10 22:19:42 -07:00
sudacode f17255c8e2 fix: refresh current subtitle after known-word mining 2026-05-10 22:19:42 -07:00
sudacode 1c1f498f9e Fix managed playback exit and tokenizer grammar splits
- Ignore background stats daemons during regular app startup
- Split standalone grammar endings before applying annotations
- Clear helper-span annotations for auxiliary-only tokens
2026-05-10 22:19:42 -07:00
sudacode 939a0e650e Fix kana-only N+1 tokenizer regression test
- Use a pure-kana fixture for the subtitle token N+1 case
- Update task notes for the latest CodeRabbit follow-up
2026-05-10 22:19:42 -07:00
sudacode 166cdb06ec Suppress subtitle annotations for grammar fragments
- Hide annotation metadata for auxiliary inflection and ja-nai endings
- Preserve lexical `くれる` forms and add regression coverage
2026-05-10 22:19:42 -07:00
sudacode a69e5ecfdf fix: suppress N+1 for kana-only candidates and fix minSentenceWords coun
- Treat kana-only tokens with surrounding subtitle punctuation (…, ―, etc.) as kana-only so they are not promoted to N+1 targets
- Exclude unknown tokens filtered from N+1 targeting from the minSentenceWords count so filtered kana-only unknowns cannot satisfy sentence length threshold
- Add regression tests for kana-only candidate suppression and filtered-unknown padding cases
2026-05-10 22:19:42 -07:00
sudacode d991499dda Cancel pending Linux MPV fullscreen overlay refresh bursts
- return a cancel handle from the Linux refresh burst scheduler
- clear pending refresh bursts when overlays hide or windows close
- tighten the burst test polling to wait for the async refresh
2026-05-10 22:19:42 -07:00
sudacode 6053a1b6ac fix: accept modified digits for multi-line sentence mining 2026-05-10 22:19:42 -07:00
sudacode 5cc5df4b18 fix: address CodeRabbit review comments 2026-05-10 22:19:42 -07:00
sudacode d92a2072eb fix: address fullscreen and n-plus-one review notes 2026-05-10 22:19:42 -07:00
sudacode 2bb7be3552 fix: refresh overlay on Hyprland fullscreen 2026-05-10 22:19:42 -07:00
sudacode 0855a7dfcc fix: exclude kana-only n+1 targets 2026-05-10 22:19:42 -07:00
sudacode 077c852a08 fix: restore jlpt subtitle underlines 2026-05-10 22:19:42 -07:00
sudacode 09e10c18d2 fix(tokenizer): preserve annotation and enrichment behavior 2026-05-10 22:19:42 -07:00
sudacode 8b26559203 feat(tokenizer): use Yomitan word classes for subtitle POS filtering
- Carry matched headword wordClasses from termsFind into YomitanScanToken
- Map recognized Yomitan wordClasses to SubMiner coarse POS before annotation
- MeCab enrichment now fills only missing POS fields, preserving existing coarse pos1
- Exclude standalone grammar particles, して helper fragments, and single-kana surfaces from annotations
- Respect source-text punctuation gaps when counting N+1 sentence words
- Preserve known-word highlight on excluded kanji-containing tokens
- Add backlog tasks 304 (N+1 boundary bug) and 305 (wordClasses POS, done)
2026-05-10 22:19:42 -07:00
43 changed files with 478 additions and 2113 deletions
+21 -12
View File
@@ -1,9 +1,10 @@
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows install-plugin uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop
APP_NAME := subminer
THEME_SOURCE := assets/themes/subminer.rasi
LAUNCHER_OUT := dist/launcher/$(APP_NAME)
THEME_FILE := subminer.rasi
PLUGIN_CONF := plugin/subminer.conf
# Default install prefix for the wrapper script.
PREFIX ?= $(HOME)/.local
@@ -63,7 +64,8 @@ help:
" dev-stop Stop a running local Electron app" \
" install-linux Install Linux wrapper/theme/app artifacts" \
" install-macos Install macOS wrapper/theme/app artifacts" \
" install-windows Print Windows packaging/install guidance" \
" install-windows Install Windows mpv plugin artifacts" \
" install-plugin Install mpv Lua plugin and plugin config" \
" generate-config Generate ~/.config/SubMiner/config.jsonc from centralized defaults" \
"" \
"Other targets:" \
@@ -198,8 +200,6 @@ install-linux: build-launcher
@install -m 0755 "$(LAUNCHER_OUT)" "$(BINDIR)/$(APP_NAME)"
@install -d "$(LINUX_DATA_DIR)/themes"
@install -m 0644 "./$(THEME_SOURCE)" "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
@install -d "$(LINUX_DATA_DIR)/plugin/subminer"
@cp -R ./plugin/subminer/. "$(LINUX_DATA_DIR)/plugin/subminer/"
@if [ -n "$(APPIMAGE_SRC)" ]; then \
install -m 0755 "$(APPIMAGE_SRC)" "$(BINDIR)/SubMiner.AppImage"; \
else \
@@ -214,8 +214,6 @@ install-macos: build-launcher
@install -m 0755 "$(LAUNCHER_OUT)" "$(BINDIR)/$(APP_NAME)"
@install -d "$(MACOS_DATA_DIR)/themes"
@install -m 0644 "./$(THEME_SOURCE)" "$(MACOS_DATA_DIR)/themes/$(THEME_FILE)"
@install -d "$(MACOS_DATA_DIR)/plugin/subminer"
@cp -R ./plugin/subminer/. "$(MACOS_DATA_DIR)/plugin/subminer/"
@install -d "$(MACOS_APP_DIR)"
@if [ -n "$(MACOS_APP_SRC)" ]; then \
rm -rf "$(MACOS_APP_DEST)"; \
@@ -232,8 +230,21 @@ install-macos: build-launcher
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_APP_DEST)"
install-windows:
@printf '%s\n' "[INFO] Windows builds run via: bun run build:win"
@printf '%s\n' "[INFO] SubMiner-managed mpv launches inject the bundled runtime plugin; no global mpv plugin install is needed."
@printf '%s\n' "[INFO] Installing Windows mpv plugin artifacts"
@$(MAKE) --no-print-directory install-plugin
install-plugin:
@printf '%s\n' "[INFO] Installing mpv plugin artifacts"
@install -d "$(MPV_SCRIPTS_DIR)"
@rm -f "$(MPV_SCRIPTS_DIR)/subminer.lua" "$(MPV_SCRIPTS_DIR)/subminer-loader.lua"
@install -d "$(MPV_SCRIPTS_DIR)/subminer"
@install -d "$(MPV_SCRIPT_OPTS_DIR)"
@cp -R ./plugin/subminer/. "$(MPV_SCRIPTS_DIR)/subminer/"
@install -m 0644 "./$(PLUGIN_CONF)" "$(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
@if [ "$(PLATFORM)" = "windows" ]; then \
bun ./scripts/configure-plugin-binary-path.mjs "$(MPV_SCRIPT_OPTS_DIR)/subminer.conf" "$(CURDIR)" win32; \
fi
@printf '%s\n' "Installed to:" " $(MPV_SCRIPTS_DIR)/subminer/main.lua" " $(MPV_SCRIPTS_DIR)/subminer/" " $(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
uninstall:
@printf '%s\n' "[INFO] Detected platform: $(PLATFORM)"
@@ -247,15 +258,13 @@ uninstall:
uninstall-linux:
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
@rm -f "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
@rm -rf "$(LINUX_DATA_DIR)/plugin/subminer"
@printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(BINDIR)/SubMiner.AppImage" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/plugin/subminer"
@printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(BINDIR)/SubMiner.AppImage" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
uninstall-macos:
@rm -f "$(BINDIR)/subminer"
@rm -f "$(MACOS_DATA_DIR)/themes/$(THEME_FILE)"
@rm -rf "$(MACOS_DATA_DIR)/plugin/subminer"
@rm -rf "$(MACOS_APP_DEST)"
@printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_DATA_DIR)/plugin/subminer" " $(MACOS_APP_DEST)"
@printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_APP_DEST)"
uninstall-windows:
@rm -rf "$(MPV_SCRIPTS_DIR)/subminer"
@@ -1,37 +0,0 @@
---
id: TASK-351
title: Remove legacy global mpv plugin from setup
status: Done
assignee: []
created_date: '2026-05-12 19:57'
updated_date: '2026-05-12 20:03'
labels:
- setup
- mpv-plugin
- launcher
- windows
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add first-run setup support for detecting all legacy SubMiner mpv plugin auto-load entries and removing them via the OS trash after user confirmation, so regular mpv stops loading SubMiner while SubMiner-managed playback can use runtime plugin loading.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Setup detects all SubMiner mpv auto-load candidates under normal mpv scripts directories and Windows portable_config scripts directories.
- [x] #2 Setup displays detected legacy plugin paths and offers a Remove legacy mpv plugin action.
- [x] #3 Removal uses Electron shell.trashItem for detected script files/directories and never permanently deletes as fallback.
- [x] #4 script-opts/subminer.conf is not removed by the legacy plugin removal action.
- [x] #5 Partial trash failures report exact failed paths and keep legacy plugin warning visible.
- [x] #6 Successful removal refreshes setup status and reports that regular mpv will no longer load SubMiner while SubMiner-managed playback keeps working.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented setup detection for all legacy SubMiner mpv auto-load candidates in normal and portable mpv script directories, added a confirmed Remove legacy mpv plugin action that uses Electron shell.trashItem only, preserves script-opts/subminer.conf, reports exact partial failures, and refreshes setup status after successful removal. Added focused tests plus changelog/docs updates.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -1,39 +0,0 @@
---
id: TASK-352
title: Inject bundled mpv plugin for managed launches
status: Done
assignee: []
created_date: '2026-05-12 20:06'
updated_date: '2026-05-12 20:15'
labels:
- launcher
- mpv-plugin
- windows
- setup
dependencies: []
references:
- app-managed-mpv-runtime-plugin-plan.md
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement app-managed mpv runtime plugin loading so SubMiner-managed launcher and Windows mpv shortcut launches do not require a globally installed mpv plugin, while installed legacy/global plugins are detected and take precedence until removed.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Launcher-managed mpv launch injects the bundled plugin when no installed SubMiner mpv plugin is detected.
- [x] #2 Launcher idle/Jellyfin mpv launch follows the same bundled-vs-installed plugin policy.
- [x] #3 Windows SubMiner mpv shortcut launch skips bundled injection when an installed plugin is detected and injects bundled plugin otherwise.
- [x] #4 First-run setup no longer requires global mpv plugin installation to finish; plugin install remains optional compatibility action.
- [x] #5 Runtime plugin path resolution is test-covered and reports a clear failure when no bundled plugin path is available and no installed plugin exists.
- [x] #6 Release docs/changelog explain that managed launches no longer require global plugin installation and legacy plugin removal switches to bundled runtime loading.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented app-managed mpv runtime plugin policy. Launcher-managed playback and idle/Jellyfin mpv startup now inject the bundled plugin when no global SubMiner plugin is detected, and keep using/logging the installed plugin when one is present. Windows SubMiner mpv shortcut launches use the same installed-vs-bundled policy while still passing SubMiner script opts. First-run setup no longer requires global plugin installation to finish, keeps legacy install as optional compatibility, and documents/removes legacy global plugin files via OS trash.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -1,37 +0,0 @@
---
id: TASK-353
title: Remove Makefile global mpv plugin installer
status: Done
assignee: []
created_date: '2026-05-12 14:07'
updated_date: '2026-05-12 14:07'
labels:
- launcher
- mpv-plugin
- docs
dependencies: []
references:
- app-managed-mpv-runtime-plugin-plan.md
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Remove the legacy Makefile path that installs SubMiner into mpv's global scripts directory, including the Windows config rewrite script hook, because managed playback now injects the bundled runtime plugin.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Makefile no longer exposes an `install-plugin` target or help entry.
- [x] #2 Windows install no longer delegates to global mpv plugin installation.
- [x] #3 The obsolete config rewrite bun script is removed when no longer referenced.
- [x] #4 Docs no longer tell users to run `make install-plugin`.
- [x] #5 Regression coverage prevents reintroducing the target or script hook.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Removed the legacy global mpv plugin install target from the Makefile, removed the obsolete Windows config rewrite script, updated docs to describe bundled runtime plugin loading instead of separate installation, and removed the setup window's legacy install action while keeping legacy plugin removal available.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -1,38 +0,0 @@
---
id: TASK-354
title: Show legacy mpv plugin removal before managed playback
status: Done
assignee: []
created_date: '2026-05-12 14:30'
updated_date: '2026-05-12 14:35'
labels:
- launcher
- mpv-plugin
- setup
- windows
dependencies: []
references:
- app-managed-mpv-runtime-plugin-plan.md
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
When SubMiner-managed playback detects a legacy global SubMiner mpv plugin, show the removal UI before mpv starts so users can optionally trash the legacy files and then launch with the bundled runtime plugin.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Launcher playback opens first-run setup before mpv starts when legacy global plugin files are detected, even if setup is already completed.
- [x] #2 Launcher playback resumes with bundled runtime plugin after the legacy plugin is removed.
- [x] #3 Windows mpv shortcut/app launch prompts before spawning mpv and re-detects after removal so bundled injection is used.
- [x] #4 Users can continue without removal; removal remains optional.
- [x] #5 Regression coverage prevents bypassing the removal prompt due to completed setup or installed-plugin detection.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Managed launcher playback now checks for legacy global SubMiner mpv plugin files before choosing/loading a video, opens first-run setup even when setup is already complete, and waits for removal or explicit user continuation before starting mpv. Windows managed mpv launches now show a pre-launch removal dialog, move detected legacy files to the OS trash on confirmation, re-detect, and inject the bundled runtime plugin after successful removal.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -1,38 +0,0 @@
---
id: TASK-355
title: Unify AniList API throttling across dictionary stats and tracking
status: In Progress
assignee: []
created_date: '2026-05-12 21:49'
updated_date: '2026-05-13 01:21'
labels:
- anilist
- rate-limit
- bug
dependencies: []
references:
- 'https://docs.anilist.co/guide/rate-limiting'
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Audit and fix AniList GraphQL usage so character dictionary generation, stats search/cover art, and post-watch tracking share conservative request pacing and honor AniList rate-limit response headers. Current logs do not show 429s, but source has separate/unthrottled call paths and repeated dictionary lookup failures for the same title.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 All AniList GraphQL call paths use a shared/conservative limiter or equivalent pacing before requests.
- [ ] #2 429 responses honor Retry-After/X-RateLimit-Reset and do not continue hammering the API.
- [x] #3 Stats AniList search endpoint no longer bypasses the AniList rate limiter.
- [x] #4 Post-watch tracking no longer bypasses the AniList rate limiter.
- [x] #5 Focused regression tests cover limiter use for stats search and post-watch tracking, plus existing limiter behavior remains green.
- [x] #6 Stats cover-art lookup reuses already stored AniList cover data for the same anime before issuing another AniList API request.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented stats cover-art cache reuse across videos in the same anime before any AniList/image fetch. Added limiter plumbing for stats manual AniList search and post-watch tracking; both paths now call acquire before GraphQL and record response headers afterward. Character dictionary still uses its existing local pacing and remains follow-up work for fully shared limiter/header handling.
<!-- SECTION:NOTES:END -->
@@ -1,58 +0,0 @@
---
id: TASK-356
title: Close launcher-started background app when mpv exits
status: Done
assignee:
- codex
created_date: '2026-05-13 01:37'
updated_date: '2026-05-13 01:40'
labels:
- bug
- launcher
- mpv
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
When SubMiner is started through the launcher-managed mpv flow, closing the mpv window should also close the background Electron app instead of leaving it running in the tray. Preserve intentional tray/background behavior for normal app startup.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Launcher-managed mpv sessions signal or otherwise cause the spawned background app to quit when the mpv process exits.
- [x] #2 Normal background/tray startup remains available when SubMiner is launched without a launcher-managed playback session.
- [x] #3 A regression test covers the launcher mpv close/shutdown behavior.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a launcher command regression test for mpv plugin auto-start playback: no direct startOverlay call, mpv exits, launcher marks the session as managed and runs cleanup.
2. Add a small launcher mpv lifecycle helper to mark a SubMiner app session as launcher-managed when the launcher relies on plugin auto-start.
3. Wire playback-command to call that helper only for launcher-managed playback paths where mpv plugin auto-start is expected.
4. Run the focused launcher tests, then update TASK-356 acceptance criteria/notes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented launcher ownership marking for plugin-auto-start playback sessions. Direct startOverlay already marks launcher ownership; the plugin-auto-start branch now does the same before waiting for mpv exit, so existing cleanup sends the app --stop when mpv closes. Added regression coverage in launcher/commands/playback-command.test.ts. Verification: bun test launcher/commands/playback-command.test.ts; bun test launcher/mpv.test.ts launcher/commands/playback-command.test.ts; bun run test:launcher:src; bun run typecheck. Typecheck initially caught a nullable test fixture assignment and passed after fixing it.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Summary:
- Added markOverlayManagedByLauncher() to centralize launcher ownership tracking for SubMiner app sessions.
- Mark plugin-auto-start playback sessions as launcher-managed, so closing mpv triggers existing cleanup and stops the background app instead of leaving it in the tray.
- Added a regression test covering mpv exit after launcher-managed plugin auto-start playback.
Tests:
- bun test launcher/commands/playback-command.test.ts
- bun test launcher/mpv.test.ts launcher/commands/playback-command.test.ts
- bun run test:launcher:src
- bun run typecheck
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -1,5 +0,0 @@
type: changed
area: setup
- SubMiner-managed mpv launches now inject the bundled mpv plugin when no global SubMiner plugin is installed, setup can remove detected legacy global plugin files via the OS trash, and legacy global plugin install entrypoints have been removed so regular mpv playback stays unaffected.
- Managed playback now surfaces the legacy plugin removal prompt before mpv starts, allowing users to trash the old global plugin and immediately relaunch with the bundled runtime plugin.
+1
View File
@@ -202,6 +202,7 @@ Run `make help` for a full list of targets. Key ones:
| `make build` | Build platform package for detected OS |
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) |
| `make install-plugin` | Install mpv Lua plugin and config |
| `make deps` | Install JS dependencies (root + stats + texthooker-ui) |
| `make pretty` | Run scoped Prettier formatting for maintained source/config files |
| `make generate-config` | Generate default config from centralized registry |
+25 -13
View File
@@ -154,15 +154,9 @@ chmod +x ~/.local/bin/SubMiner.AppImage
# Download and install the subminer launcher (recommended)
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer
chmod +x ~/.local/bin/subminer
# Download launcher support assets used for bundled runtime plugin injection
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
mkdir -p ~/.local/share/SubMiner/plugin/subminer
cp -R /tmp/plugin/subminer/. ~/.local/share/SubMiner/plugin/subminer/
```
The `subminer` launcher is the recommended way to use SubMiner on Linux. It ensures mpv is launched with the correct IPC socket, SubMiner defaults, and the bundled runtime plugin so you don't need to configure `mpv.conf` or install a global mpv plugin.
The `subminer` launcher is the recommended way to use SubMiner on Linux. It ensures mpv is launched with the correct IPC socket and SubMiner defaults so you don't need to configure `mpv.conf` manually.
### From Source
@@ -321,7 +315,7 @@ Download the latest Windows installer from [GitHub Releases](https://github.com/
### Getting Started on Windows
1. **Run `SubMiner.exe` once** — first-run setup creates `%APPDATA%\SubMiner\config.jsonc` and opens Yomitan settings for dictionary import. The global mpv plugin install is optional for compatibility; the SubMiner mpv shortcut injects the bundled runtime plugin.
1. **Run `SubMiner.exe` once** — first-run setup creates `%APPDATA%\SubMiner\config.jsonc`, installs the mpv plugin, and opens Yomitan settings for dictionary import.
2. **Create the SubMiner mpv shortcut** _(recommended)_ — the setup popup offers to create a `SubMiner mpv` Start Menu and/or Desktop shortcut. This is the recommended way to launch playback on Windows.
3. **Play a video** — double-click the shortcut, drag a video file onto it, or run from a terminal:
@@ -329,7 +323,7 @@ Download the latest Windows installer from [GitHub Releases](https://github.com/
& "C:\Program Files\SubMiner\SubMiner.exe" --launch-mpv "C:\Videos\episode 01.mkv"
```
The shortcut and `--launch-mpv` pass SubMiner's default IPC socket, subtitle args, and bundled runtime plugin directly — no `mpv.conf` profile or global mpv plugin install is needed.
The shortcut and `--launch-mpv` pass SubMiner's default IPC socket and subtitle args directly — no `mpv.conf` profile is needed.
### Windows-Specific Notes
@@ -358,15 +352,33 @@ bun run build:win
Windows installer builds already get the required NSIS `WinShell` helper through electron-builder's cached `nsis-resources` bundle.
No extra repo-local WinShell plugin install step is required.
## MPV Plugin
## MPV Plugin (Recommended)
SubMiner-managed playback loads the bundled mpv plugin at runtime. No separate global mpv plugin install is required when launching from the app, the launcher, or the packaged Windows SubMiner mpv shortcut.
The Lua plugin provides in-player keybindings to control the overlay from mpv. It communicates with SubMiner by invoking the binary with CLI flags.
::: warning Important
If first-run setup detects an older global SubMiner mpv plugin under mpv's `scripts` directory, use **Remove legacy mpv plugin** so regular mpv playback stops loading SubMiner.
mpv must be launched with `--input-ipc-server=/tmp/subminer-socket` for SubMiner to connect.
:::
See [MPV Plugin](/mpv-plugin) for the keybindings, script messages, and runtime configuration reference.
On Windows, the packaged plugin config is rewritten to `socket_path=\\.\pipe\subminer-socket`.
First-run setup also pins `binary_path` to the current app binary so mpv launches the same SubMiner build that installed the plugin.
```bash
# Option 1: install from release assets bundle
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
mkdir -p ~/.config/SubMiner
cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
mkdir -p ~/.config/mpv/scripts/subminer
mkdir -p ~/.config/mpv/script-opts
cp -R /tmp/plugin/subminer/. ~/.config/mpv/scripts/subminer/
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
# Option 2: from source checkout
# make install-plugin
```
See [MPV Plugin](/mpv-plugin) for the full configuration reference, keybindings, script messages, and binary auto-detection details.
## Anki Setup (Recommended)
+15 -5
View File
@@ -1,12 +1,22 @@
# MPV Plugin
The SubMiner mpv plugin (`subminer/main.lua`) provides in-player keybindings to control the overlay without leaving mpv. SubMiner-managed launches inject the bundled runtime plugin, so users do not need to install it into mpv's global `scripts` directory.
The SubMiner mpv plugin (`subminer/main.lua`) provides in-player keybindings to control the overlay without leaving mpv. It communicates with SubMiner by invoking the AppImage (or binary) with CLI flags.
## Runtime Loading
## Installation
Launch mpv through the SubMiner app, the `subminer` launcher, or the packaged Windows SubMiner mpv shortcut. These paths pass mpv a bundled plugin path for that playback session only, leaving regular mpv playback untouched.
```bash
# From release bundle:
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
mkdir -p ~/.config/SubMiner
cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
mkdir -p ~/.config/mpv/scripts/subminer
mkdir -p ~/.config/mpv/script-opts
cp -R /tmp/plugin/subminer/. ~/.config/mpv/scripts/subminer/
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
If setup detects an older global SubMiner plugin in mpv's `scripts` directory, use **Remove legacy mpv plugin** in first-run setup. The global plugin is not needed once runtime loading is available.
# Or from source checkout: make install-plugin
```
mpv must have IPC enabled for SubMiner to connect:
@@ -57,7 +67,7 @@ Select an item by pressing its number.
## Configuration
For advanced/manual runtime use, create or edit `~/.config/mpv/script-opts/subminer.conf`:
Create or edit `~/.config/mpv/script-opts/subminer.conf`:
```ini
# Path to SubMiner binary. Leave empty for auto-detection.
+3 -4
View File
@@ -151,7 +151,7 @@ Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for sta
### Windows mpv Shortcut
First-run setup creates the config file, then requires Yomitan dictionaries before it can finish. The global mpv plugin install is optional because SubMiner-managed mpv launches inject the bundled runtime plugin.
First-run setup creates the config file, then requires the mpv plugin and Yomitan dictionaries before it can finish.
If you enabled the optional Windows shortcut during install, SubMiner creates a `SubMiner mpv` shortcut in the Start menu and/or on the desktop. On Windows, that shortcut is the recommended way to launch local files with SubMiner because it starts `mpv.exe` with the right defaults directly.
After setup completes, the shortcut is the normal Windows playback entry point.
@@ -195,14 +195,13 @@ SubMiner.AppImage --setup
Setup flow:
- config file: create the default config directory and prefer `config.jsonc`
- plugin compatibility: optionally install the legacy global mpv plugin; managed launches use the bundled runtime plugin without it
- legacy plugin cleanup: remove detected global SubMiner mpv plugin files from mpv script directories via the OS trash when you do not want regular mpv to load SubMiner
- plugin status: install the bundled mpv plugin before finishing setup
- Yomitan shortcut: open bundled Yomitan settings directly from the setup window
- dictionary check: ensure at least one bundled Yomitan dictionary is available, unless an external Yomitan profile is configured
- Windows: optionally create or remove `SubMiner mpv` Start Menu/Desktop shortcuts (`SubMiner.exe --launch-mpv`)
- Windows: optionally set `mpv.executablePath` if `mpv.exe` is not on `PATH`
- refresh: re-check plugin + dictionary state without restarting
- `Finish setup` stays disabled until the config and dictionary gates are satisfied
- `Finish setup` stays disabled until the config, plugin, and dictionary gates are satisfied
- finish action writes setup completion state and suppresses future auto-open prompts
AniList character dictionary auto-sync (optional):
+3 -13
View File
@@ -1,9 +1,5 @@
import { fail, log } from '../log.js';
import {
waitForUnixSocketReady,
launchMpvIdleDetached,
resolveLauncherRuntimePluginPath,
} from '../mpv.js';
import { waitForUnixSocketReady, launchMpvIdleDetached } from '../mpv.js';
import type { LauncherCommandContext } from './context.js';
interface MpvCommandDeps {
@@ -12,7 +8,6 @@ interface MpvCommandDeps {
socketPath: string,
appPath: string,
args: LauncherCommandContext['args'],
runtimePluginPath?: string | null,
): Promise<void>;
}
@@ -49,7 +44,7 @@ export async function runMpvPostAppCommand(
context: LauncherCommandContext,
deps: MpvCommandDeps = defaultDeps,
): Promise<boolean> {
const { args, appPath, scriptPath, mpvSocketPath } = context;
const { args, appPath, mpvSocketPath } = context;
if (!args.mpvIdle) {
return false;
}
@@ -57,12 +52,7 @@ export async function runMpvPostAppCommand(
fail('SubMiner app binary not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
}
await deps.launchMpvIdleDetached(
mpvSocketPath,
appPath,
args,
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
);
await deps.launchMpvIdleDetached(mpvSocketPath, appPath, args);
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
if (!ready) {
fail(`MPV IPC socket not ready after idle launch: ${mpvSocketPath}`);
+5 -64
View File
@@ -1,9 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import type { LauncherCommandContext } from './context.js';
import { runPlaybackCommandWithDeps } from './playback-command.js';
import { state } from '../mpv.js';
function createContext(): LauncherCommandContext {
return {
@@ -97,7 +95,7 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
};
const receivedStartMpvOptions: Record<string, unknown>[] = [];
let receivedStartMpvOptions: Record<string, unknown> | null = null;
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
@@ -113,9 +111,7 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
_preloadedSubtitles,
options,
) => {
if (options) {
receivedStartMpvOptions.push(options as Record<string, unknown>);
}
receivedStartMpvOptions = options ?? null;
calls.push('startMpv');
},
waitForUnixSocketReady: async () => true,
@@ -134,63 +130,8 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
'startMpv',
'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw --youtube-mode download',
]);
assert.equal(receivedStartMpvOptions[0]?.startPaused, true);
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
assert.deepEqual(receivedStartMpvOptions, {
startPaused: 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;
}
});
+10 -12
View File
@@ -7,8 +7,6 @@ import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js';
import {
cleanupPlaybackSession,
launchAppCommandDetached,
markOverlayManagedByLauncher,
resolveLauncherRuntimePluginPath,
startMpv,
startOverlay,
state,
@@ -23,8 +21,9 @@ import {
getDefaultConfigDir,
getSetupStatePath,
readSetupState,
resolveDefaultMpvInstallPaths,
} from '../../src/shared/setup-state.js';
import { detectInstalledFirstRunPluginCandidates } from '../../src/main/runtime/first-run-setup-plugin.js';
import { detectInstalledFirstRunPlugin } from '../../src/main/runtime/first-run-setup-plugin.js';
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
@@ -108,13 +107,14 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
const ready = await ensureLauncherSetupReady({
readSetupState: () => readSetupState(statePath),
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
hasLegacyMpvPlugin: () =>
detectInstalledFirstRunPluginCandidates({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
appDataDir: process.env.APPDATA,
}).length > 0,
isPluginInstalled: () => {
const installPaths = resolveDefaultMpvInstallPaths(
process.platform,
os.homedir(),
process.env.XDG_CONFIG_HOME,
);
return detectInstalledFirstRunPlugin(installPaths);
},
launchSetupApp: () => {
const setupArgs = ['--background', '--setup'];
if (args.logLevel) {
@@ -237,7 +237,6 @@ export async function runPlaybackCommandWithDeps(
{
startPaused: shouldPauseUntilOverlayReady || isAppOwnedYoutubeFlow,
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
},
);
@@ -263,7 +262,6 @@ export async function runPlaybackCommandWithDeps(
: [],
);
} else if (pluginAutoStartEnabled) {
markOverlayManagedByLauncher(appPath);
if (ready) {
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
} else {
+1 -7
View File
@@ -26,7 +26,6 @@ import {
runAppCommandCaptureOutput,
launchAppStartDetached,
launchMpvIdleDetached,
resolveLauncherRuntimePluginPath,
waitForUnixSocketReady,
} from './mpv.js';
@@ -1015,12 +1014,7 @@ export async function runJellyfinPlayMenu(
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 250);
}
if (!mpvReady) {
await launchMpvIdleDetached(
mpvSocketPath,
appPath,
args,
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
);
await launchMpvIdleDetached(mpvSocketPath, appPath, args);
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000);
}
log('debug', args.logLevel, `MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`);
-85
View File
@@ -17,8 +17,6 @@ import {
launchTexthookerOnly,
parseMpvArgString,
runAppCommandCaptureOutput,
resolveLauncherRuntimePluginPath,
resolveLauncherRuntimePluginPlan,
shouldResolveAniSkipMetadata,
stopOverlay,
startOverlay,
@@ -264,89 +262,6 @@ test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured
});
});
test('resolveLauncherRuntimePluginPath finds bundled plugin from explicit environment path', () => {
const pluginDir = '/opt/SubMiner/plugin/subminer';
assert.equal(
resolveLauncherRuntimePluginPath({
appPath: '/opt/SubMiner/SubMiner.AppImage',
env: { SUBMINER_MPV_PLUGIN_PATH: pluginDir },
existsSync: (candidate) => candidate === path.join(pluginDir, 'main.lua'),
}),
path.join(pluginDir, 'main.lua'),
);
});
test('resolveLauncherRuntimePluginPath finds Linux app-support plugin assets', () => {
const homeDir = '/home/tester';
const expected = path.join(
homeDir,
'.local',
'share',
'SubMiner',
'plugin',
'subminer',
'main.lua',
);
assert.equal(
resolveLauncherRuntimePluginPath({
appPath: '/home/tester/.local/bin/SubMiner.AppImage',
scriptPath: '/home/tester/.local/bin/subminer',
platform: 'linux',
homeDir,
env: {},
existsSync: (candidate) => candidate === expected,
}),
expected,
);
});
test('resolveLauncherRuntimePluginPlan injects bundled plugin when no installed plugin exists', () => {
const plan = resolveLauncherRuntimePluginPlan({
runtimePluginPath: '/opt/SubMiner/plugin/subminer/main.lua',
platform: 'linux',
homeDir: '/home/tester',
existsSync: () => false,
});
assert.equal(plan.scriptPath, '/opt/SubMiner/plugin/subminer/main.lua');
assert.equal(plan.installedPlugin.installed, false);
assert.equal(plan.warningMessage, null);
assert.equal(plan.errorMessage, null);
});
test('resolveLauncherRuntimePluginPlan uses installed plugin instead of bundled injection', () => {
const installedPath = '/home/tester/.config/mpv/scripts/subminer/main.lua';
const versionPath = '/home/tester/.config/mpv/scripts/subminer/version.lua';
const existing = new Set([installedPath, versionPath]);
const plan = resolveLauncherRuntimePluginPlan({
runtimePluginPath: '/opt/SubMiner/plugin/subminer/main.lua',
platform: 'linux',
homeDir: '/home/tester',
existsSync: (candidate) => existing.has(candidate),
readFileSync: () => 'return { version = "0.12.0" }',
});
assert.equal(plan.scriptPath, null);
assert.equal(plan.installedPlugin.path, installedPath);
assert.equal(plan.installedPlugin.version, '0.12.0');
assert.match(plan.warningMessage ?? '', /This mpv session will use the installed plugin/);
assert.equal(plan.errorMessage, null);
});
test('resolveLauncherRuntimePluginPlan reports missing bundled plugin when no installed plugin exists', () => {
const plan = resolveLauncherRuntimePluginPlan({
runtimePluginPath: null,
platform: 'linux',
homeDir: '/home/tester',
existsSync: () => false,
});
assert.equal(plan.scriptPath, null);
assert.equal(plan.installedPlugin.installed, false);
assert.match(plan.errorMessage ?? '', /Packaged mpv plugin assets were not found/);
});
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
const error = withProcessExitIntercept(() => {
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
+2 -216
View File
@@ -4,10 +4,6 @@ import os from 'node:os';
import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process';
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
import {
detectInstalledMpvPlugin,
type InstalledMpvPluginDetection,
} from '../src/main/runtime/first-run-setup-plugin.js';
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
@@ -46,13 +42,6 @@ const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid
const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900;
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700;
export interface LauncherRuntimePluginPlan {
scriptPath: string | null;
installedPlugin: InstalledMpvPluginDetection;
warningMessage: string | null;
errorMessage: string | null;
}
export function parseMpvArgString(input: string): string[] {
const chars = input;
const args: string[] = [];
@@ -237,182 +226,6 @@ export function makeTempDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function normalizeRuntimePluginEntrypoint(
candidate: string,
deps: {
pathModule: typeof path;
existsSync: (candidate: string) => boolean;
},
): string | null {
const trimmed = candidate.trim();
if (!trimmed) return null;
if (trimmed.endsWith('.lua')) {
return deps.existsSync(trimmed) ? trimmed : null;
}
const entrypoint = deps.pathModule.join(trimmed, 'main.lua');
return deps.existsSync(entrypoint) ? entrypoint : null;
}
function pushMacAppRuntimePluginCandidate(
candidates: string[],
appPath: string,
pathModule: typeof path,
): void {
const appIndex = appPath.indexOf('.app');
if (appIndex < 0) return;
candidates.push(
pathModule.join(
appPath.slice(0, appIndex + '.app'.length),
'Contents',
'Resources',
'plugin',
'subminer',
),
);
}
export function resolveLauncherRuntimePluginPath(options: {
appPath: string;
scriptPath?: string;
platform?: NodeJS.Platform;
homeDir?: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
dirname?: string;
pathModule?: typeof path;
existsSync?: (candidate: string) => boolean;
}): string | null {
const pathModule = options.pathModule ?? path;
const existsSync = options.existsSync ?? fs.existsSync;
const env = options.env ?? process.env;
const dirname = options.dirname ?? __dirname;
const cwd = options.cwd ?? process.cwd();
const platform = options.platform ?? process.platform;
const homeDir = options.homeDir ?? os.homedir();
const candidates: string[] = [];
if (env.SUBMINER_MPV_PLUGIN_PATH) {
candidates.push(env.SUBMINER_MPV_PLUGIN_PATH);
}
pushMacAppRuntimePluginCandidate(candidates, options.appPath, pathModule);
const appDir = pathModule.dirname(options.appPath);
candidates.push(
pathModule.join(appDir, 'resources', 'plugin', 'subminer'),
pathModule.join(appDir, 'plugin', 'subminer'),
);
if (options.scriptPath) {
const scriptDir = pathModule.dirname(realpathMaybe(options.scriptPath));
candidates.push(
pathModule.join(scriptDir, '..', 'share', 'SubMiner', 'plugin', 'subminer'),
pathModule.join(scriptDir, '..', 'lib', 'SubMiner', 'plugin', 'subminer'),
pathModule.join(scriptDir, 'plugin', 'subminer'),
);
}
if (platform === 'darwin') {
candidates.push(
pathModule.join(homeDir, 'Library', 'Application Support', 'SubMiner', 'plugin', 'subminer'),
);
} else if (platform !== 'win32') {
candidates.push(
pathModule.join(
env.XDG_DATA_HOME?.trim() || pathModule.join(homeDir, '.local', 'share'),
'SubMiner',
'plugin',
'subminer',
),
);
}
candidates.push(
pathModule.join(cwd, 'plugin', 'subminer'),
pathModule.join(dirname, '..', 'plugin', 'subminer'),
pathModule.join(dirname, '..', '..', 'plugin', 'subminer'),
);
const seen = new Set<string>();
for (const candidate of candidates) {
const resolved = pathModule.resolve(candidate);
if (seen.has(resolved)) continue;
seen.add(resolved);
const entrypoint = normalizeRuntimePluginEntrypoint(resolved, { pathModule, existsSync });
if (entrypoint) {
return entrypoint;
}
}
return null;
}
export function resolveLauncherRuntimePluginPlan(options: {
runtimePluginPath?: string | null;
platform?: NodeJS.Platform;
homeDir?: string;
xdgConfigHome?: string;
appDataDir?: string;
mpvExecutablePath?: string;
existsSync?: (candidate: string) => boolean;
readFileSync?: (candidate: string, encoding: BufferEncoding) => string;
}): LauncherRuntimePluginPlan {
const installedPlugin = detectInstalledMpvPlugin({
platform: options.platform ?? process.platform,
homeDir: options.homeDir ?? os.homedir(),
xdgConfigHome: options.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
appDataDir: options.appDataDir ?? process.env.APPDATA,
mpvExecutablePath: options.mpvExecutablePath,
existsSync: options.existsSync,
readFileSync: options.readFileSync,
});
if (installedPlugin.installed) {
const versionText = installedPlugin.version
? `Detected plugin version: ${installedPlugin.version}.`
: 'Detected plugin version: unknown or legacy.';
return {
scriptPath: null,
installedPlugin,
warningMessage: `SubMiner detected an installed mpv plugin at: ${installedPlugin.path}. This mpv session will use the installed plugin. Remove it to use SubMiner's bundled runtime plugin automatically. ${versionText}`,
errorMessage: null,
};
}
if (options.runtimePluginPath) {
return {
scriptPath: options.runtimePluginPath,
installedPlugin,
warningMessage: null,
errorMessage: null,
};
}
return {
scriptPath: null,
installedPlugin,
warningMessage: null,
errorMessage:
'Packaged mpv plugin assets were not found. Install the SubMiner assets bundle or set SUBMINER_MPV_PLUGIN_PATH to plugin/subminer/main.lua.',
};
}
function appendRuntimePluginLaunchArgs(
mpvArgs: string[],
plan: LauncherRuntimePluginPlan,
logLevel: LogLevel,
): void {
if (plan.warningMessage) {
log('warn', logLevel, plan.warningMessage);
}
if (plan.errorMessage) {
fail(plan.errorMessage);
}
if (plan.scriptPath) {
mpvArgs.push(`--script=${plan.scriptPath}`);
}
}
export function detectBackend(
backend: Backend,
env: NodeJS.ProcessEnv = process.env,
@@ -845,11 +658,7 @@ export async function startMpv(
socketPath: string,
appPath: string,
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
options?: {
startPaused?: boolean;
disableYoutubeSubtitleAutoLoad?: boolean;
runtimePluginPath?: string | null;
},
options?: { startPaused?: boolean; disableYoutubeSubtitleAutoLoad?: boolean },
): Promise<void> {
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
fail(`Video file not found: ${target}`);
@@ -863,14 +672,6 @@ export async function startMpv(
const mpvArgs: string[] = [];
mpvArgs.push(...buildConfiguredMpvDefaultArgs(args));
appendRuntimePluginLaunchArgs(
mpvArgs,
resolveLauncherRuntimePluginPlan({
runtimePluginPath:
options?.runtimePluginPath ?? resolveLauncherRuntimePluginPath({ appPath }),
}),
args.logLevel,
);
if (targetKind === 'url' && isYoutubeTarget(target)) {
log('info', args.logLevel, 'Applying URL playback options');
mpvArgs.push('--ytdl=yes');
@@ -1010,7 +811,7 @@ export async function startOverlay(
env: buildAppEnv(),
});
attachAppProcessLogging(state.overlayProc);
markOverlayManagedByLauncher(appPath);
state.overlayManagedByLauncher = true;
const [socketReady] = await Promise.all([
waitForUnixSocketReady(socketPath, OVERLAY_START_SOCKET_READY_TIMEOUT_MS),
@@ -1030,13 +831,6 @@ export async function startOverlay(
}
}
export function markOverlayManagedByLauncher(appPath?: string): void {
if (appPath) {
state.appPath = appPath;
}
state.overlayManagedByLauncher = true;
}
export function openUrlInDefaultBrowser(url: string, logLevel: LogLevel): void {
const target =
process.platform === 'darwin'
@@ -1442,7 +1236,6 @@ export function launchMpvIdleDetached(
socketPath: string,
appPath: string,
args: Args,
runtimePluginPath?: string | null,
): Promise<void> {
return (async () => {
await terminateTrackedDetachedMpv(args.logLevel);
@@ -1453,13 +1246,6 @@ export function launchMpvIdleDetached(
}
const mpvArgs: string[] = buildConfiguredMpvDefaultArgs(args);
appendRuntimePluginLaunchArgs(
mpvArgs,
resolveLauncherRuntimePluginPlan({
runtimePluginPath: runtimePluginPath ?? resolveLauncherRuntimePluginPath({ appPath }),
}),
args.logLevel,
);
if (args.mpvArgs) {
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
}
+9 -56
View File
@@ -116,81 +116,34 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf
assert.deepEqual(calls, []);
});
test('ensureLauncherSetupReady waits for finish after legacy mpv plugin removal', async () => {
test('ensureLauncherSetupReady bypasses setup gate when plugin is already installed', async () => {
const calls: string[] = [];
let legacyPluginInstalled = true;
let reads = 0;
const ready = await ensureLauncherSetupReady({
readSetupState: () => {
reads += 1;
return {
readSetupState: () => ({
version: 3,
status: 'completed',
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:40:00.000Z',
completionSource: 'user',
status: 'cancelled',
completedAt: null,
completionSource: null,
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
};
},
hasLegacyMpvPlugin: () => legacyPluginInstalled,
}),
isPluginInstalled: () => true,
launchSetupApp: () => {
calls.push('launch');
legacyPluginInstalled = false;
},
sleep: async () => undefined,
now: (() => {
let value = 0;
return () => (value += 100);
})(),
now: () => 0,
timeoutMs: 5_000,
pollIntervalMs: 100,
});
assert.equal(ready, true);
assert.deepEqual(calls, ['launch']);
assert.equal(reads >= 3, true);
});
test('ensureLauncherSetupReady lets users continue without removing a legacy mpv plugin', async () => {
const calls: string[] = [];
let reads = 0;
const ready = await ensureLauncherSetupReady({
readSetupState: () => {
reads += 1;
return {
version: 3,
status: 'completed',
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:30:00.000Z',
completionSource: 'user',
yomitanSetupMode: 'internal',
lastSeenYomitanDictionaryCount: 2,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
};
},
hasLegacyMpvPlugin: () => true,
launchSetupApp: () => {
calls.push('launch');
},
sleep: async () => undefined,
now: (() => {
let value = 0;
return () => (value += 100);
})(),
timeoutMs: 5_000,
pollIntervalMs: 100,
});
assert.equal(ready, true);
assert.deepEqual(calls, ['launch']);
assert.deepEqual(calls, []);
});
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
+8 -58
View File
@@ -32,81 +32,31 @@ export async function waitForSetupCompletion(deps: {
return 'timeout';
}
export async function waitForLegacyMpvPluginPromptResolution(deps: {
readSetupState: () => SetupState | null;
sleep: (ms: number) => Promise<void>;
now: () => number;
timeoutMs: number;
pollIntervalMs: number;
initialState?: SetupState | null;
}): Promise<'acknowledged' | 'cancelled' | 'timeout'> {
const deadline = deps.now() + deps.timeoutMs;
const initialCompleted = isSetupCompleted(deps.initialState);
const initialCompletedAt = deps.initialState?.completedAt ?? null;
while (deps.now() <= deadline) {
const state = deps.readSetupState();
if (
isSetupCompleted(state) &&
(!initialCompleted || state?.completedAt !== initialCompletedAt)
) {
return 'acknowledged';
}
if (!initialCompleted && state?.status === 'cancelled') {
return 'cancelled';
}
await deps.sleep(deps.pollIntervalMs);
}
return 'timeout';
}
export async function ensureLauncherSetupReady(deps: {
readSetupState: () => SetupState | null;
isExternalYomitanConfigured?: () => boolean;
hasLegacyMpvPlugin?: () => boolean;
isPluginInstalled?: () => boolean;
launchSetupApp: () => void;
sleep: (ms: number) => Promise<void>;
now: () => number;
timeoutMs: number;
pollIntervalMs: number;
}): Promise<boolean> {
const initialState = deps.readSetupState();
let setupLaunched = false;
const launchSetupApp = () => {
if (setupLaunched) return;
setupLaunched = true;
deps.launchSetupApp();
};
if (deps.hasLegacyMpvPlugin?.()) {
launchSetupApp();
const result = await waitForLegacyMpvPluginPromptResolution({
readSetupState: deps.readSetupState,
sleep: deps.sleep,
now: deps.now,
timeoutMs: deps.timeoutMs,
pollIntervalMs: deps.pollIntervalMs,
initialState,
});
if (result === 'cancelled' || result === 'timeout') {
return false;
}
}
if (deps.isExternalYomitanConfigured?.()) {
return true;
}
const stateAfterLegacyPrompt = deps.readSetupState();
if (isSetupCompleted(stateAfterLegacyPrompt)) {
if (deps.isPluginInstalled?.()) {
return true;
}
const initialState = deps.readSetupState();
if (isSetupCompleted(initialState)) {
return true;
}
launchSetupApp();
deps.launchSetupApp();
const result = await waitForSetupCompletion({
...deps,
ignoreInitialCancelledState: stateAfterLegacyPrompt?.status === 'cancelled',
ignoreInitialCancelledState: initialState?.status === 'cancelled',
});
return result === 'completed';
}
+1 -5
View File
@@ -14,10 +14,6 @@ function M.init()
local utils = require("mp.utils")
local options_helper = require("options")
local ok_version, version = pcall(require, "version")
if not ok_version or type(version) ~= "table" then
version = { version = "unknown" }
end
local environment = require("environment").create({ mp = mp, utils = utils })
local opts = options_helper.load(options_lib, environment.default_socket_path())
local state = require("state").new()
@@ -82,7 +78,7 @@ function M.init()
ctx.session_bindings.register_bindings()
ctx.messages.register_script_messages()
ctx.lifecycle.register_lifecycle_hooks()
ctx.log.subminer_log("info", "lifecycle", "SubMiner plugin loaded " .. tostring(version.version or "unknown"))
ctx.log.subminer_log("info", "lifecycle", "SubMiner plugin loaded")
end
return M
-4
View File
@@ -1,4 +0,0 @@
return {
name = "SubMiner mpv plugin",
version = "0.12.0",
}
+101
View File
@@ -0,0 +1,101 @@
import fs from 'node:fs';
import path from 'node:path';
function normalizeCandidate(candidate) {
if (typeof candidate !== 'string') return '';
const trimmed = candidate.trim();
return trimmed.length > 0 ? trimmed : '';
}
function fileExists(candidate) {
try {
return fs.statSync(candidate).isFile();
} catch {
return false;
}
}
function unique(values) {
return Array.from(new Set(values.filter((value) => value.length > 0)));
}
function findWindowsBinary(repoRoot) {
const homeDir = process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || '';
const appDataDir = process.env.APPDATA?.trim() || '';
const derivedLocalAppData =
appDataDir && /[\\/]Roaming$/i.test(appDataDir)
? appDataDir.replace(/[\\/]Roaming$/i, `${path.sep}Local`)
: '';
const localAppData =
process.env.LOCALAPPDATA?.trim() ||
derivedLocalAppData ||
(homeDir ? path.join(homeDir, 'AppData', 'Local') : '');
const programFiles = process.env.ProgramFiles?.trim() || 'C:\\Program Files';
const programFilesX86 = process.env['ProgramFiles(x86)']?.trim() || 'C:\\Program Files (x86)';
const candidates = unique([
normalizeCandidate(process.env.SUBMINER_BINARY_PATH),
normalizeCandidate(process.env.SUBMINER_APPIMAGE_PATH),
localAppData ? path.join(localAppData, 'Programs', 'SubMiner', 'SubMiner.exe') : '',
path.join(programFiles, 'SubMiner', 'SubMiner.exe'),
path.join(programFilesX86, 'SubMiner', 'SubMiner.exe'),
'C:\\SubMiner\\SubMiner.exe',
path.join(repoRoot, 'release', 'win-unpacked', 'SubMiner.exe'),
path.join(repoRoot, 'release', 'SubMiner', 'SubMiner.exe'),
path.join(repoRoot, 'release', 'SubMiner.exe'),
]);
return candidates.find((candidate) => fileExists(candidate)) || '';
}
function rewriteBinaryPath(configPath, binaryPath) {
const content = fs.readFileSync(configPath, 'utf8');
const normalizedPath = binaryPath.replace(/\r?\n/g, ' ').trim();
const updated = content.replace(/^binary_path=.*$/m, `binary_path=${normalizedPath}`);
if (updated !== content) {
fs.writeFileSync(configPath, updated, 'utf8');
}
}
function rewriteSocketPath(configPath, socketPath) {
const content = fs.readFileSync(configPath, 'utf8');
const normalizedPath = socketPath.replace(/\r?\n/g, ' ').trim();
const updated = content.replace(/^socket_path=.*$/m, `socket_path=${normalizedPath}`);
if (updated !== content) {
fs.writeFileSync(configPath, updated, 'utf8');
}
}
const [, , configPathArg, repoRootArg, platformArg] = process.argv;
const configPath = normalizeCandidate(configPathArg);
const repoRoot = normalizeCandidate(repoRootArg) || process.cwd();
const platform = normalizeCandidate(platformArg) || process.platform;
if (!configPath) {
console.error('[ERROR] Missing plugin config path');
process.exit(1);
}
if (!fileExists(configPath)) {
console.error(`[ERROR] Plugin config not found: ${configPath}`);
process.exit(1);
}
if (platform !== 'win32') {
console.log('[INFO] Skipping binary_path rewrite for non-Windows platform');
process.exit(0);
}
const windowsSocketPath = '\\\\.\\pipe\\subminer-socket';
rewriteSocketPath(configPath, windowsSocketPath);
const binaryPath = findWindowsBinary(repoRoot);
if (!binaryPath) {
console.warn(
`[WARN] Configured plugin socket_path=${windowsSocketPath} but could not detect SubMiner.exe; set binary_path manually or provide SUBMINER_BINARY_PATH`,
);
process.exit(0);
}
rewriteBinaryPath(configPath, binaryPath);
console.log(`[INFO] Configured plugin socket_path=${windowsSocketPath} binary_path=${binaryPath}`);
-9
View File
@@ -1,8 +1,6 @@
local MODULE_PATHS = {
"plugin/subminer/bootstrap.lua",
"plugin/subminer/hover.lua",
"plugin/subminer/environment.lua",
"plugin/subminer/version.lua",
}
local LEGACY_PARSER_CANDIDATES = {
@@ -50,12 +48,6 @@ local function assert_loadfile_ok(path)
assert_true(chunk ~= nil, "loadfile failed for " .. path .. ": " .. tostring(err))
end
local function assert_bootstrap_uses_defensive_version_load()
local source = read_file("plugin/subminer/bootstrap.lua")
assert_true(not source:find('require%("version"%)'), "bootstrap.lua must not hard-require version.lua")
assert_true(source:find('pcall%(require, "version"%)') ~= nil, "bootstrap.lua must load version.lua with pcall")
end
local function normalize_execute_result(ok, why, code)
if type(ok) == "number" then
return ok == 0, ok
@@ -136,7 +128,6 @@ for _, path in ipairs(MODULE_PATHS) do
assert_no_legacy_incompatible_continue(path)
assert_loadfile_ok(path)
end
assert_bootstrap_uses_defensive_version_load()
local parser = find_legacy_parser()
if parser then
@@ -1025,46 +1025,6 @@ describe('stats server API routes', () => {
assert.equal(res.status, 400);
});
it('GET /api/stats/anilist/search uses the configured AniList rate limiter', async () => {
const originalFetch = globalThis.fetch;
let acquireCalls = 0;
let recordCalls = 0;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
data: {
Page: {
media: [{ id: 21858, title: { romaji: 'Little Witch Academia' } }],
},
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json', 'X-RateLimit-Remaining': '29' },
},
)) as typeof fetch;
try {
const app = createStatsApp(createMockTracker(), {
anilistRateLimiter: {
acquire: async () => {
acquireCalls += 1;
},
recordResponse: () => {
recordCalls += 1;
},
},
});
const res = await app.request('/api/stats/anilist/search?q=Little%20Witch%20Academia');
assert.equal(res.status, 200);
assert.equal(acquireCalls, 1);
assert.equal(recordCalls, 1);
} finally {
globalThis.fetch = originalFetch;
}
});
it('POST /api/stats/anki/notesInfo resolves stale note ids through the configured alias resolver', async () => {
const originalFetch = globalThis.fetch;
const requests: unknown[] = [];
@@ -184,57 +184,6 @@ test('updateAnilistPostWatchProgress updates progress when behind', async () =>
}
});
test('updateAnilistPostWatchProgress uses the configured AniList rate limiter', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
let acquireCalls = 0;
let recordCalls = 0;
globalThis.fetch = (async () => {
call += 1;
if (call === 1) {
return createJsonResponse({
data: {
Page: {
media: [{ id: 11, episodes: 24, title: { english: 'Demo Show' } }],
},
},
});
}
if (call === 2) {
return createJsonResponse({
data: {
Media: {
id: 11,
mediaListEntry: { progress: 2, status: 'CURRENT' },
},
},
});
}
return createJsonResponse({
data: { SaveMediaListEntry: { progress: 3, status: 'CURRENT' } },
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 3, {
rateLimiter: {
acquire: async () => {
acquireCalls += 1;
},
recordResponse: () => {
recordCalls += 1;
},
},
});
assert.equal(result.status, 'updated');
assert.equal(acquireCalls, 3);
assert.equal(recordCalls, 3);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress skips when progress already reached', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
@@ -2,7 +2,6 @@ import * as childProcess from 'child_process';
import * as path from 'path';
import { parseMediaInfo } from '../../../jimaku/utils';
import type { AnilistRateLimiter } from './rate-limiter';
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
@@ -20,10 +19,6 @@ export interface AnilistPostWatchUpdateResult {
message: string;
}
export interface AnilistPostWatchUpdateOptions {
rateLimiter?: AnilistRateLimiter;
}
interface AnilistGraphQlError {
message?: string;
}
@@ -160,10 +155,8 @@ async function anilistGraphQl<T>(
accessToken: string,
query: string,
variables: Record<string, unknown>,
options: AnilistPostWatchUpdateOptions = {},
): Promise<AnilistGraphQlResponse<T>> {
try {
await options.rateLimiter?.acquire();
const response = await fetch(ANILIST_GRAPHQL_URL, {
method: 'POST',
headers: {
@@ -173,7 +166,6 @@ async function anilistGraphQl<T>(
body: JSON.stringify({ query, variables }),
});
options.rateLimiter?.recordResponse(response.headers);
const payload = (await response.json()) as AnilistGraphQlResponse<T>;
return payload;
} catch (error) {
@@ -277,7 +269,6 @@ export async function updateAnilistPostWatchProgress(
accessToken: string,
title: string,
episode: number,
options: AnilistPostWatchUpdateOptions = {},
): Promise<AnilistPostWatchUpdateResult> {
const searchResponse = await anilistGraphQl<AnilistSearchData>(
accessToken,
@@ -297,7 +288,6 @@ export async function updateAnilistPostWatchProgress(
}
`,
{ search: title },
options,
);
const searchError = firstErrorMessage(searchResponse);
if (searchError) {
@@ -327,7 +317,6 @@ export async function updateAnilistPostWatchProgress(
}
`,
{ mediaId: picked.id },
options,
);
const entryError = firstErrorMessage(entryResponse);
if (entryError) {
@@ -356,7 +345,6 @@ export async function updateAnilistPostWatchProgress(
}
`,
{ mediaId: picked.id, progress: episode },
options,
);
const saveError = firstErrorMessage(saveResponse);
if (saveError) {
@@ -5,12 +5,7 @@ import path from 'node:path';
import test from 'node:test';
import { createCoverArtFetcher, stripFilenameTags } from './cover-art-fetcher.js';
import { Database } from '../immersion-tracker/sqlite.js';
import {
ensureSchema,
getOrCreateAnimeRecord,
getOrCreateVideoRecord,
linkVideoToAnimeRecord,
} from '../immersion-tracker/storage.js';
import { ensureSchema, getOrCreateVideoRecord } from '../immersion-tracker/storage.js';
import { getCoverArt } from '../immersion-tracker/query-library.js';
import { upsertCoverArt } from '../immersion-tracker/query-maintenance.js';
import { SOURCE_TYPE_LOCAL } from '../immersion-tracker/types.js';
@@ -105,82 +100,6 @@ test('fetchIfMissing backfills a missing blob from an existing cover URL', async
}
});
test('fetchIfMissing reuses cached cover art from another video in the same anime', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
ensureSchema(db);
const firstVideoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-cache-1.mkv', {
canonicalTitle: 'Shared Cover Show',
sourcePath: '/tmp/cover-fetcher-cache-1.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const secondVideoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-cache-2.mkv', {
canonicalTitle: 'Shared Cover Show',
sourcePath: '/tmp/cover-fetcher-cache-2.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Shared Cover Show',
canonicalTitle: 'Shared Cover Show',
anilistId: 99,
titleRomaji: 'Shared Cover Show',
titleEnglish: 'Shared Cover Show',
titleNative: null,
metadataJson: null,
});
for (const videoId of [firstVideoId, secondVideoId]) {
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: null,
parsedTitle: 'Shared Cover Show',
parsedSeason: 1,
parsedEpisode: videoId,
parserSource: 'fallback',
parserConfidence: 1,
parseMetadataJson: null,
});
}
upsertCoverArt(db, firstVideoId, {
anilistId: 99,
coverUrl: 'https://images.test/shared-cover.jpg',
coverBlob: Buffer.from([9, 8, 7, 6]),
titleRomaji: 'Shared Cover Show',
titleEnglish: 'Shared Cover Show',
episodesTotal: 12,
});
let fetchCalls = 0;
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
fetchCalls += 1;
throw new Error('unexpected AniList or image request');
}) as typeof fetch;
try {
const fetcher = createCoverArtFetcher(
{
acquire: async () => {},
recordResponse: () => {},
},
console,
);
const fetched = await fetcher.fetchIfMissing(db, secondVideoId, 'Shared Cover Show');
const stored = getCoverArt(db, secondVideoId);
assert.equal(fetched, true);
assert.equal(fetchCalls, 0);
assert.equal(stored?.anilistId, 99);
assert.equal(Buffer.from(stored?.coverBlob ?? []).toString('hex'), '09080706');
} finally {
globalThis.fetch = originalFetch;
db.close();
cleanupDbPath(dbPath);
}
});
function createJsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
+1 -34
View File
@@ -1,11 +1,6 @@
import type { AnilistRateLimiter } from './rate-limiter';
import type { DatabaseSync } from '../immersion-tracker/sqlite';
import {
getAnimeCoverArt,
getCoverArt,
upsertCoverArt,
updateAnimeAnilistInfo,
} from '../immersion-tracker/query';
import { getCoverArt, upsertCoverArt, updateAnimeAnilistInfo } from '../immersion-tracker/query';
import {
guessAnilistMediaInfo,
runGuessit,
@@ -262,30 +257,6 @@ export function createCoverArtFetcher(
logger: Logger,
options: CoverArtFetcherOptions = {},
): CoverArtFetcher {
const reuseAnimeCoverArt = (db: DatabaseSync, videoId: number): boolean => {
const row = db
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
.get(videoId) as { animeId: number | null } | undefined;
if (!row?.animeId) {
return false;
}
const shared = getAnimeCoverArt(db, row.animeId);
if (!shared?.coverBlob) {
return false;
}
upsertCoverArt(db, videoId, {
anilistId: shared.anilistId,
coverUrl: shared.coverUrl,
coverBlob: shared.coverBlob,
titleRomaji: shared.titleRomaji,
titleEnglish: shared.titleEnglish,
episodesTotal: shared.episodesTotal,
});
return true;
};
const resolveCanonicalTitle = (
db: DatabaseSync,
videoId: number,
@@ -346,10 +317,6 @@ export function createCoverArtFetcher(
}
}
if (reuseAnimeCoverArt(db, videoId)) {
return true;
}
if (
existing &&
existing.coverUrl === null &&
-9
View File
@@ -14,7 +14,6 @@ import {
getPreferredNoteFieldValue,
} from '../../anki-field-config.js';
import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js';
import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
type StatsServerNoteInfo = {
noteId: number;
@@ -256,7 +255,6 @@ export interface StatsServerConfig {
knownWordCachePath?: string;
mpvSocketPath?: string;
ankiConnectConfig?: AnkiConnectConfig;
anilistRateLimiter?: AnilistRateLimiter;
addYomitanNote?: (word: string) => Promise<number | null>;
resolveAnkiNoteId?: (noteId: number) => number;
}
@@ -340,7 +338,6 @@ export function createStatsApp(
knownWordCachePath?: string;
mpvSocketPath?: string;
ankiConnectConfig?: AnkiConnectConfig;
anilistRateLimiter?: AnilistRateLimiter;
addYomitanNote?: (word: string) => Promise<number | null>;
resolveAnkiNoteId?: (noteId: number) => number;
},
@@ -635,7 +632,6 @@ export function createStatsApp(
const query = (c.req.query('q') ?? '').trim();
if (!query) return c.json([]);
try {
await options?.anilistRateLimiter?.acquire();
const res = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -656,10 +652,6 @@ export function createStatsApp(
variables: { search: query },
}),
});
options?.anilistRateLimiter?.recordResponse(res.headers);
if (res.status === 429) {
return c.json([]);
}
const json = (await res.json()) as { data?: { Page?: { media?: unknown[] } } };
return c.json(json.data?.Page?.media ?? []);
} catch {
@@ -1139,7 +1131,6 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
knownWordCachePath: config.knownWordCachePath,
mpvSocketPath: config.mpvSocketPath,
ankiConnectConfig: config.ankiConnectConfig,
anilistRateLimiter: config.anilistRateLimiter,
addYomitanNote: config.addYomitanNote,
resolveAnkiNoteId: config.resolveAnkiNoteId,
});
+7 -103
View File
@@ -1,7 +1,6 @@
import path from 'node:path';
import os from 'node:os';
import { spawn } from 'node:child_process';
import { app, dialog, shell } from 'electron';
import { app, dialog } from 'electron';
import { printHelp } from './cli/help';
import { loadRawConfigStrict } from './config/load';
import {
@@ -19,12 +18,7 @@ import {
shouldHandleStatsDaemonCommandAtEntry,
} from './main-entry-runtime';
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
import {
detectInstalledFirstRunPluginCandidates,
detectInstalledMpvPlugin,
removeLegacyMpvPluginCandidates,
resolvePackagedRuntimePluginPath,
} from './main/runtime/first-run-setup-plugin';
import { resolvePackagedFirstRunPluginAssets } from './main/runtime/first-run-setup-plugin';
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
import { parseMpvLaunchMode } from './shared/mpv-launch-mode';
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
@@ -44,105 +38,16 @@ function applySanitizedEnv(sanitizedEnv: NodeJS.ProcessEnv): void {
}
function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined {
return (
resolvePackagedRuntimePluginPath({
const assets = resolvePackagedFirstRunPluginAssets({
dirname: __dirname,
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
}) ?? undefined
);
});
if (!assets) {
return 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 (response.response === 2) {
return 'cancel';
}
if (response.response === 1) {
return 'continue';
}
const candidates = detectInstalledFirstRunPluginCandidates({
platform: 'win32',
homeDir: os.homedir(),
appDataDir: app.getPath('appData'),
mpvExecutablePath: mpvPath,
});
const result = await removeLegacyMpvPluginCandidates({
candidates,
trashItem: (candidatePath) => shell.trashItem(candidatePath),
});
if (result.ok) {
await dialog.showMessageBox({
type: 'info',
title: 'Legacy mpv plugin removed',
message:
'Legacy mpv plugin removed. SubMiner-managed playback will use the bundled runtime plugin.',
});
return 'removed';
}
await dialog.showMessageBox({
type: 'error',
title: 'Could not remove legacy mpv plugin',
message: 'Some legacy SubMiner mpv plugin files could not be moved to the trash.',
detail: result.failedPaths.map((failure) => `${failure.path}: ${failure.message}`).join('\n'),
});
return 'cancel';
}
function createWindowsRuntimePluginPolicy() {
return {
detectInstalledMpvPlugin: (mpvPath: string) =>
detectInstalledMpvPlugin({
platform: 'win32',
homeDir: os.homedir(),
appDataDir: app.getPath('appData'),
mpvExecutablePath: mpvPath,
}),
notifyInstalledPluginDetected: (detection: {
installed: boolean;
path: string | null;
version: string | null;
}) => {
if (!detection.installed || !detection.path) return;
dialog.showMessageBoxSync({
type: 'warning',
title: 'SubMiner mpv plugin detected',
message: buildInstalledWindowsMpvPluginMessage(detection.path, detection.version),
});
},
resolveInstalledPluginBeforeLaunch: (
detection: { path: string | null; version: string | null },
mpvPath: string,
) => promptForWindowsLegacyMpvPluginRemoval(mpvPath, detection),
};
return path.join(assets.pluginDirSource, 'main.lua');
}
function readConfiguredWindowsMpvLaunch(configDir: string): {
@@ -212,7 +117,6 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
resolveBundledWindowsMpvPluginEntrypoint(),
configuredMpvLaunch.executablePath,
configuredMpvLaunch.launchMode,
createWindowsRuntimePluginPolicy(),
);
app.exit(result.ok ? 0 : 1);
});
+14 -126
View File
@@ -382,10 +382,7 @@ import {
} from './main/runtime/first-run-setup-window';
import {
detectInstalledFirstRunPlugin,
detectInstalledFirstRunPluginCandidates,
detectInstalledMpvPlugin,
removeLegacyMpvPluginCandidates,
resolvePackagedRuntimePluginPath,
installFirstRunPluginToDefaultLocation,
syncInstalledFirstRunPluginBinaryPath,
} from './main/runtime/first-run-setup-plugin';
import {
@@ -1066,89 +1063,6 @@ const managedLocalSubtitleSelectionRuntime = createManagedLocalSubtitleSelection
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
clearScheduled: (timer) => clearTimeout(timer),
});
function resolveBundledMpvRuntimePluginEntrypoint(): string | undefined {
return (
resolvePackagedRuntimePluginPath({
dirname: __dirname,
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
}) ?? undefined
);
}
function detectWindowsInstalledMpvPlugin(mpvExecutablePath: string) {
return detectInstalledMpvPlugin({
platform: 'win32',
homeDir: os.homedir(),
appDataDir: app.getPath('appData'),
mpvExecutablePath,
});
}
function logInstalledMpvPluginDetected(detection: { path: string | null; version: string | null }) {
if (!detection.path) return;
logger.warn(
`SubMiner detected an installed mpv plugin at ${detection.path}. This mpv session will use the installed plugin. Remove it to use the bundled runtime plugin automatically. Detected plugin version: ${detection.version ?? 'unknown or legacy'}.`,
);
}
async function promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(
mpvPath: string,
detection: { path: string | null; version: string | null },
): Promise<'removed' | 'continue' | 'cancel'> {
const response = await dialog.showMessageBox({
type: 'warning',
title: 'SubMiner mpv plugin detected',
message: [
'SubMiner detected an installed mpv plugin at:',
detection.path ?? 'unknown path',
'',
"This mpv session will use the installed plugin unless it is removed. Remove it now to use SubMiner's bundled runtime plugin automatically.",
`Detected plugin version: ${detection.version ?? 'unknown or legacy'}`,
].join('\n'),
detail:
'Remove the legacy SubMiner mpv plugin files from mpv before launching this video? This moves the files to the OS trash.',
buttons: ['Remove legacy plugin', 'Continue with installed plugin', 'Cancel'],
defaultId: 0,
cancelId: 2,
});
if (response.response === 2) {
return 'cancel';
}
if (response.response === 1) {
return 'continue';
}
const result = await removeLegacyMpvPluginCandidates({
candidates: detectInstalledFirstRunPluginCandidates({
platform: 'win32',
homeDir: os.homedir(),
appDataDir: app.getPath('appData'),
mpvExecutablePath: mpvPath,
}),
trashItem: (candidatePath) => shell.trashItem(candidatePath),
});
if (result.ok) {
await dialog.showMessageBox({
type: 'info',
title: 'Legacy mpv plugin removed',
message:
'Legacy mpv plugin removed. SubMiner-managed playback will use the bundled runtime plugin.',
});
return 'removed';
}
await dialog.showMessageBox({
type: 'error',
title: 'Could not remove legacy mpv plugin',
message: 'Some legacy SubMiner mpv plugin files could not be moved to the trash.',
detail: result.failedPaths.map((failure) => `${failure.path}: ${failure.message}`).join('\n'),
});
return 'cancel';
}
const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
platform: process.platform,
directPlaybackFormat: YOUTUBE_DIRECT_PLAYBACK_FORMAT,
@@ -1173,16 +1087,10 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
showError: (title, content) => dialog.showErrorBox(title, content),
}),
[...args, `--log-file=${DEFAULT_MPV_LOG_PATH}`],
process.execPath,
resolveBundledMpvRuntimePluginEntrypoint(),
undefined,
undefined,
getResolvedConfig().mpv.executablePath,
getResolvedConfig().mpv.launchMode,
{
detectInstalledMpvPlugin: detectWindowsInstalledMpvPlugin,
notifyInstalledPluginDetected: logInstalledMpvPluginDetected,
resolveInstalledPluginBeforeLaunch: (detection, mpvPath) =>
promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(mpvPath, detection),
},
),
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
@@ -1219,16 +1127,6 @@ const firstRunSetupService = createFirstRunSetupService({
isExternalYomitanConfigured: () =>
getResolvedConfig().yomitan.externalProfilePath.trim().length > 0,
detectPluginInstalled: () => {
const candidates = detectInstalledFirstRunPluginCandidates({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
appDataDir: app.getPath('appData'),
mpvExecutablePath: getResolvedConfig().mpv.executablePath,
});
if (candidates.length > 0) {
return true;
}
const installPaths = resolveDefaultMpvInstallPaths(
process.platform,
os.homedir(),
@@ -1236,18 +1134,15 @@ const firstRunSetupService = createFirstRunSetupService({
);
return detectInstalledFirstRunPlugin(installPaths);
},
detectLegacyMpvPluginCandidates: () =>
detectInstalledFirstRunPluginCandidates({
installPlugin: async () =>
installFirstRunPluginToDefaultLocation({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
appDataDir: app.getPath('appData'),
mpvExecutablePath: getResolvedConfig().mpv.executablePath,
}),
removeLegacyMpvPlugins: (candidates) =>
removeLegacyMpvPluginCandidates({
candidates,
trashItem: (candidatePath) => shell.trashItem(candidatePath),
dirname: __dirname,
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
binaryPath: process.execPath,
}),
detectWindowsMpvShortcuts: () => {
if (process.platform !== 'win32') {
@@ -1414,9 +1309,8 @@ const buildMainSubsyncRuntimeMainDepsHandler = createBuildMainSubsyncRuntimeMain
const immersionMediaRuntime = createImmersionMediaRuntime(
buildImmersionMediaRuntimeMainDepsHandler(),
);
const anilistRateLimiter = createAnilistRateLimiter();
const statsCoverArtFetcher = createCoverArtFetcher(
anilistRateLimiter,
createAnilistRateLimiter(),
createLogger('main:stats-cover-art'),
);
const anilistStateRuntime = createAnilistStateRuntime(buildAnilistStateRuntimeMainDepsHandler());
@@ -2745,7 +2639,6 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
externalYomitanConfigured: snapshot.externalYomitanConfigured,
pluginStatus: snapshot.pluginStatus,
pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
legacyMpvPluginPaths: snapshot.legacyMpvPluginPaths,
mpvExecutablePath,
mpvExecutablePathStatus: getConfiguredWindowsMpvPathStatus(mpvExecutablePath),
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
@@ -2755,8 +2648,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
buildSetupHtml: (model) => buildFirstRunSetupHtml(model),
parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl),
handleAction: async (submission: FirstRunSetupSubmission) => {
if (submission.action === 'remove-legacy-plugin') {
const snapshot = await firstRunSetupService.removeLegacyMpvPlugin();
if (submission.action === 'install-plugin') {
const snapshot = await firstRunSetupService.installMpvPlugin();
firstRunSetupMessage = snapshot.message;
return;
}
@@ -3105,9 +2998,7 @@ const {
},
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
updateAnilistPostWatchProgress(accessToken, title, episode, {
rateLimiter: anilistRateLimiter,
}),
updateAnilistPostWatchProgress(accessToken, title, episode),
markSuccess: (key) => {
anilistUpdateQueue.markSuccess(key);
},
@@ -3153,9 +3044,7 @@ const {
},
refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(),
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
updateAnilistPostWatchProgress(accessToken, title, episode, {
rateLimiter: anilistRateLimiter,
}),
updateAnilistPostWatchProgress(accessToken, title, episode),
rememberAttemptedUpdateKey: (key) => {
rememberAnilistAttemptedUpdate(key);
},
@@ -3362,7 +3251,6 @@ const startLocalStatsServer = (): void => {
knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'),
mpvSocketPath: appState.mpvSocketPath,
ankiConnectConfig: getResolvedConfig().ankiConnect,
anilistRateLimiter,
resolveAnkiNoteId: (noteId: number) =>
appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
addYomitanNote: async (word: string) => {
+112 -146
View File
@@ -5,11 +5,8 @@ import os from 'node:os';
import path from 'node:path';
import {
detectInstalledFirstRunPlugin,
detectInstalledFirstRunPluginCandidates,
detectInstalledMpvPlugin,
removeLegacyMpvPluginCandidates,
installFirstRunPluginToDefaultLocation,
resolvePackagedFirstRunPluginAssets,
resolvePackagedRuntimePluginPath,
syncInstalledFirstRunPluginBinaryPath,
} from './first-run-setup-plugin';
import { resolveDefaultMpvInstallPaths } from '../../shared/setup-state';
@@ -46,22 +43,125 @@ test('resolvePackagedFirstRunPluginAssets finds packaged plugin assets', () => {
});
});
test('resolvePackagedRuntimePluginPath returns packaged plugin entrypoint', () => {
test('installFirstRunPluginToDefaultLocation installs plugin and backs up existing files', () => {
withTempDir((root) => {
const resourcesPath = path.join(root, 'resources');
const pluginRoot = path.join(resourcesPath, 'plugin');
const entrypoint = path.join(pluginRoot, 'subminer', 'main.lua');
fs.mkdirSync(path.dirname(entrypoint), { recursive: true });
fs.writeFileSync(entrypoint, '-- plugin');
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n');
assert.equal(
resolvePackagedRuntimePluginPath({
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,
}),
entrypoint,
binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
});
assert.equal(result.ok, true);
assert.equal(result.pluginInstallStatus, 'installed');
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
assert.equal(fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'), '-- packaged plugin');
assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'configured=true\nbinary_path=/Applications/SubMiner.app/Contents/MacOS/SubMiner\n',
);
const scriptsDirEntries = fs.readdirSync(installPaths.scriptsDir);
const scriptOptsEntries = fs.readdirSync(installPaths.scriptOptsDir);
assert.equal(
scriptsDirEntries.some((entry) => entry.startsWith('subminer.bak.')),
true,
);
assert.equal(
scriptsDirEntries.some((entry) => entry.startsWith('subminer-loader.lua.bak.')),
true,
);
assert.equal(
scriptOptsEntries.some((entry) => entry.startsWith('subminer.conf.bak.')),
true,
);
});
});
test('installFirstRunPluginToDefaultLocation installs plugin to Windows mpv defaults', () => {
if (process.platform !== 'win32') {
return;
}
withTempDir((root) => {
const resourcesPath = path.join(root, 'resources');
const pluginRoot = path.join(resourcesPath, 'plugin');
const homeDir = path.join(root, 'home');
const installPaths = resolveDefaultMpvInstallPaths('win32', homeDir);
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n');
const result = installFirstRunPluginToDefaultLocation({
platform: 'win32',
homeDir,
dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'),
resourcesPath,
binaryPath: 'C:\\Program Files\\SubMiner\\SubMiner.exe',
});
assert.equal(result.ok, true);
assert.equal(result.pluginInstallStatus, 'installed');
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
assert.equal(fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'), '-- packaged plugin');
assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'configured=true\nbinary_path=C:\\Program Files\\SubMiner\\SubMiner.exe\n',
);
});
});
test('installFirstRunPluginToDefaultLocation rewrites Windows plugin socket_path', () => {
if (process.platform !== 'win32') {
return;
}
withTempDir((root) => {
const resourcesPath = path.join(root, 'resources');
const pluginRoot = path.join(resourcesPath, 'plugin');
const homeDir = path.join(root, 'home');
const installPaths = resolveDefaultMpvInstallPaths('win32', homeDir);
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
fs.writeFileSync(
path.join(pluginRoot, 'subminer.conf'),
'binary_path=\nsocket_path=/tmp/subminer-socket\n',
);
const result = installFirstRunPluginToDefaultLocation({
platform: 'win32',
homeDir,
dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'),
resourcesPath,
binaryPath: 'C:\\Program Files\\SubMiner\\SubMiner.exe',
});
assert.equal(result.ok, true);
assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'binary_path=C:\\Program Files\\SubMiner\\SubMiner.exe\nsocket_path=\\\\.\\pipe\\subminer-socket\n',
);
});
});
@@ -170,140 +270,6 @@ test('detectInstalledFirstRunPlugin ignores legacy loader file', () => {
});
});
test('detectInstalledFirstRunPluginCandidates returns all legacy autoload entries without script opts', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
const directoryInstall = installPaths.pluginDir;
const legacyScript = path.join(installPaths.scriptsDir, 'subminer.lua');
const legacyLoader = path.join(installPaths.scriptsDir, 'subminer-loader.lua');
fs.mkdirSync(directoryInstall, { recursive: true });
fs.writeFileSync(path.join(directoryInstall, 'main.lua'), '-- plugin');
fs.writeFileSync(legacyScript, '-- legacy plugin');
fs.writeFileSync(legacyLoader, '-- legacy loader');
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
fs.writeFileSync(installPaths.pluginConfigPath, 'socket_path=/tmp/subminer-socket\n');
const candidates = detectInstalledFirstRunPluginCandidates({
platform: 'linux',
homeDir,
xdgConfigHome,
});
assert.deepEqual(
candidates.map((candidate) => candidate.path).sort(),
[directoryInstall, legacyLoader, legacyScript].sort(),
);
assert.equal(
candidates.some((candidate) => candidate.path === installPaths.pluginConfigPath),
false,
);
});
});
test('detectInstalledFirstRunPluginCandidates includes Windows portable mpv scripts', () => {
withTempDir((root) => {
const homeDir = path.win32.join('C:\\Users', 'tester');
const appDataDir = path.win32.join(root, 'AppData', 'Roaming');
const mpvExecutablePath = path.win32.join(root, 'mpv', 'mpv.exe');
const portablePluginDir = path.win32.join(
path.win32.dirname(mpvExecutablePath),
'portable_config',
'scripts',
'subminer',
);
const portableLegacyScript = path.win32.join(
path.win32.dirname(mpvExecutablePath),
'portable_config',
'scripts',
'subminer.lua',
);
const existing = new Set([portablePluginDir, portableLegacyScript]);
const candidates = detectInstalledFirstRunPluginCandidates({
platform: 'win32',
homeDir,
appDataDir,
mpvExecutablePath,
existsSync: (candidate) => existing.has(candidate),
});
assert.deepEqual(
candidates.map((candidate) => candidate.path),
[portablePluginDir, portableLegacyScript],
);
});
});
test('detectInstalledMpvPlugin prefers Windows portable plugin and parses version', () => {
const homeDir = 'C:\\Users\\tester';
const appDataDir = 'C:\\Users\\tester\\AppData\\Roaming';
const mpvExecutablePath = 'C:\\tools\\mpv\\mpv.exe';
const portableEntrypoint = 'C:\\tools\\mpv\\portable_config\\scripts\\subminer\\main.lua';
const portableVersion = 'C:\\tools\\mpv\\portable_config\\scripts\\subminer\\version.lua';
const appDataEntrypoint = 'C:\\Users\\tester\\AppData\\Roaming\\mpv\\scripts\\subminer\\main.lua';
const existing = new Set([portableEntrypoint, portableVersion, appDataEntrypoint]);
const detection = detectInstalledMpvPlugin({
platform: 'win32',
homeDir,
appDataDir,
mpvExecutablePath,
existsSync: (candidate) => existing.has(candidate),
readFileSync: (candidate) =>
candidate === portableVersion ? 'return { version = "0.12.0" }' : '',
});
assert.equal(detection.installed, true);
assert.equal(detection.path, portableEntrypoint);
assert.equal(detection.version, '0.12.0');
assert.equal(detection.source, 'portable-config');
});
test('detectInstalledMpvPlugin detects Linux legacy single-file plugin without version', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const legacyPath = path.join(homeDir, '.config', 'mpv', 'scripts', 'subminer-loader.lua');
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
fs.writeFileSync(legacyPath, '-- legacy');
const detection = detectInstalledMpvPlugin({
platform: 'linux',
homeDir,
});
assert.equal(detection.installed, true);
assert.equal(detection.path, legacyPath);
assert.equal(detection.version, null);
assert.equal(detection.source, 'legacy-file');
});
});
test('removeLegacyMpvPluginCandidates trashes candidates and reports partial failures', async () => {
const calls: string[] = [];
const result = await removeLegacyMpvPluginCandidates({
candidates: [
{ path: '/tmp/mpv/scripts/subminer', kind: 'directory' },
{ path: '/tmp/mpv/scripts/subminer.lua', kind: 'file' },
],
trashItem: async (candidate) => {
calls.push(candidate);
if (candidate.endsWith('subminer.lua')) {
throw new Error('permission denied');
}
},
});
assert.deepEqual(calls, ['/tmp/mpv/scripts/subminer', '/tmp/mpv/scripts/subminer.lua']);
assert.equal(result.ok, false);
assert.deepEqual(result.removedPaths, ['/tmp/mpv/scripts/subminer']);
assert.deepEqual(result.failedPaths, [
{ path: '/tmp/mpv/scripts/subminer.lua', message: 'permission denied' },
]);
});
test('detectInstalledFirstRunPlugin requires main.lua in subminer directory', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
+47 -228
View File
@@ -1,30 +1,15 @@
import fs from 'node:fs';
import path from 'node:path';
import { resolveDefaultMpvInstallPaths, type MpvInstallPaths } from '../../shared/setup-state';
import type { PluginInstallResult } from './first-run-setup-service';
export interface InstalledFirstRunPluginCandidate {
path: string;
kind: 'directory' | 'file';
function timestamp(): string {
return new Date().toISOString().replaceAll(':', '-');
}
export type InstalledMpvPluginSource =
| 'default-config'
| 'xdg-config'
| 'portable-config'
| 'legacy-file';
export interface InstalledMpvPluginDetection {
installed: boolean;
path: string | null;
version: string | null;
source: InstalledMpvPluginSource | null;
message: string | null;
}
export interface LegacyMpvPluginRemovalResult {
ok: boolean;
removedPaths: string[];
failedPaths: Array<{ path: string; message: string }>;
function backupExistingPath(targetPath: string): void {
if (!fs.existsSync(targetPath)) return;
fs.renameSync(targetPath, `${targetPath}.bak.${timestamp()}`);
}
function rewriteInstalledWindowsPluginConfig(configPath: string): void {
@@ -104,30 +89,6 @@ export function resolvePackagedFirstRunPluginAssets(deps: {
return null;
}
export function resolvePackagedRuntimePluginPath(deps: {
dirname: string;
appPath: string;
resourcesPath: string;
joinPath?: (...parts: string[]) => string;
existsSync?: (candidate: string) => boolean;
}): string | null {
const joinPath = deps.joinPath ?? path.join;
const existsSync = deps.existsSync ?? fs.existsSync;
const assets = resolvePackagedFirstRunPluginAssets({
dirname: deps.dirname,
appPath: deps.appPath,
resourcesPath: deps.resourcesPath,
joinPath,
existsSync,
});
if (!assets) {
return null;
}
const entrypoint = joinPath(assets.pluginDirSource, 'main.lua');
return existsSync(entrypoint) ? entrypoint : null;
}
export function detectInstalledFirstRunPlugin(
installPaths: MpvInstallPaths,
deps?: {
@@ -139,203 +100,61 @@ export function detectInstalledFirstRunPlugin(
return existsSync(pluginEntrypointPath);
}
function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
return platform === 'win32' ? path.win32 : path.posix;
}
interface MpvConfigRootCandidate {
root: string;
source: Exclude<InstalledMpvPluginSource, 'legacy-file'>;
}
function collectMpvConfigRootCandidates(options: {
export function installFirstRunPluginToDefaultLocation(options: {
platform: NodeJS.Platform;
homeDir: string;
xdgConfigHome?: string;
appDataDir?: string;
mpvExecutablePath?: string;
}): MpvConfigRootCandidate[] {
const platformPath = getPlatformPath(options.platform);
if (options.platform === 'win32') {
const roots: MpvConfigRootCandidate[] = [];
if (options.mpvExecutablePath?.trim()) {
roots.push({
root: platformPath.join(
platformPath.dirname(options.mpvExecutablePath.trim()),
'portable_config',
),
source: 'portable-config',
});
}
roots.push({
root: platformPath.join(
options.appDataDir?.trim() || platformPath.join(options.homeDir, 'AppData', 'Roaming'),
'mpv',
),
source: 'default-config',
});
return roots;
}
const xdgRoot = options.xdgConfigHome?.trim()
? platformPath.join(options.xdgConfigHome.trim(), 'mpv')
: null;
const homeRoot = platformPath.join(options.homeDir, '.config', 'mpv');
const roots: MpvConfigRootCandidate[] = [];
if (xdgRoot) {
roots.push({ root: xdgRoot, source: 'xdg-config' });
}
if (!xdgRoot || xdgRoot !== homeRoot) {
roots.push({ root: homeRoot, source: 'default-config' });
}
return roots;
}
export function detectInstalledFirstRunPluginCandidates(options: {
platform: NodeJS.Platform;
homeDir: string;
xdgConfigHome?: string;
appDataDir?: string;
mpvExecutablePath?: string;
existsSync?: (candidate: string) => boolean;
}): InstalledFirstRunPluginCandidate[] {
const platformPath = getPlatformPath(options.platform);
const existsSync = options.existsSync ?? fs.existsSync;
const roots = collectMpvConfigRootCandidates(options);
const candidates: InstalledFirstRunPluginCandidate[] = [];
const seen = new Set<string>();
const pushIfExists = (
candidate: InstalledFirstRunPluginCandidate,
verifyPath = candidate.path,
) => {
if (seen.has(candidate.path) || !existsSync(verifyPath)) return;
seen.add(candidate.path);
candidates.push(candidate);
};
for (const root of roots) {
const scriptsDir = platformPath.join(root.root, 'scripts');
const pluginDir = platformPath.join(scriptsDir, 'subminer');
pushIfExists({ path: pluginDir, kind: 'directory' });
pushIfExists({ path: platformPath.join(scriptsDir, 'subminer.lua'), kind: 'file' });
pushIfExists({ path: platformPath.join(scriptsDir, 'subminer-loader.lua'), kind: 'file' });
}
return candidates;
}
function parseInstalledPluginVersion(content: string): string | null {
const match = content.match(/\bversion\s*=\s*["']([^"']+)["']/);
return match?.[1] ?? null;
}
function readInstalledPluginVersion(options: {
pluginEntrypointPath: string;
platformPath: typeof path.posix | typeof path.win32;
existsSync: (candidate: string) => boolean;
readFileSync: (candidate: string, encoding: BufferEncoding) => string;
}): string | null {
const versionPath = options.platformPath.join(
options.platformPath.dirname(options.pluginEntrypointPath),
'version.lua',
dirname: string;
appPath: string;
resourcesPath: string;
binaryPath: string;
}): PluginInstallResult {
const installPaths = resolveDefaultMpvInstallPaths(
options.platform,
options.homeDir,
options.xdgConfigHome,
);
if (!options.existsSync(versionPath)) {
return null;
}
try {
return parseInstalledPluginVersion(options.readFileSync(versionPath, 'utf8'));
} catch {
return null;
}
if (!installPaths.supported) {
return {
ok: false,
pluginInstallStatus: 'failed',
pluginInstallPathSummary: installPaths.mpvConfigDir,
message: 'Automatic mpv plugin install is not supported on this platform yet.',
};
}
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,
const assets = resolvePackagedFirstRunPluginAssets({
dirname: options.dirname,
appPath: options.appPath,
resourcesPath: options.resourcesPath,
});
if (!assets) {
return {
installed: true,
path: directoryEntrypoint,
version,
source: root.source,
message: `SubMiner detected an installed mpv plugin at: ${directoryEntrypoint}`,
ok: false,
pluginInstallStatus: 'failed',
pluginInstallPathSummary: installPaths.mpvConfigDir,
message: 'Packaged mpv plugin assets were not found.',
};
}
for (const legacyPath of [
platformPath.join(scriptsDir, 'subminer.lua'),
platformPath.join(scriptsDir, 'subminer-loader.lua'),
]) {
if (existsSync(legacyPath)) {
return {
installed: true,
path: legacyPath,
version: null,
source: root.source === 'portable-config' ? 'portable-config' : 'legacy-file',
message: `SubMiner detected an installed mpv plugin at: ${legacyPath}`,
};
}
}
fs.mkdirSync(installPaths.scriptsDir, { recursive: true });
fs.mkdirSync(installPaths.scriptOptsDir, { recursive: true });
backupExistingPath(path.join(installPaths.scriptsDir, 'subminer.lua'));
backupExistingPath(path.join(installPaths.scriptsDir, 'subminer-loader.lua'));
backupExistingPath(installPaths.pluginDir);
backupExistingPath(installPaths.pluginConfigPath);
fs.cpSync(assets.pluginDirSource, installPaths.pluginDir, { recursive: true });
fs.copyFileSync(assets.pluginConfigSource, installPaths.pluginConfigPath);
rewriteInstalledPluginBinaryPath(installPaths.pluginConfigPath, options.binaryPath);
if (options.platform === 'win32') {
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
}
return {
installed: false,
path: null,
version: null,
source: null,
message: null,
};
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export async function removeLegacyMpvPluginCandidates(options: {
candidates: InstalledFirstRunPluginCandidate[];
trashItem: (path: string) => Promise<void>;
}): Promise<LegacyMpvPluginRemovalResult> {
const removedPaths: string[] = [];
const failedPaths: Array<{ path: string; message: string }> = [];
const seen = new Set<string>();
for (const candidate of options.candidates) {
if (seen.has(candidate.path)) continue;
seen.add(candidate.path);
try {
await options.trashItem(candidate.path);
removedPaths.push(candidate.path);
} catch (error) {
failedPaths.push({ path: candidate.path, message: errorMessage(error) });
}
}
return {
ok: failedPaths.length === 0,
removedPaths,
failedPaths,
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: installPaths.mpvConfigDir,
message: `Installed mpv plugin to ${installPaths.mpvConfigDir}.`,
};
}
+11 -117
View File
@@ -159,17 +159,18 @@ test('setup service auto-completes legacy installs with config and dictionaries'
});
});
test('setup service allows finish without global mpv plugin once dictionaries are ready', async () => {
test('setup service requires mpv plugin install before finish', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
let dictionaryCount = 0;
let pluginInstalled = false;
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => dictionaryCount,
detectPluginInstalled: () => false,
detectPluginInstalled: () => pluginInstalled,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
@@ -183,6 +184,11 @@ test('setup service allows finish without global mpv plugin once dictionaries ar
assert.equal(initial.state.status, 'incomplete');
assert.equal(initial.canFinish, false);
const installed = await service.installMpvPlugin();
assert.equal(installed.state.pluginInstallStatus, 'installed');
assert.equal(installed.pluginInstallPathSummary, '/tmp/mpv');
pluginInstalled = true;
dictionaryCount = 1;
const refreshed = await service.refreshStatus();
assert.equal(refreshed.canFinish, true);
@@ -298,7 +304,7 @@ test('setup service reopens when external-yomitan completion later has no extern
});
});
test('setup service keeps completed when a global mpv plugin is removed later', async () => {
test('setup service reopens when a completed setup no longer has the mpv plugin installed', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
@@ -334,41 +340,12 @@ test('setup service keeps completed when a global mpv plugin is removed later',
});
const snapshot = await service.ensureSetupStateInitialized();
assert.equal(snapshot.state.status, 'completed');
assert.equal(snapshot.canFinish, true);
assert.equal(snapshot.state.status, 'incomplete');
assert.equal(snapshot.canFinish, false);
assert.equal(snapshot.pluginStatus, 'required');
});
});
test('setup service reopens completed setup as in-progress when legacy mpv plugin removal is needed', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 2,
detectPluginInstalled: () => true,
detectLegacyMpvPluginCandidates: () => [
{ path: '/tmp/mpv/scripts/subminer', kind: 'directory' },
],
onStateChanged: () => undefined,
});
await service.ensureSetupStateInitialized();
await service.markSetupCompleted();
const inProgress = await service.markSetupInProgress();
assert.equal(inProgress.state.status, 'in_progress');
assert.equal(inProgress.state.completedAt, null);
const completed = await service.markSetupCompleted();
assert.equal(completed.state.status, 'completed');
assert.notEqual(completed.state.completedAt, null);
});
});
test('setup service keeps completed when external-yomitan completion later has internal dictionaries available', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
@@ -513,86 +490,3 @@ test('setup service persists Windows mpv shortcut preferences and status with on
assert.deepEqual(stateChanges, ['installed']);
});
});
test('setup service removes legacy mpv plugin candidates and refreshes detection', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
let legacyCandidates = [{ path: '/tmp/mpv/scripts/subminer', kind: 'directory' as const }];
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => legacyCandidates.length > 0,
detectLegacyMpvPluginCandidates: () => legacyCandidates,
removeLegacyMpvPlugins: async (candidates) => {
assert.deepEqual(candidates, legacyCandidates);
legacyCandidates = [];
return {
ok: true,
removedPaths: ['/tmp/mpv/scripts/subminer'],
failedPaths: [],
};
},
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
const before = await service.refreshStatus();
assert.deepEqual(before.legacyMpvPluginPaths, ['/tmp/mpv/scripts/subminer']);
const removed = await service.removeLegacyMpvPlugin();
assert.equal(
removed.message,
'Legacy mpv plugin removed. Regular mpv will no longer load SubMiner. SubMiner-managed playback will use the bundled runtime plugin.',
);
assert.deepEqual(removed.legacyMpvPluginPaths, []);
});
});
test('setup service reports failed legacy mpv plugin trash paths', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const legacyCandidates = [
{ path: '/tmp/mpv/scripts/subminer', kind: 'directory' as const },
{ path: '/tmp/mpv/scripts/subminer.lua', kind: 'file' as const },
];
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => true,
detectLegacyMpvPluginCandidates: () => legacyCandidates,
removeLegacyMpvPlugins: async () => ({
ok: false,
removedPaths: ['/tmp/mpv/scripts/subminer'],
failedPaths: [{ path: '/tmp/mpv/scripts/subminer.lua', message: 'permission denied' }],
}),
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
const removed = await service.removeLegacyMpvPlugin();
assert.equal(
removed.message,
'Removed 1 legacy mpv plugin path, but failed to remove: /tmp/mpv/scripts/subminer.lua (permission denied). Delete the failed paths manually from mpv scripts.',
);
assert.deepEqual(removed.legacyMpvPluginPaths, [
'/tmp/mpv/scripts/subminer',
'/tmp/mpv/scripts/subminer.lua',
]);
});
});
+20 -56
View File
@@ -11,10 +11,6 @@ import {
type SetupState,
} from '../../shared/setup-state';
import type { CliArgs } from '../../cli/args';
import type {
InstalledFirstRunPluginCandidate,
LegacyMpvPluginRemovalResult,
} from './first-run-setup-plugin';
export interface SetupWindowsMpvShortcutSnapshot {
supported: boolean;
@@ -33,7 +29,6 @@ export interface SetupStatusSnapshot {
externalYomitanConfigured: boolean;
pluginStatus: 'installed' | 'required' | 'failed';
pluginInstallPathSummary: string | null;
legacyMpvPluginPaths: string[];
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
message: string | null;
state: SetupState;
@@ -53,7 +48,7 @@ export interface FirstRunSetupService {
markSetupInProgress: () => Promise<SetupStatusSnapshot>;
markSetupCancelled: () => Promise<SetupStatusSnapshot>;
markSetupCompleted: () => Promise<SetupStatusSnapshot>;
removeLegacyMpvPlugin: () => Promise<SetupStatusSnapshot>;
installMpvPlugin: () => Promise<SetupStatusSnapshot>;
configureWindowsMpvShortcuts: (preferences: {
startMenuEnabled: boolean;
desktopEnabled: boolean;
@@ -181,6 +176,9 @@ export function getFirstRunSetupCompletionMessage(snapshot: {
if (!snapshot.configReady) {
return 'Create or provide the config file before finishing setup.';
}
if (snapshot.pluginStatus !== 'installed') {
return 'Install the mpv plugin before finishing setup.';
}
if (!snapshot.externalYomitanConfigured && snapshot.dictionaryCount < 1) {
return 'Install at least one Yomitan dictionary before finishing setup.';
}
@@ -221,13 +219,7 @@ export function createFirstRunSetupService(deps: {
getYomitanDictionaryCount: () => Promise<number>;
isExternalYomitanConfigured?: () => boolean;
detectPluginInstalled: () => boolean | Promise<boolean>;
detectLegacyMpvPluginCandidates?: () =>
| InstalledFirstRunPluginCandidate[]
| Promise<InstalledFirstRunPluginCandidate[]>;
installPlugin?: () => Promise<PluginInstallResult>;
removeLegacyMpvPlugins?: (
candidates: InstalledFirstRunPluginCandidate[],
) => Promise<LegacyMpvPluginRemovalResult>;
installPlugin: () => Promise<PluginInstallResult>;
detectWindowsMpvShortcuts?: () =>
| { startMenuInstalled: boolean; desktopInstalled: boolean }
| Promise<{ startMenuInstalled: boolean; desktopInstalled: boolean }>;
@@ -258,7 +250,6 @@ export function createFirstRunSetupService(deps: {
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
});
const pluginInstalled = await deps.detectPluginInstalled();
const legacyMpvPluginCandidates = (await deps.detectLegacyMpvPluginCandidates?.()) ?? [];
const detectedWindowsMpvShortcuts = isWindows
? await deps.detectWindowsMpvShortcuts?.()
: undefined;
@@ -273,7 +264,9 @@ export function createFirstRunSetupService(deps: {
return {
configReady,
dictionaryCount,
canFinish: isYomitanSetupSatisfied({
canFinish:
pluginInstalled &&
isYomitanSetupSatisfied({
configReady,
dictionaryCount,
externalYomitanConfigured,
@@ -281,7 +274,6 @@ export function createFirstRunSetupService(deps: {
externalYomitanConfigured,
pluginStatus: getPluginStatus(state, pluginInstalled),
pluginInstallPathSummary: state.pluginInstallPathSummary,
legacyMpvPluginPaths: legacyMpvPluginCandidates.map((candidate) => candidate.path),
windowsMpvShortcuts: {
supported: isWindows,
startMenuEnabled: effectiveWindowsMpvShortcutPreferences.startMenuEnabled,
@@ -316,7 +308,10 @@ export function createFirstRunSetupService(deps: {
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
});
const canFinish = isYomitanSetupSatisfied({
const pluginInstalled = await deps.detectPluginInstalled();
const canFinish =
pluginInstalled &&
isYomitanSetupSatisfied({
configReady,
dictionaryCount,
externalYomitanConfigured,
@@ -354,21 +349,9 @@ export function createFirstRunSetupService(deps: {
markSetupInProgress: async () => {
const state = readState();
if (state.status === 'completed') {
const legacyMpvPluginCandidates = (await deps.detectLegacyMpvPluginCandidates?.()) ?? [];
if (legacyMpvPluginCandidates.length === 0) {
completed = true;
return refreshWithState(state);
}
completed = false;
return refreshWithState(
writeState({
...state,
status: 'in_progress',
completedAt: null,
completionSource: null,
}),
);
}
return refreshWithState(writeState({ ...state, status: 'in_progress' }));
},
markSetupCancelled: async () => {
@@ -396,34 +379,15 @@ export function createFirstRunSetupService(deps: {
}),
);
},
removeLegacyMpvPlugin: async () => {
const candidates = (await deps.detectLegacyMpvPluginCandidates?.()) ?? [];
if (candidates.length === 0) {
return refreshWithState(readState(), 'No legacy mpv plugin files were found.');
}
if (!deps.removeLegacyMpvPlugins) {
installMpvPlugin: async () => {
const result = await deps.installPlugin();
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(
readState(),
`Removed ${removedText}, but failed to remove: ${failedText}. Delete the failed paths manually from mpv scripts.`,
writeState({
...readState(),
pluginInstallStatus: result.pluginInstallStatus,
pluginInstallPathSummary: result.pluginInstallPathSummary,
}),
result.message,
);
},
configureWindowsMpvShortcuts: async (preferences) => {
@@ -30,11 +30,8 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
});
assert.match(html, /SubMiner setup/);
assert.doesNotMatch(html, /Install legacy mpv plugin/);
assert.doesNotMatch(html, /action=install-plugin/);
assert.match(html, /Ready/);
assert.doesNotMatch(html, /Bundled ready/);
assert.match(html, /Managed mpv launches use the bundled runtime plugin\./);
assert.match(html, /Install mpv plugin/);
assert.match(html, /Required before SubMiner setup can finish/);
assert.match(html, /Open Yomitan Settings/);
assert.match(html, /Finish setup/);
assert.match(html, /disabled/);
@@ -61,49 +58,14 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
message: null,
});
assert.doesNotMatch(html, /Reinstall mpv plugin/);
assert.doesNotMatch(html, /action=install-plugin/);
assert.match(html, /Reinstall mpv plugin/);
assert.match(html, /mpv executable path/);
assert.match(html, /Leave blank to auto-discover mpv\.exe from PATH\./);
assert.match(html, /aria-label="Path to mpv\.exe"/);
assert.match(html, /SubMiner-managed mpv launches use the bundled runtime plugin\./);
});
test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirmation', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 1,
canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
legacyMpvPluginPaths: ['/tmp/mpv/scripts/subminer', '/tmp/mpv/scripts/subminer.lua'],
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
message: null,
});
assert.match(html, /Legacy mpv plugin/);
assert.match(html, /Legacy detected/);
assert.match(html, /\/tmp\/mpv\/scripts\/subminer/);
assert.match(html, /\/tmp\/mpv\/scripts\/subminer\.lua/);
assert.match(html, /Remove legacy mpv plugin/);
assert.match(html, /class="legacy-remove"/);
assert.match(html, /\.legacy-remove/);
assert.match(html, /Continue without removing/);
assert.match(
html,
/Remove these SubMiner mpv plugin files from mpv.s scripts directory\? This stops regular mpv from loading SubMiner\./,
/Finish stays unlocked once the mpv plugin is installed and Yomitan reports at least one installed dictionary\./,
);
assert.match(html, /action=remove-legacy-plugin/);
});
test('buildFirstRunSetupHtml marks an invalid configured mpv path as invalid', () => {
@@ -196,12 +158,6 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
action: 'refresh',
});
assert.deepEqual(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=remove-legacy-plugin'),
{
action: 'remove-legacy-plugin',
},
);
assert.equal(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'),
null,
@@ -221,7 +177,7 @@ test('first-run setup window handler focuses existing window', () => {
assert.deepEqual(calls, ['focus']);
});
test('first-run setup navigation handler prevents default and dispatches supported action', async () => {
test('first-run setup navigation handler prevents default and dispatches action', async () => {
const calls: string[] = [];
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
@@ -232,20 +188,13 @@ test('first-run setup navigation handler prevents default and dispatches support
});
const prevented = handleNavigation({
url: 'subminer://first-run-setup?action=refresh',
url: 'subminer://first-run-setup?action=install-plugin',
preventDefault: () => calls.push('preventDefault'),
});
assert.equal(prevented, true);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['preventDefault', 'refresh']);
});
test('first-run setup parser rejects legacy global plugin install action', () => {
assert.equal(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=install-plugin'),
null,
);
assert.deepEqual(calls, ['preventDefault', 'install-plugin']);
});
test('first-run setup navigation handler swallows stale custom-scheme actions', () => {
+18 -54
View File
@@ -18,7 +18,7 @@ type FirstRunSetupWindowLike = FocusableWindowLike & {
export type FirstRunSetupAction =
| 'configure-mpv-executable-path'
| 'remove-legacy-plugin'
| 'install-plugin'
| 'configure-windows-mpv-shortcuts'
| 'open-yomitan-settings'
| 'refresh'
@@ -38,7 +38,6 @@ export interface FirstRunSetupHtmlModel {
externalYomitanConfigured: boolean;
pluginStatus: 'installed' | 'required' | 'failed';
pluginInstallPathSummary: string | null;
legacyMpvPluginPaths?: string[];
mpvExecutablePath: string;
mpvExecutablePathStatus: 'blank' | 'configured' | 'invalid';
windowsMpvShortcuts: {
@@ -65,19 +64,20 @@ function renderStatusBadge(value: string, tone: 'ready' | 'warn' | 'muted' | 'da
}
export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
const legacyMpvPluginPaths = model.legacyMpvPluginPaths ?? [];
const finishButtonLabel =
legacyMpvPluginPaths.length > 0 && model.canFinish
? 'Continue without removing'
: 'Finish setup';
const pluginActionLabel =
model.pluginStatus === 'installed' ? 'Reinstall mpv plugin' : 'Install mpv plugin';
const pluginLabel =
legacyMpvPluginPaths.length > 0
? 'Legacy detected'
model.pluginStatus === 'installed'
? 'Installed'
: model.pluginStatus === 'failed'
? 'Failed'
: 'Ready';
: 'Required';
const pluginTone =
legacyMpvPluginPaths.length > 0 ? 'warn' : model.pluginStatus === 'failed' ? 'danger' : 'ready';
model.pluginStatus === 'installed'
? 'ready'
: model.pluginStatus === 'failed'
? 'danger'
: 'warn';
const windowsShortcutLabel =
model.windowsMpvShortcuts.status === 'installed'
? 'Installed'
@@ -159,23 +159,6 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</form>
</div>`
: '';
const legacyPluginCard =
legacyMpvPluginPaths.length > 0
? `
<div class="card block">
<div class="card-head">
<div>
<strong>Legacy mpv plugin</strong>
<div class="meta">Regular mpv still loads SubMiner from these mpv scripts paths.</div>
</div>
${renderStatusBadge('Found', 'warn')}
</div>
<ul class="legacy-paths">
${legacyMpvPluginPaths.map((pluginPath) => `<li>${escapeHtml(pluginPath)}</li>`).join('')}
</ul>
<button class="legacy-remove" onclick="if (confirm(&quot;Remove these SubMiner mpv plugin files from mpv's scripts directory? This stops regular mpv from loading SubMiner. SubMiner-managed playback will keep working with the bundled runtime plugin.&quot;)) window.location.href='subminer://first-run-setup?action=remove-legacy-plugin'">Remove legacy mpv plugin</button>
</div>`
: '';
const yomitanMeta = model.externalYomitanConfigured
? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.'
@@ -196,8 +179,8 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
: model.canFinish
? model.externalYomitanConfigured
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
: 'Finish stays unlocked once Yomitan reports at least one installed dictionary. SubMiner-managed mpv launches use the bundled runtime plugin.'
: 'Finish stays locked until Yomitan reports at least one installed dictionary.';
: 'Finish stays unlocked once the mpv plugin is installed and Yomitan reports at least one installed dictionary.'
: 'Finish stays locked until the mpv plugin is installed and Yomitan reports at least one installed dictionary.';
return `<!doctype html>
<html>
@@ -324,18 +307,6 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
background: transparent;
border: 1px solid rgba(202, 211, 245, 0.12);
}
button.legacy-remove {
display: inline-flex;
justify-content: center;
align-items: center;
min-width: 220px;
border: 1px solid rgba(237, 135, 150, 0.38);
background: rgba(237, 135, 150, 0.14);
color: #f5b1ba;
}
button.legacy-remove:hover {
background: rgba(237, 135, 150, 0.22);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
@@ -350,13 +321,6 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
color: var(--muted);
font-size: 12px;
}
.legacy-paths {
margin: 10px 0 12px;
padding-left: 18px;
color: var(--muted);
font-size: 12px;
overflow-wrap: anywhere;
}
</style>
</head>
<body>
@@ -371,9 +335,9 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</div>
<div class="card">
<div>
<strong>mpv runtime plugin</strong>
<strong>mpv plugin</strong>
<div class="meta">${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}</div>
<div class="meta">Managed mpv launches use the bundled runtime plugin.</div>
<div class="meta">Required before SubMiner setup can finish.</div>
</div>
${renderStatusBadge(pluginLabel, pluginTone)}
</div>
@@ -386,11 +350,11 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</div>
${mpvExecutablePathCard}
${windowsShortcutCard}
${legacyPluginCard}
<div class="actions">
<button onclick="window.location.href='subminer://first-run-setup?action=install-plugin'">${pluginActionLabel}</button>
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">${finishButtonLabel}</button>
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">Finish setup</button>
</div>
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
<div class="footer">${escapeHtml(footerMessage)}</div>
@@ -407,7 +371,7 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
const action = parsed.searchParams.get('action');
if (
action !== 'configure-mpv-executable-path' &&
action !== 'remove-legacy-plugin' &&
action !== 'install-plugin' &&
action !== 'configure-windows-mpv-shortcuts' &&
action !== 'open-yomitan-settings' &&
action !== 'refresh' &&
@@ -17,8 +17,8 @@ test('createCreateFirstRunSetupWindowHandler builds first-run setup window', ()
assert.deepEqual(createSetupWindow(), { id: 'first-run' });
assert.deepEqual(options, {
width: 560,
height: 640,
width: 480,
height: 460,
title: 'SubMiner Setup',
show: true,
autoHideMenuBar: true,
+2 -2
View File
@@ -32,8 +32,8 @@ export function createCreateFirstRunSetupWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
}) {
return createSetupWindowHandler(deps, {
width: 560,
height: 640,
width: 480,
height: 460,
title: 'SubMiner Setup',
resizable: false,
minimizable: false,
@@ -230,104 +230,6 @@ test('launchWindowsMpv spawns detached mpv with targets', async () => {
]);
});
test('launchWindowsMpv skips bundled script when installed plugin is detected', async () => {
const calls: string[] = [];
const notifications: string[] = [];
const result = await launchWindowsMpv(
['C:\\video.mkv'],
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
spawnDetached: async (command, args) => {
calls.push(command);
calls.push(args.join('|'));
},
}),
[],
'C:\\SubMiner\\SubMiner.exe',
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
'',
'normal',
{
detectInstalledMpvPlugin: () => ({
installed: true,
path: 'C:\\Users\\tester\\AppData\\Roaming\\mpv\\scripts\\subminer\\main.lua',
version: null,
source: 'default-config',
message: null,
}),
notifyInstalledPluginDetected: (detection) => {
notifications.push(detection.path ?? '');
},
},
);
assert.equal(result.ok, true);
assert.equal(calls[0], 'C:\\mpv\\mpv.exe');
assert.doesNotMatch(calls[1] ?? '', /--script=C:\\Program Files\\SubMiner/);
assert.match(calls[1] ?? '', /--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner\.exe/);
assert.deepEqual(notifications, [
'C:\\Users\\tester\\AppData\\Roaming\\mpv\\scripts\\subminer\\main.lua',
]);
});
test('launchWindowsMpv prompts before launch and injects bundled script after legacy plugin removal', async () => {
const calls: string[] = [];
const prompts: string[] = [];
let detectCalls = 0;
const result = await launchWindowsMpv(
['C:\\video.mkv'],
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
spawnDetached: async (command, args) => {
calls.push(command);
calls.push(args.join('|'));
},
}),
[],
'C:\\SubMiner\\SubMiner.exe',
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
'',
'normal',
{
detectInstalledMpvPlugin: () => {
detectCalls += 1;
return detectCalls === 1
? {
installed: true,
path: 'C:\\Users\\tester\\AppData\\Roaming\\mpv\\scripts\\subminer\\main.lua',
version: '0.12.0',
source: 'default-config',
message: null,
}
: {
installed: false,
path: null,
version: null,
source: null,
message: null,
};
},
resolveInstalledPluginBeforeLaunch: async (detection) => {
prompts.push(detection.path ?? '');
return 'removed' as const;
},
},
);
assert.equal(result.ok, true);
assert.equal(detectCalls, 2);
assert.deepEqual(prompts, [
'C:\\Users\\tester\\AppData\\Roaming\\mpv\\scripts\\subminer\\main.lua',
]);
assert.equal(calls[0], 'C:\\mpv\\mpv.exe');
assert.match(
calls[1] ?? '',
/--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main\.lua/,
);
});
test('launchWindowsMpv reports spawn failures with path context', async () => {
const errors: string[] = [];
const result = await launchWindowsMpv(
+3 -43
View File
@@ -2,7 +2,6 @@ import fs from 'node:fs';
import { spawn, spawnSync } from 'node:child_process';
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
import type { MpvLaunchMode } from '../../types/config';
import type { InstalledMpvPluginDetection } from './first-run-setup-plugin';
export interface WindowsMpvLaunchDeps {
getEnv: (name: string) => string | undefined;
@@ -14,15 +13,6 @@ export interface WindowsMpvLaunchDeps {
export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid';
export interface WindowsMpvRuntimePluginPolicy {
detectInstalledMpvPlugin?: (mpvPath: string) => InstalledMpvPluginDetection;
notifyInstalledPluginDetected?: (detection: InstalledMpvPluginDetection) => void;
resolveInstalledPluginBeforeLaunch?: (
detection: InstalledMpvPluginDetection,
mpvPath: string,
) => Promise<'removed' | 'continue' | 'cancel'> | 'removed' | 'continue' | 'cancel';
}
function normalizeCandidate(candidate: string | undefined): string {
return typeof candidate === 'string' ? candidate.trim() : '';
}
@@ -110,12 +100,10 @@ export function buildWindowsMpvLaunchArgs(
typeof pluginEntrypointPath === 'string' && pluginEntrypointPath.trim().length > 0
? `--script=${pluginEntrypointPath.trim()}`
: null;
const hasBinaryPath = typeof binaryPath === 'string' && binaryPath.trim().length > 0;
const shouldPassSubminerScriptOpts = scriptEntrypoint || hasBinaryPath;
const scriptOptPairs = shouldPassSubminerScriptOpts
const scriptOptPairs = scriptEntrypoint
? [`subminer-socket_path=${inputIpcServer.replace(/,/g, '\\,')}`]
: [];
if (hasBinaryPath) {
if (scriptEntrypoint && typeof binaryPath === 'string' && binaryPath.trim().length > 0) {
scriptOptPairs.unshift(`subminer-binary_path=${binaryPath.trim().replace(/,/g, '\\,')}`);
}
const scriptOpts = scriptOptPairs.length > 0 ? `--script-opts=${scriptOptPairs.join(',')}` : null;
@@ -148,7 +136,6 @@ export async function launchWindowsMpv(
pluginEntrypointPath?: string,
configuredMpvPath?: string,
launchMode: MpvLaunchMode = 'normal',
runtimePluginPolicy?: WindowsMpvRuntimePluginPolicy,
): Promise<{ ok: boolean; mpvPath: string }> {
const normalizedConfiguredPath = normalizeCandidate(configuredMpvPath);
const mpvPath = resolveWindowsMpvPath(deps, normalizedConfiguredPath);
@@ -163,36 +150,9 @@ export async function launchWindowsMpv(
}
try {
let installedPlugin = runtimePluginPolicy?.detectInstalledMpvPlugin?.(mpvPath);
let installedPluginPrompted = false;
if (installedPlugin?.installed) {
const resolution = await runtimePluginPolicy?.resolveInstalledPluginBeforeLaunch?.(
installedPlugin,
mpvPath,
);
installedPluginPrompted = resolution != null;
if (resolution === 'cancel') {
return { ok: false, mpvPath };
}
if (resolution === 'removed') {
installedPlugin = runtimePluginPolicy?.detectInstalledMpvPlugin?.(mpvPath);
}
}
const runtimePluginEntrypointPath = installedPlugin?.installed
? undefined
: pluginEntrypointPath;
if (installedPlugin?.installed && !installedPluginPrompted) {
runtimePluginPolicy?.notifyInstalledPluginDetected?.(installedPlugin);
}
await deps.spawnDetached(
mpvPath,
buildWindowsMpvLaunchArgs(
targets,
extraArgs,
binaryPath,
runtimePluginEntrypointPath,
launchMode,
),
buildWindowsMpvLaunchArgs(targets, extraArgs, binaryPath, pluginEntrypointPath, launchMode),
);
return { ok: true, mpvPath };
} catch (error) {
+2 -11
View File
@@ -178,19 +178,10 @@ test('release workflow skips empty AUR sync commits', () => {
assert.match(releaseWorkflow, /if git diff --quiet -- PKGBUILD \.SRCINFO; then/);
});
test('Makefile does not expose the legacy global mpv plugin installer', () => {
test('Makefile routes Windows install-plugin setup through bun and documents Windows builds', () => {
assert.match(
makefile,
/windows\) printf '%s\\n' "\[INFO\] Windows builds run via: bun run build:win" ;;/,
);
assert.doesNotMatch(makefile, /^\s*install-plugin:/m);
assert.doesNotMatch(makefile, /\binstall-plugin\b/);
assert.doesNotMatch(makefile, /configure-plugin-binary-path\.mjs/);
});
test('Makefile uninstall targets remove bundled runtime plugin app-data copies', () => {
assert.match(makefile, /uninstall-linux:[\s\S]*@rm -rf "\$\(LINUX_DATA_DIR\)\/plugin\/subminer"/);
assert.match(makefile, /uninstall-macos:[\s\S]*@rm -rf "\$\(MACOS_DATA_DIR\)\/plugin\/subminer"/);
assert.match(makefile, /Removed:[\s\S]*\$\(LINUX_DATA_DIR\)\/plugin\/subminer/);
assert.match(makefile, /Removed:[\s\S]*\$\(MACOS_DATA_DIR\)\/plugin\/subminer/);
assert.match(makefile, /bun \.\/scripts\/configure-plugin-binary-path\.mjs/);
});