diff --git a/changes/272-windows-mpv-executable-path.md b/changes/272-windows-mpv-executable-path.md new file mode 100644 index 00000000..bc73fd00 --- /dev/null +++ b/changes/272-windows-mpv-executable-path.md @@ -0,0 +1,5 @@ +type: fixed +area: launcher + +- Added a blank-by-default `mpv.executablePath` override for Windows playback so users can point SubMiner at `mpv.exe` when it is not on `PATH`. +- Kept the Windows shortcut and `--launch-mpv` flow simple by preserving PATH auto-discovery as the default and exposing the override in first-run setup. diff --git a/config.example.jsonc b/config.example.jsonc index 71c16ffe..c8e2f9eb 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -458,6 +458,15 @@ "externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay }, // Optional external Yomitan profile integration. + // ========================================== + // MPV Launcher + // Optional mpv.exe override for Windows playback entry points. + // Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH. + // ========================================== + "mpv": { + "executablePath": "" // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH. + }, // Optional mpv.exe override for Windows playback entry points. + // ========================================== // Jellyfin // Optional Jellyfin integration for auth, browsing, and playback launch. diff --git a/docs-site/installation.md b/docs-site/installation.md index e567e8d8..1f3b76ed 100644 --- a/docs-site/installation.md +++ b/docs-site/installation.md @@ -23,7 +23,7 @@ **macOS** — macOS 10.13 or later. Accessibility permission required for window tracking. -**Windows** — Windows 10 or later. Install `mpv` and keep it available on `PATH`; SubMiner's packaged build handles window tracking directly. +**Windows** — Windows 10 or later. Install `mpv`; keep it on `PATH` for auto-discovery or set `mpv.executablePath` in config if `mpv.exe` lives elsewhere. SubMiner's packaged build handles window tracking directly. ### Optional Tools @@ -172,6 +172,7 @@ Install `mpv` separately and ensure `mpv.exe` is on `PATH`. `ffmpeg` is still re ### Windows Usage Notes - Launch `SubMiner.exe` once to let the first-run setup flow seed `%APPDATA%\\SubMiner\\config.jsonc`, require mpv plugin installation, and open bundled Yomitan settings. The optional `SubMiner mpv` Start Menu/Desktop shortcut can also be created during setup, and on Windows it is the recommended way to launch mpv playback with SubMiner defaults. +- If `mpv.exe` is not on `PATH`, set `mpv.executablePath` in `config.jsonc` or use the first-run setup field to point at the executable. Leave it blank to keep PATH auto-discovery. - `SubMiner.exe --launch-mpv` and the optional `SubMiner mpv` shortcut pass SubMiner's default mpv socket/subtitle args directly and do not require an `mpv.conf` profile named `subminer`. - First-run mpv plugin installs pin `binary_path` to the current `SubMiner.exe` automatically. Manual plugin configs can leave `binary_path` empty unless SubMiner is installed in a non-standard location. - Windows plugin installs rewrite `socket_path` to `\\.\pipe\subminer-socket`; do not keep `/tmp/subminer-socket` on Windows. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 71c16ffe..c8e2f9eb 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -458,6 +458,15 @@ "externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay }, // Optional external Yomitan profile integration. + // ========================================== + // MPV Launcher + // Optional mpv.exe override for Windows playback entry points. + // Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH. + // ========================================== + "mpv": { + "executablePath": "" // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH. + }, // Optional mpv.exe override for Windows playback entry points. + // ========================================== // Jellyfin // Optional Jellyfin integration for auth, browsing, and playback launch. diff --git a/docs-site/usage.md b/docs-site/usage.md index 9bc75b7b..5823c1d6 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -133,7 +133,7 @@ You can use it three ways: & "C:\Program Files\SubMiner\SubMiner.exe" --launch-mpv "C:\Videos\episode 01.mkv" ``` -This flow requires `mpv.exe` to be on `PATH`. If it is installed elsewhere, set `SUBMINER_MPV_PATH` to the full `mpv.exe` path before launching. On Windows, `--launch-mpv` does not require an `mpv.conf` profile named `subminer`. +This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blank to auto-discover from `PATH`, or set it to the full `mpv.exe` path if mpv is installed elsewhere. `SUBMINER_MPV_PATH` is still honored as a fallback. On Windows, `--launch-mpv` does not require an `mpv.conf` profile named `subminer`. ### Launcher Subcommands @@ -164,6 +164,7 @@ Setup flow: - Yomitan shortcut: open bundled Yomitan settings directly from the setup window - dictionary check: ensure at least one bundled Yomitan dictionary is available, unless an external Yomitan profile is configured - Windows: optionally create or remove `SubMiner mpv` Start Menu/Desktop shortcuts (`SubMiner.exe --launch-mpv`) +- Windows: optionally set `mpv.executablePath` if `mpv.exe` is not on `PATH` - refresh: re-check plugin + dictionary state without restarting - `Finish setup` stays disabled until the config, plugin, and dictionary gates are satisfied - finish action writes setup completion state and suppresses future auto-open prompts diff --git a/package.json b/package.json index 686a61c5..bd5c4a0c 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,8 @@ "docs:preview": "bun run --cwd docs-site docs:preview", "docs:test": "bun run --cwd docs-site test", "test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts", - "test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts src/verify-config-example.test.ts", - "test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js dist/verify-config-example.test.js", + "test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/integrations.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts src/verify-config-example.test.ts", + "test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/integrations.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js dist/verify-config-example.test.js", "test:config:smoke:dist": "bun test dist/config/path-resolution.test.js", "test:plugin:src": "lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-binary-windows.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", diff --git a/src/config/definitions.ts b/src/config/definitions.ts index 9d58399b..77949b05 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -35,8 +35,17 @@ const { startupWarmups, auto_start_overlay, } = CORE_DEFAULT_CONFIG; -const { ankiConnect, jimaku, anilist, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } = - INTEGRATIONS_DEFAULT_CONFIG; +const { + ankiConnect, + jimaku, + anilist, + mpv, + yomitan, + jellyfin, + discordPresence, + ai, + youtubeSubgen, +} = INTEGRATIONS_DEFAULT_CONFIG; const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG; const { immersionTracking } = IMMERSION_DEFAULT_CONFIG; const { stats } = STATS_DEFAULT_CONFIG; @@ -60,6 +69,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { auto_start_overlay, jimaku, anilist, + mpv, yomitan, jellyfin, discordPresence, diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index af38b210..104d17d8 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -5,6 +5,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< | 'ankiConnect' | 'jimaku' | 'anilist' + | 'mpv' | 'yomitan' | 'jellyfin' | 'discordPresence' @@ -90,6 +91,9 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< languagePreference: 'ja', maxEntryResults: 10, }, + mpv: { + executablePath: '', + }, anilist: { enabled: false, accessToken: '', diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts index 17051fe5..c8b3cadf 100644 --- a/src/config/definitions/domain-registry.test.ts +++ b/src/config/definitions/domain-registry.test.ts @@ -28,6 +28,7 @@ test('config option registry includes critical paths and has unique entries', () 'ankiConnect.enabled', 'anilist.characterDictionary.enabled', 'anilist.characterDictionary.collapsibleSections.description', + 'mpv.executablePath', 'yomitan.externalProfilePath', 'immersionTracking.enabled', ]) { @@ -48,6 +49,7 @@ test('config template sections include expected domains and unique keys', () => 'subtitleStyle', 'ankiConnect', 'yomitan', + 'mpv', 'immersionTracking', ]; diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 5e60605b..61550613 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -238,6 +238,13 @@ export function buildIntegrationConfigOptionRegistry( description: 'Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay', }, + { + path: 'mpv.executablePath', + kind: 'string', + defaultValue: defaultConfig.mpv.executablePath, + description: + 'Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.', + }, { path: 'jellyfin.enabled', kind: 'boolean', diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index ff9ae797..43d9992b 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -153,6 +153,14 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ ], key: 'yomitan', }, + { + title: 'MPV Launcher', + description: [ + 'Optional mpv.exe override for Windows playback entry points.', + 'Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.', + ], + key: 'mpv', + }, { title: 'Jellyfin', description: [ diff --git a/src/config/resolve/integrations.test.ts b/src/config/resolve/integrations.test.ts new file mode 100644 index 00000000..4067ab5c --- /dev/null +++ b/src/config/resolve/integrations.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resolveConfig } from '../resolve'; + +test('resolveConfig trims configured mpv executable path', () => { + const { resolved, warnings } = resolveConfig({ + mpv: { + executablePath: ' C:\\Program Files\\mpv\\mpv.exe ', + }, + }); + + assert.equal(resolved.mpv.executablePath, 'C:\\Program Files\\mpv\\mpv.exe'); + assert.deepEqual(warnings, []); +}); + +test('resolveConfig warns for invalid mpv executable path type', () => { + const { resolved, warnings } = resolveConfig({ + mpv: { + executablePath: 42 as never, + }, + }); + + assert.equal(resolved.mpv.executablePath, ''); + assert.equal(warnings.length, 1); + assert.deepEqual(warnings[0], { + path: 'mpv.executablePath', + value: 42, + fallback: '', + message: 'Expected string.', + }); +}); diff --git a/src/config/resolve/integrations.ts b/src/config/resolve/integrations.ts index 5b4d6d98..68f3380d 100644 --- a/src/config/resolve/integrations.ts +++ b/src/config/resolve/integrations.ts @@ -228,6 +228,22 @@ export function applyIntegrationConfig(context: ResolveContext): void { warn('yomitan', src.yomitan, resolved.yomitan, 'Expected object.'); } + if (isObject(src.mpv)) { + const executablePath = asString(src.mpv.executablePath); + if (executablePath !== undefined) { + resolved.mpv.executablePath = executablePath.trim(); + } else if (src.mpv.executablePath !== undefined) { + warn( + 'mpv.executablePath', + src.mpv.executablePath, + resolved.mpv.executablePath, + 'Expected string.', + ); + } + } else if (src.mpv !== undefined) { + warn('mpv', src.mpv, resolved.mpv, 'Expected object.'); + } + if (isObject(src.jellyfin)) { const enabled = asBoolean(src.jellyfin.enabled); if (enabled !== undefined) { diff --git a/src/main-entry.ts b/src/main-entry.ts index b87fc58e..730f151b 100644 --- a/src/main-entry.ts +++ b/src/main-entry.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { spawn } from 'node:child_process'; import { app, dialog } from 'electron'; import { printHelp } from './cli/help'; +import { loadRawConfigStrict } from './config/load'; import { configureEarlyAppPaths, normalizeLaunchMpvExtraArgs, @@ -35,6 +36,21 @@ function applySanitizedEnv(sanitizedEnv: NodeJS.ProcessEnv): void { } } +function readConfiguredWindowsMpvExecutablePath(configDir: string): string { + const loadResult = loadRawConfigStrict({ + configDir, + configFileJsonc: path.join(configDir, 'config.jsonc'), + configFileJson: path.join(configDir, 'config.json'), + }); + if (!loadResult.ok) { + return ''; + } + + return typeof loadResult.config.mpv?.executablePath === 'string' + ? loadResult.config.mpv.executablePath.trim() + : ''; +} + function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined { const assets = resolvePackagedFirstRunPluginAssets({ dirname: __dirname, @@ -50,7 +66,7 @@ function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined { process.argv = normalizeStartupArgv(process.argv, process.env); applySanitizedEnv(sanitizeStartupEnv(process.env)); -configureEarlyAppPaths(app); +const userDataPath = configureEarlyAppPaths(app); if (shouldDetachBackgroundLaunch(process.argv, process.env)) { const child = spawn(process.execPath, process.argv.slice(1), { @@ -87,6 +103,7 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) { normalizeLaunchMpvExtraArgs(process.argv), process.execPath, resolveBundledWindowsMpvPluginEntrypoint(), + readConfiguredWindowsMpvExecutablePath(userDataPath), ); app.exit(result.ok ? 0 : 1); }); diff --git a/src/main.ts b/src/main.ts index b497eb16..d60fc1f3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1037,6 +1037,9 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({ showError: (title, content) => dialog.showErrorBox(title, content), }), [...args, `--log-file=${DEFAULT_MPV_LOG_PATH}`], + undefined, + undefined, + getResolvedConfig().mpv.executablePath, ), waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs), prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request), @@ -2220,6 +2223,7 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ externalYomitanConfigured: snapshot.externalYomitanConfigured, pluginStatus: snapshot.pluginStatus, pluginInstallPathSummary: snapshot.pluginInstallPathSummary, + mpvExecutablePath: getResolvedConfig().mpv.executablePath, windowsMpvShortcuts: snapshot.windowsMpvShortcuts, message: firstRunSetupMessage, }; @@ -2232,6 +2236,18 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ firstRunSetupMessage = snapshot.message; return; } + if (submission.action === 'configure-mpv-executable-path') { + const mpvExecutablePath = submission.mpvExecutablePath?.trim() ?? ''; + configService.patchRawConfig({ + mpv: { + executablePath: mpvExecutablePath, + }, + }); + firstRunSetupMessage = mpvExecutablePath + ? `Saved mpv executable path: ${mpvExecutablePath}` + : 'Cleared mpv executable path. SubMiner will auto-discover mpv.exe from PATH.'; + return; + } if (submission.action === 'configure-windows-mpv-shortcuts') { const snapshot = await firstRunSetupService.configureWindowsMpvShortcuts({ startMenuEnabled: submission.startMenuEnabled === true, diff --git a/src/main/runtime/first-run-setup-window.test.ts b/src/main/runtime/first-run-setup-window.test.ts index 6278b7c7..27e2b208 100644 --- a/src/main/runtime/first-run-setup-window.test.ts +++ b/src/main/runtime/first-run-setup-window.test.ts @@ -16,6 +16,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish externalYomitanConfigured: false, pluginStatus: 'required', pluginInstallPathSummary: null, + mpvExecutablePath: '', windowsMpvShortcuts: { supported: false, startMenuEnabled: true, @@ -43,6 +44,7 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in externalYomitanConfigured: false, pluginStatus: 'installed', pluginInstallPathSummary: '/tmp/mpv', + mpvExecutablePath: 'C:\\Program Files\\mpv\\mpv.exe', windowsMpvShortcuts: { supported: true, startMenuEnabled: true, @@ -55,6 +57,8 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in }); 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, /Finish stays unlocked once the mpv plugin is installed and Yomitan reports at least one installed dictionary\./, @@ -69,6 +73,7 @@ test('buildFirstRunSetupHtml explains the config blocker when setup is missing c externalYomitanConfigured: false, pluginStatus: 'required', pluginInstallPathSummary: null, + mpvExecutablePath: '', windowsMpvShortcuts: { supported: false, startMenuEnabled: true, @@ -91,6 +96,7 @@ test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish ena externalYomitanConfigured: true, pluginStatus: 'installed', pluginInstallPathSummary: null, + mpvExecutablePath: '', windowsMpvShortcuts: { supported: false, startMenuEnabled: true, @@ -107,6 +113,15 @@ test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish ena }); test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => { + assert.deepEqual( + parseFirstRunSetupSubmissionUrl( + 'subminer://first-run-setup?action=configure-mpv-executable-path&mpvExecutablePath=C%3A%5CApps%5Cmpv%5Cmpv.exe', + ), + { + action: 'configure-mpv-executable-path', + mpvExecutablePath: 'C:\\Apps\\mpv\\mpv.exe', + }, + ); assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), { action: 'refresh', }); @@ -192,6 +207,7 @@ test('closing incomplete first-run setup quits app outside background mode', asy externalYomitanConfigured: false, pluginStatus: 'required', pluginInstallPathSummary: null, + mpvExecutablePath: '', windowsMpvShortcuts: { supported: false, startMenuEnabled: true, diff --git a/src/main/runtime/first-run-setup-window.ts b/src/main/runtime/first-run-setup-window.ts index 401c6c1d..fe31d358 100644 --- a/src/main/runtime/first-run-setup-window.ts +++ b/src/main/runtime/first-run-setup-window.ts @@ -17,6 +17,7 @@ type FirstRunSetupWindowLike = FocusableWindowLike & { }; export type FirstRunSetupAction = + | 'configure-mpv-executable-path' | 'install-plugin' | 'configure-windows-mpv-shortcuts' | 'open-yomitan-settings' @@ -25,6 +26,7 @@ export type FirstRunSetupAction = export interface FirstRunSetupSubmission { action: FirstRunSetupAction; + mpvExecutablePath?: string; startMenuEnabled?: boolean; desktopEnabled?: boolean; } @@ -36,6 +38,7 @@ export interface FirstRunSetupHtmlModel { externalYomitanConfigured: boolean; pluginStatus: 'installed' | 'required' | 'failed'; pluginInstallPathSummary: string | null; + mpvExecutablePath: string; windowsMpvShortcuts: { supported: boolean; startMenuEnabled: boolean; @@ -90,6 +93,34 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string { : model.windowsMpvShortcuts.status === 'skipped' ? 'muted' : 'warn'; + const mpvExecutablePathLabel = + model.mpvExecutablePath.trim().length > 0 ? 'Configured' : 'Blank'; + const mpvExecutablePathTone = model.mpvExecutablePath.trim().length > 0 ? 'ready' : 'muted'; + const mpvExecutablePathCard = model.windowsMpvShortcuts.supported + ? ` +
+
+
+ mpv executable path +
Leave blank to auto-discover mpv.exe from PATH.
+
Current: ${escapeHtml(model.mpvExecutablePath.trim().length > 0 ? model.mpvExecutablePath : 'blank (PATH discovery)')}
+
+ ${renderStatusBadge(mpvExecutablePathLabel, mpvExecutablePathTone)} +
+
+ + +
+
` + : ''; const windowsShortcutCard = model.windowsMpvShortcuts.supported ? `
@@ -218,6 +249,24 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string { .badge.warn { background: rgba(238, 212, 159, 0.18); color: var(--yellow); } .badge.muted { background: rgba(184, 192, 224, 0.12); color: var(--muted); } .badge.danger { background: rgba(237, 135, 150, 0.16); color: var(--red); } + .path-form { + display: grid; + gap: 8px; + margin-top: 12px; + } + .path-form input[type='text'] { + width: 100%; + box-sizing: border-box; + border: 1px solid rgba(202, 211, 245, 0.12); + border-radius: 10px; + padding: 9px 10px; + color: var(--text); + background: rgba(30, 32, 48, 0.72); + font: inherit; + } + .path-form input[type='text']::placeholder { + color: rgba(184, 192, 224, 0.65); + } .actions { display: grid; grid-template-columns: 1fr 1fr; @@ -282,6 +331,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)} + ${mpvExecutablePathCard} ${windowsShortcutCard}
@@ -303,6 +353,7 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu const parsed = new URL(rawUrl); const action = parsed.searchParams.get('action'); if ( + action !== 'configure-mpv-executable-path' && action !== 'install-plugin' && action !== 'configure-windows-mpv-shortcuts' && action !== 'open-yomitan-settings' && @@ -311,6 +362,12 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu ) { return null; } + if (action === 'configure-mpv-executable-path') { + return { + action, + mpvExecutablePath: parsed.searchParams.get('mpvExecutablePath') ?? '', + }; + } if (action === 'configure-windows-mpv-shortcuts') { return { action, diff --git a/src/main/runtime/windows-mpv-launch.test.ts b/src/main/runtime/windows-mpv-launch.test.ts index b1f39f17..54d2e5f6 100644 --- a/src/main/runtime/windows-mpv-launch.test.ts +++ b/src/main/runtime/windows-mpv-launch.test.ts @@ -29,6 +29,19 @@ test('resolveWindowsMpvPath prefers SUBMINER_MPV_PATH', () => { assert.equal(resolved, 'C:\\mpv\\mpv.exe'); }); +test('resolveWindowsMpvPath prefers configured executable path before PATH', () => { + const resolved = resolveWindowsMpvPath( + createDeps({ + getEnv: () => undefined, + runWhere: () => ({ status: 0, stdout: 'C:\\tools\\mpv.exe\r\n' }), + fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe', + }), + ' C:\\mpv\\mpv.exe ', + ); + + assert.equal(resolved, 'C:\\mpv\\mpv.exe'); +}); + test('resolveWindowsMpvPath falls back to where.exe output', () => { const resolved = resolveWindowsMpvPath( createDeps({ @@ -132,7 +145,7 @@ test('launchWindowsMpv reports missing mpv path', () => { assert.equal(result.ok, false); assert.equal(result.mpvPath, ''); - assert.match(errors[0] ?? '', /Could not find mpv\.exe/i); + assert.match(errors[0] ?? '', /mpv\.executablePath/i); }); test('launchWindowsMpv spawns detached mpv with targets', () => { diff --git a/src/main/runtime/windows-mpv-launch.ts b/src/main/runtime/windows-mpv-launch.ts index 44a71be3..0d966e86 100644 --- a/src/main/runtime/windows-mpv-launch.ts +++ b/src/main/runtime/windows-mpv-launch.ts @@ -13,7 +13,15 @@ function normalizeCandidate(candidate: string | undefined): string { return typeof candidate === 'string' ? candidate.trim() : ''; } -export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string { +export function resolveWindowsMpvPath( + deps: WindowsMpvLaunchDeps, + configuredMpvPath = '', +): string { + const configPath = normalizeCandidate(configuredMpvPath); + if (configPath && deps.fileExists(configPath)) { + return configPath; + } + const envPath = normalizeCandidate(deps.getEnv('SUBMINER_MPV_PATH')); if (envPath && deps.fileExists(envPath)) { return envPath; @@ -97,12 +105,13 @@ export function launchWindowsMpv( extraArgs: string[] = [], binaryPath?: string, pluginEntrypointPath?: string, + configuredMpvPath?: string, ): { ok: boolean; mpvPath: string } { - const mpvPath = resolveWindowsMpvPath(deps); + const mpvPath = resolveWindowsMpvPath(deps, configuredMpvPath); if (!mpvPath) { deps.showError( 'SubMiner mpv launcher', - 'Could not find mpv.exe. Install mpv and add it to PATH, or set SUBMINER_MPV_PATH.', + 'Could not find mpv.exe. Set mpv.executablePath, set SUBMINER_MPV_PATH, or add mpv.exe to PATH.', ); return { ok: false, mpvPath: '' }; } diff --git a/src/types/config.ts b/src/types/config.ts index fd44f4eb..a775a87f 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -50,6 +50,10 @@ export interface TexthookerConfig { openBrowser?: boolean; } +export interface MpvConfig { + executablePath?: string; +} + export type SubsyncMode = 'auto' | 'manual'; export interface SubsyncConfig { @@ -90,6 +94,7 @@ export interface Config { websocket?: WebSocketConfig; annotationWebsocket?: AnnotationWebSocketConfig; texthooker?: TexthookerConfig; + mpv?: MpvConfig; controller?: ControllerConfig; ankiConnect?: AnkiConnectConfig; shortcuts?: ShortcutsConfig; @@ -122,6 +127,9 @@ export interface ResolvedConfig { websocket: Required; annotationWebsocket: Required; texthooker: Required; + mpv: { + executablePath: string; + }; controller: { enabled: boolean; preferredGamepadId: string;