From 518015f534ff250b7b2935855a300730f17cbb78 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 17 Feb 2026 03:22:04 -0800 Subject: [PATCH] refactor(launcher): extract types, logging, and utilities - launcher/types.ts: shared types, interfaces, and constants - launcher/log.ts: logging infrastructure (COLORS, log, fail, etc.) - launcher/util.ts: pure utilities, lang helpers, and child process runner - runExternalCommand accepts childTracker param instead of referencing state - inferWhisperLanguage placed in util.ts to avoid circular deps --- launcher/log.ts | 65 ++++++++++++++ launcher/types.ts | 188 ++++++++++++++++++++++++++++++++++++++ launcher/util.ts | 225 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 478 insertions(+) create mode 100644 launcher/log.ts create mode 100644 launcher/types.ts create mode 100644 launcher/util.ts diff --git a/launcher/log.ts b/launcher/log.ts new file mode 100644 index 0000000..e523326 --- /dev/null +++ b/launcher/log.ts @@ -0,0 +1,65 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { LogLevel } from "./types.js"; +import { DEFAULT_MPV_LOG_FILE } from "./types.js"; + +export const COLORS = { + red: "\x1b[0;31m", + green: "\x1b[0;32m", + yellow: "\x1b[0;33m", + cyan: "\x1b[0;36m", + reset: "\x1b[0m", +}; + +export const LOG_PRI: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +export function shouldLog(level: LogLevel, configured: LogLevel): boolean { + return LOG_PRI[level] >= LOG_PRI[configured]; +} + +export function getMpvLogPath(): string { + const envPath = process.env.SUBMINER_MPV_LOG?.trim(); + if (envPath) return envPath; + return DEFAULT_MPV_LOG_FILE; +} + +export function appendToMpvLog(message: string): void { + const logPath = getMpvLogPath(); + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync( + logPath, + `[${new Date().toISOString()}] ${message}\n`, + { encoding: "utf8" }, + ); + } catch { + // ignore logging failures + } +} + +export function log(level: LogLevel, configured: LogLevel, message: string): void { + if (!shouldLog(level, configured)) return; + const color = + level === "info" + ? COLORS.green + : level === "warn" + ? COLORS.yellow + : level === "error" + ? COLORS.red + : COLORS.cyan; + process.stdout.write( + `${color}[${level.toUpperCase()}]${COLORS.reset} ${message}\n`, + ); + appendToMpvLog(`[${level.toUpperCase()}] ${message}`); +} + +export function fail(message: string): never { + process.stderr.write(`${COLORS.red}[ERROR]${COLORS.reset} ${message}\n`); + appendToMpvLog(`[ERROR] ${message}`); + process.exit(1); +} diff --git a/launcher/types.ts b/launcher/types.ts new file mode 100644 index 0000000..b3b9cf0 --- /dev/null +++ b/launcher/types.ts @@ -0,0 +1,188 @@ +import path from "node:path"; +import os from "node:os"; + +export const VIDEO_EXTENSIONS = new Set([ + "mkv", + "mp4", + "avi", + "webm", + "mov", + "flv", + "wmv", + "m4v", + "ts", + "m2ts", +]); + +export const ROFI_THEME_FILE = "subminer.rasi"; +export const DEFAULT_SOCKET_PATH = "/tmp/subminer-socket"; +export const DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS = ["ja", "jpn"]; +export const DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS = ["en", "eng"]; +export const YOUTUBE_SUB_EXTENSIONS = new Set([".srt", ".vtt", ".ass"]); +export const YOUTUBE_AUDIO_EXTENSIONS = new Set([ + ".m4a", + ".mp3", + ".webm", + ".opus", + ".wav", + ".aac", + ".flac", +]); +export const DEFAULT_YOUTUBE_SUBGEN_OUT_DIR = path.join( + os.homedir(), + ".cache", + "subminer", + "youtube-subs", +); +export const DEFAULT_MPV_LOG_FILE = path.join( + os.homedir(), + ".cache", + "SubMiner", + "mp.log", +); +export const DEFAULT_YOUTUBE_YTDL_FORMAT = "bestvideo*+bestaudio/best"; +export const DEFAULT_JIMAKU_API_BASE_URL = "https://jimaku.cc"; +export const DEFAULT_MPV_SUBMINER_ARGS = [ + "--sub-auto=fuzzy", + "--sub-file-paths=.;subs;subtitles", + "--sid=auto", + "--secondary-sid=auto", + "--secondary-sub-visibility=no", + "--slang=ja,jpn,en,eng", +] as const; + +export type LogLevel = "debug" | "info" | "warn" | "error"; +export type YoutubeSubgenMode = "automatic" | "preprocess" | "off"; +export type Backend = "auto" | "hyprland" | "x11" | "macos"; +export type JimakuLanguagePreference = "ja" | "en" | "none"; + +export interface Args { + backend: Backend; + directory: string; + recursive: boolean; + profile: string; + startOverlay: boolean; + youtubeSubgenMode: YoutubeSubgenMode; + whisperBin: string; + whisperModel: string; + youtubeSubgenOutDir: string; + youtubeSubgenAudioFormat: string; + youtubeSubgenKeepTemp: boolean; + youtubePrimarySubLangs: string[]; + youtubeSecondarySubLangs: string[]; + youtubeAudioLangs: string[]; + youtubeWhisperSourceLanguage: string; + useTexthooker: boolean; + autoStartOverlay: boolean; + texthookerOnly: boolean; + useRofi: boolean; + logLevel: LogLevel; + target: string; + targetKind: "" | "file" | "url"; + jimakuApiKey: string; + jimakuApiKeyCommand: string; + jimakuApiBaseUrl: string; + jimakuLanguagePreference: JimakuLanguagePreference; + jimakuMaxEntryResults: number; + jellyfin: boolean; + jellyfinLogin: boolean; + jellyfinLogout: boolean; + jellyfinPlay: boolean; + jellyfinServer: string; + jellyfinUsername: string; + jellyfinPassword: string; +} + +export interface LauncherYoutubeSubgenConfig { + mode?: YoutubeSubgenMode; + whisperBin?: string; + whisperModel?: string; + primarySubLanguages?: string[]; + secondarySubLanguages?: string[]; + jimakuApiKey?: string; + jimakuApiKeyCommand?: string; + jimakuApiBaseUrl?: string; + jimakuLanguagePreference?: JimakuLanguagePreference; + jimakuMaxEntryResults?: number; +} + +export interface LauncherJellyfinConfig { + enabled?: boolean; + serverUrl?: string; + username?: string; + accessToken?: string; + userId?: string; + defaultLibraryId?: string; + pullPictures?: boolean; + iconCacheDir?: string; +} + +export interface PluginRuntimeConfig { + autoStartOverlay: boolean; + socketPath: string; +} + +export interface CommandExecOptions { + allowFailure?: boolean; + captureStdout?: boolean; + logLevel?: LogLevel; + commandLabel?: string; + streamOutput?: boolean; + env?: NodeJS.ProcessEnv; +} + +export interface CommandExecResult { + code: number; + stdout: string; + stderr: string; +} + +export interface SubtitleCandidate { + path: string; + lang: "primary" | "secondary"; + ext: string; + size: number; + source: "manual" | "auto" | "whisper" | "whisper-translate"; +} + +export interface YoutubeSubgenOutputs { + basename: string; + primaryPath?: string; + secondaryPath?: string; +} + +export interface MpvTrack { + type?: string; + id?: number; + lang?: string; + title?: string; +} + +export interface JellyfinSessionConfig { + serverUrl: string; + accessToken: string; + userId: string; + defaultLibraryId: string; + pullPictures: boolean; + iconCacheDir: string; +} + +export interface JellyfinLibraryEntry { + id: string; + name: string; + kind: string; +} + +export interface JellyfinItemEntry { + id: string; + name: string; + type: string; + display: string; +} + +export interface JellyfinGroupEntry { + id: string; + name: string; + type: string; + display: string; +} diff --git a/launcher/util.ts b/launcher/util.ts new file mode 100644 index 0000000..6bf3e9c --- /dev/null +++ b/launcher/util.ts @@ -0,0 +1,225 @@ +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 }); + }); + }); +}