import fs from 'node:fs'; import { spawn, spawnSync } from 'node:child_process'; export interface WindowsMpvLaunchDeps { getEnv: (name: string) => string | undefined; runWhere: () => { status: number | null; stdout: string; error?: Error }; fileExists: (candidate: string) => boolean; spawnDetached: (command: string, args: string[]) => Promise; showError: (title: string, content: string) => void; } export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid'; function normalizeCandidate(candidate: string | undefined): string { 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'; function readExtraArgValue(extraArgs: string[], flag: string): string | undefined { let value: string | undefined; for (let i = 0; i < extraArgs.length; i += 1) { const arg = extraArgs[i]; if (arg === flag) { const next = extraArgs[i + 1]; if (next && !next.startsWith('-')) { value = next; i += 1; } continue; } if (arg?.startsWith(`${flag}=`)) { value = arg.slice(flag.length + 1); } } return value; } export function buildWindowsMpvLaunchArgs( targets: string[], extraArgs: string[] = [], binaryPath?: string, pluginEntrypointPath?: string, ): string[] { const launchIdle = targets.length === 0; const inputIpcServer = readExtraArgValue(extraArgs, '--input-ipc-server') ?? DEFAULT_WINDOWS_MPV_SOCKET; const scriptEntrypoint = typeof pluginEntrypointPath === 'string' && pluginEntrypointPath.trim().length > 0 ? `--script=${pluginEntrypointPath.trim()}` : null; const scriptOptPairs = scriptEntrypoint ? [`subminer-socket_path=${inputIpcServer.replace(/,/g, '\\,')}`] : []; if (scriptEntrypoint && typeof binaryPath === 'string' && binaryPath.trim().length > 0) { scriptOptPairs.unshift(`subminer-binary_path=${binaryPath.trim().replace(/,/g, '\\,')}`); } const scriptOpts = scriptOptPairs.length > 0 ? `--script-opts=${scriptOptPairs.join(',')}` : null; return [ '--player-operation-mode=pseudo-gui', '--force-window=immediate', ...(launchIdle ? ['--idle=yes'] : []), ...(scriptEntrypoint ? [scriptEntrypoint] : []), `--input-ipc-server=${inputIpcServer}`, '--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', '--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', '--sub-auto=fuzzy', '--sub-file-paths=subs;subtitles', '--sid=auto', '--secondary-sid=auto', '--secondary-sub-visibility=no', ...(scriptOpts ? [scriptOpts] : []), ...extraArgs, ...targets, ]; } export async function launchWindowsMpv( targets: string[], deps: WindowsMpvLaunchDeps, extraArgs: string[] = [], binaryPath?: string, pluginEntrypointPath?: string, configuredMpvPath?: string, ): Promise<{ ok: boolean; mpvPath: string }> { const normalizedConfiguredPath = normalizeCandidate(configuredMpvPath); const mpvPath = resolveWindowsMpvPath(deps, normalizedConfiguredPath); if (!mpvPath) { deps.showError( 'SubMiner mpv launcher', normalizedConfiguredPath ? `Configured mpv.executablePath was not found: ${normalizedConfiguredPath}` : 'Could not find mpv.exe. Set mpv.executablePath, set SUBMINER_MPV_PATH, or add mpv.exe to PATH.', ); return { ok: false, mpvPath: '' }; } try { await deps.spawnDetached( mpvPath, buildWindowsMpvLaunchArgs(targets, extraArgs, binaryPath, pluginEntrypointPath), ); return { ok: true, mpvPath }; } catch (error) { const message = error instanceof Error ? error.message : String(error); deps.showError('SubMiner mpv launcher', `Failed to launch mpv.\nPath: ${mpvPath}\n${message}`); return { ok: false, mpvPath }; } } export function createWindowsMpvLaunchDeps(options: { getEnv?: (name: string) => string | undefined; fileExists?: (candidate: string) => boolean; showError: (title: string, content: string) => void; }): WindowsMpvLaunchDeps { return { getEnv: options.getEnv ?? ((name) => process.env[name]), 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, spawnDetached: (command, args) => new Promise((resolve, reject) => { try { const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true, }); let settled = false; child.once('error', (error) => { if (settled) return; settled = true; reject(error); }); child.once('spawn', () => { if (settled) return; settled = true; child.unref(); resolve(); }); } catch (error) { reject(error); } }), showError: options.showError, }; }