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 - name: Verify Windows launcher bootstrap
run: bun test src/main/runtime/windows-launcher-bootstrap.test.ts 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 - name: Verify POSIX launcher bootstrap
run: bun test src/main/runtime/posix-launcher-bootstrap.test.ts 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. 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? ::: 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: 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 ## 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. - **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. - **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. - **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. - **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/` `src/config/resolve/anki-connect/`
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts` - Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
- MPV runtime and protocol: `src/core/services/mpv*.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/` - 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` - Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
- Immersion tracking: `src/core/services/immersion-tracker/` - Immersion tracking: `src/core/services/immersion-tracker/`
+6 -25
View File
@@ -75,7 +75,6 @@ protocol.registerSchemesAsPrivileged([
]); ]);
import * as fs from 'fs'; import * as fs from 'fs';
import { spawn } from 'node:child_process';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { MecabTokenizer } from './mecab-tokenizer'; import { MecabTokenizer } from './mecab-tokenizer';
@@ -122,11 +121,6 @@ import {
import { printHelp } from './cli/help'; import { printHelp } from './cli/help';
import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts'; import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts';
import { buildMpvLoggingArgs } from './shared/mpv-logging-args'; 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 { AnkiConnectClient } from './anki-connect';
import { import {
getStartupModeFlags, getStartupModeFlags,
@@ -393,6 +387,7 @@ import {
getConfiguredWindowsMpvPathStatus, getConfiguredWindowsMpvPathStatus,
launchWindowsMpv, launchWindowsMpv,
} from './main/runtime/windows-mpv-launch'; } from './main/runtime/windows-mpv-launch';
import { resolveMpvExecutablePath, spawnMpvProcess } from './main/runtime/mpv-process';
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection'; import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
import { import {
DEFAULT_JELLYFIN_CLIENT_NAME, DEFAULT_JELLYFIN_CLIENT_NAME,
@@ -677,22 +672,6 @@ const MPV_JELLYFIN_DEFAULT_ARGS = [
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', '--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
] as const; ] 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 activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null;
let jellyfinRemoteLastProgressAtMs = 0; let jellyfinRemoteLastProgressAtMs = 0;
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null; let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
@@ -3179,18 +3158,20 @@ const {
sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)), sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
}, },
launchMpvIdleForJellyfinPlaybackMainDeps: { launchMpvIdleForJellyfinPlaybackMainDeps: {
getMpvExecutablePath: () =>
resolveMpvExecutablePath(configService.getConfig().mpv.executablePath),
getSocketPath: () => appState.mpvSocketPath, getSocketPath: () => appState.mpvSocketPath,
getLaunchMode: () => configService.getConfig().mpv.launchMode, getLaunchMode: () => configService.getConfig().mpv.launchMode,
platform: process.platform, platform: process.platform,
execPath: process.execPath, execPath: process.execPath,
getRuntimePluginEntrypoint: () => resolveBundledMpvRuntimePluginEntrypoint(), getRuntimePluginEntrypoint: () => resolveBundledMpvRuntimePluginEntrypoint(),
getInstalledPluginDetection: () => getInstalledPluginDetection: (mpvExecutablePath) =>
detectInstalledMpvPlugin({ detectInstalledMpvPlugin({
platform: process.platform, platform: process.platform,
homeDir: os.homedir(), homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME, xdgConfigHome: process.env.XDG_CONFIG_HOME,
appDataDir: app.getPath('appData'), appDataDir: app.getPath('appData'),
mpvExecutablePath: configService.getConfig().mpv.executablePath, mpvExecutablePath,
}), }),
getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(), getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(),
getDefaultMpvLogPath: () => (isLogFileEnabled('mpv') ? DEFAULT_MPV_LOG_PATH : ''), getDefaultMpvLogPath: () => (isLogFileEnabled('mpv') ? DEFAULT_MPV_LOG_PATH : ''),
@@ -3198,7 +3179,7 @@ const {
removeSocketPath: (socketPath) => { removeSocketPath: (socketPath) => {
fs.rmSync(socketPath, { force: true }); fs.rmSync(socketPath, { force: true });
}, },
spawnMpv: (args) => spawnManagedMpvProcess(args), spawnMpv: spawnMpvProcess,
logWarn: (message, error) => logger.warn(message, error), logWarn: (message, error) => logger.warn(message, error),
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
}, },
@@ -54,6 +54,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
sleep: async () => {}, sleep: async () => {},
}, },
launchMpvIdleForJellyfinPlaybackMainDeps: { launchMpvIdleForJellyfinPlaybackMainDeps: {
getMpvExecutablePath: () => 'mpv',
getSocketPath: () => '/tmp/test-mpv.sock', getSocketPath: () => '/tmp/test-mpv.sock',
getLaunchMode: () => 'normal', getLaunchMode: () => 'normal',
platform: 'linux', platform: 'linux',
+1 -1
View File
@@ -165,7 +165,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
const mpvClient = deps.getMpvClient(); const mpvClient = deps.getMpvClient();
if (!connected || !mpvClient) { if (!connected || !mpvClient) {
throw new Error( 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({ const deps = createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler({
getMpvExecutablePath: () => '/usr/local/bin/mpv',
getSocketPath: () => '/tmp/mpv.sock', getSocketPath: () => '/tmp/mpv.sock',
getLaunchMode: () => 'fullscreen', getLaunchMode: () => 'fullscreen',
platform: 'darwin', platform: 'darwin',
@@ -47,8 +48,8 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
getDefaultMpvLogPath: () => '/tmp/mpv.log', getDefaultMpvLogPath: () => '/tmp/mpv.log',
defaultMpvArgs: ['--no-config'], defaultMpvArgs: ['--no-config'],
removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`), removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`),
spawnMpv: (args) => { spawnMpv: (executablePath, args) => {
calls.push(`spawn:${args.join(' ')}`); calls.push(`spawn:${executablePath} ${args.join(' ')}`);
return proc; return proc;
}, },
logWarn: (message) => calls.push(`warn:${message}`), 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.platform, 'darwin');
assert.equal(deps.execPath, '/tmp/subminer'); assert.equal(deps.execPath, '/tmp/subminer');
assert.equal(deps.getRuntimePluginEntrypoint?.(), '/tmp/plugin/subminer/main.lua'); 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.equal(deps.getDefaultMpvLogPath(), '/tmp/mpv.log');
assert.deepEqual(deps.defaultMpvArgs, ['--no-config']); assert.deepEqual(deps.defaultMpvArgs, ['--no-config']);
deps.removeSocketPath('/tmp/mpv.sock'); deps.removeSocketPath('/tmp/mpv.sock');
deps.spawnMpv(['--idle=yes']); deps.spawnMpv('/usr/local/bin/mpv', ['--idle=yes']);
deps.logInfo('launched'); deps.logInfo('launched');
deps.logWarn('bad', null); 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 () => { test('ensure mpv connected for jellyfin main deps builder maps callbacks', async () => {
@@ -16,6 +16,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
deps: LaunchMpvForJellyfinDeps, deps: LaunchMpvForJellyfinDeps,
) { ) {
return (): LaunchMpvForJellyfinDeps => ({ return (): LaunchMpvForJellyfinDeps => ({
getMpvExecutablePath: () => deps.getMpvExecutablePath(),
getSocketPath: () => deps.getSocketPath(), getSocketPath: () => deps.getSocketPath(),
getLaunchMode: () => deps.getLaunchMode(), getLaunchMode: () => deps.getLaunchMode(),
platform: deps.platform, platform: deps.platform,
@@ -26,7 +27,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
getDefaultMpvLogPath: () => deps.getDefaultMpvLogPath(), getDefaultMpvLogPath: () => deps.getDefaultMpvLogPath(),
defaultMpvArgs: deps.defaultMpvArgs, defaultMpvArgs: deps.defaultMpvArgs,
removeSocketPath: (socketPath: string) => deps.removeSocketPath(socketPath), 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), logWarn: (message: string, error: unknown) => deps.logWarn(message, error),
logInfo: (message: string) => deps.logInfo(message), logInfo: (message: string) => deps.logInfo(message),
}); });
@@ -1,5 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { detectInstalledMpvPlugin } from './first-run-setup-plugin';
import { resolveWindowsMpvPath } from './mpv-process';
import { import {
createEnsureMpvConnectedForJellyfinPlaybackHandler, createEnsureMpvConnectedForJellyfinPlaybackHandler,
createLaunchMpvIdleForJellyfinPlaybackHandler, createLaunchMpvIdleForJellyfinPlaybackHandler,
@@ -30,6 +32,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
const spawnedArgs: string[][] = []; const spawnedArgs: string[][] = [];
const logs: string[] = []; const logs: string[] = [];
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({ const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
getMpvExecutablePath: () => 'mpv',
getSocketPath: () => '/tmp/subminer.sock', getSocketPath: () => '/tmp/subminer.sock',
getLaunchMode: () => 'maximized', getLaunchMode: () => 'maximized',
platform: 'darwin', platform: 'darwin',
@@ -39,7 +42,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
getDefaultMpvLogPath: () => ' /tmp/mp.log ', getDefaultMpvLogPath: () => ' /tmp/mp.log ',
defaultMpvArgs: ['--sid=auto'], defaultMpvArgs: ['--sid=auto'],
removeSocketPath: () => {}, removeSocketPath: () => {},
spawnMpv: (args) => { spawnMpv: (_executable, args) => {
spawnedArgs.push(args); spawnedArgs.push(args);
return { return {
on: () => {}, on: () => {},
@@ -67,6 +70,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin config', () => { test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin config', () => {
const spawnedArgs: string[][] = []; const spawnedArgs: string[][] = [];
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({ const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
getMpvExecutablePath: () => 'mpv',
getSocketPath: () => '/tmp/subminer.sock', getSocketPath: () => '/tmp/subminer.sock',
getLaunchMode: () => 'normal', getLaunchMode: () => 'normal',
platform: 'linux', platform: 'linux',
@@ -84,7 +88,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
getDefaultMpvLogPath: () => '/tmp/mp.log', getDefaultMpvLogPath: () => '/tmp/mp.log',
defaultMpvArgs: ['--sid=auto'], defaultMpvArgs: ['--sid=auto'],
removeSocketPath: () => {}, removeSocketPath: () => {},
spawnMpv: (args) => { spawnMpv: (_executable, args) => {
spawnedArgs.push(args); spawnedArgs.push(args);
return { return {
on: () => {}, on: () => {},
@@ -108,41 +112,53 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
assert.doesNotMatch(scriptOpts ?? '', /subminer-aniskip_button_key=/); assert.doesNotMatch(scriptOpts ?? '', /subminer-aniskip_button_key=/);
}); });
test('createLaunchMpvIdleForJellyfinPlaybackHandler skips bundled script when installed plugin exists', () => { test('Jellyfin detects portable plugins beside the executable selected for launch', () => {
const spawnedArgs: string[][] = []; 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({ const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
getSocketPath: () => '/tmp/subminer.sock', 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', getLaunchMode: () => 'normal',
platform: 'linux', platform: 'win32',
execPath: '/opt/SubMiner/SubMiner.AppImage', execPath: 'C:\\SubMiner\\SubMiner.exe',
getRuntimePluginEntrypoint: () => '/opt/SubMiner/plugin/subminer/main.lua', getRuntimePluginEntrypoint: () => 'C:\\SubMiner\\plugin\\subminer\\main.lua',
getInstalledPluginDetection: () => ({ getInstalledPluginDetection: (mpvExecutablePath) =>
installed: true, detectInstalledMpvPlugin({
path: '/home/tester/.config/mpv/scripts/subminer/main.lua', platform: 'win32',
version: '0.1.0', homeDir: 'C:\\Users\\test',
source: 'default-config', mpvExecutablePath,
message: null, existsSync: (candidate) => candidate === pluginPath,
}), }),
getDefaultMpvLogPath: () => '/tmp/mp.log', getDefaultMpvLogPath: () => '',
defaultMpvArgs: ['--sid=auto'], defaultMpvArgs: [],
removeSocketPath: () => {}, removeSocketPath: () => {},
spawnMpv: (args) => { spawnMpv: (executable, args) => {
spawnedArgs.push(args); spawned.push({ executable, args });
return { return { on: () => {}, unref: () => {} };
on: () => {},
unref: () => {},
};
}, },
logWarn: () => {}, logWarn: () => {},
logInfo: () => {}, logInfo: () => {},
}); });
launch(); launch();
assert.equal(resolutions, 1, source);
assert.equal(spawned.length, 1);
assert.equal(spawned[0]!.executable, mpvPath);
assert.equal( assert.equal(
spawnedArgs[0]?.some((arg) => arg.startsWith('--script=/opt/SubMiner/plugin/subminer')), spawned[0]!.args.some((arg) => arg.startsWith('--script=')),
false, false,
); );
assert.ok(spawnedArgs[0]?.some((arg) => arg.startsWith('--script-opts='))); }
}); });
test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => { test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => {
@@ -41,23 +41,25 @@ export function createWaitForMpvConnectedHandler(deps: WaitForMpvConnectedDeps)
} }
export type LaunchMpvForJellyfinDeps = { export type LaunchMpvForJellyfinDeps = {
getMpvExecutablePath: () => string;
getSocketPath: () => string; getSocketPath: () => string;
getLaunchMode: () => MpvLaunchMode; getLaunchMode: () => MpvLaunchMode;
platform: NodeJS.Platform; platform: NodeJS.Platform;
execPath: string; execPath: string;
getRuntimePluginEntrypoint?: () => string | null | undefined; getRuntimePluginEntrypoint?: () => string | null | undefined;
getInstalledPluginDetection?: () => InstalledMpvPluginDetection; getInstalledPluginDetection?: (mpvExecutablePath: string) => InstalledMpvPluginDetection;
getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig; getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig;
getDefaultMpvLogPath: () => string; getDefaultMpvLogPath: () => string;
defaultMpvArgs: readonly string[]; defaultMpvArgs: readonly string[];
removeSocketPath: (socketPath: string) => void; removeSocketPath: (socketPath: string) => void;
spawnMpv: (args: string[]) => SpawnedProcessLike; spawnMpv: (executablePath: string, args: string[]) => SpawnedProcessLike;
logWarn: (message: string, error: unknown) => void; logWarn: (message: string, error: unknown) => void;
logInfo: (message: string) => void; logInfo: (message: string) => void;
}; };
export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvForJellyfinDeps) { export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvForJellyfinDeps) {
return (): void => { return (): void => {
const executablePath = deps.getMpvExecutablePath();
const socketPath = deps.getSocketPath(); const socketPath = deps.getSocketPath();
if (deps.platform !== 'win32') { if (deps.platform !== 'win32') {
try { try {
@@ -78,7 +80,7 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
) )
: [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`]; : [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`];
const scriptOpts = `--script-opts=${scriptOptParts.join(',')}`; const scriptOpts = `--script-opts=${scriptOptParts.join(',')}`;
const installedPlugin = deps.getInstalledPluginDetection?.(); const installedPlugin = deps.getInstalledPluginDetection?.(executablePath);
const runtimePluginEntrypoint = installedPlugin?.installed const runtimePluginEntrypoint = installedPlugin?.installed
? '' ? ''
: (deps.getRuntimePluginEntrypoint?.()?.trim() ?? ''); : (deps.getRuntimePluginEntrypoint?.()?.trim() ?? '');
@@ -95,7 +97,7 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
...(defaultMpvLogPath ? [`--log-file=${defaultMpvLogPath}`] : []), ...(defaultMpvLogPath ? [`--log-file=${defaultMpvLogPath}`] : []),
`--input-ipc-server=${socketPath}`, `--input-ipc-server=${socketPath}`,
]; ];
const proc = deps.spawnMpv(mpvArgs); const proc = deps.spawnMpv(executablePath, mpvArgs);
proc.on('error', (error) => { proc.on('error', (error) => {
deps.logWarn('Failed to launch mpv for Jellyfin remote playback', 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'); 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( const resolved = resolveWindowsMpvPath(
createDeps({ createDeps({
getEnv: () => undefined, getEnv: () => 'C:\\other\\mpv.exe',
runWhere: () => ({ status: 0, stdout: 'C:\\tools\\mpv.exe\r\n' }), 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 ', ' C:\\mpv\\mpv.exe ',
); );
@@ -53,6 +53,16 @@ test('resolveWindowsMpvPath falls back to where.exe output', () => {
assert.equal(resolved, 'C:\\tools\\mpv.exe'); 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', () => { test('buildWindowsMpvLaunchArgs uses explicit SubMiner defaults and targets', () => {
assert.deepEqual( assert.deepEqual(
buildWindowsMpvLaunchArgs( 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 { isLogFileEnabled } from '../../shared/log-files';
import { canConnectSocket } from '../../shared/socket-probe'; import { canConnectSocket } from '../../shared/socket-probe';
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode'; import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
@@ -8,11 +6,19 @@ import { buildSubminerPluginRuntimeScriptOptParts } from '../../shared/subminer-
import type { MpvLaunchMode } from '../../types/config'; import type { MpvLaunchMode } from '../../types/config';
import type { SubminerPluginRuntimeScriptOptConfig } from '../../shared/subminer-plugin-script-opts'; import type { SubminerPluginRuntimeScriptOptConfig } from '../../shared/subminer-plugin-script-opts';
import type { InstalledMpvPluginDetection } from './first-run-setup-plugin'; 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 { export interface WindowsMpvLaunchDeps extends WindowsMpvPathDeps {
getEnv: (name: string) => string | undefined;
runWhere: () => { status: number | null; stdout: string; error?: Error };
fileExists: (candidate: string) => boolean;
spawnDetached: (command: string, args: string[], env?: NodeJS.ProcessEnv) => Promise<void>; spawnDetached: (command: string, args: string[], env?: NodeJS.ProcessEnv) => Promise<void>;
isAppControlServerAvailable?: () => Promise<boolean>; isAppControlServerAvailable?: () => Promise<boolean>;
sendAppControlCommand?: ( sendAppControlCommand?: (
@@ -23,8 +29,6 @@ export interface WindowsMpvLaunchDeps {
logInfo?: (message: string) => void; logInfo?: (message: string) => void;
} }
export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid';
export interface WindowsMpvRuntimePluginPolicy { export interface WindowsMpvRuntimePluginPolicy {
detectInstalledMpvPlugin?: (mpvPath: string) => InstalledMpvPluginDetection; detectInstalledMpvPlugin?: (mpvPath: string) => InstalledMpvPluginDetection;
notifyInstalledPluginDetected?: (detection: InstalledMpvPluginDetection) => void; notifyInstalledPluginDetected?: (detection: InstalledMpvPluginDetection) => void;
@@ -38,54 +42,6 @@ function normalizeCandidate(candidate: string | undefined): string {
return typeof candidate === 'string' ? candidate.trim() : ''; 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 DEFAULT_WINDOWS_MPV_SOCKET = '\\\\.\\pipe\\subminer-socket';
const RUNNING_APP_ATTACH_SOCKET_WAIT_MS = 10000; const RUNNING_APP_ATTACH_SOCKET_WAIT_MS = 10000;
@@ -332,19 +288,7 @@ export function createWindowsMpvLaunchDeps(options: {
logInfo?: (message: string) => void; logInfo?: (message: string) => void;
}): WindowsMpvLaunchDeps { }): WindowsMpvLaunchDeps {
return { return {
getEnv: options.getEnv ?? ((name) => process.env[name]), ...createWindowsMpvPathDeps(options),
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,
isAppControlServerAvailable: options.isAppControlServerAvailable, isAppControlServerAvailable: options.isAppControlServerAvailable,
sendAppControlCommand: options.sendAppControlCommand, sendAppControlCommand: options.sendAppControlCommand,
waitForSocketReady, waitForSocketReady,
@@ -352,12 +296,11 @@ export function createWindowsMpvLaunchDeps(options: {
spawnDetached: (command, args, env) => spawnDetached: (command, args, env) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
try { try {
const child = spawn(command, args, { const child = spawnMpvProcess(
detached: true, command,
stdio: 'ignore', args,
windowsHide: true, env ? { ...process.env, ...env } : process.env,
env: env ? { ...process.env, ...env } : process.env, );
});
let settled = false; let settled = false;
child.once('error', (error) => { child.once('error', (error) => {
if (settled) return; if (settled) return;