From 7c1c6743130252de4cbd852f9222739e76bfb95e Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 21 Oct 2026 00:17:14 -0700 Subject: [PATCH] fix(jellyfin): find mpv when SubMiner is launched from Finder Apps opened from Finder, the Dock, or Spotlight inherit launchd's minimal PATH, so spawning bare `mpv` failed for Homebrew installs and Jellyfin casting never got an IPC socket. - On macOS and Linux, resolve mpv from mpv.executablePath, SUBMINER_MPV_PATH, PATH, then (macOS) Homebrew, /usr/local/bin, and /Applications/mpv.app - An invalid mpv.executablePath errors instead of silently falling back - Timing preview uses the same lookup - Docs: mpv lookup order, a Jellyfin casting troubleshooting entry, and the macOS 27 rename of Accessibility to Device Control and Data Access Closes #297 --- README.md | 2 +- changes/macos-mpv-discovery-docs.md | 4 + changes/macos-mpv-discovery.md | 5 ++ config.example.jsonc | 4 +- docs-site/configuration.md | 24 +++--- docs-site/installation.md | 2 +- docs-site/jellyfin-integration.md | 2 +- docs-site/public/config.example.jsonc | 4 +- docs-site/troubleshooting.md | 11 ++- .../definitions/options-integrations.ts | 2 +- src/config/definitions/template-sections.ts | 2 +- src/main.ts | 9 ++- src/main/runtime/mpv-process.test.ts | 75 +++++++++++++++++++ src/main/runtime/mpv-process.ts | 71 ++++++++++++++++-- 14 files changed, 186 insertions(+), 31 deletions(-) create mode 100644 changes/macos-mpv-discovery-docs.md create mode 100644 changes/macos-mpv-discovery.md diff --git a/README.md b/README.md index c07a02c4..87a14f66 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ The AppImage is all you need. First-run setup can install the optional `subminer Download the latest DMG from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) and drag `SubMiner.app` into `/Applications`. -Then enable SubMiner under **System Settings > Privacy & Security > Accessibility**, or the overlay cannot follow the mpv window. If macOS blocks the first launch, right-click the app and choose **Open**. +Then enable SubMiner under **System Settings > Privacy & Security > Accessibility** (named **Device Control and Data Access** on macOS 27), or the overlay cannot follow the mpv window. If macOS blocks the first launch, right-click the app and choose **Open**. diff --git a/changes/macos-mpv-discovery-docs.md b/changes/macos-mpv-discovery-docs.md new file mode 100644 index 00000000..b39dd2bf --- /dev/null +++ b/changes/macos-mpv-discovery-docs.md @@ -0,0 +1,4 @@ +type: docs +area: docs + +- Documented how casting finds mpv on every platform, added a troubleshooting entry for casts that never start mpv, and noted that macOS 27 renames the Accessibility privacy list to "Device Control and Data Access". diff --git a/changes/macos-mpv-discovery.md b/changes/macos-mpv-discovery.md new file mode 100644 index 00000000..79fa6a56 --- /dev/null +++ b/changes/macos-mpv-discovery.md @@ -0,0 +1,5 @@ +type: fixed +area: jellyfin + +- Fixed casting from Jellyfin on macOS when SubMiner is opened from Finder, the Dock, or Spotlight. SubMiner now honors `mpv.executablePath` and `SUBMINER_MPV_PATH` on macOS and Linux, and on macOS also finds mpv in `/opt/homebrew/bin`, `/usr/local/bin`, and `/Applications/mpv.app` instead of failing with `connect ENOENT /tmp/subminer-socket`. +- The subtitle timing preview uses the same mpv lookup. diff --git a/config.example.jsonc b/config.example.jsonc index fe7f4f96..2c0eecb1 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -734,10 +734,10 @@ // 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. // ========================================== "mpv": { - "executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH. + "executablePath": "", // 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. "launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen "profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile. "socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index efb8b2a8..3bbbe41c 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -579,18 +579,18 @@ A local web dashboard at `http://127.0.0.1:`, also available as an o Settings for mpv instances that SubMiner starts, and for the bundled mpv plugin. See [mpv plugin](/mpv-plugin). -| Key | Default | What it does | -| ---------------------------- | ----------------- | --------------------------------------------------------------------------- | -| `mpv.executablePath` | `""` | Path to `mpv.exe` on Windows. Empty checks `SUBMINER_MPV_PATH`, then `PATH` | -| `mpv.launchMode` | `"normal"` | Window state: `normal`, `maximized`, or `fullscreen` | -| `mpv.profile` | `""` | mpv profile to pass. Combined with a launcher `--profile` if both are set | -| `mpv.socketPath` | platform-specific | mpv IPC socket. See the warning under [Config file](#configuration-file) | -| `mpv.backend` | `"auto"` | Window tracking: `auto`, `hyprland`, `sway`, `x11`, `macos`, `windows` | -| `mpv.autoStartSubMiner` | `true` | Start SubMiner in the background when mpv loads a file | -| `mpv.pauseUntilOverlayReady` | `true` | Keep mpv paused until subtitles are ready, up to 30 seconds | -| `mpv.subminerBinaryPath` | `""` | SubMiner app path for the plugin. Empty uses the detected path | -| `mpv.aniskipEnabled` | `true` | Detect intros with AniSkip and show a skip prompt | -| `mpv.aniskipButtonKey` | `"TAB"` | mpv key that skips the intro while the prompt is shown | +| Key | Default | What it does | +| ---------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------- | +| `mpv.executablePath` | `""` | Full path to mpv. Empty auto-discovers it ([lookup order](/jellyfin-integration#casting-from-jellyfin)) | +| `mpv.launchMode` | `"normal"` | Window state: `normal`, `maximized`, or `fullscreen` | +| `mpv.profile` | `""` | mpv profile to pass. Combined with a launcher `--profile` if both are set | +| `mpv.socketPath` | platform-specific | mpv IPC socket. See the warning under [Config file](#configuration-file) | +| `mpv.backend` | `"auto"` | Window tracking: `auto`, `hyprland`, `sway`, `x11`, `macos`, `windows` | +| `mpv.autoStartSubMiner` | `true` | Start SubMiner in the background when mpv loads a file | +| `mpv.pauseUntilOverlayReady` | `true` | Keep mpv paused until subtitles are ready, up to 30 seconds | +| `mpv.subminerBinaryPath` | `""` | SubMiner app path for the plugin. Empty uses the detected path | +| `mpv.aniskipEnabled` | `true` | Detect intros with AniSkip and show a skip prompt | +| `mpv.aniskipButtonKey` | `"TAB"` | mpv key that skips the intro while the prompt is shown | ### YouTube playback settings diff --git a/docs-site/installation.md b/docs-site/installation.md index 64d5d879..08f7b3fb 100644 --- a/docs-site/installation.md +++ b/docs-site/installation.md @@ -187,7 +187,7 @@ First-run setup can install the `subminer` command for you. xattr -d com.apple.quarantine /Applications/SubMiner.app ``` -3. Open **System Settings > Privacy & Security > Accessibility** and enable SubMiner (add it if it is missing). The overlay cannot follow the mpv window without this. +3. Open **System Settings > Privacy & Security > Accessibility** and enable SubMiner (add it if it is missing). On macOS 27 this list is named **Device Control and Data Access**. The overlay cannot follow the mpv window without this. First-run setup can install the `subminer` command for you. diff --git a/docs-site/jellyfin-integration.md b/docs-site/jellyfin-integration.md index b0904307..e4b1e26a 100644 --- a/docs-site/jellyfin-integration.md +++ b/docs-site/jellyfin-integration.md @@ -25,7 +25,7 @@ SubMiner selects a Japanese subtitle track automatically and resets mpv's subtit If an episode has no Japanese subtitles, you can [generate them with Whisper](/subtitle-generation#generating-from-the-overlay) while it plays. -On Windows, casting finds mpv through `mpv.executablePath`, then `SUBMINER_MPV_PATH`, then `PATH`. An invalid `mpv.executablePath` stops mpv from starting. +Casting finds mpv through `mpv.executablePath`, then `SUBMINER_MPV_PATH`, then `PATH`. On macOS it then checks `/opt/homebrew/bin/mpv`, `/usr/local/bin/mpv`, and `/Applications/mpv.app`, because an app opened from Finder, the Dock, or Spotlight does not get your shell's `PATH`. If mpv lives somewhere else, set `mpv.executablePath` to its full path (`command -v mpv` prints it). An invalid `mpv.executablePath` stops mpv from starting. ## Playing from the terminal diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index fe7f4f96..2c0eecb1 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -734,10 +734,10 @@ // 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. // ========================================== "mpv": { - "executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH. + "executablePath": "", // 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. "launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen "profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile. "socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin. diff --git a/docs-site/troubleshooting.md b/docs-site/troubleshooting.md index 514e4e50..cb0e3984 100644 --- a/docs-site/troubleshooting.md +++ b/docs-site/troubleshooting.md @@ -39,11 +39,18 @@ SubMiner reads subtitles from mpv over an IPC socket (a named pipe on Windows). SubMiner reconnects on its own if mpv restarts. +## Casting from Jellyfin does nothing + +When `connect ENOENT /tmp/subminer-socket` follows a cast in the app log, SubMiner could not start mpv, so the socket never appeared. Look for `Failed handling Jellyfin remote Play event` or `Failed to launch mpv for Jellyfin remote playback` just before it. + +- Check that `mpv --version` works in a terminal. +- If it does, SubMiner is not finding mpv where it is installed. Set `mpv.executablePath` to the output of `command -v mpv`. See [Casting from Jellyfin](/jellyfin-integration#casting-from-jellyfin) for where SubMiner looks. + ## Overlay does not appear - Confirm SubMiner is running (`SubMiner.AppImage --start`, or check for the process). - Linux: Hyprland and Sway work natively. Any other compositor needs mpv and SubMiner under X11 or Xwayland, with `xdotool`, `xprop`, and `xwininfo` installed. See [KDE Plasma and other Wayland compositors](#kde-plasma-and-other-wayland-compositors). -- macOS: grant Accessibility permission in System Settings > Privacy & Security > Accessibility. +- macOS: grant Accessibility permission. See [macOS](#macos). ## Overlay is on the wrong monitor or position @@ -255,5 +262,5 @@ mpv --gpu-context=x11vk,x11egl,x11 video.mkv ## macOS -- Accessibility permission is required for window tracking: System Settings > Privacy & Security > Accessibility. +- Accessibility permission is required for window tracking: System Settings > Privacy & Security > Accessibility. On macOS 27 this list is named **Device Control and Data Access**. - If Gatekeeper blocks the app, right-click it and choose Open, or run `xattr -d com.apple.quarantine /path/to/SubMiner.app`. diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 68545d92..d0840ba9 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -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', diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index 6f8f9af2..8d7bbe19 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -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', }, diff --git a/src/main.ts b/src/main.ts index 5d10fe8a..8b73bb97 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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,7 @@ 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() || '', + getMpvExecutablePath: () => findMpvExecutablePath(configService.getConfig().mpv.executablePath), createPreviewSession: () => new MediaTimingPreviewSession(), generateWaveform: (options) => generateSpeechWaveform(options), generateFrame: (options) => mediaTimingFrameExtractor.generate(options), diff --git a/src/main/runtime/mpv-process.test.ts b/src/main/runtime/mpv-process.test.ts index efe215c3..484facf0 100644 --- a/src/main/runtime/mpv-process.test.ts +++ b/src/main/runtime/mpv-process.test.ts @@ -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; +}) { + 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', diff --git a/src/main/runtime/mpv-process.ts b/src/main/runtime/mpv-process.ts index f0e3b94c..d599e8a4 100644 --- a/src/main/runtime/mpv-process.ts +++ b/src/main/runtime/mpv-process.ts @@ -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 { + 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;