From b4df3f8295a94fddd2530664770a9d1e69ad0e9c Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 17 Feb 2026 03:27:15 -0800 Subject: [PATCH] refactor(launcher): extract config, jimaku, and picker modules - launcher/config.ts: config loading, arg parsing, plugin runtime config - launcher/jimaku.ts: Jimaku API client, media parsing, subtitle helpers - launcher/picker.ts: rofi/fzf menu UI, video collection, Jellyfin pickers - JellyfinSessionConfig moved to types.ts to avoid circular deps - picker functions accept ensureIcon callback to decouple from jellyfin --- launcher/config.ts | 695 +++++++++++++++++++++++++++++++++++++++++++++ launcher/jimaku.ts | 524 ++++++++++++++++++++++++++++++++++ launcher/picker.ts | 551 +++++++++++++++++++++++++++++++++++ 3 files changed, 1770 insertions(+) create mode 100644 launcher/config.ts create mode 100644 launcher/jimaku.ts create mode 100644 launcher/picker.ts diff --git a/launcher/config.ts b/launcher/config.ts new file mode 100644 index 0000000..8378b47 --- /dev/null +++ b/launcher/config.ts @@ -0,0 +1,695 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +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 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) + --start Explicitly start SubMiner overlay + --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 + --log-level LEVEL Set log level: debug, info, warn, error + -R, --rofi Use rofi file browser instead of fzf for video selection + -S, --start-overlay Auto-start SubMiner overlay after MPV socket is ready + -T, --no-texthooker Disable texthooker-ui server + --texthooker Launch only texthooker page (no MPV/overlay workflow) + --jellyfin Open Jellyfin setup window in the app + --jellyfin-login Login via app CLI (requires server/username/password) + --jellyfin-logout Clear Jellyfin token/session via app CLI + --jellyfin-play Pick Jellyfin library/item and play in mpv + --jellyfin-server URL + Jellyfin server URL (for login/play menu API) + --jellyfin-username NAME + Jellyfin username (for login) + --jellyfin-password PASS + Jellyfin password (for login) + -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} -S video.mkv # Start overlay immediately after MPV launch + ${scriptName} --texthooker # Launch only texthooker page + ${scriptName} --jellyfin # Open Jellyfin setup form window + ${scriptName} --jellyfin-play # Pick Jellyfin library/item and play +`; +} + +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; +} + +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, + 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; + + 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 === "--start") { + parsed.startOverlay = true; + i += 1; + 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 === "--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 === "-S" || arg === "--start-overlay") { + parsed.autoStartOverlay = 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 === "--jellyfin") { + parsed.jellyfin = true; + i += 1; + continue; + } + + if (arg === "--jellyfin-login") { + parsed.jellyfinLogin = true; + i += 1; + continue; + } + + if (arg === "--jellyfin-logout") { + parsed.jellyfinLogout = true; + i += 1; + continue; + } + + if (arg === "--jellyfin-play") { + parsed.jellyfinPlay = true; + i += 1; + continue; + } + + if (arg === "--jellyfin-server") { + const value = argv[i + 1]; + if (!value) fail("--jellyfin-server requires a value"); + parsed.jellyfinServer = value; + i += 2; + continue; + } + + if (arg.startsWith("--jellyfin-server=")) { + parsed.jellyfinServer = arg.split("=", 2)[1] || ""; + i += 1; + continue; + } + + if (arg === "--jellyfin-username") { + const value = argv[i + 1]; + if (!value) fail("--jellyfin-username requires a value"); + parsed.jellyfinUsername = value; + i += 2; + continue; + } + + if (arg.startsWith("--jellyfin-username=")) { + parsed.jellyfinUsername = arg.split("=", 2)[1] || ""; + i += 1; + continue; + } + + if (arg === "--jellyfin-password") { + const value = argv[i + 1]; + if (!value) fail("--jellyfin-password requires a value"); + parsed.jellyfinPassword = value; + i += 2; + continue; + } + + if (arg.startsWith("--jellyfin-password=")) { + parsed.jellyfinPassword = arg.split("=", 2)[1] || ""; + 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; +} diff --git a/launcher/jimaku.ts b/launcher/jimaku.ts new file mode 100644 index 0000000..d6cfd91 --- /dev/null +++ b/launcher/jimaku.ts @@ -0,0 +1,524 @@ +import fs from "node:fs"; +import path from "node:path"; +import http from "node:http"; +import https from "node:https"; +import { spawnSync } from "node:child_process"; +import type { Args, JimakuLanguagePreference } from "./types.js"; +import { DEFAULT_JIMAKU_API_BASE_URL } from "./types.js"; +import { commandExists } from "./util.js"; + +export interface JimakuEntry { + id: number; + name: string; + english_name?: string | null; + japanese_name?: string | null; + flags?: { + anime?: boolean; + movie?: boolean; + adult?: boolean; + external?: boolean; + unverified?: boolean; + }; +} + +interface JimakuFileEntry { + name: string; + url: string; + size: number; + last_modified: string; +} + +interface JimakuApiError { + error: string; + code?: number; + retryAfter?: number; +} + +type JimakuApiResponse = + | { ok: true; data: T } + | { ok: false; error: JimakuApiError }; + +type JimakuDownloadResult = + | { ok: true; path: string } + | { ok: false; error: JimakuApiError }; + +interface JimakuConfig { + apiKey: string; + apiKeyCommand: string; + apiBaseUrl: string; + languagePreference: JimakuLanguagePreference; + maxEntryResults: number; +} + +interface JimakuMediaInfo { + title: string; + season: number | null; + episode: number | null; + confidence: "high" | "medium" | "low"; + filename: string; + rawTitle: string; +} + +function getRetryAfter(headers: http.IncomingHttpHeaders): number | undefined { + const value = headers["x-ratelimit-reset-after"]; + if (!value) return undefined; + const raw = Array.isArray(value) ? value[0] : value; + const parsed = Number.parseFloat(raw); + if (!Number.isFinite(parsed)) return undefined; + return parsed; +} + +export function matchEpisodeFromName(name: string): { + season: number | null; + episode: number | null; + index: number | null; + confidence: "high" | "medium" | "low"; +} { + const seasonEpisode = name.match(/S(\d{1,2})E(\d{1,3})/i); + if (seasonEpisode && seasonEpisode.index !== undefined) { + return { + season: Number.parseInt(seasonEpisode[1], 10), + episode: Number.parseInt(seasonEpisode[2], 10), + index: seasonEpisode.index, + confidence: "high", + }; + } + + const alt = name.match(/(\d{1,2})x(\d{1,3})/i); + if (alt && alt.index !== undefined) { + return { + season: Number.parseInt(alt[1], 10), + episode: Number.parseInt(alt[2], 10), + index: alt.index, + confidence: "high", + }; + } + + const epOnly = name.match(/(?:^|[\s._-])E(?:P)?(\d{1,3})(?:\b|[\s._-])/i); + if (epOnly && epOnly.index !== undefined) { + return { + season: null, + episode: Number.parseInt(epOnly[1], 10), + index: epOnly.index, + confidence: "medium", + }; + } + + const numeric = name.match(/(?:^|[-–—]\s*)(\d{1,3})\s*[-–—]/); + if (numeric && numeric.index !== undefined) { + return { + season: null, + episode: Number.parseInt(numeric[1], 10), + index: numeric.index, + confidence: "medium", + }; + } + + return { season: null, episode: null, index: null, confidence: "low" }; +} + +function detectSeasonFromDir(mediaPath: string): number | null { + const parent = path.basename(path.dirname(mediaPath)); + const match = parent.match(/(?:Season|S)\s*(\d{1,2})/i); + if (!match) return null; + const parsed = Number.parseInt(match[1], 10); + return Number.isFinite(parsed) ? parsed : null; +} + +function parseGuessitOutput( + mediaPath: string, + stdout: string, +): JimakuMediaInfo | null { + const payload = stdout.trim(); + if (!payload) return null; + + try { + const parsed = JSON.parse(payload) as { + title?: string; + title_original?: string; + series?: string; + season?: number | string; + episode?: number | string; + episode_list?: Array; + }; + const season = + typeof parsed.season === "number" + ? parsed.season + : typeof parsed.season === "string" + ? Number.parseInt(parsed.season, 10) + : null; + const directEpisode = + typeof parsed.episode === "number" + ? parsed.episode + : typeof parsed.episode === "string" + ? Number.parseInt(parsed.episode, 10) + : null; + const episodeFromList = + parsed.episode_list && parsed.episode_list.length > 0 + ? Number.parseInt(String(parsed.episode_list[0]), 10) + : null; + const episodeValue = + directEpisode !== null && Number.isFinite(directEpisode) + ? directEpisode + : episodeFromList; + const episode = + Number.isFinite(episodeValue as number) ? (episodeValue as number) : null; + const title = ( + parsed.title || + parsed.title_original || + parsed.series || + "" + ).trim(); + const hasStructuredData = + title.length > 0 || Number.isFinite(season as number) || Number.isFinite(episodeValue as number); + + if (!hasStructuredData) return null; + + return { + title: title || "", + season: Number.isFinite(season as number) ? season : detectSeasonFromDir(mediaPath), + episode: episode, + confidence: "high", + filename: path.basename(mediaPath), + rawTitle: path.basename(mediaPath).replace(/\.[^/.]+$/, ""), + }; + } catch { + return null; + } +} + +function parseMediaInfoWithGuessit(mediaPath: string): JimakuMediaInfo | null { + if (!commandExists("guessit")) return null; + + try { + const fileName = path.basename(mediaPath); + const result = spawnSync("guessit", ["--json", fileName], { + cwd: path.dirname(mediaPath), + encoding: "utf8", + maxBuffer: 2_000_000, + windowsHide: true, + }); + if (result.error || result.status !== 0) return null; + return parseGuessitOutput(mediaPath, result.stdout || ""); + } catch { + return null; + } +} + +function cleanupTitle(value: string): string { + return value + .replace(/^[\s-–—]+/, "") + .replace(/[\s-–—]+$/, "") + .replace(/\s+/g, " ") + .trim(); +} + +function formatLangScore(name: string, pref: JimakuLanguagePreference): number { + if (pref === "none") return 0; + const upper = name.toUpperCase(); + const hasJa = + /(^|[\W_])JA([\W_]|$)/.test(upper) || + /(^|[\W_])JPN([\W_]|$)/.test(upper) || + upper.includes(".JA."); + const hasEn = + /(^|[\W_])EN([\W_]|$)/.test(upper) || + /(^|[\W_])ENG([\W_]|$)/.test(upper) || + upper.includes(".EN."); + if (pref === "ja") { + if (hasJa) return 2; + if (hasEn) return 1; + } else if (pref === "en") { + if (hasEn) return 2; + if (hasJa) return 1; + } + return 0; +} + +export async function resolveJimakuApiKey(config: JimakuConfig): Promise { + if (config.apiKey && config.apiKey.trim()) { + return config.apiKey.trim(); + } + if (config.apiKeyCommand && config.apiKeyCommand.trim()) { + try { + const commandResult = spawnSync(config.apiKeyCommand, { + shell: true, + encoding: "utf8", + timeout: 10000, + }); + if (commandResult.error) return null; + const key = (commandResult.stdout || "").trim(); + return key.length > 0 ? key : null; + } catch { + return null; + } + } + return null; +} + +export function jimakuFetchJson( + endpoint: string, + query: Record, + options: { baseUrl: string; apiKey: string }, +): Promise> { + const url = new URL(endpoint, options.baseUrl); + for (const [key, value] of Object.entries(query)) { + if (value === null || value === undefined) continue; + url.searchParams.set(key, String(value)); + } + + return new Promise((resolve) => { + const requestUrl = new URL(url.toString()); + const transport = requestUrl.protocol === "https:" ? https : http; + const req = transport.request( + requestUrl, + { + method: "GET", + headers: { + Authorization: options.apiKey, + "User-Agent": "SubMiner", + }, + }, + (res) => { + let data = ""; + res.on("data", (chunk) => { + data += chunk.toString(); + }); + res.on("end", () => { + const status = res.statusCode || 0; + if (status >= 200 && status < 300) { + try { + const parsed = JSON.parse(data) as T; + resolve({ ok: true, data: parsed }); + } catch { + resolve({ + ok: false, + error: { error: "Failed to parse Jimaku response JSON." }, + }); + } + return; + } + + let errorMessage = `Jimaku API error (HTTP ${status})`; + try { + const parsed = JSON.parse(data) as { error?: string }; + if (parsed && parsed.error) { + errorMessage = parsed.error; + } + } catch { + // ignore parse errors + } + + resolve({ + ok: false, + error: { + error: errorMessage, + code: status || undefined, + retryAfter: + status === 429 ? getRetryAfter(res.headers) : undefined, + }, + }); + }); + }, + ); + + req.on("error", (error) => { + resolve({ + ok: false, + error: { error: `Jimaku request failed: ${(error as Error).message}` }, + }); + }); + + req.end(); + }); +} + +export function parseMediaInfo(mediaPath: string | null): JimakuMediaInfo { + if (!mediaPath) { + return { + title: "", + season: null, + episode: null, + confidence: "low", + filename: "", + rawTitle: "", + }; + } + + const guessitInfo = parseMediaInfoWithGuessit(mediaPath); + if (guessitInfo) return guessitInfo; + + const filename = path.basename(mediaPath); + let name = filename.replace(/\.[^/.]+$/, ""); + name = name.replace(/\[[^\]]*]/g, " "); + name = name.replace(/\(\d{4}\)/g, " "); + name = name.replace(/[._]/g, " "); + name = name.replace(/[–—]/g, "-"); + name = name.replace(/\s+/g, " ").trim(); + + const parsed = matchEpisodeFromName(name); + let titlePart = name; + if (parsed.index !== null) { + titlePart = name.slice(0, parsed.index); + } + + const seasonFromDir = parsed.season ?? detectSeasonFromDir(mediaPath); + const title = cleanupTitle(titlePart || name); + + return { + title, + season: seasonFromDir, + episode: parsed.episode, + confidence: parsed.confidence, + filename, + rawTitle: name, + }; +} + +export function sortJimakuFiles( + files: JimakuFileEntry[], + pref: JimakuLanguagePreference, +): JimakuFileEntry[] { + if (pref === "none") return files; + return [...files].sort((a, b) => { + const scoreDiff = formatLangScore(b.name, pref) - formatLangScore(a.name, pref); + if (scoreDiff !== 0) return scoreDiff; + return a.name.localeCompare(b.name); + }); +} + +export async function downloadToFile( + url: string, + destPath: string, + headers: Record, + redirectCount = 0, +): Promise { + if (redirectCount > 3) { + return { + ok: false, + error: { error: "Too many redirects while downloading subtitle." }, + }; + } + + return new Promise((resolve) => { + const parsedUrl = new URL(url); + const transport = parsedUrl.protocol === "https:" ? https : http; + + const req = transport.get(parsedUrl, { headers }, (res) => { + const status = res.statusCode || 0; + if ([301, 302, 303, 307, 308].includes(status) && res.headers.location) { + const redirectUrl = new URL(res.headers.location, parsedUrl).toString(); + res.resume(); + downloadToFile(redirectUrl, destPath, headers, redirectCount + 1).then( + resolve, + ); + return; + } + + if (status < 200 || status >= 300) { + res.resume(); + resolve({ + ok: false, + error: { + error: `Failed to download subtitle (HTTP ${status}).`, + code: status, + }, + }); + return; + } + + const fileStream = fs.createWriteStream(destPath); + res.pipe(fileStream); + fileStream.on("finish", () => { + fileStream.close(() => { + resolve({ ok: true, path: destPath }); + }); + }); + fileStream.on("error", (err: Error) => { + resolve({ + ok: false, + error: { error: `Failed to save subtitle: ${err.message}` }, + }); + }); + }); + + req.on("error", (err) => { + resolve({ + ok: false, + error: { + error: `Download request failed: ${(err as Error).message}`, + }, + }); + }); + }); +} + +export function isValidSubtitleCandidateFile(filename: string): boolean { + const ext = path.extname(filename).toLowerCase(); + return ( + ext === ".srt" || + ext === ".vtt" || + ext === ".ass" || + ext === ".ssa" || + ext === ".sub" + ); +} + +export function mapPreferenceToLanguages(preference: JimakuLanguagePreference): string[] { + if (preference === "en") return ["en", "eng"]; + if (preference === "none") return []; + return ["ja", "jpn"]; +} + +export function normalizeJimakuSearchInput(mediaPath: string): string { + const trimmed = (mediaPath || "").trim(); + if (!trimmed) return ""; + if (!/^https?:\/\/.*/.test(trimmed)) return trimmed; + + try { + const url = new URL(trimmed); + const titleParam = + url.searchParams.get("title") || url.searchParams.get("name") || + url.searchParams.get("q"); + if (titleParam && titleParam.trim()) return titleParam.trim(); + + const pathParts = url.pathname.split("/").filter(Boolean).reverse(); + const candidate = pathParts.find((part) => { + const decoded = decodeURIComponent(part || "").replace(/\.[^/.]+$/, ""); + const lowered = decoded.toLowerCase(); + return ( + lowered.length > 2 && + !/^[0-9.]+$/.test(lowered) && + !/^[a-f0-9]{16,}$/i.test(lowered) + ); + }); + + const fallback = candidate || url.hostname.replace(/^www\./, ""); + return sanitizeJimakuQueryInput(decodeURIComponent(fallback)); + } catch { + return trimmed; + } +} + +export function sanitizeJimakuQueryInput(value: string): string { + return value + .replace(/^\s*-\s*/, "") + .replace(/[^\w\s\-'".:(),]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +export function buildJimakuConfig(args: Args): { + apiKey: string; + apiKeyCommand: string; + apiBaseUrl: string; + languagePreference: JimakuLanguagePreference; + maxEntryResults: number; +} { + return { + apiKey: args.jimakuApiKey, + apiKeyCommand: args.jimakuApiKeyCommand, + apiBaseUrl: args.jimakuApiBaseUrl || DEFAULT_JIMAKU_API_BASE_URL, + languagePreference: args.jimakuLanguagePreference, + maxEntryResults: args.jimakuMaxEntryResults || 10, + }; +} diff --git a/launcher/picker.ts b/launcher/picker.ts new file mode 100644 index 0000000..2434bed --- /dev/null +++ b/launcher/picker.ts @@ -0,0 +1,551 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { spawnSync } from "node:child_process"; +import type { LogLevel, JellyfinSessionConfig, JellyfinLibraryEntry, JellyfinItemEntry, JellyfinGroupEntry } from "./types.js"; +import { VIDEO_EXTENSIONS, ROFI_THEME_FILE } from "./types.js"; +import { log, fail } from "./log.js"; +import { commandExists, realpathMaybe } from "./util.js"; + +export function escapeShellSingle(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +export function showRofiFlatMenu( + items: string[], + prompt: string, + initialQuery = "", + themePath: string | null = null, +): string { + const args = [ + "-dmenu", + "-i", + "-matching", + "fuzzy", + "-p", + prompt, + ]; + if (themePath) { + args.push("-theme", themePath); + } else { + args.push( + "-theme-str", + 'configuration { font: "Noto Sans CJK JP Regular 8";}', + ); + } + if (initialQuery.trim().length > 0) { + args.push("-filter", initialQuery.trim()); + } + const result = spawnSync( + "rofi", + args, + { + input: `${items.join("\n")}\n`, + encoding: "utf8", + stdio: ["pipe", "pipe", "ignore"], + }, + ); + if (result.error) { + fail(formatPickerLaunchError("rofi", result.error as NodeJS.ErrnoException)); + } + return (result.stdout || "").trim(); +} + +export function showFzfFlatMenu( + lines: string[], + prompt: string, + previewCommand: string, + initialQuery = "", +): string { + const args = [ + "--ansi", + "--reverse", + "--ignore-case", + `--prompt=${prompt}`, + "--delimiter=\t", + "--with-nth=2", + "--preview-window=right:50%:wrap", + "--preview", + previewCommand, + ]; + if (initialQuery.trim().length > 0) { + args.push("--query", initialQuery.trim()); + } + const result = spawnSync( + "fzf", + args, + { + input: `${lines.join("\n")}\n`, + encoding: "utf8", + stdio: ["pipe", "pipe", "inherit"], + }, + ); + if (result.error) { + fail(formatPickerLaunchError("fzf", result.error as NodeJS.ErrnoException)); + } + return (result.stdout || "").trim(); +} + +export function parseSelectionId(selection: string): string { + if (!selection) return ""; + const tab = selection.indexOf("\t"); + if (tab === -1) return ""; + return selection.slice(0, tab); +} + +export function parseSelectionLabel(selection: string): string { + const tab = selection.indexOf("\t"); + if (tab === -1) return selection; + return selection.slice(tab + 1); +} + +function fuzzySubsequenceMatch(haystack: string, needle: string): boolean { + if (!needle) return true; + let j = 0; + for (let i = 0; i < haystack.length && j < needle.length; i += 1) { + if (haystack[i] === needle[j]) j += 1; + } + return j === needle.length; +} + +function matchesMenuQuery(label: string, query: string): boolean { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return true; + const target = label.toLowerCase(); + const tokens = normalizedQuery.split(/\s+/).filter(Boolean); + if (tokens.length === 0) return true; + return tokens.every((token) => fuzzySubsequenceMatch(target, token)); +} + +export async function promptOptionalJellyfinSearch( + useRofi: boolean, + themePath: string | null = null, +): Promise { + if (useRofi && commandExists("rofi")) { + const rofiArgs = [ + "-dmenu", + "-i", + "-p", + "Jellyfin Search (optional)", + ]; + if (themePath) { + rofiArgs.push("-theme", themePath); + } else { + rofiArgs.push( + "-theme-str", + 'configuration { font: "Noto Sans CJK JP Regular 8";}', + ); + } + const result = spawnSync( + "rofi", + rofiArgs, + { + input: "\n", + encoding: "utf8", + stdio: ["pipe", "pipe", "ignore"], + }, + ); + if (result.error) return ""; + return (result.stdout || "").trim(); + } + + if (!process.stdin.isTTY || !process.stdout.isTTY) return ""; + + process.stdout.write("Jellyfin search term (optional, press Enter to skip): "); + const chunks: Buffer[] = []; + return await new Promise((resolve) => { + const onData = (data: Buffer) => { + const line = data.toString("utf8"); + if (line.includes("\n") || line.includes("\r")) { + chunks.push(Buffer.from(line, "utf8")); + process.stdin.off("data", onData); + const text = Buffer.concat(chunks).toString("utf8").trim(); + resolve(text); + return; + } + chunks.push(data); + }; + process.stdin.on("data", onData); + }); +} + +interface RofiIconEntry { + label: string; + iconPath?: string; +} + +function showRofiIconMenu( + entries: RofiIconEntry[], + prompt: string, + initialQuery = "", + themePath: string | null = null, +): number { + if (entries.length === 0) return -1; + const rofiArgs = ["-dmenu", "-i", "-show-icons", "-format", "i", "-p", prompt]; + if (initialQuery) rofiArgs.push("-filter", initialQuery); + if (themePath) { + rofiArgs.push("-theme", themePath); + } else { + rofiArgs.push( + "-theme-str", + 'configuration { font: "Noto Sans CJK JP Regular 8";}', + ); + } + + const lines = entries.map((entry) => + entry.iconPath + ? `${entry.label}\u0000icon\u001f${entry.iconPath}` + : entry.label + ); + const result = spawnSync( + "rofi", + rofiArgs, + { + input: `${lines.join("\n")}\n`, + encoding: "utf8", + stdio: ["pipe", "pipe", "ignore"], + }, + ); + if (result.error) return -1; + const out = (result.stdout || "").trim(); + if (!out) return -1; + const idx = Number.parseInt(out, 10); + return Number.isFinite(idx) ? idx : -1; +} + +export function pickLibrary( + session: JellyfinSessionConfig, + libraries: JellyfinLibraryEntry[], + useRofi: boolean, + ensureIcon: (session: JellyfinSessionConfig, id: string) => string | null, + initialQuery = "", + themePath: string | null = null, +): string { + const visibleLibraries = initialQuery.trim().length > 0 + ? libraries.filter((lib) => + matchesMenuQuery(`${lib.name} ${lib.kind}`, initialQuery) + ) + : libraries; + if (visibleLibraries.length === 0) fail("No Jellyfin libraries found."); + + if (useRofi) { + const entries = visibleLibraries.map((lib) => ({ + label: `${lib.name} [${lib.kind}]`, + iconPath: ensureIcon(session, lib.id) || undefined, + })); + const idx = showRofiIconMenu( + entries, + "Jellyfin Library", + initialQuery, + themePath, + ); + return idx >= 0 ? visibleLibraries[idx].id : ""; + } + + const lines = visibleLibraries.map( + (lib) => `${lib.id}\t${lib.name} [${lib.kind}]`, + ); + const preview = commandExists("chafa") && commandExists("curl") + ? ` +id={1} +url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)} +curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${"${FZF_PREVIEW_COLUMNS}"}x${"${FZF_PREVIEW_LINES}"} - 2>/dev/null +`.trim() + : 'echo "Install curl + chafa for image preview"'; + + const picked = showFzfFlatMenu( + lines, + "Jellyfin Library: ", + preview, + initialQuery, + ); + return parseSelectionId(picked); +} + +export function pickItem( + session: JellyfinSessionConfig, + items: JellyfinItemEntry[], + useRofi: boolean, + ensureIcon: (session: JellyfinSessionConfig, id: string) => string | null, + initialQuery = "", + themePath: string | null = null, +): string { + const visibleItems = initialQuery.trim().length > 0 + ? items.filter((item) => matchesMenuQuery(item.display, initialQuery)) + : items; + if (visibleItems.length === 0) fail("No playable Jellyfin items found."); + + if (useRofi) { + const entries = visibleItems.map((item) => ({ + label: item.display, + iconPath: ensureIcon(session, item.id) || undefined, + })); + const idx = showRofiIconMenu( + entries, + "Jellyfin Item", + initialQuery, + themePath, + ); + return idx >= 0 ? visibleItems[idx].id : ""; + } + + const lines = visibleItems.map((item) => `${item.id}\t${item.display}`); + const preview = commandExists("chafa") && commandExists("curl") + ? ` +id={1} +url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)} +curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${"${FZF_PREVIEW_COLUMNS}"}x${"${FZF_PREVIEW_LINES}"} - 2>/dev/null +`.trim() + : 'echo "Install curl + chafa for image preview"'; + + const picked = showFzfFlatMenu(lines, "Jellyfin Item: ", preview, initialQuery); + return parseSelectionId(picked); +} + +export function pickGroup( + session: JellyfinSessionConfig, + groups: JellyfinGroupEntry[], + useRofi: boolean, + ensureIcon: (session: JellyfinSessionConfig, id: string) => string | null, + initialQuery = "", + themePath: string | null = null, +): string { + const visibleGroups = initialQuery.trim().length > 0 + ? groups.filter((group) => matchesMenuQuery(group.display, initialQuery)) + : groups; + if (visibleGroups.length === 0) return ""; + + if (useRofi) { + const entries = visibleGroups.map((group) => ({ + label: group.display, + iconPath: ensureIcon(session, group.id) || undefined, + })); + const idx = showRofiIconMenu( + entries, + "Jellyfin Anime/Folder", + initialQuery, + themePath, + ); + return idx >= 0 ? visibleGroups[idx].id : ""; + } + + const lines = visibleGroups.map((group) => `${group.id}\t${group.display}`); + const preview = commandExists("chafa") && commandExists("curl") + ? ` +id={1} +url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)} +curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${"${FZF_PREVIEW_COLUMNS}"}x${"${FZF_PREVIEW_LINES}"} - 2>/dev/null +`.trim() + : 'echo "Install curl + chafa for image preview"'; + + const picked = showFzfFlatMenu( + lines, + "Jellyfin Anime/Folder: ", + preview, + initialQuery, + ); + return parseSelectionId(picked); +} + +export 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}`; +} + +export 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" }), + ); +} + +export 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); +} + +export 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; +} + +export 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); +} + +export function buildFzfMenu(videos: string[]): string { + return videos.map((video) => `${path.basename(video)}\t${video}`).join("\n"); +} + +export 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); +}