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
This commit is contained in:
2026-02-17 03:27:15 -08:00
parent 518015f534
commit b4df3f8295
3 changed files with 1770 additions and 0 deletions

695
launcher/config.ts Normal file
View File

@@ -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<string, unknown>;
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;
}

524
launcher/jimaku.ts Normal file
View File

@@ -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<T> =
| { 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<number | string>;
};
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<string | null> {
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<T>(
endpoint: string,
query: Record<string, string | number | boolean | null | undefined>,
options: { baseUrl: string; apiKey: string },
): Promise<JimakuApiResponse<T>> {
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<string, string>,
redirectCount = 0,
): Promise<JimakuDownloadResult> {
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,
};
}

551
launcher/picker.ts Normal file
View File

@@ -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<string> {
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<string>((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);
}