#!/usr/bin/env bun /** * SubMiner launcher (Bun runtime) * Local-only wrapper for mpv + SubMiner overlay orchestration. */ 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 { parse as parseJsonc } from "jsonc-parser"; const VIDEO_EXTENSIONS = new Set([ "mkv", "mp4", "avi", "webm", "mov", "flv", "wmv", "m4v", "ts", "m2ts", ]); const ROFI_THEME_FILE = "subminer.rasi"; const DEFAULT_SOCKET_PATH = "/tmp/subminer-socket"; const DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS = ["ja", "jpn"]; const DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS = ["en", "eng"]; const YOUTUBE_SUB_EXTENSIONS = new Set([".srt", ".vtt", ".ass"]); const YOUTUBE_AUDIO_EXTENSIONS = new Set([ ".m4a", ".mp3", ".webm", ".opus", ".wav", ".aac", ".flac", ]); const DEFAULT_YOUTUBE_SUBGEN_OUT_DIR = path.join( os.homedir(), ".cache", "subminer", "youtube-subs", ); const DEFAULT_YOUTUBE_YTDL_FORMAT = "bestvideo*+bestaudio/best"; type LogLevel = "debug" | "info" | "warn" | "error"; type YoutubeSubgenMode = "automatic" | "preprocess" | "off"; type Backend = "auto" | "hyprland" | "x11" | "macos"; interface Args { backend: Backend; directory: string; recursive: boolean; profile: string; youtubeSubgenMode: YoutubeSubgenMode; whisperBin: string; whisperModel: string; youtubeSubgenOutDir: string; youtubeSubgenAudioFormat: string; youtubeSubgenKeepTemp: boolean; youtubePrimarySubLangs: string[]; youtubeSecondarySubLangs: string[]; youtubeAudioLangs: string[]; youtubeWhisperSourceLanguage: string; useTexthooker: boolean; texthookerOnly: boolean; useRofi: boolean; logLevel: LogLevel; target: string; targetKind: "" | "file" | "url"; } interface LauncherYoutubeSubgenConfig { mode?: YoutubeSubgenMode; whisperBin?: string; whisperModel?: string; primarySubLanguages?: string[]; secondarySubLanguages?: string[]; } const COLORS = { red: "\x1b[0;31m", green: "\x1b[0;32m", yellow: "\x1b[0;33m", cyan: "\x1b[0;36m", reset: "\x1b[0m", }; const LOG_PRI: Record = { debug: 10, info: 20, warn: 30, error: 40, }; const state = { overlayProc: null as ReturnType | null, mpvProc: null as ReturnType | null, youtubeSubgenChildren: new Set>(), appPath: "" as string, stopRequested: false, }; function usage(scriptName: string): string { return `subminer - Launch MPV with SubMiner sentence mining overlay Usage: ${scriptName} [OPTIONS] [FILE|DIRECTORY|URL] Options: -b, --backend BACKEND Display backend to use: auto, hyprland, x11, macos (default: auto) -d, --directory DIR Directory to browse for videos (default: current directory) -r, --recursive Search for videos recursively -p, --profile PROFILE MPV profile to use (default: subminer) --yt-subgen-mode MODE YouTube subtitle generation mode: automatic, preprocess, off (default: automatic) --whisper-bin PATH whisper.cpp CLI binary (used for fallback transcription) --whisper-model PATH whisper model file path (used for fallback transcription) --yt-subgen-out-dir DIR Output directory for generated YouTube subtitles (default: ${DEFAULT_YOUTUBE_SUBGEN_OUT_DIR}) --yt-subgen-audio-format FORMAT Audio format for extraction (default: m4a) --yt-subgen-keep-temp Keep YouTube subtitle temp directory -v, --verbose Enable verbose/debug logging --log-level LEVEL Set log level: debug, info, warn, error -R, --rofi Use rofi file browser instead of fzf for video selection -T, --no-texthooker Disable texthooker-ui server --texthooker Launch only texthooker page (no MPV/overlay workflow) -h, --help Show this help message Environment: SUBMINER_APPIMAGE_PATH Path to SubMiner AppImage/binary (optional override) SUBMINER_ROFI_THEME Path to rofi theme file (optional override) SUBMINER_YT_SUBGEN_MODE automatic, preprocess, off (optional default) SUBMINER_WHISPER_BIN whisper.cpp binary path (optional fallback) SUBMINER_WHISPER_MODEL whisper model path (optional fallback) SUBMINER_YT_SUBGEN_OUT_DIR Generated subtitle output directory Examples: ${scriptName} # Browse current directory with fzf ${scriptName} -R # Browse current directory with rofi ${scriptName} -d ~/Videos # Browse ~/Videos ${scriptName} -r -d ~/Anime # Recursively browse ~/Anime ${scriptName} video.mkv # Play specific file ${scriptName} https://youtu.be/... # Play a YouTube URL ${scriptName} ytsearch:query # Play first YouTube search result ${scriptName} --yt-subgen-mode preprocess --whisper-bin /path/whisper-cli --whisper-model /path/model.bin https://youtu.be/... ${scriptName} video.mkv # Play with subminer profile ${scriptName} -p gpu-hq video.mkv # Play with gpu-hq profile ${scriptName} -b x11 video.mkv # Force x11 backend ${scriptName} --texthooker # Launch only texthooker page `; } function shouldLog(level: LogLevel, configured: LogLevel): boolean { return LOG_PRI[level] >= LOG_PRI[configured]; } 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`, ); } function fail(message: string): never { process.stderr.write(`${COLORS.red}[ERROR]${COLORS.reset} ${message}\n`); process.exit(1); } function isExecutable(filePath: string): boolean { try { fs.accessSync(filePath, fs.constants.X_OK); return true; } catch { return false; } } 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; } function resolvePathMaybe(input: string): string { if (input.startsWith("~")) { return path.join(os.homedir(), input.slice(1)); } return input; } function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig { const configDir = path.join(os.homedir(), ".config", "SubMiner"); const jsoncPath = path.join(configDir, "config.jsonc"); const jsonPath = path.join(configDir, "config.json"); const configPath = fs.existsSync(jsoncPath) ? jsoncPath : fs.existsSync(jsonPath) ? jsonPath : ""; if (!configPath) return {}; try { const data = fs.readFileSync(configPath, "utf8"); const parsed = configPath.endsWith(".jsonc") ? parseJsonc(data) : JSON.parse(data); if (!parsed || typeof parsed !== "object") return {}; const root = parsed as { youtubeSubgen?: unknown; secondarySub?: { secondarySubLanguages?: unknown }; }; const youtubeSubgen = root.youtubeSubgen; const mode = youtubeSubgen && typeof youtubeSubgen === "object" ? (youtubeSubgen as { mode?: unknown }).mode : undefined; const whisperBin = youtubeSubgen && typeof youtubeSubgen === "object" ? (youtubeSubgen as { whisperBin?: unknown }).whisperBin : undefined; const whisperModel = youtubeSubgen && typeof youtubeSubgen === "object" ? (youtubeSubgen as { whisperModel?: unknown }).whisperModel : undefined; const primarySubLanguagesRaw = youtubeSubgen && typeof youtubeSubgen === "object" ? (youtubeSubgen as { primarySubLanguages?: unknown }).primarySubLanguages : undefined; const secondarySubLanguagesRaw = root.secondarySub?.secondarySubLanguages; const primarySubLanguages = Array.isArray(primarySubLanguagesRaw) ? primarySubLanguagesRaw.filter( (value): value is string => typeof value === "string", ) : undefined; const secondarySubLanguages = Array.isArray(secondarySubLanguagesRaw) ? secondarySubLanguagesRaw.filter( (value): value is string => typeof value === "string", ) : undefined; return { mode: mode === "automatic" || mode === "preprocess" || mode === "off" ? mode : undefined, whisperBin: typeof whisperBin === "string" ? whisperBin : undefined, whisperModel: typeof whisperModel === "string" ? whisperModel : undefined, primarySubLanguages, secondarySubLanguages, }; } catch { return {}; } } function realpathMaybe(filePath: string): string { try { return fs.realpathSync(filePath); } catch { return path.resolve(filePath); } } function isUrlTarget(target: string): boolean { return /^https?:\/\//.test(target) || /^ytsearch:/.test(target); } function isYoutubeTarget(target: string): boolean { return ( /^ytsearch:/.test(target) || /^https?:\/\/(www\.)?(youtube\.com|youtu\.be)\//.test(target) ); } interface CommandExecOptions { allowFailure?: boolean; captureStdout?: boolean; logLevel?: LogLevel; commandLabel?: string; streamOutput?: boolean; } interface CommandExecResult { code: number; stdout: string; stderr: string; } interface SubtitleCandidate { path: string; lang: "primary" | "secondary"; ext: string; size: number; source: "manual" | "auto" | "whisper" | "whisper-translate"; } interface YoutubeSubgenOutputs { basename: string; primaryPath?: string; secondaryPath?: string; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function sanitizeToken(value: string): string { return String(value) .toLowerCase() .replace(/[^a-z0-9._-]+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); } 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()}`; } function normalizeLangCode(value: string): string { return value.trim().toLowerCase().replace(/[^a-z0-9-]+/g, ""); } 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; } function toYtdlpLangPattern(langCodes: string[]): string { return langCodes.map((lang) => `${lang}.*`).join(","); } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function filenameHasLanguageTag(filenameLower: string, langCode: string): boolean { const escaped = escapeRegExp(langCode); const pattern = new RegExp(`(^|[._-])${escaped}([._-]|$)`); return pattern.test(filenameLower); } function classifyLanguage( filename: string, primaryLangCodes: string[], secondaryLangCodes: string[], ): "primary" | "secondary" | null { const lower = filename.toLowerCase(); const primary = primaryLangCodes.some((code) => filenameHasLanguageTag(lower, code), ); const secondary = secondaryLangCodes.some((code) => filenameHasLanguageTag(lower, code), ); if (primary && !secondary) return "primary"; if (secondary && !primary) return "secondary"; return null; } function preferredLangLabel(langCodes: string[], fallback: string): string { return uniqueNormalizedLangCodes(langCodes)[0] || fallback; } 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; } function sourceTag(source: SubtitleCandidate["source"]): string { if (source === "manual" || source === "auto") return `ytdlp-${source}`; if (source === "whisper-translate") return "whisper-translate"; return "whisper"; } function runExternalCommand( executable: string, args: string[], opts: CommandExecOptions = {}, ): 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"], }); state.youtubeSubgenChildren.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) => { state.youtubeSubgenChildren.delete(child); reject(new Error(`Failed to start "${executable}": ${error.message}`)); }); child.on("close", (code) => { state.youtubeSubgenChildren.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 }); }); }); } function pickBestCandidate(candidates: SubtitleCandidate[]): SubtitleCandidate | null { if (candidates.length === 0) return null; const scored = [...candidates].sort((a, b) => { const sourceA = a.source === "manual" ? 1 : 0; const sourceB = b.source === "manual" ? 1 : 0; if (sourceA !== sourceB) return sourceB - sourceA; const srtA = a.ext === ".srt" ? 1 : 0; const srtB = b.ext === ".srt" ? 1 : 0; if (srtA !== srtB) return srtB - srtA; return b.size - a.size; }); return scored[0]; } function scanSubtitleCandidates( tempDir: string, knownSet: Set, source: "manual" | "auto", primaryLangCodes: string[], secondaryLangCodes: string[], ): SubtitleCandidate[] { const entries = fs.readdirSync(tempDir); const out: SubtitleCandidate[] = []; for (const name of entries) { const fullPath = path.join(tempDir, name); if (knownSet.has(fullPath)) continue; let stat: fs.Stats; try { stat = fs.statSync(fullPath); } catch { continue; } if (!stat.isFile()) continue; const ext = path.extname(fullPath).toLowerCase(); if (!YOUTUBE_SUB_EXTENSIONS.has(ext)) continue; const lang = classifyLanguage(name, primaryLangCodes, secondaryLangCodes); if (!lang) continue; out.push({ path: fullPath, lang, ext, size: stat.size, source }); } return out; } async function convertToSrt( inputPath: string, tempDir: string, langLabel: string, ): Promise { if (path.extname(inputPath).toLowerCase() === ".srt") return inputPath; const outputPath = path.join(tempDir, `converted.${langLabel}.srt`); await runExternalCommand("ffmpeg", ["-y", "-loglevel", "error", "-i", inputPath, outputPath]); return outputPath; } function findAudioFile(tempDir: string, preferredExt: string): string | null { const entries = fs.readdirSync(tempDir); const audioFiles: Array<{ path: string; ext: string; mtimeMs: number }> = []; for (const name of entries) { const fullPath = path.join(tempDir, name); let stat: fs.Stats; try { stat = fs.statSync(fullPath); } catch { continue; } if (!stat.isFile()) continue; const ext = path.extname(name).toLowerCase(); if (!YOUTUBE_AUDIO_EXTENSIONS.has(ext)) continue; audioFiles.push({ path: fullPath, ext, mtimeMs: stat.mtimeMs }); } if (audioFiles.length === 0) return null; const preferred = audioFiles.find((entry) => entry.ext === `.${preferredExt.toLowerCase()}`); if (preferred) return preferred.path; audioFiles.sort((a, b) => b.mtimeMs - a.mtimeMs); return audioFiles[0].path; } async function runWhisper( whisperBin: string, modelPath: string, audioPath: string, language: string, translate: boolean, outputPrefix: string, ): Promise { const args = [ "-m", modelPath, "-f", audioPath, "--output-srt", "--output-file", outputPrefix, "--language", language, ]; if (translate) args.push("--translate"); await runExternalCommand(whisperBin, args, { commandLabel: "whisper", streamOutput: true, }); const outputPath = `${outputPrefix}.srt`; if (!fs.existsSync(outputPath)) { throw new Error(`whisper output not found: ${outputPath}`); } return outputPath; } async function convertAudioForWhisper(inputPath: string, tempDir: string): Promise { const wavPath = path.join(tempDir, "whisper-input.wav"); await runExternalCommand("ffmpeg", [ "-y", "-loglevel", "error", "-i", inputPath, "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", wavPath, ]); if (!fs.existsSync(wavPath)) { throw new Error(`Failed to prepare whisper audio input: ${wavPath}`); } return wavPath; } 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); }); }); } 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); } } } 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 findRofiTheme(scriptPath: string): string | null { const envTheme = process.env.SUBMINER_ROFI_THEME; if (envTheme && fs.existsSync(envTheme)) return envTheme; const scriptDir = path.dirname(realpathMaybe(scriptPath)); const candidates: string[] = []; if (process.platform === "darwin") { candidates.push( path.join( os.homedir(), "Library/Application Support/SubMiner/themes", ROFI_THEME_FILE, ), ); } else { const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local/share"); candidates.push(path.join(xdgDataHome, "SubMiner/themes", ROFI_THEME_FILE)); candidates.push( path.join("/usr/local/share/SubMiner/themes", ROFI_THEME_FILE), ); candidates.push(path.join("/usr/share/SubMiner/themes", ROFI_THEME_FILE)); } candidates.push(path.join(scriptDir, ROFI_THEME_FILE)); for (const candidate of candidates) { if (fs.existsSync(candidate)) return candidate; } return null; } function collectVideos(dir: string, recursive: boolean): string[] { const root = path.resolve(dir); const out: string[] = []; const walk = (current: string): void => { let entries: fs.Dirent[]; try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const full = path.join(current, entry.name); if (entry.isDirectory()) { if (recursive) walk(full); continue; } if (!entry.isFile()) continue; const ext = path.extname(entry.name).slice(1).toLowerCase(); if (VIDEO_EXTENSIONS.has(ext)) out.push(full); } }; walk(root); return out.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }), ); } function buildRofiMenu( videos: string[], dir: string, recursive: boolean, ): Buffer { const chunks: Buffer[] = []; for (const video of videos) { const display = recursive ? path.relative(dir, video) : path.basename(video); const line = `${display}\0icon\x1fthumbnail://${video}\n`; chunks.push(Buffer.from(line, "utf8")); } return Buffer.concat(chunks); } function showRofiMenu( videos: string[], dir: string, recursive: boolean, scriptPath: string, logLevel: LogLevel, ): string { const args = [ "-dmenu", "-i", "-p", "Select Video ", "-show-icons", "-theme-str", 'configuration { font: "Noto Sans CJK JP Regular 8";}', ]; const theme = findRofiTheme(scriptPath); if (theme) { args.push("-theme", theme); } else { log( "warn", logLevel, "Rofi theme not found; using rofi defaults (set SUBMINER_ROFI_THEME to override)", ); } const result = spawnSync("rofi", args, { input: buildRofiMenu(videos, dir, recursive), encoding: "utf8", stdio: ["pipe", "pipe", "ignore"], }); if (result.error) { fail( formatPickerLaunchError("rofi", result.error as NodeJS.ErrnoException), ); } const selection = (result.stdout || "").trim(); if (!selection) return ""; return path.join(dir, selection); } function buildFzfMenu(videos: string[]): string { return videos.map((video) => `${path.basename(video)}\t${video}`).join("\n"); } function showFzfMenu(videos: string[]): string { const chafaFormat = process.env.TMUX ? "--format=symbols --symbols=vhalf+wide --color-space=din99d" : "--format=kitty"; const previewCmd = commandExists("chafa") ? ` video={2} thumb_dir="$HOME/.cache/thumbnails/large" video_uri="file://$(realpath "$video")" if command -v md5sum >/dev/null 2>&1; then thumb_hash=$(echo -n "$video_uri" | md5sum | cut -d' ' -f1) else thumb_hash=$(echo -n "$video_uri" | md5 -q) fi thumb_path="$thumb_dir/$thumb_hash.png" get_thumb() { if [[ -f "$thumb_path" ]]; then echo "$thumb_path" elif command -v ffmpegthumbnailer >/dev/null 2>&1; then tmp="/tmp/subminer-preview.jpg" ffmpegthumbnailer -i "$video" -o "$tmp" -s 512 -q 5 2>/dev/null && echo "$tmp" elif command -v ffmpeg >/dev/null 2>&1; then tmp="/tmp/subminer-preview.jpg" ffmpeg -y -i "$video" -ss 00:00:05 -vframes 1 -vf "scale=512:-1" "$tmp" 2>/dev/null && echo "$tmp" fi } thumb=$(get_thumb) [[ -n "$thumb" ]] && chafa ${chafaFormat} --size=${"${FZF_PREVIEW_COLUMNS}"}x${"${FZF_PREVIEW_LINES}"} "$thumb" 2>/dev/null `.trim() : 'echo "Install chafa for thumbnail preview"'; const result = spawnSync( "fzf", [ "--ansi", "--reverse", "--prompt=Select Video: ", "--delimiter=\t", "--with-nth=1", "--preview-window=right:50%:wrap", "--preview", previewCmd, ], { input: buildFzfMenu(videos), encoding: "utf8", stdio: ["pipe", "pipe", "inherit"], }, ); if (result.error) { fail(formatPickerLaunchError("fzf", result.error as NodeJS.ErrnoException)); } const selection = (result.stdout || "").trim(); if (!selection) return ""; const tabIndex = selection.indexOf("\t"); if (tabIndex === -1) return ""; return selection.slice(tabIndex + 1); } function formatPickerLaunchError( picker: "rofi" | "fzf", error: NodeJS.ErrnoException, ): string { if (error.code === "ENOENT") { return picker === "rofi" ? "rofi not found. Install rofi or use --no-rofi to use fzf." : "fzf not found. Install fzf or use --rofi to use rofi."; } return `Failed to launch ${picker}: ${error.message}`; } function findAppBinary(selfPath: string): string | null { const envPath = process.env.SUBMINER_APPIMAGE_PATH; if (envPath && isExecutable(envPath)) return envPath; 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; } function checkDependencies(args: Args): void { const missing: string[] = []; if (!commandExists("mpv")) missing.push("mpv"); if ( args.targetKind === "url" && isYoutubeTarget(args.target) && !commandExists("yt-dlp") ) { missing.push("yt-dlp"); } if ( args.targetKind === "url" && isYoutubeTarget(args.target) && args.youtubeSubgenMode !== "off" && !commandExists("ffmpeg") ) { missing.push("ffmpeg"); } if (missing.length > 0) fail(`Missing dependencies: ${missing.join(" ")}`); } function resolveWhisperBinary(args: Args): string | null { const explicit = args.whisperBin.trim(); if (explicit) return resolvePathMaybe(explicit); if (commandExists("whisper-cli")) return "whisper-cli"; return null; } async function generateYoutubeSubtitles( target: string, args: Args, onReady?: (lang: "primary" | "secondary", pathToLoad: string) => Promise, ): Promise { const outDir = path.resolve(resolvePathMaybe(args.youtubeSubgenOutDir)); fs.mkdirSync(outDir, { recursive: true }); const primaryLangCodes = uniqueNormalizedLangCodes(args.youtubePrimarySubLangs); const secondaryLangCodes = uniqueNormalizedLangCodes(args.youtubeSecondarySubLangs); const primaryLabel = preferredLangLabel(primaryLangCodes, "primary"); const secondaryLabel = preferredLangLabel(secondaryLangCodes, "secondary"); const secondaryCanUseWhisperTranslate = secondaryLangCodes.includes("en") || secondaryLangCodes.includes("eng"); const ytdlpManualLangs = toYtdlpLangPattern([ ...primaryLangCodes, ...secondaryLangCodes, ]); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "subminer-yt-subgen-")); const knownFiles = new Set(); let keepTemp = args.youtubeSubgenKeepTemp; const publishTrack = async ( lang: "primary" | "secondary", source: SubtitleCandidate["source"], selectedPath: string, basename: string, ): Promise => { const langLabel = lang === "primary" ? primaryLabel : secondaryLabel; const taggedPath = path.join( outDir, `${basename}.${langLabel}.${sourceTag(source)}.srt`, ); const aliasPath = path.join(outDir, `${basename}.${langLabel}.srt`); fs.copyFileSync(selectedPath, taggedPath); fs.copyFileSync(taggedPath, aliasPath); log( "info", args.logLevel, `Generated subtitle (${langLabel}, ${source}) -> ${aliasPath}`, ); if (onReady) await onReady(lang, aliasPath); return aliasPath; }; try { log("debug", args.logLevel, `YouTube subtitle temp dir: ${tempDir}`); const meta = await runExternalCommand( "yt-dlp", ["--dump-single-json", "--no-warnings", target], { captureStdout: true, logLevel: args.logLevel, commandLabel: "yt-dlp:meta", }, ); const metadata = JSON.parse(meta.stdout) as { id?: string }; const videoId = metadata.id || `${Date.now()}`; const basename = normalizeBasename(videoId, videoId); await runExternalCommand( "yt-dlp", [ "--skip-download", "--no-warnings", "--write-subs", "--sub-format", "srt/vtt/best", "--sub-langs", ytdlpManualLangs, "-o", path.join(tempDir, "%(id)s.%(ext)s"), target, ], { allowFailure: true, logLevel: args.logLevel, commandLabel: "yt-dlp:manual-subs", streamOutput: true, }, ); const manualSubs = scanSubtitleCandidates( tempDir, knownFiles, "manual", primaryLangCodes, secondaryLangCodes, ); for (const sub of manualSubs) knownFiles.add(sub.path); let primaryCandidates = manualSubs.filter((entry) => entry.lang === "primary"); let secondaryCandidates = manualSubs.filter( (entry) => entry.lang === "secondary", ); const missingAuto: string[] = []; if (primaryCandidates.length === 0) missingAuto.push(toYtdlpLangPattern(primaryLangCodes)); if (secondaryCandidates.length === 0) missingAuto.push(toYtdlpLangPattern(secondaryLangCodes)); if (missingAuto.length > 0) { await runExternalCommand( "yt-dlp", [ "--skip-download", "--no-warnings", "--write-auto-subs", "--sub-format", "srt/vtt/best", "--sub-langs", missingAuto.join(","), "-o", path.join(tempDir, "%(id)s.%(ext)s"), target, ], { allowFailure: true, logLevel: args.logLevel, commandLabel: "yt-dlp:auto-subs", streamOutput: true, }, ); const autoSubs = scanSubtitleCandidates( tempDir, knownFiles, "auto", primaryLangCodes, secondaryLangCodes, ); for (const sub of autoSubs) knownFiles.add(sub.path); primaryCandidates = primaryCandidates.concat( autoSubs.filter((entry) => entry.lang === "primary"), ); secondaryCandidates = secondaryCandidates.concat( autoSubs.filter((entry) => entry.lang === "secondary"), ); } let primaryAlias = ""; let secondaryAlias = ""; const selectedPrimary = pickBestCandidate(primaryCandidates); const selectedSecondary = pickBestCandidate(secondaryCandidates); if (selectedPrimary) { const srt = await convertToSrt(selectedPrimary.path, tempDir, primaryLabel); primaryAlias = await publishTrack( "primary", selectedPrimary.source, srt, basename, ); } if (selectedSecondary) { const srt = await convertToSrt( selectedSecondary.path, tempDir, secondaryLabel, ); secondaryAlias = await publishTrack( "secondary", selectedSecondary.source, srt, basename, ); } const needsPrimaryWhisper = !selectedPrimary; const needsSecondaryWhisper = !selectedSecondary && secondaryCanUseWhisperTranslate; if (needsPrimaryWhisper || needsSecondaryWhisper) { const whisperBin = resolveWhisperBinary(args); const modelPath = args.whisperModel.trim() ? path.resolve(resolvePathMaybe(args.whisperModel.trim())) : ""; const hasWhisperFallback = !!whisperBin && !!modelPath && fs.existsSync(modelPath); if (!hasWhisperFallback) { log( "warn", args.logLevel, "Whisper fallback is not configured; continuing with available subtitle tracks.", ); } else { try { await runExternalCommand( "yt-dlp", [ "-f", "bestaudio/best", "--extract-audio", "--audio-format", args.youtubeSubgenAudioFormat, "--no-warnings", "-o", path.join(tempDir, "%(id)s.%(ext)s"), target, ], { logLevel: args.logLevel, commandLabel: "yt-dlp:audio", streamOutput: true, }, ); const audioPath = findAudioFile(tempDir, args.youtubeSubgenAudioFormat); if (!audioPath) { throw new Error("Audio extraction succeeded, but no audio file was found."); } const whisperAudioPath = await convertAudioForWhisper(audioPath, tempDir); if (needsPrimaryWhisper) { try { const primaryPrefix = path.join(tempDir, `${basename}.${primaryLabel}`); const primarySrt = await runWhisper( whisperBin!, modelPath, whisperAudioPath, args.youtubeWhisperSourceLanguage, false, primaryPrefix, ); primaryAlias = await publishTrack( "primary", "whisper", primarySrt, basename, ); } catch (error) { log( "warn", args.logLevel, `Failed to generate primary subtitle via whisper fallback: ${(error as Error).message}`, ); } } if (needsSecondaryWhisper) { try { const secondaryPrefix = path.join( tempDir, `${basename}.${secondaryLabel}`, ); const secondarySrt = await runWhisper( whisperBin!, modelPath, whisperAudioPath, args.youtubeWhisperSourceLanguage, true, secondaryPrefix, ); secondaryAlias = await publishTrack( "secondary", "whisper-translate", secondarySrt, basename, ); } catch (error) { log( "warn", args.logLevel, `Failed to generate secondary subtitle via whisper fallback: ${(error as Error).message}`, ); } } } catch (error) { log( "warn", args.logLevel, `Whisper fallback pipeline failed: ${(error as Error).message}`, ); } } } if (!secondaryCanUseWhisperTranslate && !selectedSecondary) { log( "warn", args.logLevel, `Secondary subtitle language (${secondaryLabel}) has no whisper translate fallback; relying on yt-dlp subtitles only.`, ); } if (!primaryAlias && !secondaryAlias) { throw new Error("Failed to generate any subtitle tracks."); } if (!primaryAlias || !secondaryAlias) { log( "warn", args.logLevel, `Generated partial subtitle result: primary=${primaryAlias ? "ok" : "missing"}, secondary=${secondaryAlias ? "ok" : "missing"}`, ); } return { basename, primaryPath: primaryAlias || undefined, secondaryPath: secondaryAlias || undefined, }; } catch (error) { keepTemp = true; throw error; } finally { if (keepTemp) { log("warn", args.logLevel, `Keeping subtitle temp dir: ${tempDir}`); } else { try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { // ignore cleanup failures } } } } function checkPickerDependencies(args: Args): void { if (args.useRofi) { if (!commandExists("rofi")) fail("Missing dependency: rofi"); return; } if (!commandExists("fzf")) fail("Missing dependency: fzf"); } function parseArgs( argv: string[], scriptName: string, launcherConfig: LauncherYoutubeSubgenConfig, ): Args { const envMode = (process.env.SUBMINER_YT_SUBGEN_MODE || "").toLowerCase(); const defaultMode: YoutubeSubgenMode = envMode === "preprocess" || envMode === "off" || envMode === "automatic" ? (envMode as YoutubeSubgenMode) : launcherConfig.mode ? launcherConfig.mode : "automatic"; const configuredSecondaryLangs = uniqueNormalizedLangCodes( launcherConfig.secondarySubLanguages ?? [], ); const configuredPrimaryLangs = uniqueNormalizedLangCodes( launcherConfig.primarySubLanguages ?? [], ); const primarySubLangs = configuredPrimaryLangs.length > 0 ? configuredPrimaryLangs : [...DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS]; const secondarySubLangs = configuredSecondaryLangs.length > 0 ? configuredSecondaryLangs : [...DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS]; const youtubeAudioLangs = uniqueNormalizedLangCodes([ ...primarySubLangs, ...secondarySubLangs, ]); const parsed: Args = { backend: "auto", directory: ".", recursive: false, profile: "subminer", youtubeSubgenMode: defaultMode, whisperBin: process.env.SUBMINER_WHISPER_BIN || launcherConfig.whisperBin || "", whisperModel: process.env.SUBMINER_WHISPER_MODEL || launcherConfig.whisperModel || "", youtubeSubgenOutDir: process.env.SUBMINER_YT_SUBGEN_OUT_DIR || DEFAULT_YOUTUBE_SUBGEN_OUT_DIR, youtubeSubgenAudioFormat: process.env.SUBMINER_YT_SUBGEN_AUDIO_FORMAT || "m4a", youtubeSubgenKeepTemp: process.env.SUBMINER_YT_SUBGEN_KEEP_TEMP === "1", youtubePrimarySubLangs: primarySubLangs, youtubeSecondarySubLangs: secondarySubLangs, youtubeAudioLangs, youtubeWhisperSourceLanguage: inferWhisperLanguage(primarySubLangs, "ja"), useTexthooker: true, texthookerOnly: false, useRofi: false, logLevel: "info", target: "", targetKind: "", }; const isValidLogLevel = (value: string): value is LogLevel => value === "debug" || value === "info" || value === "warn" || value === "error"; const isValidYoutubeSubgenMode = (value: string): value is YoutubeSubgenMode => value === "automatic" || value === "preprocess" || value === "off"; let i = 0; while (i < argv.length) { const arg = argv[i]; if (arg === "-b" || arg === "--backend") { const value = argv[i + 1]; if (!value) fail("--backend requires a value"); if (!["auto", "hyprland", "x11", "macos"].includes(value)) { fail( `Invalid backend: ${value} (must be auto, hyprland, x11, or macos)`, ); } parsed.backend = value as Backend; i += 2; continue; } if (arg === "-d" || arg === "--directory") { const value = argv[i + 1]; if (!value) fail("--directory requires a value"); parsed.directory = value; i += 2; continue; } if (arg === "-r" || arg === "--recursive") { parsed.recursive = true; i += 1; continue; } if (arg === "-p" || arg === "--profile") { const value = argv[i + 1]; if (!value) fail("--profile requires a value"); parsed.profile = value; i += 2; continue; } if (arg === "--yt-subgen-mode" || arg === "--youtube-subgen-mode") { const value = (argv[i + 1] || "").toLowerCase(); if (!isValidYoutubeSubgenMode(value)) { fail( `Invalid yt-subgen mode: ${value || ""} (must be automatic, preprocess, or off)`, ); } parsed.youtubeSubgenMode = value; i += 2; continue; } if ( arg.startsWith("--yt-subgen-mode=") || arg.startsWith("--youtube-subgen-mode=") ) { const value = arg.split("=", 2)[1]?.toLowerCase() || ""; if (!isValidYoutubeSubgenMode(value)) { fail( `Invalid yt-subgen mode: ${value || ""} (must be automatic, preprocess, or off)`, ); } parsed.youtubeSubgenMode = value; i += 1; continue; } if (arg === "--whisper-bin") { const value = argv[i + 1]; if (!value) fail("--whisper-bin requires a value"); parsed.whisperBin = value; i += 2; continue; } if (arg.startsWith("--whisper-bin=")) { const value = arg.slice("--whisper-bin=".length); if (!value) fail("--whisper-bin requires a value"); parsed.whisperBin = value; i += 1; continue; } if (arg === "--whisper-model") { const value = argv[i + 1]; if (!value) fail("--whisper-model requires a value"); parsed.whisperModel = value; i += 2; continue; } if (arg.startsWith("--whisper-model=")) { const value = arg.slice("--whisper-model=".length); if (!value) fail("--whisper-model requires a value"); parsed.whisperModel = value; i += 1; continue; } if (arg === "--yt-subgen-out-dir") { const value = argv[i + 1]; if (!value) fail("--yt-subgen-out-dir requires a value"); parsed.youtubeSubgenOutDir = value; i += 2; continue; } if (arg.startsWith("--yt-subgen-out-dir=")) { const value = arg.slice("--yt-subgen-out-dir=".length); if (!value) fail("--yt-subgen-out-dir requires a value"); parsed.youtubeSubgenOutDir = value; i += 1; continue; } if (arg === "--yt-subgen-audio-format") { const value = argv[i + 1]; if (!value) fail("--yt-subgen-audio-format requires a value"); parsed.youtubeSubgenAudioFormat = value; i += 2; continue; } if (arg.startsWith("--yt-subgen-audio-format=")) { const value = arg.slice("--yt-subgen-audio-format=".length); if (!value) fail("--yt-subgen-audio-format requires a value"); parsed.youtubeSubgenAudioFormat = value; i += 1; continue; } if (arg === "--yt-subgen-keep-temp") { parsed.youtubeSubgenKeepTemp = true; i += 1; continue; } if (arg === "-v" || arg === "--verbose") { parsed.logLevel = "debug"; i += 1; continue; } if (arg === "--log-level") { const value = argv[i + 1]; if (!value || !isValidLogLevel(value)) { fail( `Invalid log level: ${value ?? ""} (must be debug, info, warn, or error)`, ); } parsed.logLevel = value; i += 2; continue; } if (arg.startsWith("--log-level=")) { const value = arg.slice("--log-level=".length); if (!isValidLogLevel(value)) { fail( `Invalid log level: ${value} (must be debug, info, warn, or error)`, ); } parsed.logLevel = value; i += 1; continue; } if (arg === "-R" || arg === "--rofi") { parsed.useRofi = true; i += 1; continue; } if (arg === "-T" || arg === "--no-texthooker") { parsed.useTexthooker = false; i += 1; continue; } if (arg === "--texthooker") { parsed.texthookerOnly = true; i += 1; continue; } if (arg === "-h" || arg === "--help") { process.stdout.write(usage(scriptName)); process.exit(0); } if (arg === "--") { i += 1; break; } if (arg.startsWith("-")) { fail(`Unknown option: ${arg}`); } break; } const positional = argv.slice(i); if (positional.length > 0) { const target = positional[0]; if (isUrlTarget(target)) { parsed.target = target; parsed.targetKind = "url"; } else { const resolved = resolvePathMaybe(target); if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) { parsed.target = resolved; parsed.targetKind = "file"; } else if ( fs.existsSync(resolved) && fs.statSync(resolved).isDirectory() ) { parsed.directory = resolved; } else { fail(`Not a file, directory, or supported URL: ${target}`); } } } return parsed; } 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 === "debug") overlayArgs.push("--verbose"); else if (args.logLevel !== "info") overlayArgs.push("--log-level", args.logLevel); if (args.useTexthooker) overlayArgs.push("--texthooker"); state.overlayProc = spawn(appPath, overlayArgs, { stdio: "inherit" }); return new Promise((resolve) => { setTimeout(resolve, 2000); }); } function launchTexthookerOnly(appPath: string, args: Args): never { const overlayArgs = ["--texthooker"]; if (args.logLevel === "debug") overlayArgs.push("--verbose"); else 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); } function stopOverlay(args: Args): void { if (!state.appPath || state.stopRequested) return; state.stopRequested = true; log("info", args.logLevel, "Stopping SubMiner overlay..."); const stopArgs = ["--stop"]; if (args.logLevel === "debug") stopArgs.push("--verbose"); else 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 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); }); } function startMpv( target: string, targetKind: "file" | "url", args: Args, socketPath: 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}`); if (targetKind === "url" && 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=yes", `--ytdl-format=${DEFAULT_YOUTUBE_YTDL_FORMAT}`, "--ytdl-raw-options=", `--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}`); } try { fs.rmSync(socketPath, { force: true }); } catch { // ignore } mpvArgs.push(`--input-ipc-server=${socketPath}`); mpvArgs.push(target); state.mpvProc = spawn("mpv", mpvArgs, { stdio: "inherit" }); } async function chooseTarget( args: Args, scriptPath: string, ): Promise<{ target: string; kind: "file" | "url" } | null> { if (args.target) { return { target: args.target, kind: args.targetKind as "file" | "url" }; } const searchDir = realpathMaybe(resolvePathMaybe(args.directory)); if (!fs.existsSync(searchDir) || !fs.statSync(searchDir).isDirectory()) { fail(`Directory not found: ${searchDir}`); } const videos = collectVideos(searchDir, args.recursive); if (videos.length === 0) { fail(`No video files found in: ${searchDir}`); } log( "info", args.logLevel, `Browsing: ${searchDir} (${videos.length} videos found)`, ); const selected = args.useRofi ? showRofiMenu(videos, searchDir, args.recursive, scriptPath, args.logLevel) : showFzfMenu(videos); if (!selected) return null; return { target: selected, kind: "file" }; } function registerCleanup(args: Args): void { process.on("SIGINT", () => { stopOverlay(args); process.exit(130); }); process.on("SIGTERM", () => { stopOverlay(args); process.exit(143); }); } async function main(): Promise { const scriptName = path.basename(process.argv[1] || "subminer"); const launcherConfig = loadLauncherYoutubeSubgenConfig(); const args = parseArgs(process.argv.slice(2), scriptName, launcherConfig); log("debug", args.logLevel, `Wrapper log level set to: ${args.logLevel}`); const appPath = findAppBinary(process.argv[1] || "subminer"); if (!appPath) { if (process.platform === "darwin") { fail( "SubMiner app binary not found. Install SubMiner.app to /Applications or ~/Applications, or set SUBMINER_APPIMAGE_PATH.", ); } fail( "SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.", ); } state.appPath = appPath; if (args.texthookerOnly) { launchTexthookerOnly(appPath, args); } if (!args.target) { checkPickerDependencies(args); } const targetChoice = await chooseTarget(args, process.argv[1] || "subminer"); if (!targetChoice) { log("info", args.logLevel, "No video selected, exiting"); process.exit(0); } checkDependencies({ ...args, target: targetChoice.target, targetKind: targetChoice.kind, }); registerCleanup(args); const isYoutubeUrl = targetChoice.kind === "url" && isYoutubeTarget(targetChoice.target); let preloadedSubtitles: | { primaryPath?: string; secondaryPath?: string } | undefined; if (isYoutubeUrl && args.youtubeSubgenMode === "preprocess") { log("info", args.logLevel, "YouTube subtitle mode: preprocess"); const generated = await generateYoutubeSubtitles(targetChoice.target, args); preloadedSubtitles = { primaryPath: generated.primaryPath, secondaryPath: generated.secondaryPath, }; log( "info", args.logLevel, `YouTube preprocess result: primary=${generated.primaryPath ? "ready" : "missing"}, secondary=${generated.secondaryPath ? "ready" : "missing"}`, ); } else if (isYoutubeUrl && args.youtubeSubgenMode === "automatic") { log("info", args.logLevel, "YouTube subtitle mode: automatic (background)"); } else if (isYoutubeUrl) { log("info", args.logLevel, "YouTube subtitle mode: off"); } startMpv( targetChoice.target, targetChoice.kind, args, DEFAULT_SOCKET_PATH, preloadedSubtitles, ); if (isYoutubeUrl && args.youtubeSubgenMode === "automatic") { void generateYoutubeSubtitles(targetChoice.target, args, async (lang, subtitlePath) => { try { await loadSubtitleIntoMpv( DEFAULT_SOCKET_PATH, subtitlePath, lang === "primary", args.logLevel, ); } catch (error) { log( "warn", args.logLevel, `Generated subtitle ready but failed to load in mpv: ${(error as Error).message}`, ); } }).catch((error) => { log( "warn", args.logLevel, `Background subtitle generation failed: ${(error as Error).message}`, ); }); } const ready = await waitForSocket(DEFAULT_SOCKET_PATH); if (ready) { log( "info", args.logLevel, "MPV IPC socket ready, starting SubMiner overlay", ); } else { log( "info", args.logLevel, "MPV IPC socket not ready after timeout, starting SubMiner overlay anyway", ); } await startOverlay(appPath, args, DEFAULT_SOCKET_PATH); await new Promise((resolve) => { if (!state.mpvProc) { stopOverlay(args); resolve(); return; } state.mpvProc.on("exit", (code) => { stopOverlay(args); process.exitCode = code ?? 0; resolve(); }); }); } main().catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); fail(message); }); // vim: ft=typescript