mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
refactor(launcher): extract mpv, youtube, and jellyfin modules
- launcher/mpv.ts: state object, mpv IPC, process management, socket helpers - launcher/youtube.ts: YouTube subtitle generation pipeline and helpers - launcher/jellyfin.ts: Jellyfin API client, icon caching, play menu - runAppCommandWithInherit and related functions placed in mpv.ts - buildAppEnv deduplicated into single helper in mpv.ts
This commit is contained in:
671
launcher/mpv.ts
Normal file
671
launcher/mpv.ts
Normal file
@@ -0,0 +1,671 @@
|
||||
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<typeof spawn> | null,
|
||||
mpvProc: null as ReturnType<typeof spawn> | null,
|
||||
youtubeSubgenChildren: new Set<ReturnType<typeof spawn>>(),
|
||||
appPath: "" as string,
|
||||
overlayManagedByLauncher: false,
|
||||
stopRequested: false,
|
||||
};
|
||||
|
||||
export function makeTempDir(prefix: string): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
export function detectBackend(backend: Backend): Exclude<Backend, "auto"> {
|
||||
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<void> {
|
||||
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<unknown> {
|
||||
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<MpvTrack[]> {
|
||||
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<string, unknown>;
|
||||
return candidate.type === "sub";
|
||||
})
|
||||
.map((track) => {
|
||||
const candidate = track as Record<string, unknown>;
|
||||
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<MpvTrack[]> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
function buildAppEnv(): NodeJS.ProcessEnv {
|
||||
const env: Record<string, string | undefined> = { ...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,
|
||||
): void {
|
||||
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,
|
||||
});
|
||||
proc.unref();
|
||||
}
|
||||
|
||||
async function sleepMs(ms: number): Promise<void> {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForPathExists(
|
||||
filePath: string,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
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<boolean> {
|
||||
return await new Promise<boolean>((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<boolean> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user