fix(jellyfin): respect Windows mpv configuration when casting (#267)

This commit is contained in:
Abdulrazzaq Alhendi
2026-09-22 18:29:53 -07:00
committed by GitHub
parent 6a1d1211ae
commit a7a302bdd0
17 changed files with 277 additions and 152 deletions
+3
View File
@@ -29,6 +29,9 @@ jobs:
- name: Verify Windows launcher bootstrap
run: bun test src/main/runtime/windows-launcher-bootstrap.test.ts
- name: Verify native mpv process launch
run: bun test src/main/runtime/mpv-process.test.ts
- name: Verify POSIX launcher bootstrap
run: bun test src/main/runtime/posix-launcher-bootstrap.test.ts
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: jellyfin
- Clarify that Windows mpv playback and Jellyfin casting can use a configured executable path instead of PATH.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Honor the configured mpv executable when Jellyfin starts playback, allowing casting when mpv is installed outside PATH, and detect portable plugins beside the selected executable.
+2 -2
View File
@@ -120,10 +120,10 @@ pip install ffsubsync
Windows 10 or later. No compositor tools or window helpers are needed - native window tracking is built in.
You need **mpv** (required) and **ffmpeg** (strongly recommended, for card audio and screenshots), and both must be on your `PATH`.
You need **mpv** (required) and **ffmpeg** (strongly recommended, for card audio and screenshots). Put mpv on `PATH` or set `mpv.executablePath` during setup. ffmpeg must be on `PATH`.
::: tip What is PATH?
`PATH` is the list of folders Windows searches when a program asks to run another program by name. SubMiner runs `mpv` and `ffmpeg` by name, so if their folders are not on `PATH`, SubMiner cannot find them even though they are installed. The routes below mostly handle `PATH` for you; the manual route explains how to add a folder yourself.
`PATH` is the list of folders Windows searches when a program asks to run another program by name. SubMiner uses it to find ffmpeg and, unless an executable path is configured, mpv. The routes below mostly handle `PATH` for you; the manual route explains how to add a folder yourself.
:::
You can install these with a package manager or by hand. Coverage differs, so pick based on what you need:
+1
View File
@@ -50,6 +50,7 @@ From then on, pause / resume / seek / stop and audio or subtitle track changes y
## What happens during playback
- **mpv launches automatically.** If mpv isn't already running when you cast, SubMiner starts it with SubMiner defaults and the bundled mpv plugin, so keybindings work right away.
- **Windows respects your mpv settings.** Casting checks `mpv.executablePath`, then `SUBMINER_MPV_PATH`, then `PATH`. An invalid configured path prevents automatic startup.
- **The overlay is managed by SubMiner,** so your configured `subtitleStyle` controls how subtitles look. Use the [overlay-toggle shortcut](/shortcuts) to hide it for a session.
- **Resume works.** If Jellyfin has a saved position for the item, SubMiner seeks there on load.
- **Titles and credentials stay separate.** AniList, character dictionaries, Anki source fields, and Discord presence use media titles, never authenticated stream URLs. If a usable title is unavailable, lookups are skipped and source fields show an unknown-media label. Stats identifies Jellyfin videos by server and item ID without the stream URL or API key.
+1
View File
@@ -21,6 +21,7 @@ Read when: you need to find the owner module for a behavior or test surface
`src/config/resolve/anki-connect/`
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
- MPV runtime and protocol: `src/core/services/mpv*.ts`
Windows executable lookup and detached process creation are shared in `src/main/runtime/mpv-process.ts`. The Windows launcher and Jellyfin handlers retain their own playback and connection workflows.
- Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/`
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
- Immersion tracking: `src/core/services/immersion-tracker/`
+6 -25
View File
@@ -75,7 +75,6 @@ protocol.registerSchemesAsPrivileged([
]);
import * as fs from 'fs';
import { spawn } from 'node:child_process';
import * as os from 'os';
import * as path from 'path';
import { MecabTokenizer } from './mecab-tokenizer';
@@ -122,11 +121,6 @@ import {
import { printHelp } from './cli/help';
import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts';
import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
import {
MPV_X11_BACKEND_ARGS,
applyX11EnvOverrides,
shouldForceX11WaylandSession,
} from './shared/mpv-x11-backend';
import { AnkiConnectClient } from './anki-connect';
import {
getStartupModeFlags,
@@ -393,6 +387,7 @@ import {
getConfiguredWindowsMpvPathStatus,
launchWindowsMpv,
} from './main/runtime/windows-mpv-launch';
import { resolveMpvExecutablePath, spawnMpvProcess } from './main/runtime/mpv-process';
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
import {
DEFAULT_JELLYFIN_CLIENT_NAME,
@@ -677,22 +672,6 @@ const MPV_JELLYFIN_DEFAULT_ARGS = [
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
] as const;
/**
* Spawn a SubMiner-managed mpv (Jellyfin/YouTube) detached. On unsupported Wayland
* sessions it is pinned to XWayland Wayland-hint env stripped and an X11 GPU context
* appended so the XWayland overlay can stay above it, matching the `subminer` launcher.
*/
function spawnManagedMpvProcess(args: string[]): ReturnType<typeof spawn> {
if (!shouldForceX11WaylandSession(process.env)) {
return spawn('mpv', args, { detached: true, stdio: 'ignore' });
}
return spawn('mpv', [...args, ...MPV_X11_BACKEND_ARGS], {
detached: true,
stdio: 'ignore',
env: applyX11EnvOverrides({ ...process.env }),
});
}
let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null;
let jellyfinRemoteLastProgressAtMs = 0;
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
@@ -3179,18 +3158,20 @@ const {
sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
},
launchMpvIdleForJellyfinPlaybackMainDeps: {
getMpvExecutablePath: () =>
resolveMpvExecutablePath(configService.getConfig().mpv.executablePath),
getSocketPath: () => appState.mpvSocketPath,
getLaunchMode: () => configService.getConfig().mpv.launchMode,
platform: process.platform,
execPath: process.execPath,
getRuntimePluginEntrypoint: () => resolveBundledMpvRuntimePluginEntrypoint(),
getInstalledPluginDetection: () =>
getInstalledPluginDetection: (mpvExecutablePath) =>
detectInstalledMpvPlugin({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
appDataDir: app.getPath('appData'),
mpvExecutablePath: configService.getConfig().mpv.executablePath,
mpvExecutablePath,
}),
getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(),
getDefaultMpvLogPath: () => (isLogFileEnabled('mpv') ? DEFAULT_MPV_LOG_PATH : ''),
@@ -3198,7 +3179,7 @@ const {
removeSocketPath: (socketPath) => {
fs.rmSync(socketPath, { force: true });
},
spawnMpv: (args) => spawnManagedMpvProcess(args),
spawnMpv: spawnMpvProcess,
logWarn: (message, error) => logger.warn(message, error),
logInfo: (message) => logger.info(message),
},
@@ -54,6 +54,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
sleep: async () => {},
},
launchMpvIdleForJellyfinPlaybackMainDeps: {
getMpvExecutablePath: () => 'mpv',
getSocketPath: () => '/tmp/test-mpv.sock',
getLaunchMode: () => 'normal',
platform: 'linux',
+1 -1
View File
@@ -165,7 +165,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
const mpvClient = deps.getMpvClient();
if (!connected || !mpvClient) {
throw new Error(
'MPV not connected and auto-launch failed. Ensure mpv is installed and available in PATH.',
'MPV not connected and auto-launch failed. Check mpv.executablePath or ensure mpv is available in PATH.',
);
}
@@ -32,6 +32,7 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
},
};
const deps = createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler({
getMpvExecutablePath: () => '/usr/local/bin/mpv',
getSocketPath: () => '/tmp/mpv.sock',
getLaunchMode: () => 'fullscreen',
platform: 'darwin',
@@ -47,8 +48,8 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
getDefaultMpvLogPath: () => '/tmp/mpv.log',
defaultMpvArgs: ['--no-config'],
removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`),
spawnMpv: (args) => {
calls.push(`spawn:${args.join(' ')}`);
spawnMpv: (executablePath, args) => {
calls.push(`spawn:${executablePath} ${args.join(' ')}`);
return proc;
},
logWarn: (message) => calls.push(`warn:${message}`),
@@ -60,14 +61,20 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
assert.equal(deps.platform, 'darwin');
assert.equal(deps.execPath, '/tmp/subminer');
assert.equal(deps.getRuntimePluginEntrypoint?.(), '/tmp/plugin/subminer/main.lua');
assert.equal(deps.getInstalledPluginDetection?.().installed, false);
assert.equal(deps.getMpvExecutablePath(), '/usr/local/bin/mpv');
assert.equal(deps.getInstalledPluginDetection?.('/usr/local/bin/mpv').installed, false);
assert.equal(deps.getDefaultMpvLogPath(), '/tmp/mpv.log');
assert.deepEqual(deps.defaultMpvArgs, ['--no-config']);
deps.removeSocketPath('/tmp/mpv.sock');
deps.spawnMpv(['--idle=yes']);
deps.spawnMpv('/usr/local/bin/mpv', ['--idle=yes']);
deps.logInfo('launched');
deps.logWarn('bad', null);
assert.deepEqual(calls, ['rm:/tmp/mpv.sock', 'spawn:--idle=yes', 'info:launched', 'warn:bad']);
assert.deepEqual(calls, [
'rm:/tmp/mpv.sock',
'spawn:/usr/local/bin/mpv --idle=yes',
'info:launched',
'warn:bad',
]);
});
test('ensure mpv connected for jellyfin main deps builder maps callbacks', async () => {
@@ -16,6 +16,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
deps: LaunchMpvForJellyfinDeps,
) {
return (): LaunchMpvForJellyfinDeps => ({
getMpvExecutablePath: () => deps.getMpvExecutablePath(),
getSocketPath: () => deps.getSocketPath(),
getLaunchMode: () => deps.getLaunchMode(),
platform: deps.platform,
@@ -26,7 +27,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
getDefaultMpvLogPath: () => deps.getDefaultMpvLogPath(),
defaultMpvArgs: deps.defaultMpvArgs,
removeSocketPath: (socketPath: string) => deps.removeSocketPath(socketPath),
spawnMpv: (args: string[]) => deps.spawnMpv(args),
spawnMpv: (executablePath, args) => deps.spawnMpv(executablePath, args),
logWarn: (message: string, error: unknown) => deps.logWarn(message, error),
logInfo: (message: string) => deps.logInfo(message),
});
@@ -1,5 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { detectInstalledMpvPlugin } from './first-run-setup-plugin';
import { resolveWindowsMpvPath } from './mpv-process';
import {
createEnsureMpvConnectedForJellyfinPlaybackHandler,
createLaunchMpvIdleForJellyfinPlaybackHandler,
@@ -30,6 +32,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
const spawnedArgs: string[][] = [];
const logs: string[] = [];
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
getMpvExecutablePath: () => 'mpv',
getSocketPath: () => '/tmp/subminer.sock',
getLaunchMode: () => 'maximized',
platform: 'darwin',
@@ -39,7 +42,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
getDefaultMpvLogPath: () => ' /tmp/mp.log ',
defaultMpvArgs: ['--sid=auto'],
removeSocketPath: () => {},
spawnMpv: (args) => {
spawnMpv: (_executable, args) => {
spawnedArgs.push(args);
return {
on: () => {},
@@ -67,6 +70,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin config', () => {
const spawnedArgs: string[][] = [];
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
getMpvExecutablePath: () => 'mpv',
getSocketPath: () => '/tmp/subminer.sock',
getLaunchMode: () => 'normal',
platform: 'linux',
@@ -84,7 +88,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
getDefaultMpvLogPath: () => '/tmp/mp.log',
defaultMpvArgs: ['--sid=auto'],
removeSocketPath: () => {},
spawnMpv: (args) => {
spawnMpv: (_executable, args) => {
spawnedArgs.push(args);
return {
on: () => {},
@@ -108,41 +112,53 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
assert.doesNotMatch(scriptOpts ?? '', /subminer-aniskip_button_key=/);
});
test('createLaunchMpvIdleForJellyfinPlaybackHandler skips bundled script when installed plugin exists', () => {
const spawnedArgs: string[][] = [];
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
getSocketPath: () => '/tmp/subminer.sock',
getLaunchMode: () => 'normal',
platform: 'linux',
execPath: '/opt/SubMiner/SubMiner.AppImage',
getRuntimePluginEntrypoint: () => '/opt/SubMiner/plugin/subminer/main.lua',
getInstalledPluginDetection: () => ({
installed: true,
path: '/home/tester/.config/mpv/scripts/subminer/main.lua',
version: '0.1.0',
source: 'default-config',
message: null,
}),
getDefaultMpvLogPath: () => '/tmp/mp.log',
defaultMpvArgs: ['--sid=auto'],
removeSocketPath: () => {},
spawnMpv: (args) => {
spawnedArgs.push(args);
return {
on: () => {},
unref: () => {},
};
},
logWarn: () => {},
logInfo: () => {},
});
test('Jellyfin detects portable plugins beside the executable selected for launch', () => {
const mpvPath = 'C:\\portable player\\mpv.exe';
const pluginPath = 'C:\\portable player\\portable_config\\scripts\\subminer\\main.lua';
for (const source of ['environment', 'PATH']) {
let resolutions = 0;
const spawned: Array<{ executable: string; args: string[] }> = [];
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
getMpvExecutablePath: () => {
resolutions += 1;
return resolveWindowsMpvPath({
getEnv: () => (source === 'environment' ? mpvPath : undefined),
runWhere: () => ({ status: 0, stdout: mpvPath }),
fileExists: (candidate) => candidate === mpvPath,
});
},
getSocketPath: () => '\\\\.\\pipe\\subminer-test',
getLaunchMode: () => 'normal',
platform: 'win32',
execPath: 'C:\\SubMiner\\SubMiner.exe',
getRuntimePluginEntrypoint: () => 'C:\\SubMiner\\plugin\\subminer\\main.lua',
getInstalledPluginDetection: (mpvExecutablePath) =>
detectInstalledMpvPlugin({
platform: 'win32',
homeDir: 'C:\\Users\\test',
mpvExecutablePath,
existsSync: (candidate) => candidate === pluginPath,
}),
getDefaultMpvLogPath: () => '',
defaultMpvArgs: [],
removeSocketPath: () => {},
spawnMpv: (executable, args) => {
spawned.push({ executable, args });
return { on: () => {}, unref: () => {} };
},
logWarn: () => {},
logInfo: () => {},
});
launch();
assert.equal(
spawnedArgs[0]?.some((arg) => arg.startsWith('--script=/opt/SubMiner/plugin/subminer')),
false,
);
assert.ok(spawnedArgs[0]?.some((arg) => arg.startsWith('--script-opts=')));
launch();
assert.equal(resolutions, 1, source);
assert.equal(spawned.length, 1);
assert.equal(spawned[0]!.executable, mpvPath);
assert.equal(
spawned[0]!.args.some((arg) => arg.startsWith('--script=')),
false,
);
}
});
test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => {
@@ -41,23 +41,25 @@ export function createWaitForMpvConnectedHandler(deps: WaitForMpvConnectedDeps)
}
export type LaunchMpvForJellyfinDeps = {
getMpvExecutablePath: () => string;
getSocketPath: () => string;
getLaunchMode: () => MpvLaunchMode;
platform: NodeJS.Platform;
execPath: string;
getRuntimePluginEntrypoint?: () => string | null | undefined;
getInstalledPluginDetection?: () => InstalledMpvPluginDetection;
getInstalledPluginDetection?: (mpvExecutablePath: string) => InstalledMpvPluginDetection;
getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig;
getDefaultMpvLogPath: () => string;
defaultMpvArgs: readonly string[];
removeSocketPath: (socketPath: string) => void;
spawnMpv: (args: string[]) => SpawnedProcessLike;
spawnMpv: (executablePath: string, args: string[]) => SpawnedProcessLike;
logWarn: (message: string, error: unknown) => void;
logInfo: (message: string) => void;
};
export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvForJellyfinDeps) {
return (): void => {
const executablePath = deps.getMpvExecutablePath();
const socketPath = deps.getSocketPath();
if (deps.platform !== 'win32') {
try {
@@ -78,7 +80,7 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
)
: [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`];
const scriptOpts = `--script-opts=${scriptOptParts.join(',')}`;
const installedPlugin = deps.getInstalledPluginDetection?.();
const installedPlugin = deps.getInstalledPluginDetection?.(executablePath);
const runtimePluginEntrypoint = installedPlugin?.installed
? ''
: (deps.getRuntimePluginEntrypoint?.()?.trim() ?? '');
@@ -95,7 +97,7 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
...(defaultMpvLogPath ? [`--log-file=${defaultMpvLogPath}`] : []),
`--input-ipc-server=${socketPath}`,
];
const proc = deps.spawnMpv(mpvArgs);
const proc = deps.spawnMpv(executablePath, mpvArgs);
proc.on('error', (error) => {
deps.logWarn('Failed to launch mpv for Jellyfin remote playback', error);
});
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import { once } from 'node:events';
import test from 'node:test';
import { resolveMpvExecutablePath, spawnMpvProcess } from './mpv-process';
const testWindows = process.platform === 'win32' ? test : test.skip;
test('mpv process launcher forwards arguments and environment to the child', async () => {
const child = spawnMpvProcess(
process.execPath,
['-e', 'process.exit(Number(process.env.SUBMINER_TEST_EXIT))'],
{ ...process.env, DISPLAY: '', SUBMINER_TEST_EXIT: '17' },
);
try {
const [code] = await once(child, 'exit');
assert.equal(code, 17);
} finally {
if (child.exitCode === null) child.kill();
}
});
testWindows('managed Windows playback launches the configured executable', async () => {
const executable = resolveMpvExecutablePath(` ${process.execPath} `);
const child = spawnMpvProcess(executable, ['-e', 'process.exit(17)']);
try {
assert.equal(child.spawnfile, process.execPath);
const [code] = await once(child, 'exit');
assert.equal(code, 17);
} finally {
if (child.exitCode === null) child.kill();
}
});
testWindows('managed Windows playback rejects an invalid configured executable', () => {
assert.throws(
() => resolveMpvExecutablePath(`${process.execPath}/missing-mpv.exe`),
/Could not find mpv.exe/,
);
});
+112
View File
@@ -0,0 +1,112 @@
import fs from 'node:fs';
import { spawn, spawnSync } from 'node:child_process';
import {
MPV_X11_BACKEND_ARGS,
applyX11EnvOverrides,
shouldForceX11WaylandSession,
} from '../../shared/mpv-x11-backend';
export interface WindowsMpvPathDeps {
getEnv: (name: string) => string | undefined;
runWhere: () => { status: number | null; stdout: string; error?: Error };
fileExists: (candidate: string) => boolean;
}
export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid';
function fileExists(candidate: string): boolean {
try {
return fs.statSync(candidate).isFile();
} catch {
return false;
}
}
export function getConfiguredWindowsMpvPathStatus(
configuredMpvPath = '',
exists: (candidate: string) => boolean = fileExists,
): ConfiguredWindowsMpvPathStatus {
const configPath = configuredMpvPath.trim();
if (!configPath) {
return 'blank';
}
return exists(configPath) ? 'configured' : 'invalid';
}
export function createWindowsMpvPathDeps(
overrides: Partial<WindowsMpvPathDeps> = {},
): WindowsMpvPathDeps {
return {
getEnv: overrides.getEnv ?? ((name) => process.env[name]),
fileExists: overrides.fileExists ?? fileExists,
runWhere:
overrides.runWhere ??
(() => {
const result = spawnSync('where.exe', ['mpv.exe'], {
encoding: 'utf8',
windowsHide: true,
});
return {
status: result.status,
stdout: result.stdout ?? '',
error: result.error ?? undefined,
};
}),
};
}
export function resolveWindowsMpvPath(deps: WindowsMpvPathDeps, configuredMpvPath = ''): string {
const configPath = configuredMpvPath.trim();
const configuredPathStatus = getConfiguredWindowsMpvPathStatus(configPath, deps.fileExists);
if (configuredPathStatus === 'configured') {
return configPath;
}
if (configuredPathStatus === 'invalid') {
return '';
}
const envPath = deps.getEnv('SUBMINER_MPV_PATH')?.trim();
if (envPath && deps.fileExists(envPath)) {
return envPath;
}
const whereResult = deps.runWhere();
if (whereResult.status === 0) {
const firstPath = whereResult.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0 && deps.fileExists(line));
if (firstPath) {
return firstPath;
}
}
return '';
}
export function spawnMpvProcess(
executablePath: string,
args: string[],
env: NodeJS.ProcessEnv = process.env,
): ReturnType<typeof spawn> {
const forceX11 = shouldForceX11WaylandSession(env);
return spawn(executablePath, forceX11 ? [...args, ...MPV_X11_BACKEND_ARGS] : args, {
detached: true,
stdio: 'ignore',
windowsHide: true,
env: forceX11 ? applyX11EnvOverrides({ ...env }) : env,
});
}
export function resolveMpvExecutablePath(configuredMpvPath = ''): string {
const executablePath =
process.platform === 'win32'
? resolveWindowsMpvPath(createWindowsMpvPathDeps(), configuredMpvPath)
: 'mpv';
if (!executablePath) {
throw new Error(
'Could not find mpv.exe. Check mpv.executablePath, SUBMINER_MPV_PATH, or PATH.',
);
}
return executablePath;
}
+13 -3
View File
@@ -29,12 +29,12 @@ test('resolveWindowsMpvPath prefers SUBMINER_MPV_PATH', () => {
assert.equal(resolved, 'C:\\mpv\\mpv.exe');
});
test('resolveWindowsMpvPath prefers configured executable path before PATH', () => {
test('resolveWindowsMpvPath prefers configured executable path before environment and PATH', () => {
const resolved = resolveWindowsMpvPath(
createDeps({
getEnv: () => undefined,
getEnv: () => 'C:\\other\\mpv.exe',
runWhere: () => ({ status: 0, stdout: 'C:\\tools\\mpv.exe\r\n' }),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
fileExists: (candidate) => ['C:\\mpv\\mpv.exe', 'C:\\other\\mpv.exe'].includes(candidate),
}),
' C:\\mpv\\mpv.exe ',
);
@@ -53,6 +53,16 @@ test('resolveWindowsMpvPath falls back to where.exe output', () => {
assert.equal(resolved, 'C:\\tools\\mpv.exe');
});
test('resolveWindowsMpvPath ignores an invalid environment override but keeps config authoritative', () => {
const deps = createDeps({
getEnv: () => 'C:\\missing\\mpv.exe',
runWhere: () => ({ status: 0, stdout: 'C:\\tools\\mpv.exe\r\n' }),
fileExists: (candidate) => candidate === 'C:\\tools\\mpv.exe',
});
assert.equal(resolveWindowsMpvPath(deps), 'C:\\tools\\mpv.exe');
assert.equal(resolveWindowsMpvPath(deps, 'C:\\missing\\mpv.exe'), '');
});
test('buildWindowsMpvLaunchArgs uses explicit SubMiner defaults and targets', () => {
assert.deepEqual(
buildWindowsMpvLaunchArgs(
+18 -75
View File
@@ -1,5 +1,3 @@
import fs from 'node:fs';
import { spawn, spawnSync } from 'node:child_process';
import { isLogFileEnabled } from '../../shared/log-files';
import { canConnectSocket } from '../../shared/socket-probe';
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
@@ -8,11 +6,19 @@ import { buildSubminerPluginRuntimeScriptOptParts } from '../../shared/subminer-
import type { MpvLaunchMode } from '../../types/config';
import type { SubminerPluginRuntimeScriptOptConfig } from '../../shared/subminer-plugin-script-opts';
import type { InstalledMpvPluginDetection } from './first-run-setup-plugin';
import {
createWindowsMpvPathDeps,
resolveWindowsMpvPath,
spawnMpvProcess,
type WindowsMpvPathDeps,
} from './mpv-process';
export {
getConfiguredWindowsMpvPathStatus,
resolveWindowsMpvPath,
type ConfiguredWindowsMpvPathStatus,
} from './mpv-process';
export interface WindowsMpvLaunchDeps {
getEnv: (name: string) => string | undefined;
runWhere: () => { status: number | null; stdout: string; error?: Error };
fileExists: (candidate: string) => boolean;
export interface WindowsMpvLaunchDeps extends WindowsMpvPathDeps {
spawnDetached: (command: string, args: string[], env?: NodeJS.ProcessEnv) => Promise<void>;
isAppControlServerAvailable?: () => Promise<boolean>;
sendAppControlCommand?: (
@@ -23,8 +29,6 @@ export interface WindowsMpvLaunchDeps {
logInfo?: (message: string) => void;
}
export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid';
export interface WindowsMpvRuntimePluginPolicy {
detectInstalledMpvPlugin?: (mpvPath: string) => InstalledMpvPluginDetection;
notifyInstalledPluginDetected?: (detection: InstalledMpvPluginDetection) => void;
@@ -38,54 +42,6 @@ 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);
const configuredPathStatus = getConfiguredWindowsMpvPathStatus(configPath, deps.fileExists);
if (configuredPathStatus === 'configured') {
return configPath;
}
if (configuredPathStatus === 'invalid') {
return '';
}
const envPath = normalizeCandidate(deps.getEnv('SUBMINER_MPV_PATH'));
if (envPath && deps.fileExists(envPath)) {
return envPath;
}
const whereResult = deps.runWhere();
if (whereResult.status === 0) {
const firstPath = whereResult.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0 && deps.fileExists(line));
if (firstPath) {
return firstPath;
}
}
return '';
}
const DEFAULT_WINDOWS_MPV_SOCKET = '\\\\.\\pipe\\subminer-socket';
const RUNNING_APP_ATTACH_SOCKET_WAIT_MS = 10000;
@@ -332,19 +288,7 @@ export function createWindowsMpvLaunchDeps(options: {
logInfo?: (message: string) => void;
}): WindowsMpvLaunchDeps {
return {
getEnv: options.getEnv ?? ((name) => process.env[name]),
runWhere: () => {
const result = spawnSync('where.exe', ['mpv.exe'], {
encoding: 'utf8',
windowsHide: true,
});
return {
status: result.status,
stdout: result.stdout ?? '',
error: result.error ?? undefined,
};
},
fileExists: options.fileExists ?? defaultWindowsMpvFileExists,
...createWindowsMpvPathDeps(options),
isAppControlServerAvailable: options.isAppControlServerAvailable,
sendAppControlCommand: options.sendAppControlCommand,
waitForSocketReady,
@@ -352,12 +296,11 @@ export function createWindowsMpvLaunchDeps(options: {
spawnDetached: (command, args, env) =>
new Promise((resolve, reject) => {
try {
const child = spawn(command, args, {
detached: true,
stdio: 'ignore',
windowsHide: true,
env: env ? { ...process.env, ...env } : process.env,
});
const child = spawnMpvProcess(
command,
args,
env ? { ...process.env, ...env } : process.env,
);
let settled = false;
child.once('error', (error) => {
if (settled) return;