From ecb41a490b41d22b6d3155bc82c2e28043845486 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 17 Mar 2026 21:51:52 -0700 Subject: [PATCH] feat(launcher): add mpv args passthrough --- docs-site/launcher-script.md | 1 + docs-site/usage.md | 3 + launcher/config/args-normalizer.ts | 2 + launcher/config/cli-parser-builder.ts | 3 + launcher/main.test.ts | 79 +++++++++++++++++++++++++++ launcher/mpv.test.ts | 1 + launcher/mpv.ts | 79 +++++++++++++++++++++++++++ launcher/parse-args.test.ts | 6 ++ launcher/types.ts | 1 + 9 files changed, 175 insertions(+) diff --git a/docs-site/launcher-script.md b/docs-site/launcher-script.md index 4f1d6a7..c65b7d8 100644 --- a/docs-site/launcher-script.md +++ b/docs-site/launcher-script.md @@ -91,6 +91,7 @@ Use `subminer -h` for command-specific help. | `-S, --start-overlay` | Explicitly start overlay after mpv launches | | `-T, --no-texthooker` | Disable texthooker server | | `-p, --profile` | mpv profile name (default: `subminer`) | +| `-a, --args` | Pass additional mpv arguments as a quoted string | | `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`) | | `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) | | `--dev`, `--debug` | Enable app dev-mode (not tied to log level) | diff --git a/docs-site/usage.md b/docs-site/usage.md index 4480e6d..7c938f3 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -56,6 +56,7 @@ subminer ytsearch:"jp news" # Play first YouTube search result subminer --setup # Open first-run setup popup subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging subminer --log-level warn video.mkv # Set logging level explicitly +subminer --args '--fs=opengl-hq --ytdl-format=bestvideo*+bestaudio/best' video.mkv # Pass extra mpv args # Options subminer -T video.mkv # Disable texthooker server @@ -189,6 +190,8 @@ Top-level launcher flags like `--jellyfin-*` and `--yt-subgen-*` are intentional - `--secondary-sid=auto` - `--secondary-sub-visibility=no` +You can append additional MPV arguments with launcher `-a/--args`, for example `--args "--ao=alsa --volume=80"`. + You can define a matching profile in `~/.config/mpv/mpv.conf` for consistency when launching `mpv` manually or from other tools. `subminer` launches with `--profile=subminer` by default (or override with `subminer -p ...`): ```ini diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index 3be167b..b2db497 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -134,6 +134,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig): mpvIdle: false, mpvSocket: false, mpvStatus: false, + mpvArgs: '', appPassthrough: false, appArgs: [], jellyfinServer: '', @@ -189,6 +190,7 @@ export function applyRootOptionsToArgs( if (options.rofi === true) parsed.useRofi = true; if (options.startOverlay === true) parsed.autoStartOverlay = true; if (options.texthooker === false) parsed.useTexthooker = false; + if (typeof options.args === 'string') parsed.mpvArgs = options.args; if (typeof rootTarget === 'string' && rootTarget) ensureTarget(rootTarget, parsed); } diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts index 07fc159..2fd64ba 100644 --- a/launcher/config/cli-parser-builder.ts +++ b/launcher/config/cli-parser-builder.ts @@ -57,6 +57,7 @@ function applyRootOptions(program: Command): void { program .option('-b, --backend ', 'Display backend') .option('-d, --directory ', 'Directory to browse') + .option('-a, --args ', 'Pass arguments to MPV') .option('-r, --recursive', 'Search directories recursively') .option('-p, --profile ', 'MPV profile') .option('--start', 'Explicitly start overlay') @@ -103,6 +104,8 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n const optionsWithValue = new Set([ '-b', '--backend', + '-a', + '--args', '-d', '--directory', '-p', diff --git a/launcher/main.test.ts b/launcher/main.test.ts index a312f6d..34a3bbb 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -308,6 +308,85 @@ done }); }); +test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, () => { + withTempDir((root) => { + const homeDir = path.join(root, 'home'); + const xdgConfigHome = path.join(root, 'xdg'); + const binDir = path.join(root, 'bin'); + const appPath = path.join(root, 'fake-subminer.sh'); + const videoPath = path.join(root, 'movie.mkv'); + const mpvArgsPath = path.join(root, 'mpv-args.txt'); + const socketPath = path.join(root, 'mpv.sock'); + const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/')); + + fs.mkdirSync(binDir, { recursive: true }); + fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true }); + fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true }); + fs.writeFileSync(videoPath, 'fake video content'); + fs.writeFileSync( + path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'), + JSON.stringify({ + version: 1, + status: 'completed', + completedAt: '2026-03-08T00:00:00.000Z', + completionSource: 'user', + lastSeenYomitanDictionaryCount: 0, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: null, + }), + ); + fs.writeFileSync( + path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'), + `socket_path=${socketPath}\nauto_start=no\nauto_start_visible_overlay=no\nauto_start_pause_until_ready=no\n`, + ); + fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n'); + fs.chmodSync(appPath, 0o755); + + fs.writeFileSync( + path.join(binDir, 'mpv'), + `#!/bin/sh +set -eu +printf '%s\\n' "$@" > "$SUBMINER_TEST_MPV_ARGS" +socket_path="" +for arg in "$@"; do + case "$arg" in + --input-ipc-server=*) + socket_path="\${arg#--input-ipc-server=}" + ;; + esac +done +${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const path=require('node:path'); const socket=process.argv[1]||''; try{ if (socket) fs.mkdirSync(path.dirname(socket),{recursive:true}); }catch{} try{ if (socket) fs.rmSync(socket,{force:true}); }catch{} if(!socket) process.exit(0); const server=net.createServer((c)=>c.end()); server.on('error',()=>process.exit(0)); try{ server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250)); } catch { process.exit(0); }" "$socket_path" +`, + 'utf8', + ); + fs.chmodSync(path.join(binDir, 'mpv'), 0o755); + + const env = { + ...makeTestEnv(homeDir, xdgConfigHome), + PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`, + Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`, + SUBMINER_APPIMAGE_PATH: appPath, + SUBMINER_TEST_MPV_ARGS: mpvArgsPath, + }; + const result = runLauncher( + ['--args', '--pause=yes --title="movie night"', videoPath], + env, + ); + + assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + const argsFile = fs.readFileSync(mpvArgsPath, 'utf8'); + const forwardedArgs = argsFile + .trim() + .split('\n') + .map((item) => item.trim()) + .filter(Boolean); + + assert.equal(forwardedArgs.includes('--pause=yes'), true); + assert.equal(forwardedArgs.includes('--title=movie night'), true); + assert.equal(forwardedArgs.includes(videoPath), true); + }); +}); + test('dictionary command forwards --dictionary and --dictionary-target to app command path', () => { withTempDir((root) => { const homeDir = path.join(root, 'home'); diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index f8d1271..c61ce4e 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -142,6 +142,7 @@ function makeArgs(overrides: Partial = {}): Args { mpvIdle: false, mpvSocket: false, mpvStatus: false, + mpvArgs: '', appPassthrough: false, appArgs: [], jellyfinServer: '', diff --git a/launcher/mpv.ts b/launcher/mpv.ts index aad413c..d3bd197 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -38,6 +38,79 @@ const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900; const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700; +export function parseMpvArgString(input: string): string[] { + const chars = input; + const args: string[] = []; + let current = ''; + let inSingleQuote = false; + let inDoubleQuote = false; + let escaping = false; + + for (let i = 0; i < chars.length; i += 1) { + const ch = chars[i] || ''; + if (escaping) { + current += ch; + escaping = false; + continue; + } + + if (inSingleQuote) { + if (ch === "'") { + inSingleQuote = false; + } else { + current += ch; + } + continue; + } + + if (inDoubleQuote) { + if (ch === '\\') { + escaping = true; + continue; + } + if (ch === '"') { + inDoubleQuote = false; + continue; + } + current += ch; + continue; + } + + if (ch === '\\') { + escaping = true; + continue; + } + if (ch === "'") { + inSingleQuote = true; + continue; + } + if (ch === '"') { + inDoubleQuote = true; + continue; + } + if (/\s/.test(ch)) { + if (current) { + args.push(current); + current = ''; + } + continue; + } + current += ch; + } + + if (escaping) { + fail('Could not parse mpv args: trailing backslash'); + } + if (inSingleQuote || inDoubleQuote) { + fail('Could not parse mpv args: unmatched quote'); + } + if (current) { + args.push(current); + } + + return args; +} + function readTrackedDetachedMpvPid(): number | null { try { const raw = fs.readFileSync(DETACHED_IDLE_MPV_PID_FILE, 'utf8').trim(); @@ -463,6 +536,9 @@ export async function startMpv( const mpvArgs: string[] = []; if (args.profile) mpvArgs.push(`--profile=${args.profile}`); mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS); + if (args.mpvArgs) { + mpvArgs.push(...parseMpvArgString(args.mpvArgs)); + } if (targetKind === 'url' && isYoutubeTarget(target)) { log('info', args.logLevel, 'Applying URL playback options'); @@ -859,6 +935,9 @@ export function launchMpvIdleDetached( const mpvArgs: string[] = []; if (args.profile) mpvArgs.push(`--profile=${args.profile}`); mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS); + if (args.mpvArgs) { + mpvArgs.push(...parseMpvArgString(args.mpvArgs)); + } mpvArgs.push('--idle=yes'); mpvArgs.push( `--script-opts=subminer-binary_path=${appPath},subminer-socket_path=${socketPath}`, diff --git a/launcher/parse-args.test.ts b/launcher/parse-args.test.ts index 6944bbc..14c15ec 100644 --- a/launcher/parse-args.test.ts +++ b/launcher/parse-args.test.ts @@ -23,6 +23,12 @@ test('parseArgs keeps all args after app verbatim', () => { assert.deepEqual(parsed.appArgs, ['--start', '--anilist-setup', '-h']); }); +test('parseArgs captures mpv args string', () => { + const parsed = parseArgs(['--args', '--pause=yes --title="movie night"'], 'subminer', {}); + + assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"'); +}); + test('parseArgs maps jellyfin play action and log-level override', () => { const parsed = parseArgs(['jellyfin', 'play', '--log-level', 'debug'], 'subminer', {}); diff --git a/launcher/types.ts b/launcher/types.ts index 4378db8..17aa9e3 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -124,6 +124,7 @@ export interface Args { mpvIdle: boolean; mpvSocket: boolean; mpvStatus: boolean; + mpvArgs: string; appPassthrough: boolean; appArgs: string[]; jellyfinServer: string;