mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
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:
695
launcher/config.ts
Normal file
695
launcher/config.ts
Normal 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
524
launcher/jimaku.ts
Normal 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
551
launcher/picker.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user