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
|
- 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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
|
|||||||
@@ -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 launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
|
const pluginPath = 'C:\\portable player\\portable_config\\scripts\\subminer\\main.lua';
|
||||||
getSocketPath: () => '/tmp/subminer.sock',
|
for (const source of ['environment', 'PATH']) {
|
||||||
getLaunchMode: () => 'normal',
|
let resolutions = 0;
|
||||||
platform: 'linux',
|
const spawned: Array<{ executable: string; args: string[] }> = [];
|
||||||
execPath: '/opt/SubMiner/SubMiner.AppImage',
|
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
|
||||||
getRuntimePluginEntrypoint: () => '/opt/SubMiner/plugin/subminer/main.lua',
|
getMpvExecutablePath: () => {
|
||||||
getInstalledPluginDetection: () => ({
|
resolutions += 1;
|
||||||
installed: true,
|
return resolveWindowsMpvPath({
|
||||||
path: '/home/tester/.config/mpv/scripts/subminer/main.lua',
|
getEnv: () => (source === 'environment' ? mpvPath : undefined),
|
||||||
version: '0.1.0',
|
runWhere: () => ({ status: 0, stdout: mpvPath }),
|
||||||
source: 'default-config',
|
fileExists: (candidate) => candidate === mpvPath,
|
||||||
message: null,
|
});
|
||||||
}),
|
},
|
||||||
getDefaultMpvLogPath: () => '/tmp/mp.log',
|
getSocketPath: () => '\\\\.\\pipe\\subminer-test',
|
||||||
defaultMpvArgs: ['--sid=auto'],
|
getLaunchMode: () => 'normal',
|
||||||
removeSocketPath: () => {},
|
platform: 'win32',
|
||||||
spawnMpv: (args) => {
|
execPath: 'C:\\SubMiner\\SubMiner.exe',
|
||||||
spawnedArgs.push(args);
|
getRuntimePluginEntrypoint: () => 'C:\\SubMiner\\plugin\\subminer\\main.lua',
|
||||||
return {
|
getInstalledPluginDetection: (mpvExecutablePath) =>
|
||||||
on: () => {},
|
detectInstalledMpvPlugin({
|
||||||
unref: () => {},
|
platform: 'win32',
|
||||||
};
|
homeDir: 'C:\\Users\\test',
|
||||||
},
|
mpvExecutablePath,
|
||||||
logWarn: () => {},
|
existsSync: (candidate) => candidate === pluginPath,
|
||||||
logInfo: () => {},
|
}),
|
||||||
});
|
getDefaultMpvLogPath: () => '',
|
||||||
|
defaultMpvArgs: [],
|
||||||
|
removeSocketPath: () => {},
|
||||||
|
spawnMpv: (executable, args) => {
|
||||||
|
spawned.push({ executable, args });
|
||||||
|
return { on: () => {}, unref: () => {} };
|
||||||
|
},
|
||||||
|
logWarn: () => {},
|
||||||
|
logInfo: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
launch();
|
launch();
|
||||||
assert.equal(
|
assert.equal(resolutions, 1, source);
|
||||||
spawnedArgs[0]?.some((arg) => arg.startsWith('--script=/opt/SubMiner/plugin/subminer')),
|
assert.equal(spawned.length, 1);
|
||||||
false,
|
assert.equal(spawned[0]!.executable, mpvPath);
|
||||||
);
|
assert.equal(
|
||||||
assert.ok(spawnedArgs[0]?.some((arg) => arg.startsWith('--script-opts=')));
|
spawned[0]!.args.some((arg) => arg.startsWith('--script=')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user