diff --git a/src/main-entry.ts b/src/main-entry.ts
index 730f151b..98a279da 100644
--- a/src/main-entry.ts
+++ b/src/main-entry.ts
@@ -91,8 +91,8 @@ if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) {
if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
const sanitizedEnv = sanitizeLaunchMpvEnv(process.env);
applySanitizedEnv(sanitizedEnv);
- void app.whenReady().then(() => {
- const result = launchWindowsMpv(
+ void app.whenReady().then(async () => {
+ const result = await launchWindowsMpv(
normalizeLaunchMpvTargets(process.argv),
createWindowsMpvLaunchDeps({
getEnv: (name) => process.env[name],
diff --git a/src/main.ts b/src/main.ts
index d60fc1f3..b5a94301 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -367,7 +367,11 @@ import {
detectWindowsMpvShortcuts,
resolveWindowsMpvShortcutPaths,
} from './main/runtime/windows-mpv-shortcuts';
-import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
+import {
+ createWindowsMpvLaunchDeps,
+ getConfiguredWindowsMpvPathStatus,
+ launchWindowsMpv,
+} from './main/runtime/windows-mpv-launch';
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch';
import { shouldEnsureTrayOnStartupForInitialArgs } from './main/runtime/startup-tray-policy';
@@ -2216,6 +2220,7 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
}),
getSetupSnapshot: async () => {
const snapshot = await firstRunSetupService.getSetupStatus();
+ const mpvExecutablePath = getResolvedConfig().mpv.executablePath;
return {
configReady: snapshot.configReady,
dictionaryCount: snapshot.dictionaryCount,
@@ -2223,7 +2228,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
externalYomitanConfigured: snapshot.externalYomitanConfigured,
pluginStatus: snapshot.pluginStatus,
pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
- mpvExecutablePath: getResolvedConfig().mpv.executablePath,
+ mpvExecutablePath,
+ mpvExecutablePathStatus: getConfiguredWindowsMpvPathStatus(mpvExecutablePath),
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
message: firstRunSetupMessage,
};
diff --git a/src/main/runtime/first-run-setup-window.test.ts b/src/main/runtime/first-run-setup-window.test.ts
index 27e2b208..897b66fa 100644
--- a/src/main/runtime/first-run-setup-window.test.ts
+++ b/src/main/runtime/first-run-setup-window.test.ts
@@ -17,6 +17,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
pluginStatus: 'required',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
+ mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
@@ -45,6 +46,7 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
pluginStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
mpvExecutablePath: 'C:\\Program Files\\mpv\\mpv.exe',
+ mpvExecutablePathStatus: 'configured',
windowsMpvShortcuts: {
supported: true,
startMenuEnabled: true,
@@ -65,6 +67,31 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
);
});
+test('buildFirstRunSetupHtml marks an invalid configured mpv path as invalid', () => {
+ const html = buildFirstRunSetupHtml({
+ configReady: true,
+ dictionaryCount: 1,
+ canFinish: true,
+ externalYomitanConfigured: false,
+ pluginStatus: 'installed',
+ pluginInstallPathSummary: '/tmp/mpv',
+ mpvExecutablePath: 'C:\\Broken\\mpv.exe',
+ mpvExecutablePathStatus: 'invalid',
+ windowsMpvShortcuts: {
+ supported: true,
+ startMenuEnabled: true,
+ desktopEnabled: true,
+ startMenuInstalled: false,
+ desktopInstalled: false,
+ status: 'optional',
+ },
+ message: null,
+ });
+
+ assert.match(html, />Invalid);
+ assert.match(html, /Current: C:\\Broken\\mpv\.exe \(invalid; file not found\)/);
+});
+
test('buildFirstRunSetupHtml explains the config blocker when setup is missing config', () => {
const html = buildFirstRunSetupHtml({
configReady: false,
@@ -74,6 +101,7 @@ test('buildFirstRunSetupHtml explains the config blocker when setup is missing c
pluginStatus: 'required',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
+ mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
@@ -97,6 +125,7 @@ test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish ena
pluginStatus: 'installed',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
+ mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
@@ -208,6 +237,7 @@ test('closing incomplete first-run setup quits app outside background mode', asy
pluginStatus: 'required',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
+ mpvExecutablePathStatus: 'blank',
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 c52199cb..08daf671 100644
--- a/src/main/runtime/first-run-setup-window.ts
+++ b/src/main/runtime/first-run-setup-window.ts
@@ -39,6 +39,7 @@ export interface FirstRunSetupHtmlModel {
pluginStatus: 'installed' | 'required' | 'failed';
pluginInstallPathSummary: string | null;
mpvExecutablePath: string;
+ mpvExecutablePathStatus: 'blank' | 'configured' | 'invalid';
windowsMpvShortcuts: {
supported: boolean;
startMenuEnabled: boolean;
@@ -94,8 +95,23 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
? 'muted'
: 'warn';
const mpvExecutablePathLabel =
- model.mpvExecutablePath.trim().length > 0 ? 'Configured' : 'Blank';
- const mpvExecutablePathTone = model.mpvExecutablePath.trim().length > 0 ? 'ready' : 'muted';
+ model.mpvExecutablePathStatus === 'configured'
+ ? 'Configured'
+ : model.mpvExecutablePathStatus === 'invalid'
+ ? 'Invalid'
+ : 'Blank';
+ const mpvExecutablePathTone =
+ model.mpvExecutablePathStatus === 'configured'
+ ? 'ready'
+ : model.mpvExecutablePathStatus === 'invalid'
+ ? 'danger'
+ : 'muted';
+ const mpvExecutablePathCurrent =
+ model.mpvExecutablePathStatus === 'blank'
+ ? 'blank (PATH discovery)'
+ : model.mpvExecutablePathStatus === 'invalid'
+ ? `${model.mpvExecutablePath} (invalid; file not found)`
+ : model.mpvExecutablePath;
const mpvExecutablePathCard = model.windowsMpvShortcuts.supported
? `
@@ -103,7 +119,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
mpv executable path
Leave blank to auto-discover mpv.exe from PATH.
-
Current: ${escapeHtml(model.mpvExecutablePath.trim().length > 0 ? model.mpvExecutablePath : 'blank (PATH discovery)')}
+
Current: ${escapeHtml(mpvExecutablePathCurrent)}
${renderStatusBadge(mpvExecutablePathLabel, mpvExecutablePathTone)}
diff --git a/src/main/runtime/windows-mpv-launch.test.ts b/src/main/runtime/windows-mpv-launch.test.ts
index 54d2e5f6..f6d18cdf 100644
--- a/src/main/runtime/windows-mpv-launch.test.ts
+++ b/src/main/runtime/windows-mpv-launch.test.ts
@@ -12,7 +12,7 @@ function createDeps(overrides: Partial = {}): WindowsMpvLa
getEnv: () => undefined,
runWhere: () => ({ status: 1, stdout: '' }),
fileExists: () => false,
- spawnDetached: () => undefined,
+ spawnDetached: async () => undefined,
showError: () => undefined,
...overrides,
};
@@ -134,9 +134,9 @@ test('buildWindowsMpvLaunchArgs mirrors a custom input-ipc-server into script op
);
});
-test('launchWindowsMpv reports missing mpv path', () => {
+test('launchWindowsMpv reports missing mpv path', async () => {
const errors: string[] = [];
- const result = launchWindowsMpv(
+ const result = await launchWindowsMpv(
[],
createDeps({
showError: (_title, content) => errors.push(content),
@@ -148,14 +148,14 @@ test('launchWindowsMpv reports missing mpv path', () => {
assert.match(errors[0] ?? '', /mpv\.executablePath/i);
});
-test('launchWindowsMpv spawns detached mpv with targets', () => {
+test('launchWindowsMpv spawns detached mpv with targets', async () => {
const calls: string[] = [];
- const result = launchWindowsMpv(
+ 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: (command, args) => {
+ spawnDetached: async (command, args) => {
calls.push(command);
calls.push(args.join('|'));
},
@@ -173,14 +173,14 @@ test('launchWindowsMpv spawns detached mpv with targets', () => {
]);
});
-test('launchWindowsMpv reports spawn failures with path context', () => {
+test('launchWindowsMpv reports spawn failures with path context', async () => {
const errors: string[] = [];
- const result = launchWindowsMpv(
+ const result = await launchWindowsMpv(
[],
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
- spawnDetached: () => {
+ spawnDetached: async () => {
throw new Error('spawn failed');
},
showError: (_title, content) => errors.push(content),
@@ -192,3 +192,21 @@ test('launchWindowsMpv reports spawn failures with path context', () => {
assert.match(errors[0] ?? '', /Failed to launch mpv/i);
assert.match(errors[0] ?? '', /C:\\mpv\\mpv\.exe/i);
});
+
+test('launchWindowsMpv reports async spawn failures with path context', async () => {
+ const errors: string[] = [];
+ const result = await launchWindowsMpv(
+ [],
+ createDeps({
+ getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
+ fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
+ spawnDetached: () => Promise.reject(new Error('async spawn failed')),
+ showError: (_title, content) => errors.push(content),
+ }),
+ );
+
+ assert.equal(result.ok, false);
+ assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe');
+ assert.match(errors[0] ?? '', /Failed to launch mpv/i);
+ assert.match(errors[0] ?? '', /async spawn failed/i);
+});
diff --git a/src/main/runtime/windows-mpv-launch.ts b/src/main/runtime/windows-mpv-launch.ts
index 681edc4b..8cdca7ca 100644
--- a/src/main/runtime/windows-mpv-launch.ts
+++ b/src/main/runtime/windows-mpv-launch.ts
@@ -5,23 +5,45 @@ export interface WindowsMpvLaunchDeps {
getEnv: (name: string) => string | undefined;
runWhere: () => { status: number | null; stdout: string; error?: Error };
fileExists: (candidate: string) => boolean;
- spawnDetached: (command: string, args: string[]) => void;
+ spawnDetached: (command: string, args: string[]) => Promise;
showError: (title: string, content: string) => void;
}
+export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid';
+
function normalizeCandidate(candidate: string | undefined): string {
return typeof candidate === 'string' ? candidate.trim() : '';
}
+function defaultWindowsMpvFileExists(candidate: string): boolean {
+ try {
+ return fs.statSync(candidate).isFile();
+ } catch {
+ return false;
+ }
+}
+
+export function getConfiguredWindowsMpvPathStatus(
+ configuredMpvPath = '',
+ fileExists: (candidate: string) => boolean = defaultWindowsMpvFileExists,
+): ConfiguredWindowsMpvPathStatus {
+ const configPath = normalizeCandidate(configuredMpvPath);
+ if (!configPath) {
+ return 'blank';
+ }
+ return fileExists(configPath) ? 'configured' : 'invalid';
+}
+
export function resolveWindowsMpvPath(
deps: WindowsMpvLaunchDeps,
configuredMpvPath = '',
): string {
const configPath = normalizeCandidate(configuredMpvPath);
- if (configPath) {
- if (deps.fileExists(configPath)) {
- return configPath;
- }
+ const configuredPathStatus = getConfiguredWindowsMpvPathStatus(configPath, deps.fileExists);
+ if (configuredPathStatus === 'configured') {
+ return configPath;
+ }
+ if (configuredPathStatus === 'invalid') {
return '';
}
@@ -102,14 +124,14 @@ export function buildWindowsMpvLaunchArgs(
];
}
-export function launchWindowsMpv(
+export async function launchWindowsMpv(
targets: string[],
deps: WindowsMpvLaunchDeps,
extraArgs: string[] = [],
binaryPath?: string,
pluginEntrypointPath?: string,
configuredMpvPath?: string,
-): { ok: boolean; mpvPath: string } {
+): Promise<{ ok: boolean; mpvPath: string }> {
const normalizedConfiguredPath = normalizeCandidate(configuredMpvPath);
const mpvPath = resolveWindowsMpvPath(deps, normalizedConfiguredPath);
if (!mpvPath) {
@@ -123,7 +145,7 @@ export function launchWindowsMpv(
}
try {
- deps.spawnDetached(
+ await deps.spawnDetached(
mpvPath,
buildWindowsMpvLaunchArgs(targets, extraArgs, binaryPath, pluginEntrypointPath),
);
@@ -155,21 +177,31 @@ export function createWindowsMpvLaunchDeps(options: {
},
fileExists:
options.fileExists ??
- ((candidate) => {
+ defaultWindowsMpvFileExists,
+ spawnDetached: (command, args) =>
+ new Promise((resolve, reject) => {
try {
- return fs.statSync(candidate).isFile();
- } catch {
- return false;
+ const child = spawn(command, args, {
+ detached: true,
+ stdio: 'ignore',
+ windowsHide: true,
+ });
+ let settled = false;
+ child.once('error', (error) => {
+ if (settled) return;
+ settled = true;
+ reject(error);
+ });
+ child.once('spawn', () => {
+ if (settled) return;
+ settled = true;
+ child.unref();
+ resolve();
+ });
+ } catch (error) {
+ reject(error);
}
}),
- spawnDetached: (command, args) => {
- const child = spawn(command, args, {
- detached: true,
- stdio: 'ignore',
- windowsHide: true,
- });
- child.unref();
- },
showError: options.showError,
};
}
diff --git a/src/main/runtime/youtube-playback-runtime.test.ts b/src/main/runtime/youtube-playback-runtime.test.ts
index 5208bfcb..8bcf4178 100644
--- a/src/main/runtime/youtube-playback-runtime.test.ts
+++ b/src/main/runtime/youtube-playback-runtime.test.ts
@@ -29,7 +29,7 @@ test('youtube playback runtime resets flow ownership after a successful run', as
resolveYoutubePlaybackUrl: async () => {
throw new Error('linux path should not resolve direct playback url');
},
- launchWindowsMpv: () => ({ ok: false }),
+ launchWindowsMpv: async () => ({ ok: false }),
waitForYoutubeMpvConnected: async (timeoutMs) => {
calls.push(`wait-connected:${timeoutMs}`);
return true;
@@ -105,7 +105,7 @@ test('youtube playback runtime resolves the socket path lazily for windows start
calls.push(`resolve:${url}:${format}`);
return 'https://example.com/direct';
},
- launchWindowsMpv: (_playbackUrl, args) => {
+ launchWindowsMpv: async (_playbackUrl, args) => {
calls.push(`launch:${args.join(' ')}`);
return { ok: true, mpvPath: '/usr/bin/mpv' };
},
diff --git a/src/main/runtime/youtube-playback-runtime.ts b/src/main/runtime/youtube-playback-runtime.ts
index 0bf08153..f3000742 100644
--- a/src/main/runtime/youtube-playback-runtime.ts
+++ b/src/main/runtime/youtube-playback-runtime.ts
@@ -17,7 +17,7 @@ export type YoutubePlaybackRuntimeDeps = {
setAppOwnedFlowInFlight: (next: boolean) => void;
ensureYoutubePlaybackRuntimeReady: () => Promise;
resolveYoutubePlaybackUrl: (url: string, format: string) => Promise;
- launchWindowsMpv: (playbackUrl: string, args: string[]) => LaunchResult;
+ launchWindowsMpv: (playbackUrl: string, args: string[]) => Promise;
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise;
prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise;
runYoutubePlaybackFlow: (request: {
@@ -77,7 +77,7 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
if (deps.platform === 'win32' && !deps.getMpvConnected()) {
const socketPath = deps.getSocketPath();
- const launchResult = deps.launchWindowsMpv(playbackUrl, [
+ const launchResult = await deps.launchWindowsMpv(playbackUrl, [
'--pause=yes',
'--ytdl=yes',
`--ytdl-format=${deps.mpvYtdlFormat}`,