fix(jellyfin): find mpv when SubMiner is launched from Finder (#298)

This commit is contained in:
sudacode authored and GitHub committed 2026-10-24 18:27:31 -07:00
1 parent 01613f1be5
commit d047e3e0a4
16 files changed
+193 -32

No files matched your search

@@ -554,7 +554,7 @@ export function buildIntegrationConfigOptionRegistry(
kind: 'string',
defaultValue: defaultConfig.mpv.executablePath,
description:
'Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.',
'Optional absolute path to the mpv binary SubMiner starts itself (Windows launches, Jellyfin casting, timing preview). Leave empty to auto-discover from SUBMINER_MPV_PATH, PATH, and on macOS the Homebrew and /Applications/mpv.app locations.',
},
{
path: 'mpv.launchMode',
+1 -1
View File
@@ -215,7 +215,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
'autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.',
'Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.',
'Set mpv.profile to pass an mpv profile to managed mpv launches; leave it blank to pass none.',
'Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.',
'Leave mpv.executablePath blank to auto-discover mpv from SUBMINER_MPV_PATH, PATH, or (macOS) Homebrew and /Applications/mpv.app.',
],
key: 'mpv',
},
+10 -3
View File
@@ -392,7 +392,11 @@ import {
getConfiguredWindowsMpvPathStatus,
launchWindowsMpv,
} from './main/runtime/windows-mpv-launch';
import { resolveMpvExecutablePath, spawnMpvProcess } from './main/runtime/mpv-process';
import {
findMpvExecutablePath,
resolveMpvExecutablePath,
spawnMpvProcess,
} from './main/runtime/mpv-process';
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
import {
DEFAULT_JELLYFIN_CLIENT_NAME,
@@ -2995,8 +2999,11 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
getMpvClient: () => appState.mpvClient,
getCurrentMediaPath: () =>
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
getMpvExecutablePath: () =>
configService.getConfig().mpv.executablePath || process.env.SUBMINER_MPV_PATH?.trim() || '',
// An invalid configured path is passed through so the preview fails instead of using PATH mpv.
getMpvExecutablePath: () => {
const configuredPath = configService.getConfig().mpv.executablePath;
return findMpvExecutablePath(configuredPath) || configuredPath;
},
createPreviewSession: () => new MediaTimingPreviewSession(),
generateWaveform: (options) => generateSpeechWaveform(options),
generateFrame: (options) => mediaTimingFrameExtractor.generate(options),
+75
View File
@@ -5,9 +5,11 @@ import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
createPosixMpvPathDeps,
createWindowsMpvPathDeps,
getConfiguredWindowsMpvPathStatus,
resolveMpvExecutablePath,
resolvePosixMpvPath,
resolveWindowsMpvPath,
spawnMpvProcess,
} from './mpv-process';
@@ -95,6 +97,79 @@ test('resolveWindowsMpvPath returns blank when nothing resolves', () => {
assert.equal(resolveWindowsMpvPath(missing.deps), '');
});
// launchd's default PATH, which apps opened from Finder, the Dock, or Spotlight inherit.
const LAUNCHD_PATH = '/usr/bin:/bin:/usr/sbin:/sbin';
function makePosixDeps(options: {
platform: NodeJS.Platform;
executables?: string[];
env?: Record<string, string>;
}) {
const executables = new Set(options.executables ?? []);
return createPosixMpvPathDeps({
platform: options.platform,
getEnv: (name) => options.env?.[name],
isExecutable: (candidate) => executables.has(candidate),
});
}
test('resolvePosixMpvPath prefers a configured executable and does not fall back when it is invalid', () => {
const deps = makePosixDeps({
platform: 'darwin',
executables: ['/cfg/mpv', '/opt/homebrew/bin/mpv'],
env: { PATH: LAUNCHD_PATH },
});
assert.equal(resolvePosixMpvPath(deps, ' /cfg/mpv '), '/cfg/mpv');
assert.equal(resolvePosixMpvPath(deps, '/missing/mpv'), '');
});
test('resolvePosixMpvPath uses SUBMINER_MPV_PATH before searching PATH', () => {
const deps = makePosixDeps({
platform: 'linux',
executables: ['/env/mpv', '/usr/bin/mpv'],
env: { SUBMINER_MPV_PATH: ' /env/mpv ', PATH: '/usr/bin' },
});
assert.equal(resolvePosixMpvPath(deps), '/env/mpv');
});
test('resolvePosixMpvPath takes the first PATH entry that has mpv', () => {
const deps = makePosixDeps({
platform: 'darwin',
executables: ['/second/mpv', '/third/mpv', '/opt/homebrew/bin/mpv'],
env: { SUBMINER_MPV_PATH: '/stale/mpv', PATH: '/first::/second:/third' },
});
assert.equal(resolvePosixMpvPath(deps), '/second/mpv');
});
test('resolvePosixMpvPath finds Homebrew mpv on macOS when the app only has the launchd PATH', () => {
const homebrew = makePosixDeps({
platform: 'darwin',
executables: ['/opt/homebrew/bin/mpv'],
env: { PATH: LAUNCHD_PATH },
});
const appBundle = makePosixDeps({
platform: 'darwin',
executables: ['/Applications/mpv.app/Contents/MacOS/mpv'],
env: { PATH: LAUNCHD_PATH },
});
assert.equal(resolvePosixMpvPath(homebrew), '/opt/homebrew/bin/mpv');
assert.equal(resolvePosixMpvPath(appBundle), '/Applications/mpv.app/Contents/MacOS/mpv');
});
test('resolvePosixMpvPath only probes macOS install locations on macOS', () => {
const deps = makePosixDeps({
platform: 'linux',
executables: ['/opt/homebrew/bin/mpv'],
env: { PATH: LAUNCHD_PATH },
});
assert.equal(resolvePosixMpvPath(deps), '');
});
const X11_SPAWN_CASES = [
{
name: 'forces the X11 backend for an unsupported Wayland session',
+66 -5
View File
@@ -1,4 +1,5 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import {
MPV_X11_BACKEND_ARGS,
@@ -98,14 +99,74 @@ export function spawnMpvProcess(
});
}
export interface PosixMpvPathDeps {
platform: NodeJS.Platform;
getEnv: (name: string) => string | undefined;
isExecutable: (candidate: string) => boolean;
}
// Apps opened from Finder, the Dock, or Spotlight inherit launchd's minimal PATH
// (/usr/bin:/bin:/usr/sbin:/sbin), so Homebrew and app-bundle installs are probed directly.
const MACOS_MPV_FALLBACK_PATHS = [
'/opt/homebrew/bin/mpv',
'/usr/local/bin/mpv',
'/Applications/mpv.app/Contents/MacOS/mpv',
];
function isExecutableFile(candidate: string): boolean {
try {
fs.accessSync(candidate, fs.constants.X_OK);
return fs.statSync(candidate).isFile();
} catch {
return false;
}
}
export function createPosixMpvPathDeps(
overrides: Partial<PosixMpvPathDeps> = {},
): PosixMpvPathDeps {
return {
platform: overrides.platform ?? process.platform,
getEnv: overrides.getEnv ?? ((name) => process.env[name]),
isExecutable: overrides.isExecutable ?? isExecutableFile,
};
}
// Resolution order: mpv.executablePath, SUBMINER_MPV_PATH, PATH, then macOS install
// locations. A configured path that is not executable resolves to '' instead of falling back.
export function resolvePosixMpvPath(deps: PosixMpvPathDeps, configuredMpvPath = ''): string {
const configPath = configuredMpvPath.trim();
if (configPath) {
return deps.isExecutable(configPath) ? configPath : '';
}
const envPath = deps.getEnv('SUBMINER_MPV_PATH')?.trim();
if (envPath && deps.isExecutable(envPath)) {
return envPath;
}
const pathCandidates = (deps.getEnv('PATH') ?? '')
.split(path.posix.delimiter)
.filter((dir) => dir.length > 0)
.map((dir) => path.posix.join(dir, 'mpv'));
const candidates =
deps.platform === 'darwin' ? [...pathCandidates, ...MACOS_MPV_FALLBACK_PATHS] : pathCandidates;
return candidates.find((candidate) => deps.isExecutable(candidate)) ?? '';
}
// Returns the mpv binary SubMiner should spawn itself, or '' when none is found.
export function findMpvExecutablePath(configuredMpvPath = ''): string {
return process.platform === 'win32'
? resolveWindowsMpvPath(createWindowsMpvPathDeps(), configuredMpvPath)
: resolvePosixMpvPath(createPosixMpvPathDeps(), configuredMpvPath);
}
export function resolveMpvExecutablePath(configuredMpvPath = ''): string {
const executablePath =
process.platform === 'win32'
? resolveWindowsMpvPath(createWindowsMpvPathDeps(), configuredMpvPath)
: 'mpv';
const executablePath = findMpvExecutablePath(configuredMpvPath);
if (!executablePath) {
const binary = process.platform === 'win32' ? 'mpv.exe' : 'mpv';
throw new Error(
'Could not find mpv.exe. Check mpv.executablePath, SUBMINER_MPV_PATH, or PATH.',
`Could not find ${binary}. Check mpv.executablePath, SUBMINER_MPV_PATH, or PATH.`,
);
}
return executablePath;