feat(launcher): add mpv args passthrough

This commit is contained in:
2026-03-17 21:51:52 -07:00
parent b061b265c2
commit ecb41a490b
9 changed files with 175 additions and 0 deletions

View File

@@ -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);
}

View File

@@ -57,6 +57,7 @@ function applyRootOptions(program: Command): void {
program
.option('-b, --backend <backend>', 'Display backend')
.option('-d, --directory <dir>', 'Directory to browse')
.option('-a, --args <args>', 'Pass arguments to MPV')
.option('-r, --recursive', 'Search directories recursively')
.option('-p, --profile <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',

View File

@@ -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');

View File

@@ -142,6 +142,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
mpvIdle: false,
mpvSocket: false,
mpvStatus: false,
mpvArgs: '',
appPassthrough: false,
appArgs: [],
jellyfinServer: '',

View File

@@ -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}`,

View File

@@ -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', {});

View File

@@ -124,6 +124,7 @@ export interface Args {
mpvIdle: boolean;
mpvSocket: boolean;
mpvStatus: boolean;
mpvArgs: string;
appPassthrough: boolean;
appArgs: string[];
jellyfinServer: string;