From a7a302bdd0da844545b6cb2f26fafaf45060c4e9 Mon Sep 17 00:00:00 2001 From: Abdulrazzaq Alhendi Date: Wed, 23 Sep 2026 04:29:53 +0300 Subject: [PATCH] fix(jellyfin): respect Windows mpv configuration when casting (#267) --- .github/workflows/quality-gate.yml | 3 + changes/jellyfin-configured-mpv-docs.md | 4 + changes/jellyfin-configured-mpv.md | 4 + docs-site/installation.md | 4 +- docs-site/jellyfin-integration.md | 1 + docs/architecture/domains.md | 1 + src/main.ts | 31 +---- .../jellyfin-runtime-composer.test.ts | 1 + src/main/runtime/jellyfin-playback-launch.ts | 2 +- ...llyfin-remote-connection-main-deps.test.ts | 17 ++- .../jellyfin-remote-connection-main-deps.ts | 3 +- .../jellyfin-remote-connection.test.ts | 88 ++++++++------ .../runtime/jellyfin-remote-connection.ts | 10 +- src/main/runtime/mpv-process.test.ts | 39 ++++++ src/main/runtime/mpv-process.ts | 112 ++++++++++++++++++ src/main/runtime/windows-mpv-launch.test.ts | 16 ++- src/main/runtime/windows-mpv-launch.ts | 93 +++------------ 17 files changed, 277 insertions(+), 152 deletions(-) create mode 100644 changes/jellyfin-configured-mpv-docs.md create mode 100644 changes/jellyfin-configured-mpv.md create mode 100644 src/main/runtime/mpv-process.test.ts create mode 100644 src/main/runtime/mpv-process.ts diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml index 2efc760c..9339ff36 100644 --- a/.github/workflows/quality-gate.yml +++ b/.github/workflows/quality-gate.yml @@ -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 diff --git a/changes/jellyfin-configured-mpv-docs.md b/changes/jellyfin-configured-mpv-docs.md new file mode 100644 index 00000000..78282abf --- /dev/null +++ b/changes/jellyfin-configured-mpv-docs.md @@ -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. diff --git a/changes/jellyfin-configured-mpv.md b/changes/jellyfin-configured-mpv.md new file mode 100644 index 00000000..70d61f28 --- /dev/null +++ b/changes/jellyfin-configured-mpv.md @@ -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. diff --git a/docs-site/installation.md b/docs-site/installation.md index 261e4fc4..557b2aaf 100644 --- a/docs-site/installation.md +++ b/docs-site/installation.md @@ -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: diff --git a/docs-site/jellyfin-integration.md b/docs-site/jellyfin-integration.md index b2f90e5a..ba3ecab8 100644 --- a/docs-site/jellyfin-integration.md +++ b/docs-site/jellyfin-integration.md @@ -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. diff --git a/docs/architecture/domains.md b/docs/architecture/domains.md index b9272663..1cb956e9 100644 --- a/docs/architecture/domains.md +++ b/docs/architecture/domains.md @@ -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/` diff --git a/src/main.ts b/src/main.ts index 3ba0a4ac..1d36b9e7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { - 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 | 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), }, diff --git a/src/main/runtime/composers/jellyfin-runtime-composer.test.ts b/src/main/runtime/composers/jellyfin-runtime-composer.test.ts index ec2308c0..b587eabc 100644 --- a/src/main/runtime/composers/jellyfin-runtime-composer.test.ts +++ b/src/main/runtime/composers/jellyfin-runtime-composer.test.ts @@ -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', diff --git a/src/main/runtime/jellyfin-playback-launch.ts b/src/main/runtime/jellyfin-playback-launch.ts index 68589c85..bf709dcf 100644 --- a/src/main/runtime/jellyfin-playback-launch.ts +++ b/src/main/runtime/jellyfin-playback-launch.ts @@ -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.', ); } diff --git a/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts b/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts index e7101b9e..924b1914 100644 --- a/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts +++ b/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts @@ -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 () => { diff --git a/src/main/runtime/jellyfin-remote-connection-main-deps.ts b/src/main/runtime/jellyfin-remote-connection-main-deps.ts index e5faa5cf..80fbd0c3 100644 --- a/src/main/runtime/jellyfin-remote-connection-main-deps.ts +++ b/src/main/runtime/jellyfin-remote-connection-main-deps.ts @@ -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), }); diff --git a/src/main/runtime/jellyfin-remote-connection.test.ts b/src/main/runtime/jellyfin-remote-connection.test.ts index f17e6a8b..d9e0d0dc 100644 --- a/src/main/runtime/jellyfin-remote-connection.test.ts +++ b/src/main/runtime/jellyfin-remote-connection.test.ts @@ -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 () => { diff --git a/src/main/runtime/jellyfin-remote-connection.ts b/src/main/runtime/jellyfin-remote-connection.ts index 67162aa0..3731beee 100644 --- a/src/main/runtime/jellyfin-remote-connection.ts +++ b/src/main/runtime/jellyfin-remote-connection.ts @@ -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); }); diff --git a/src/main/runtime/mpv-process.test.ts b/src/main/runtime/mpv-process.test.ts new file mode 100644 index 00000000..6ab3836f --- /dev/null +++ b/src/main/runtime/mpv-process.test.ts @@ -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/, + ); +}); diff --git a/src/main/runtime/mpv-process.ts b/src/main/runtime/mpv-process.ts new file mode 100644 index 00000000..f0e3b94c --- /dev/null +++ b/src/main/runtime/mpv-process.ts @@ -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 { + 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 { + 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; +} diff --git a/src/main/runtime/windows-mpv-launch.test.ts b/src/main/runtime/windows-mpv-launch.test.ts index e1c806fd..3aba85ba 100644 --- a/src/main/runtime/windows-mpv-launch.test.ts +++ b/src/main/runtime/windows-mpv-launch.test.ts @@ -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( diff --git a/src/main/runtime/windows-mpv-launch.ts b/src/main/runtime/windows-mpv-launch.ts index 04d39c0a..c6b4634c 100644 --- a/src/main/runtime/windows-mpv-launch.ts +++ b/src/main/runtime/windows-mpv-launch.ts @@ -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; isAppControlServerAvailable?: () => Promise; 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;