import fs from "node:fs"; import path from "node:path"; import os from "node:os"; import { spawn } from "node:child_process"; import type { LogLevel, CommandExecOptions, CommandExecResult } from "./types.js"; import { log } from "./log.js"; export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } export function isExecutable(filePath: string): boolean { try { fs.accessSync(filePath, fs.constants.X_OK); return true; } catch { return false; } } export function commandExists(command: string): boolean { const pathEnv = process.env.PATH ?? ""; for (const dir of pathEnv.split(path.delimiter)) { if (!dir) continue; const full = path.join(dir, command); if (isExecutable(full)) return true; } return false; } export function resolvePathMaybe(input: string): string { if (input.startsWith("~")) { return path.join(os.homedir(), input.slice(1)); } return input; } export function resolveBinaryPathCandidate(input: string): string { const trimmed = input.trim(); if (!trimmed) return ""; const unquoted = trimmed.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1"); return resolvePathMaybe(unquoted); } export function realpathMaybe(filePath: string): string { try { return fs.realpathSync(filePath); } catch { return path.resolve(filePath); } } export function isUrlTarget(target: string): boolean { return /^https?:\/\//.test(target) || /^ytsearch:/.test(target); } export function isYoutubeTarget(target: string): boolean { return ( /^ytsearch:/.test(target) || /^https?:\/\/(www\.)?(youtube\.com|youtu\.be)\//.test(target) ); } export function sanitizeToken(value: string): string { return String(value) .toLowerCase() .replace(/[^a-z0-9._-]+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); } export function normalizeBasename(value: string, fallback: string): string { const safe = sanitizeToken(value.replace(/[\\/]+/g, "-")); if (safe) return safe; const fallbackSafe = sanitizeToken(fallback); if (fallbackSafe) return fallbackSafe; return `${Date.now()}`; } export function normalizeLangCode(value: string): string { return value.trim().toLowerCase().replace(/[^a-z0-9-]+/g, ""); } export function uniqueNormalizedLangCodes(values: string[]): string[] { const seen = new Set(); const out: string[] = []; for (const value of values) { const normalized = normalizeLangCode(value); if (!normalized || seen.has(normalized)) continue; seen.add(normalized); out.push(normalized); } return out; } export function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } export function parseBoolLike(value: string): boolean | null { const normalized = value.trim().toLowerCase(); if ( normalized === "yes" || normalized === "true" || normalized === "1" || normalized === "on" ) { return true; } if ( normalized === "no" || normalized === "false" || normalized === "0" || normalized === "off" ) { return false; } return null; } export function inferWhisperLanguage(langCodes: string[], fallback: string): string { for (const lang of uniqueNormalizedLangCodes(langCodes)) { if (lang === "jpn") return "ja"; if (lang.length >= 2) return lang.slice(0, 2); } return fallback; } export function runExternalCommand( executable: string, args: string[], opts: CommandExecOptions = {}, childTracker?: Set>, ): Promise { const allowFailure = opts.allowFailure === true; const captureStdout = opts.captureStdout === true; const configuredLogLevel = opts.logLevel ?? "info"; const commandLabel = opts.commandLabel || executable; const streamOutput = opts.streamOutput === true; return new Promise((resolve, reject) => { log("debug", configuredLogLevel, `[${commandLabel}] spawn: ${executable} ${args.join(" ")}`); const child = spawn(executable, args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...opts.env }, }); childTracker?.add(child); let stdout = ""; let stderr = ""; let stdoutBuffer = ""; let stderrBuffer = ""; const flushLines = ( buffer: string, level: LogLevel, sink: (remaining: string) => void, ): void => { const lines = buffer.split(/\r?\n/); const remaining = lines.pop() ?? ""; for (const line of lines) { const trimmed = line.trim(); if (trimmed.length > 0) { log(level, configuredLogLevel, `[${commandLabel}] ${trimmed}`); } } sink(remaining); }; child.stdout.on("data", (chunk: Buffer) => { const text = chunk.toString(); if (captureStdout) stdout += text; if (streamOutput) { stdoutBuffer += text; flushLines(stdoutBuffer, "debug", (remaining) => { stdoutBuffer = remaining; }); } }); child.stderr.on("data", (chunk: Buffer) => { const text = chunk.toString(); stderr += text; if (streamOutput) { stderrBuffer += text; flushLines(stderrBuffer, "debug", (remaining) => { stderrBuffer = remaining; }); } }); child.on("error", (error) => { childTracker?.delete(child); reject(new Error(`Failed to start "${executable}": ${error.message}`)); }); child.on("close", (code) => { childTracker?.delete(child); if (streamOutput) { const trailingOut = stdoutBuffer.trim(); if (trailingOut.length > 0) { log("debug", configuredLogLevel, `[${commandLabel}] ${trailingOut}`); } const trailingErr = stderrBuffer.trim(); if (trailingErr.length > 0) { log("debug", configuredLogLevel, `[${commandLabel}] ${trailingErr}`); } } log( code === 0 ? "debug" : "warn", configuredLogLevel, `[${commandLabel}] exit code ${code ?? 1}`, ); if (code !== 0 && !allowFailure) { const commandString = `${executable} ${args.join(" ")}`; reject( new Error( `Command failed (${commandString}): ${stderr.trim() || `exit code ${code}`}`, ), ); return; } resolve({ code: code ?? 1, stdout, stderr }); }); }); }