mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-23 05:16:23 -07:00
fix(jellyfin): respect Windows mpv configuration when casting (#267)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user