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); rofiArgs.push("-theme-str", "configuration { show-icons: true; }"); rofiArgs.push("-theme-str", "element-icon { enabled: true; size: 3em; }"); } else { rofiArgs.push( "-theme-str", 'configuration { font: "Noto Sans CJK JP Regular 8"; show-icons: true; }', ); rofiArgs.push("-theme-str", "element-icon { enabled: true; size: 3em; }"); } const lines = entries.map((entry) => entry.iconPath ? `${entry.label}\u0000icon\u001f${entry.iconPath}` : entry.label ); const input = Buffer.from(`${lines.join("\n")}\n`, "utf8"); const result = spawnSync( "rofi", rofiArgs, { input, 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); }