import fs from "node:fs"; import path from "node:path"; import os from "node:os"; import net from "node:net"; import { spawn, spawnSync } from "node:child_process"; import type { LogLevel, Backend, Args, MpvTrack } from "./types.js"; import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from "./types.js"; import { log, fail, getMpvLogPath } from "./log.js"; import { commandExists, isExecutable, resolveBinaryPathCandidate, realpathMaybe, isYoutubeTarget, uniqueNormalizedLangCodes, sleep, normalizeLangCode, } from "./util.js"; export const state = { overlayProc: null as ReturnType | null, mpvProc: null as ReturnType | null, youtubeSubgenChildren: new Set>(), appPath: "" as string, overlayManagedByLauncher: false, stopRequested: false, }; const DETACHED_IDLE_MPV_PID_FILE = path.join( os.tmpdir(), "subminer-idle-mpv.pid", ); function readTrackedDetachedMpvPid(): number | null { try { const raw = fs.readFileSync(DETACHED_IDLE_MPV_PID_FILE, "utf8").trim(); const pid = Number.parseInt(raw, 10); return Number.isInteger(pid) && pid > 0 ? pid : null; } catch { return null; } } function clearTrackedDetachedMpvPid(): void { try { fs.rmSync(DETACHED_IDLE_MPV_PID_FILE, { force: true }); } catch { // ignore } } function trackDetachedMpvPid(pid: number): void { try { fs.writeFileSync(DETACHED_IDLE_MPV_PID_FILE, String(pid), "utf8"); } catch { // ignore } } function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } } function processLooksLikeMpv(pid: number): boolean { if (process.platform !== "linux") return true; try { const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8"); return cmdline.includes("mpv"); } catch { return false; } } async function terminateTrackedDetachedMpv(logLevel: LogLevel): Promise { const pid = readTrackedDetachedMpvPid(); if (!pid) return; if (!isProcessAlive(pid)) { clearTrackedDetachedMpvPid(); return; } if (!processLooksLikeMpv(pid)) { clearTrackedDetachedMpvPid(); return; } try { process.kill(pid, "SIGTERM"); } catch { clearTrackedDetachedMpvPid(); return; } const deadline = Date.now() + 1500; while (Date.now() < deadline) { if (!isProcessAlive(pid)) { clearTrackedDetachedMpvPid(); return; } await sleep(100); } try { process.kill(pid, "SIGKILL"); } catch { // ignore } clearTrackedDetachedMpvPid(); log("debug", logLevel, `Terminated stale detached mpv pid=${pid}`); } export function makeTempDir(prefix: string): string { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); } export function detectBackend(backend: Backend): Exclude { if (backend !== "auto") return backend; if (process.platform === "darwin") return "macos"; const xdgCurrentDesktop = (process.env.XDG_CURRENT_DESKTOP || "").toLowerCase(); const xdgSessionDesktop = (process.env.XDG_SESSION_DESKTOP || "").toLowerCase(); const xdgSessionType = (process.env.XDG_SESSION_TYPE || "").toLowerCase(); const hasWayland = Boolean(process.env.WAYLAND_DISPLAY) || xdgSessionType === "wayland"; if ( process.env.HYPRLAND_INSTANCE_SIGNATURE || xdgCurrentDesktop.includes("hyprland") || xdgSessionDesktop.includes("hyprland") ) { return "hyprland"; } if (hasWayland && commandExists("hyprctl")) return "hyprland"; if (process.env.DISPLAY) return "x11"; fail("Could not detect display backend"); } function resolveMacAppBinaryCandidate(candidate: string): string { const direct = resolveBinaryPathCandidate(candidate); if (!direct) return ""; if (process.platform !== "darwin") { return isExecutable(direct) ? direct : ""; } if (isExecutable(direct)) { return direct; } const appIndex = direct.indexOf(".app/"); const appPath = direct.endsWith(".app") && direct.includes(".app") ? direct : appIndex >= 0 ? direct.slice(0, appIndex + ".app".length) : ""; if (!appPath) return ""; const candidates = [ path.join(appPath, "Contents", "MacOS", "SubMiner"), path.join(appPath, "Contents", "MacOS", "subminer"), ]; for (const candidateBinary of candidates) { if (isExecutable(candidateBinary)) { return candidateBinary; } } return ""; } export function findAppBinary(selfPath: string): string | null { const envPaths = [ process.env.SUBMINER_APPIMAGE_PATH, process.env.SUBMINER_BINARY_PATH, ].filter((candidate): candidate is string => Boolean(candidate)); for (const envPath of envPaths) { const resolved = resolveMacAppBinaryCandidate(envPath); if (resolved) { return resolved; } } const candidates: string[] = []; if (process.platform === "darwin") { candidates.push("/Applications/SubMiner.app/Contents/MacOS/SubMiner"); candidates.push("/Applications/SubMiner.app/Contents/MacOS/subminer"); candidates.push( path.join( os.homedir(), "Applications/SubMiner.app/Contents/MacOS/SubMiner", ), ); candidates.push( path.join( os.homedir(), "Applications/SubMiner.app/Contents/MacOS/subminer", ), ); } candidates.push(path.join(os.homedir(), ".local/bin/SubMiner.AppImage")); candidates.push("/opt/SubMiner/SubMiner.AppImage"); for (const candidate of candidates) { if (isExecutable(candidate)) return candidate; } const fromPath = process.env.PATH?.split(path.delimiter) .map((dir) => path.join(dir, "subminer")) .find((candidate) => isExecutable(candidate)); if (fromPath) { const resolvedSelf = realpathMaybe(selfPath); const resolvedCandidate = realpathMaybe(fromPath); if (resolvedSelf !== resolvedCandidate) return fromPath; } return null; } export function sendMpvCommand(socketPath: string, command: unknown[]): Promise { return new Promise((resolve, reject) => { const socket = net.createConnection(socketPath); socket.once("connect", () => { socket.write(`${JSON.stringify({ command })}\n`); socket.end(); resolve(); }); socket.once("error", (error) => { reject(error); }); }); } interface MpvResponseEnvelope { request_id?: number; error?: string; data?: unknown; } export function sendMpvCommandWithResponse( socketPath: string, command: unknown[], timeoutMs = 5000, ): Promise { return new Promise((resolve, reject) => { const requestId = Date.now() + Math.floor(Math.random() * 1000); const socket = net.createConnection(socketPath); let buffer = ""; const cleanup = (): void => { try { socket.destroy(); } catch { // ignore } }; const timer = setTimeout(() => { cleanup(); reject(new Error(`MPV command timed out after ${timeoutMs}ms`)); }, timeoutMs); const finish = (value: unknown): void => { clearTimeout(timer); cleanup(); resolve(value); }; socket.once("connect", () => { const message = JSON.stringify({ command, request_id: requestId }); socket.write(`${message}\n`); }); socket.on("data", (chunk: Buffer) => { buffer += chunk.toString(); const lines = buffer.split(/\r?\n/); buffer = lines.pop() ?? ""; for (const line of lines) { if (!line.trim()) continue; let parsed: MpvResponseEnvelope; try { parsed = JSON.parse(line); } catch { continue; } if (parsed.request_id !== requestId) continue; if (parsed.error && parsed.error !== "success") { reject(new Error(`MPV error: ${parsed.error}`)); cleanup(); clearTimeout(timer); return; } finish(parsed.data); return; } }); socket.once("error", (error) => { clearTimeout(timer); cleanup(); reject(error); }); }); } export async function getMpvTracks(socketPath: string): Promise { const response = await sendMpvCommandWithResponse( socketPath, ["get_property", "track-list"], 8000, ); if (!Array.isArray(response)) return []; return response .filter((track): track is MpvTrack => { if (!track || typeof track !== "object") return false; const candidate = track as Record; return candidate.type === "sub"; }) .map((track) => { const candidate = track as Record; return { type: typeof candidate.type === "string" ? candidate.type : undefined, id: typeof candidate.id === "number" ? candidate.id : typeof candidate.id === "string" ? Number.parseInt(candidate.id, 10) : undefined, lang: typeof candidate.lang === "string" ? candidate.lang : undefined, title: typeof candidate.title === "string" ? candidate.title : undefined, }; }); } function isPreferredStreamLang(candidate: string, preferred: string[]): boolean { const normalized = normalizeLangCode(candidate); if (!normalized) return false; if (preferred.includes(normalized)) return true; if (normalized === "ja" && preferred.includes("jpn")) return true; if (normalized === "jpn" && preferred.includes("ja")) return true; if (normalized === "en" && preferred.includes("eng")) return true; if (normalized === "eng" && preferred.includes("en")) return true; return false; } export function findPreferredSubtitleTrack( tracks: MpvTrack[], preferredLanguages: string[], ): MpvTrack | null { const normalizedPreferred = uniqueNormalizedLangCodes(preferredLanguages); const subtitleTracks = tracks.filter((track) => track.type === "sub"); if (normalizedPreferred.length === 0) return subtitleTracks[0] ?? null; for (const lang of normalizedPreferred) { const matched = subtitleTracks.find( (track) => track.lang && isPreferredStreamLang(track.lang, [lang]), ); if (matched) return matched; } return null; } export async function waitForSubtitleTrackList( socketPath: string, logLevel: LogLevel, ): Promise { const maxAttempts = 40; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { const tracks = await getMpvTracks(socketPath).catch(() => [] as MpvTrack[]); if (tracks.length > 0) return tracks; if (attempt % 10 === 0) { log( "debug", logLevel, `Waiting for mpv tracks (${attempt}/${maxAttempts})`, ); } await sleep(250); } return []; } export async function loadSubtitleIntoMpv( socketPath: string, subtitlePath: string, select: boolean, logLevel: LogLevel, ): Promise { for (let attempt = 1; ; attempt += 1) { const mpvExited = state.mpvProc !== null && state.mpvProc.exitCode !== null && state.mpvProc.exitCode !== undefined; if (mpvExited) { throw new Error(`mpv exited before subtitle could be loaded: ${subtitlePath}`); } if (!fs.existsSync(socketPath)) { if (attempt % 20 === 0) { log( "debug", logLevel, `Waiting for mpv socket before loading subtitle (${attempt} attempts): ${path.basename(subtitlePath)}`, ); } await sleep(250); continue; } try { await sendMpvCommand( socketPath, select ? ["sub-add", subtitlePath, "select"] : ["sub-add", subtitlePath], ); log( "info", logLevel, `Loaded generated subtitle into mpv: ${path.basename(subtitlePath)}`, ); return; } catch { if (attempt % 20 === 0) { log( "debug", logLevel, `Retrying subtitle load into mpv (${attempt} attempts): ${path.basename(subtitlePath)}`, ); } await sleep(250); } } } export function waitForSocket( socketPath: string, timeoutMs = 10000, ): Promise { const start = Date.now(); return new Promise((resolve) => { const timer = setInterval(() => { if (fs.existsSync(socketPath)) { clearInterval(timer); resolve(true); return; } if (Date.now() - start >= timeoutMs) { clearInterval(timer); resolve(false); } }, 100); }); } export function startMpv( target: string, targetKind: "file" | "url", args: Args, socketPath: string, appPath: string, preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string }, ): void { if ( targetKind === "file" && (!fs.existsSync(target) || !fs.statSync(target).isFile()) ) { fail(`Video file not found: ${target}`); } if (targetKind === "url") { log("info", args.logLevel, `Playing URL: ${target}`); } else { log("info", args.logLevel, `Playing: ${path.basename(target)}`); } const mpvArgs: string[] = []; if (args.profile) mpvArgs.push(`--profile=${args.profile}`); mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS); if (targetKind === "url" && isYoutubeTarget(target)) { log("info", args.logLevel, "Applying URL playback options"); mpvArgs.push("--ytdl=yes", "--ytdl-raw-options="); if (isYoutubeTarget(target)) { const subtitleLangs = uniqueNormalizedLangCodes([ ...args.youtubePrimarySubLangs, ...args.youtubeSecondarySubLangs, ]).join(","); const audioLangs = uniqueNormalizedLangCodes(args.youtubeAudioLangs).join(","); log("info", args.logLevel, "Applying YouTube playback options"); log("debug", args.logLevel, `YouTube subtitle langs: ${subtitleLangs}`); log("debug", args.logLevel, `YouTube audio langs: ${audioLangs}`); mpvArgs.push( `--ytdl-format=${DEFAULT_YOUTUBE_YTDL_FORMAT}`, `--alang=${audioLangs}`, ); if (args.youtubeSubgenMode === "off") { mpvArgs.push( "--sub-auto=fuzzy", `--slang=${subtitleLangs}`, "--ytdl-raw-options-append=write-auto-subs=", "--ytdl-raw-options-append=write-subs=", "--ytdl-raw-options-append=sub-format=vtt/best", `--ytdl-raw-options-append=sub-langs=${subtitleLangs}`, ); } } } if (preloadedSubtitles?.primaryPath) { mpvArgs.push(`--sub-file=${preloadedSubtitles.primaryPath}`); } if (preloadedSubtitles?.secondaryPath) { mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`); } mpvArgs.push( `--script-opts=subminer-binary_path=${appPath},subminer-socket_path=${socketPath}`, ); mpvArgs.push(`--log-file=${getMpvLogPath()}`); try { fs.rmSync(socketPath, { force: true }); } catch { // ignore } mpvArgs.push(`--input-ipc-server=${socketPath}`); mpvArgs.push(target); state.mpvProc = spawn("mpv", mpvArgs, { stdio: "inherit" }); } export function startOverlay( appPath: string, args: Args, socketPath: string, ): Promise { const backend = detectBackend(args.backend); log( "info", args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`, ); const overlayArgs = ["--start", "--backend", backend, "--socket", socketPath]; if (args.logLevel !== "info") overlayArgs.push("--log-level", args.logLevel); if (args.useTexthooker) overlayArgs.push("--texthooker"); state.overlayProc = spawn(appPath, overlayArgs, { stdio: "inherit", env: { ...process.env, SUBMINER_MPV_LOG: getMpvLogPath() }, }); state.overlayManagedByLauncher = true; return new Promise((resolve) => { setTimeout(resolve, 2000); }); } export function launchTexthookerOnly(appPath: string, args: Args): never { const overlayArgs = ["--texthooker"]; if (args.logLevel !== "info") overlayArgs.push("--log-level", args.logLevel); log("info", args.logLevel, "Launching texthooker mode..."); const result = spawnSync(appPath, overlayArgs, { stdio: "inherit" }); process.exit(result.status ?? 0); } export function stopOverlay(args: Args): void { if (state.stopRequested) return; state.stopRequested = true; if (state.overlayManagedByLauncher && state.appPath) { log("info", args.logLevel, "Stopping SubMiner overlay..."); const stopArgs = ["--stop"]; if (args.logLevel !== "info") stopArgs.push("--log-level", args.logLevel); spawnSync(state.appPath, stopArgs, { stdio: "ignore" }); if (state.overlayProc && !state.overlayProc.killed) { try { state.overlayProc.kill("SIGTERM"); } catch { // ignore } } } if (state.mpvProc && !state.mpvProc.killed) { try { state.mpvProc.kill("SIGTERM"); } catch { // ignore } } for (const child of state.youtubeSubgenChildren) { if (!child.killed) { try { child.kill("SIGTERM"); } catch { // ignore } } } state.youtubeSubgenChildren.clear(); void terminateTrackedDetachedMpv(args.logLevel); } function buildAppEnv(): NodeJS.ProcessEnv { const env: Record = { ...process.env, SUBMINER_MPV_LOG: getMpvLogPath() }; const layers = env.VK_INSTANCE_LAYERS; if (typeof layers === "string" && layers.trim().length > 0) { const filtered = layers .split(":") .map((part) => part.trim()) .filter((part) => part.length > 0 && !/lsfg/i.test(part)); if (filtered.length > 0) { env.VK_INSTANCE_LAYERS = filtered.join(":"); } else { delete env.VK_INSTANCE_LAYERS; } } return env; } export function runAppCommandWithInherit( appPath: string, appArgs: string[], ): never { const result = spawnSync(appPath, appArgs, { stdio: "inherit", env: buildAppEnv(), }); if (result.error) { fail(`Failed to run app command: ${result.error.message}`); } process.exit(result.status ?? 0); } export function runAppCommandWithInheritLogged( appPath: string, appArgs: string[], logLevel: LogLevel, label: string, ): never { log("debug", logLevel, `${label}: launching app with args: ${appArgs.join(" ")}`); const result = spawnSync(appPath, appArgs, { stdio: "inherit", env: buildAppEnv(), }); if (result.error) { fail(`Failed to run app command: ${result.error.message}`); } log( "debug", logLevel, `${label}: app command exited with status ${result.status ?? 0}`, ); process.exit(result.status ?? 0); } export function launchAppStartDetached(appPath: string, logLevel: LogLevel): void { const startArgs = ["--start"]; if (logLevel !== "info") startArgs.push("--log-level", logLevel); const proc = spawn(appPath, startArgs, { stdio: "ignore", detached: true, env: buildAppEnv(), }); proc.unref(); } export function launchMpvIdleDetached( socketPath: string, appPath: string, args: Args, ): Promise { return (async () => { await terminateTrackedDetachedMpv(args.logLevel); try { fs.rmSync(socketPath, { force: true }); } catch { // ignore } const mpvArgs: string[] = []; if (args.profile) mpvArgs.push(`--profile=${args.profile}`); mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS); mpvArgs.push("--idle=yes"); mpvArgs.push( `--script-opts=subminer-binary_path=${appPath},subminer-socket_path=${socketPath}`, ); mpvArgs.push(`--log-file=${getMpvLogPath()}`); mpvArgs.push(`--input-ipc-server=${socketPath}`); const proc = spawn("mpv", mpvArgs, { stdio: "ignore", detached: true, }); if (typeof proc.pid === "number" && proc.pid > 0) { trackDetachedMpvPid(proc.pid); } proc.unref(); })(); } async function sleepMs(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } async function waitForPathExists( filePath: string, timeoutMs: number, ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { if (fs.existsSync(filePath)) return true; } catch { // ignore transient fs errors } await sleepMs(150); } return false; } async function canConnectUnixSocket(socketPath: string): Promise { return await new Promise((resolve) => { const socket = net.createConnection(socketPath); let settled = false; const finish = (value: boolean) => { if (settled) return; settled = true; try { socket.destroy(); } catch { // ignore } resolve(value); }; socket.once("connect", () => finish(true)); socket.once("error", () => finish(false)); socket.setTimeout(400, () => finish(false)); }); } export async function waitForUnixSocketReady( socketPath: string, timeoutMs: number, ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const exists = await waitForPathExists(socketPath, 300); if (exists) { const ready = await canConnectUnixSocket(socketPath); if (ready) return true; } await sleepMs(150); } return false; }