import fs from "node:fs"; import path from "node:path"; import os from "node:os"; import { Command } from "commander"; import { parse as parseJsonc } from "jsonc-parser"; import type { LogLevel, YoutubeSubgenMode, Backend, Args, LauncherYoutubeSubgenConfig, LauncherJellyfinConfig, PluginRuntimeConfig, } from "./types.js"; import { DEFAULT_SOCKET_PATH, DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS, DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS, DEFAULT_YOUTUBE_SUBGEN_OUT_DIR, DEFAULT_JIMAKU_API_BASE_URL, } from "./types.js"; import { log, fail } from "./log.js"; import { resolvePathMaybe, isUrlTarget, uniqueNormalizedLangCodes, parseBoolLike, inferWhisperLanguage, } from "./util.js"; export 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 }; jimaku?: 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; const jimaku = root.jimaku; const jimakuApiKey = jimaku && typeof jimaku === "object" ? (jimaku as { apiKey?: unknown }).apiKey : undefined; const jimakuApiKeyCommand = jimaku && typeof jimaku === "object" ? (jimaku as { apiKeyCommand?: unknown }).apiKeyCommand : undefined; const jimakuApiBaseUrl = jimaku && typeof jimaku === "object" ? (jimaku as { apiBaseUrl?: unknown }).apiBaseUrl : undefined; const jimakuLanguagePreference = jimaku && typeof jimaku === "object" ? (jimaku as { languagePreference?: unknown }).languagePreference : undefined; const jimakuMaxEntryResults = jimaku && typeof jimaku === "object" ? (jimaku as { maxEntryResults?: unknown }).maxEntryResults : undefined; const resolvedJimakuLanguagePreference = jimakuLanguagePreference === "ja" || jimakuLanguagePreference === "en" || jimakuLanguagePreference === "none" ? jimakuLanguagePreference : undefined; const resolvedJimakuMaxEntryResults = typeof jimakuMaxEntryResults === "number" && Number.isFinite(jimakuMaxEntryResults) && jimakuMaxEntryResults > 0 ? Math.floor(jimakuMaxEntryResults) : 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, jimakuApiKey: typeof jimakuApiKey === "string" ? jimakuApiKey : undefined, jimakuApiKeyCommand: typeof jimakuApiKeyCommand === "string" ? jimakuApiKeyCommand : undefined, jimakuApiBaseUrl: typeof jimakuApiBaseUrl === "string" ? jimakuApiBaseUrl : undefined, jimakuLanguagePreference: resolvedJimakuLanguagePreference, jimakuMaxEntryResults: resolvedJimakuMaxEntryResults, }; } catch { return {}; } } export function loadLauncherJellyfinConfig(): LauncherJellyfinConfig { 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 jellyfin = (parsed as { jellyfin?: unknown }).jellyfin; if (!jellyfin || typeof jellyfin !== "object") return {}; const typed = jellyfin as Record; return { enabled: typeof typed.enabled === "boolean" ? typed.enabled : undefined, serverUrl: typeof typed.serverUrl === "string" ? typed.serverUrl : undefined, username: typeof typed.username === "string" ? typed.username : undefined, accessToken: typeof typed.accessToken === "string" ? typed.accessToken : undefined, userId: typeof typed.userId === "string" ? typed.userId : undefined, defaultLibraryId: typeof typed.defaultLibraryId === "string" ? typed.defaultLibraryId : undefined, pullPictures: typeof typed.pullPictures === "boolean" ? typed.pullPictures : undefined, iconCacheDir: typeof typed.iconCacheDir === "string" ? typed.iconCacheDir : undefined, }; } catch { return {}; } } function getPluginConfigCandidates(): string[] { const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); return Array.from( new Set([ path.join(xdgConfigHome, "mpv", "script-opts", "subminer.conf"), path.join(os.homedir(), ".config", "mpv", "script-opts", "subminer.conf"), ]), ); } export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig { const runtimeConfig: PluginRuntimeConfig = { autoStartOverlay: false, socketPath: DEFAULT_SOCKET_PATH, }; const candidates = getPluginConfigCandidates(); for (const configPath of candidates) { if (!fs.existsSync(configPath)) continue; try { const content = fs.readFileSync(configPath, "utf8"); const lines = content.split(/\r?\n/); for (const line of lines) { const trimmed = line.trim(); if (trimmed.length === 0 || trimmed.startsWith("#")) continue; const autoStartMatch = trimmed.match(/^auto_start\s*=\s*(.+)$/i); if (autoStartMatch) { const value = (autoStartMatch[1] || "").split("#", 1)[0]?.trim() || ""; const parsed = parseBoolLike(value); if (parsed !== null) { runtimeConfig.autoStartOverlay = parsed; } continue; } const socketMatch = trimmed.match(/^socket_path\s*=\s*(.+)$/i); if (socketMatch) { const value = (socketMatch[1] || "").split("#", 1)[0]?.trim() || ""; if (value) runtimeConfig.socketPath = value; } } log( "debug", logLevel, `Using mpv plugin settings from ${configPath}: auto_start=${runtimeConfig.autoStartOverlay ? "yes" : "no"} socket_path=${runtimeConfig.socketPath}`, ); return runtimeConfig; } catch { log( "warn", logLevel, `Failed to read ${configPath}; using launcher defaults`, ); return runtimeConfig; } } log( "debug", logLevel, `No mpv subminer.conf found; using launcher defaults (auto_start=no socket_path=${runtimeConfig.socketPath})`, ); return runtimeConfig; } function ensureTarget(target: string, parsed: Args): void { if (isUrlTarget(target)) { parsed.target = target; parsed.targetKind = "url"; return; } const resolved = resolvePathMaybe(target); if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) { parsed.target = resolved; parsed.targetKind = "file"; return; } if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) { parsed.directory = resolved; return; } fail(`Not a file, directory, or supported URL: ${target}`); } function parseLogLevel(value: string): LogLevel { if (value === "debug" || value === "info" || value === "warn" || value === "error") { return value; } fail(`Invalid log level: ${value} (must be debug, info, warn, or error)`); } function parseYoutubeMode(value: string): YoutubeSubgenMode { const normalized = value.toLowerCase(); if (normalized === "automatic" || normalized === "preprocess" || normalized === "off") { return normalized as YoutubeSubgenMode; } fail(`Invalid yt-subgen mode: ${value} (must be automatic, preprocess, or off)`); } function parseBackend(value: string): Backend { if (value === "auto" || value === "hyprland" || value === "x11" || value === "macos") { return value as Backend; } fail(`Invalid backend: ${value} (must be auto, hyprland, x11, or macos)`); } function applyRootOptions(program: Command): void { program .option("-b, --backend ", "Display backend") .option("-d, --directory ", "Directory to browse") .option("-r, --recursive", "Search directories recursively") .option("-p, --profile ", "MPV profile") .option("--start", "Explicitly start overlay") .option("--log-level ", "Log level") .option("-R, --rofi", "Use rofi picker") .option("-S, --start-overlay", "Auto-start overlay") .option("-T, --no-texthooker", "Disable texthooker-ui server"); } function hasTopLevelCommand(argv: string[]): boolean { const commandNames = new Set([ "jellyfin", "jf", "yt", "youtube", "doctor", "config", "mpv", "texthooker", "help", ]); const optionsWithValue = new Set([ "-b", "--backend", "-d", "--directory", "-p", "--profile", "--log-level", ]); for (let i = 0; i < argv.length; i += 1) { const token = argv[i] || ""; if (token === "--") return false; if (token.startsWith("-")) { if (optionsWithValue.has(token)) { i += 1; } continue; } return commandNames.has(token); } return false; } export 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", startOverlay: false, 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", jimakuApiKey: process.env.SUBMINER_JIMAKU_API_KEY || "", jimakuApiKeyCommand: process.env.SUBMINER_JIMAKU_API_KEY_COMMAND || "", jimakuApiBaseUrl: process.env.SUBMINER_JIMAKU_API_BASE_URL || DEFAULT_JIMAKU_API_BASE_URL, jimakuLanguagePreference: launcherConfig.jimakuLanguagePreference || "ja", jimakuMaxEntryResults: launcherConfig.jimakuMaxEntryResults || 10, jellyfin: false, jellyfinLogin: false, jellyfinLogout: false, jellyfinPlay: false, jellyfinDiscovery: false, doctor: false, configPath: false, configShow: false, mpvIdle: false, mpvSocket: false, mpvStatus: false, jellyfinServer: "", jellyfinUsername: "", jellyfinPassword: "", youtubePrimarySubLangs: primarySubLangs, youtubeSecondarySubLangs: secondarySubLangs, youtubeAudioLangs, youtubeWhisperSourceLanguage: inferWhisperLanguage(primarySubLangs, "ja"), useTexthooker: true, autoStartOverlay: false, texthookerOnly: false, useRofi: false, logLevel: "info", target: "", targetKind: "", }; if (launcherConfig.jimakuApiKey) parsed.jimakuApiKey = launcherConfig.jimakuApiKey; if (launcherConfig.jimakuApiKeyCommand) parsed.jimakuApiKeyCommand = launcherConfig.jimakuApiKeyCommand; if (launcherConfig.jimakuApiBaseUrl) parsed.jimakuApiBaseUrl = launcherConfig.jimakuApiBaseUrl; if (launcherConfig.jimakuLanguagePreference) parsed.jimakuLanguagePreference = launcherConfig.jimakuLanguagePreference; if (launcherConfig.jimakuMaxEntryResults !== undefined) parsed.jimakuMaxEntryResults = launcherConfig.jimakuMaxEntryResults; let jellyfinInvocation: | { action?: string; discovery?: boolean; play?: boolean; login?: boolean; logout?: boolean; setup?: boolean; server?: string; username?: string; password?: string; logLevel?: string; } | null = null; let ytInvocation: | { target?: string; mode?: string; outDir?: string; keepTemp?: boolean; whisperBin?: string; whisperModel?: string; ytSubgenAudioFormat?: string; logLevel?: string; } | null = null; let configInvocation: { action: string; logLevel?: string } | null = null; let mpvInvocation: { action: string; logLevel?: string } | null = null; let doctorLogLevel: string | null = null; let texthookerLogLevel: string | null = null; const commandProgram = new Command(); commandProgram .name(scriptName) .description("Launch MPV with SubMiner sentence mining overlay") .showHelpAfterError(true) .enablePositionalOptions() .allowExcessArguments(false) .allowUnknownOption(false) .exitOverride(); applyRootOptions(commandProgram); const rootProgram = new Command(); rootProgram .name(scriptName) .description("Launch MPV with SubMiner sentence mining overlay") .usage("[options] [command] [target]") .showHelpAfterError(true) .allowExcessArguments(false) .allowUnknownOption(false) .exitOverride() .argument("[target]", "file, directory, or URL"); applyRootOptions(rootProgram); commandProgram .command("jellyfin") .alias("jf") .description("Jellyfin workflows") .argument("[action]", "setup|discovery|play|login|logout") .option("-d, --discovery", "Cast discovery mode") .option("-p, --play", "Interactive play picker") .option("-l, --login", "Login flow") .option("--logout", "Clear token/session") .option("--setup", "Open setup window") .option("-s, --server ", "Jellyfin server URL") .option("-u, --username ", "Jellyfin username") .option("-w, --password ", "Jellyfin password") .option("--log-level ", "Log level") .action((action: string | undefined, options: Record) => { jellyfinInvocation = { action, discovery: options.discovery === true, play: options.play === true, login: options.login === true, logout: options.logout === true, setup: options.setup === true, server: typeof options.server === "string" ? options.server : undefined, username: typeof options.username === "string" ? options.username : undefined, password: typeof options.password === "string" ? options.password : undefined, logLevel: typeof options.logLevel === "string" ? options.logLevel : undefined, }; }); commandProgram .command("yt") .alias("youtube") .description("YouTube workflows") .argument("[target]", "YouTube URL or ytsearch: query") .option("-m, --mode ", "Subtitle generation mode") .option("-o, --out-dir ", "Subtitle output dir") .option("--keep-temp", "Keep temp files") .option("--whisper-bin ", "whisper.cpp CLI path") .option("--whisper-model ", "whisper model path") .option("--yt-subgen-audio-format ", "Audio extraction format") .option("--log-level ", "Log level") .action((target: string | undefined, options: Record) => { ytInvocation = { target, mode: typeof options.mode === "string" ? options.mode : undefined, outDir: typeof options.outDir === "string" ? options.outDir : undefined, keepTemp: options.keepTemp === true, whisperBin: typeof options.whisperBin === "string" ? options.whisperBin : undefined, whisperModel: typeof options.whisperModel === "string" ? options.whisperModel : undefined, ytSubgenAudioFormat: typeof options.ytSubgenAudioFormat === "string" ? options.ytSubgenAudioFormat : undefined, logLevel: typeof options.logLevel === "string" ? options.logLevel : undefined, }; }); commandProgram .command("doctor") .description("Run dependency and environment checks") .option("--log-level ", "Log level") .action((options: Record) => { parsed.doctor = true; doctorLogLevel = typeof options.logLevel === "string" ? options.logLevel : null; }); commandProgram .command("config") .description("Config helpers") .argument("[action]", "path|show", "path") .option("--log-level ", "Log level") .action((action: string, options: Record) => { configInvocation = { action, logLevel: typeof options.logLevel === "string" ? options.logLevel : undefined, }; }); commandProgram .command("mpv") .description("MPV helpers") .argument("[action]", "status|socket|idle", "status") .option("--log-level ", "Log level") .action((action: string, options: Record) => { mpvInvocation = { action, logLevel: typeof options.logLevel === "string" ? options.logLevel : undefined, }; }); commandProgram .command("texthooker") .description("Launch texthooker-only mode") .option("--log-level ", "Log level") .action((options: Record) => { parsed.texthookerOnly = true; texthookerLogLevel = typeof options.logLevel === "string" ? options.logLevel : null; }); const selectedProgram = hasTopLevelCommand(argv) ? commandProgram : rootProgram; try { selectedProgram.parse(["node", scriptName, ...argv]); } catch (error) { const commanderError = error as { code?: string; message?: string }; if (commanderError?.code === "commander.helpDisplayed") { process.exit(0); } fail(commanderError?.message || String(error)); } const options = selectedProgram.opts>(); if (typeof options.backend === "string") { parsed.backend = parseBackend(options.backend); } if (typeof options.directory === "string") { parsed.directory = options.directory; } if (options.recursive === true) parsed.recursive = true; if (typeof options.profile === "string") { parsed.profile = options.profile; } if (options.start === true) parsed.startOverlay = true; if (typeof options.logLevel === "string") { parsed.logLevel = parseLogLevel(options.logLevel); } if (options.rofi === true) parsed.useRofi = true; if (options.startOverlay === true) parsed.autoStartOverlay = true; if (options.texthooker === false) parsed.useTexthooker = false; const rootTarget = rootProgram.processedArgs[0]; if (typeof rootTarget === "string" && rootTarget) { ensureTarget(rootTarget, parsed); } if (jellyfinInvocation) { if (jellyfinInvocation.logLevel) { parsed.logLevel = parseLogLevel(jellyfinInvocation.logLevel); } const action = (jellyfinInvocation.action || "").toLowerCase(); if (action && !["setup", "discovery", "play", "login", "logout"].includes(action)) { fail(`Unknown jellyfin action: ${jellyfinInvocation.action}`); } parsed.jellyfinServer = jellyfinInvocation.server || ""; parsed.jellyfinUsername = jellyfinInvocation.username || ""; parsed.jellyfinPassword = jellyfinInvocation.password || ""; const modeFlags = { setup: jellyfinInvocation.setup || action === "setup", discovery: jellyfinInvocation.discovery || action === "discovery", play: jellyfinInvocation.play || action === "play", login: jellyfinInvocation.login || action === "login", logout: jellyfinInvocation.logout || action === "logout", }; if (!modeFlags.setup && !modeFlags.discovery && !modeFlags.play && !modeFlags.login && !modeFlags.logout) { modeFlags.setup = true; } parsed.jellyfin = Boolean(modeFlags.setup); parsed.jellyfinDiscovery = Boolean(modeFlags.discovery); parsed.jellyfinPlay = Boolean(modeFlags.play); parsed.jellyfinLogin = Boolean(modeFlags.login); parsed.jellyfinLogout = Boolean(modeFlags.logout); } if (ytInvocation) { if (ytInvocation.logLevel) { parsed.logLevel = parseLogLevel(ytInvocation.logLevel); } const mode = ytInvocation.mode; if (mode) parsed.youtubeSubgenMode = parseYoutubeMode(mode); const outDir = ytInvocation.outDir; if (outDir) parsed.youtubeSubgenOutDir = outDir; if (ytInvocation.keepTemp) { parsed.youtubeSubgenKeepTemp = true; } if (ytInvocation.whisperBin) parsed.whisperBin = ytInvocation.whisperBin; if (ytInvocation.whisperModel) parsed.whisperModel = ytInvocation.whisperModel; if (ytInvocation.ytSubgenAudioFormat) { parsed.youtubeSubgenAudioFormat = ytInvocation.ytSubgenAudioFormat; } if (ytInvocation.target) { ensureTarget(ytInvocation.target, parsed); } } if (doctorLogLevel) { parsed.logLevel = parseLogLevel(doctorLogLevel); } if (texthookerLogLevel) { parsed.logLevel = parseLogLevel(texthookerLogLevel); } if (configInvocation !== null) { if (configInvocation.logLevel) { parsed.logLevel = parseLogLevel(configInvocation.logLevel); } const action = (configInvocation.action || "path").toLowerCase(); if (action === "path") parsed.configPath = true; else if (action === "show") parsed.configShow = true; else fail(`Unknown config action: ${configInvocation.action}`); } if (mpvInvocation !== null) { if (mpvInvocation.logLevel) { parsed.logLevel = parseLogLevel(mpvInvocation.logLevel); } const action = (mpvInvocation.action || "status").toLowerCase(); if (action === "status") parsed.mpvStatus = true; else if (action === "socket") parsed.mpvSocket = true; else if (action === "idle" || action === "start") parsed.mpvIdle = true; else fail(`Unknown mpv action: ${mpvInvocation.action}`); } return parsed; }