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