This commit is contained in:
2026-02-17 22:50:57 -08:00
parent ffeef9c136
commit f20d019c11
315 changed files with 9876 additions and 12537 deletions

View File

@@ -1,40 +1,44 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { Command } from "commander";
import { parse as parseJsonc } from "jsonc-parser";
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { Command } from 'commander';
import { parse as parseJsonc } from 'jsonc-parser';
import type {
LogLevel, YoutubeSubgenMode, Backend, Args,
LauncherYoutubeSubgenConfig, LauncherJellyfinConfig, PluginRuntimeConfig,
} from "./types.js";
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_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";
} from './types.js';
import { log, fail } from './log.js';
import {
resolvePathMaybe, isUrlTarget, uniqueNormalizedLangCodes, parseBoolLike,
resolvePathMaybe,
isUrlTarget,
uniqueNormalizedLangCodes,
parseBoolLike,
inferWhisperLanguage,
} from "./util.js";
} from './util.js';
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
: "";
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 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 };
@@ -42,83 +46,70 @@ export function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig {
};
const youtubeSubgen = root.youtubeSubgen;
const mode =
youtubeSubgen && typeof youtubeSubgen === "object"
youtubeSubgen && typeof youtubeSubgen === 'object'
? (youtubeSubgen as { mode?: unknown }).mode
: undefined;
const whisperBin =
youtubeSubgen && typeof youtubeSubgen === "object"
youtubeSubgen && typeof youtubeSubgen === 'object'
? (youtubeSubgen as { whisperBin?: unknown }).whisperBin
: undefined;
const whisperModel =
youtubeSubgen && typeof youtubeSubgen === "object"
youtubeSubgen && typeof youtubeSubgen === 'object'
? (youtubeSubgen as { whisperModel?: unknown }).whisperModel
: undefined;
const primarySubLanguagesRaw =
youtubeSubgen && typeof youtubeSubgen === "object"
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",
)
? primarySubLanguagesRaw.filter((value): value is string => typeof value === 'string')
: undefined;
const secondarySubLanguages = Array.isArray(secondarySubLanguagesRaw)
? secondarySubLanguagesRaw.filter(
(value): value is string => typeof value === "string",
)
? 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;
jimaku && typeof jimaku === 'object' ? (jimaku as { apiKey?: unknown }).apiKey : undefined;
const jimakuApiKeyCommand =
jimaku && typeof jimaku === "object"
jimaku && typeof jimaku === 'object'
? (jimaku as { apiKeyCommand?: unknown }).apiKeyCommand
: undefined;
const jimakuApiBaseUrl =
jimaku && typeof jimaku === "object"
jimaku && typeof jimaku === 'object'
? (jimaku as { apiBaseUrl?: unknown }).apiBaseUrl
: undefined;
const jimakuLanguagePreference = jimaku && typeof jimaku === "object"
? (jimaku as { languagePreference?: unknown }).languagePreference
: undefined;
const jimakuLanguagePreference =
jimaku && typeof jimaku === 'object'
? (jimaku as { languagePreference?: unknown }).languagePreference
: undefined;
const jimakuMaxEntryResults =
jimaku && typeof jimaku === "object"
jimaku && typeof jimaku === 'object'
? (jimaku as { maxEntryResults?: unknown }).maxEntryResults
: undefined;
const resolvedJimakuLanguagePreference =
jimakuLanguagePreference === "ja" ||
jimakuLanguagePreference === "en" ||
jimakuLanguagePreference === "none"
jimakuLanguagePreference === 'ja' ||
jimakuLanguagePreference === 'en' ||
jimakuLanguagePreference === 'none'
? jimakuLanguagePreference
: undefined;
const resolvedJimakuMaxEntryResults =
typeof jimakuMaxEntryResults === "number" &&
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,
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,
jimakuApiKey: typeof jimakuApiKey === 'string' ? jimakuApiKey : undefined,
jimakuApiKeyCommand:
typeof jimakuApiKeyCommand === "string"
? jimakuApiKeyCommand
: undefined,
jimakuApiBaseUrl:
typeof jimakuApiBaseUrl === "string"
? jimakuApiBaseUrl
: undefined,
typeof jimakuApiKeyCommand === 'string' ? jimakuApiKeyCommand : undefined,
jimakuApiBaseUrl: typeof jimakuApiBaseUrl === 'string' ? jimakuApiBaseUrl : undefined,
jimakuLanguagePreference: resolvedJimakuLanguagePreference,
jimakuMaxEntryResults: resolvedJimakuMaxEntryResults,
};
@@ -128,48 +119,29 @@ export function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig {
}
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
: "";
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 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 {};
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,
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,
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 {};
@@ -177,12 +149,11 @@ export function loadLauncherJellyfinConfig(): LauncherJellyfinConfig {
}
function getPluginConfigCandidates(): string[] {
const xdgConfigHome =
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
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"),
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
path.join(os.homedir(), '.config', 'mpv', 'script-opts', 'subminer.conf'),
]),
);
}
@@ -197,14 +168,14 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
for (const configPath of candidates) {
if (!fs.existsSync(configPath)) continue;
try {
const content = fs.readFileSync(configPath, "utf8");
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;
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 value = (autoStartMatch[1] || '').split('#', 1)[0]?.trim() || '';
const parsed = parseBoolLike(value);
if (parsed !== null) {
runtimeConfig.autoStartOverlay = parsed;
@@ -214,28 +185,24 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
const socketMatch = trimmed.match(/^socket_path\s*=\s*(.+)$/i);
if (socketMatch) {
const value = (socketMatch[1] || "").split("#", 1)[0]?.trim() || "";
const value = (socketMatch[1] || '').split('#', 1)[0]?.trim() || '';
if (value) runtimeConfig.socketPath = value;
}
}
log(
"debug",
'debug',
logLevel,
`Using mpv plugin settings from ${configPath}: auto_start=${runtimeConfig.autoStartOverlay ? "yes" : "no"} socket_path=${runtimeConfig.socketPath}`,
`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`,
);
log('warn', logLevel, `Failed to read ${configPath}; using launcher defaults`);
return runtimeConfig;
}
}
log(
"debug",
'debug',
logLevel,
`No mpv subminer.conf found; using launcher defaults (auto_start=no socket_path=${runtimeConfig.socketPath})`,
);
@@ -245,13 +212,13 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
function ensureTarget(target: string, parsed: Args): void {
if (isUrlTarget(target)) {
parsed.target = target;
parsed.targetKind = "url";
parsed.targetKind = 'url';
return;
}
const resolved = resolvePathMaybe(target);
if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
parsed.target = resolved;
parsed.targetKind = "file";
parsed.targetKind = 'file';
return;
}
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
@@ -262,7 +229,7 @@ function ensureTarget(target: string, parsed: Args): void {
}
function parseLogLevel(value: string): LogLevel {
if (value === "debug" || value === "info" || value === "warn" || value === "error") {
if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') {
return value;
}
fail(`Invalid log level: ${value} (must be debug, info, warn, or error)`);
@@ -270,14 +237,14 @@ function parseLogLevel(value: string): LogLevel {
function parseYoutubeMode(value: string): YoutubeSubgenMode {
const normalized = value.toLowerCase();
if (normalized === "automatic" || normalized === "preprocess" || normalized === "off") {
if (normalized === 'automatic' || normalized === 'preprocess' || normalized === 'off') {
return normalized as YoutubeSubgenMode;
}
fail(`Invalid yt-subgen mode: ${value} (must be automatic, preprocess, or off)`);
}
function parseBackend(value: string): Backend {
if (value === "auto" || value === "hyprland" || value === "x11" || value === "macos") {
if (value === 'auto' || value === 'hyprland' || value === 'x11' || value === 'macos') {
return value as Backend;
}
fail(`Invalid backend: ${value} (must be auto, hyprland, x11, or macos)`);
@@ -285,42 +252,42 @@ function parseBackend(value: string): Backend {
function applyRootOptions(program: Command): void {
program
.option("-b, --backend <backend>", "Display backend")
.option("-d, --directory <dir>", "Directory to browse")
.option("-r, --recursive", "Search directories recursively")
.option("-p, --profile <profile>", "MPV profile")
.option("--start", "Explicitly start overlay")
.option("--log-level <level>", "Log level")
.option("-R, --rofi", "Use rofi picker")
.option("-S, --start-overlay", "Auto-start overlay")
.option("-T, --no-texthooker", "Disable texthooker-ui server");
.option('-b, --backend <backend>', 'Display backend')
.option('-d, --directory <dir>', 'Directory to browse')
.option('-r, --recursive', 'Search directories recursively')
.option('-p, --profile <profile>', 'MPV profile')
.option('--start', 'Explicitly start overlay')
.option('--log-level <level>', 'Log level')
.option('-R, --rofi', 'Use rofi picker')
.option('-S, --start-overlay', 'Auto-start overlay')
.option('-T, --no-texthooker', 'Disable texthooker-ui server');
}
function hasTopLevelCommand(argv: string[]): boolean {
const commandNames = new Set([
"jellyfin",
"jf",
"yt",
"youtube",
"doctor",
"config",
"mpv",
"texthooker",
"help",
'jellyfin',
'jf',
'yt',
'youtube',
'doctor',
'config',
'mpv',
'texthooker',
'help',
]);
const optionsWithValue = new Set([
"-b",
"--backend",
"-d",
"--directory",
"-p",
"--profile",
"--log-level",
'-b',
'--backend',
'-d',
'--directory',
'-p',
'--profile',
'--log-level',
]);
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i] || "";
if (token === "--") return false;
if (token.startsWith("-")) {
const token = argv[i] || '';
if (token === '--') return false;
if (token.startsWith('-')) {
if (optionsWithValue.has(token)) {
i += 1;
}
@@ -336,13 +303,13 @@ export function parseArgs(
scriptName: string,
launcherConfig: LauncherYoutubeSubgenConfig,
): Args {
const envMode = (process.env.SUBMINER_YT_SUBGEN_MODE || "").toLowerCase();
const envMode = (process.env.SUBMINER_YT_SUBGEN_MODE || '').toLowerCase();
const defaultMode: YoutubeSubgenMode =
envMode === "preprocess" || envMode === "off" || envMode === "automatic"
envMode === 'preprocess' || envMode === 'off' || envMode === 'automatic'
? (envMode as YoutubeSubgenMode)
: launcherConfig.mode
? launcherConfig.mode
: "automatic";
: 'automatic';
const configuredSecondaryLangs = uniqueNormalizedLangCodes(
launcherConfig.secondarySubLanguages ?? [],
);
@@ -357,30 +324,23 @@ export function parseArgs(
configuredSecondaryLangs.length > 0
? configuredSecondaryLangs
: [...DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS];
const youtubeAudioLangs = uniqueNormalizedLangCodes([
...primarySubLangs,
...secondarySubLangs,
]);
const youtubeAudioLangs = uniqueNormalizedLangCodes([...primarySubLangs, ...secondarySubLangs]);
const parsed: Args = {
backend: "auto",
directory: ".",
backend: 'auto',
directory: '.',
recursive: false,
profile: "subminer",
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",
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,
@@ -393,58 +353,53 @@ export function parseArgs(
mpvIdle: false,
mpvSocket: false,
mpvStatus: false,
jellyfinServer: "",
jellyfinUsername: "",
jellyfinPassword: "",
jellyfinServer: '',
jellyfinUsername: '',
jellyfinPassword: '',
youtubePrimarySubLangs: primarySubLangs,
youtubeSecondarySubLangs: secondarySubLangs,
youtubeAudioLangs,
youtubeWhisperSourceLanguage: inferWhisperLanguage(primarySubLangs, "ja"),
youtubeWhisperSourceLanguage: inferWhisperLanguage(primarySubLangs, 'ja'),
useTexthooker: true,
autoStartOverlay: false,
texthookerOnly: false,
useRofi: false,
logLevel: "info",
target: "",
targetKind: "",
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.jimakuApiBaseUrl) parsed.jimakuApiBaseUrl = launcherConfig.jimakuApiBaseUrl;
if (launcherConfig.jimakuLanguagePreference)
parsed.jimakuLanguagePreference = launcherConfig.jimakuLanguagePreference;
if (launcherConfig.jimakuMaxEntryResults !== undefined)
parsed.jimakuMaxEntryResults = launcherConfig.jimakuMaxEntryResults;
let jellyfinInvocation:
| {
action?: string;
discovery?: boolean;
play?: boolean;
login?: boolean;
logout?: boolean;
setup?: boolean;
server?: string;
username?: string;
password?: string;
logLevel?: string;
}
| null = null;
let ytInvocation:
| {
target?: string;
mode?: string;
outDir?: string;
keepTemp?: boolean;
whisperBin?: string;
whisperModel?: string;
ytSubgenAudioFormat?: string;
logLevel?: string;
}
| null = null;
let jellyfinInvocation: {
action?: string;
discovery?: boolean;
play?: boolean;
login?: boolean;
logout?: boolean;
setup?: boolean;
server?: string;
username?: string;
password?: string;
logLevel?: string;
} | null = null;
let ytInvocation: {
target?: string;
mode?: string;
outDir?: string;
keepTemp?: boolean;
whisperBin?: string;
whisperModel?: string;
ytSubgenAudioFormat?: string;
logLevel?: string;
} | null = null;
let configInvocation: { action: string; logLevel?: string } | null = null;
let mpvInvocation: { action: string; logLevel?: string } | null = null;
let doctorLogLevel: string | null = null;
@@ -453,7 +408,7 @@ export function parseArgs(
const commandProgram = new Command();
commandProgram
.name(scriptName)
.description("Launch MPV with SubMiner sentence mining overlay")
.description('Launch MPV with SubMiner sentence mining overlay')
.showHelpAfterError(true)
.enablePositionalOptions()
.allowExcessArguments(false)
@@ -464,29 +419,29 @@ export function parseArgs(
const rootProgram = new Command();
rootProgram
.name(scriptName)
.description("Launch MPV with SubMiner sentence mining overlay")
.usage("[options] [command] [target]")
.description('Launch MPV with SubMiner sentence mining overlay')
.usage('[options] [command] [target]')
.showHelpAfterError(true)
.allowExcessArguments(false)
.allowUnknownOption(false)
.exitOverride()
.argument("[target]", "file, directory, or URL");
.argument('[target]', 'file, directory, or URL');
applyRootOptions(rootProgram);
commandProgram
.command("jellyfin")
.alias("jf")
.description("Jellyfin workflows")
.argument("[action]", "setup|discovery|play|login|logout")
.option("-d, --discovery", "Cast discovery mode")
.option("-p, --play", "Interactive play picker")
.option("-l, --login", "Login flow")
.option("--logout", "Clear token/session")
.option("--setup", "Open setup window")
.option("-s, --server <url>", "Jellyfin server URL")
.option("-u, --username <name>", "Jellyfin username")
.option("-w, --password <pass>", "Jellyfin password")
.option("--log-level <level>", "Log level")
.command('jellyfin')
.alias('jf')
.description('Jellyfin workflows')
.argument('[action]', 'setup|discovery|play|login|logout')
.option('-d, --discovery', 'Cast discovery mode')
.option('-p, --play', 'Interactive play picker')
.option('-l, --login', 'Login flow')
.option('--logout', 'Clear token/session')
.option('--setup', 'Open setup window')
.option('-s, --server <url>', 'Jellyfin server URL')
.option('-u, --username <name>', 'Jellyfin username')
.option('-w, --password <pass>', 'Jellyfin password')
.option('--log-level <level>', 'Log level')
.action((action: string | undefined, options: Record<string, unknown>) => {
jellyfinInvocation = {
action,
@@ -495,115 +450,105 @@ export function parseArgs(
login: options.login === true,
logout: options.logout === true,
setup: options.setup === true,
server: typeof options.server === "string" ? options.server : undefined,
username: typeof options.username === "string" ? options.username : undefined,
password: typeof options.password === "string" ? options.password : undefined,
logLevel:
typeof options.logLevel === "string" ? options.logLevel : undefined,
server: typeof options.server === 'string' ? options.server : undefined,
username: typeof options.username === 'string' ? options.username : undefined,
password: typeof options.password === 'string' ? options.password : undefined,
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
};
});
commandProgram
.command("yt")
.alias("youtube")
.description("YouTube workflows")
.argument("[target]", "YouTube URL or ytsearch: query")
.option("-m, --mode <mode>", "Subtitle generation mode")
.option("-o, --out-dir <dir>", "Subtitle output dir")
.option("--keep-temp", "Keep temp files")
.option("--whisper-bin <path>", "whisper.cpp CLI path")
.option("--whisper-model <path>", "whisper model path")
.option("--yt-subgen-audio-format <format>", "Audio extraction format")
.option("--log-level <level>", "Log level")
.command('yt')
.alias('youtube')
.description('YouTube workflows')
.argument('[target]', 'YouTube URL or ytsearch: query')
.option('-m, --mode <mode>', 'Subtitle generation mode')
.option('-o, --out-dir <dir>', 'Subtitle output dir')
.option('--keep-temp', 'Keep temp files')
.option('--whisper-bin <path>', 'whisper.cpp CLI path')
.option('--whisper-model <path>', 'whisper model path')
.option('--yt-subgen-audio-format <format>', 'Audio extraction format')
.option('--log-level <level>', 'Log level')
.action((target: string | undefined, options: Record<string, unknown>) => {
ytInvocation = {
target,
mode: typeof options.mode === "string" ? options.mode : undefined,
outDir: typeof options.outDir === "string" ? options.outDir : undefined,
mode: typeof options.mode === 'string' ? options.mode : undefined,
outDir: typeof options.outDir === 'string' ? options.outDir : undefined,
keepTemp: options.keepTemp === true,
whisperBin:
typeof options.whisperBin === "string" ? options.whisperBin : undefined,
whisperModel:
typeof options.whisperModel === "string" ? options.whisperModel : undefined,
whisperBin: typeof options.whisperBin === 'string' ? options.whisperBin : undefined,
whisperModel: typeof options.whisperModel === 'string' ? options.whisperModel : undefined,
ytSubgenAudioFormat:
typeof options.ytSubgenAudioFormat === "string"
? options.ytSubgenAudioFormat
: undefined,
logLevel:
typeof options.logLevel === "string" ? options.logLevel : undefined,
typeof options.ytSubgenAudioFormat === 'string' ? options.ytSubgenAudioFormat : undefined,
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
};
});
commandProgram
.command("doctor")
.description("Run dependency and environment checks")
.option("--log-level <level>", "Log level")
.command('doctor')
.description('Run dependency and environment checks')
.option('--log-level <level>', 'Log level')
.action((options: Record<string, unknown>) => {
parsed.doctor = true;
doctorLogLevel =
typeof options.logLevel === "string" ? options.logLevel : null;
doctorLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
});
commandProgram
.command("config")
.description("Config helpers")
.argument("[action]", "path|show", "path")
.option("--log-level <level>", "Log level")
.command('config')
.description('Config helpers')
.argument('[action]', 'path|show', 'path')
.option('--log-level <level>', 'Log level')
.action((action: string, options: Record<string, unknown>) => {
configInvocation = {
action,
logLevel:
typeof options.logLevel === "string" ? options.logLevel : undefined,
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
};
});
commandProgram
.command("mpv")
.description("MPV helpers")
.argument("[action]", "status|socket|idle", "status")
.option("--log-level <level>", "Log level")
.command('mpv')
.description('MPV helpers')
.argument('[action]', 'status|socket|idle', 'status')
.option('--log-level <level>', 'Log level')
.action((action: string, options: Record<string, unknown>) => {
mpvInvocation = {
action,
logLevel:
typeof options.logLevel === "string" ? options.logLevel : undefined,
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
};
});
commandProgram
.command("texthooker")
.description("Launch texthooker-only mode")
.option("--log-level <level>", "Log level")
.command('texthooker')
.description('Launch texthooker-only mode')
.option('--log-level <level>', 'Log level')
.action((options: Record<string, unknown>) => {
parsed.texthookerOnly = true;
texthookerLogLevel =
typeof options.logLevel === "string" ? options.logLevel : null;
texthookerLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
});
const selectedProgram = hasTopLevelCommand(argv) ? commandProgram : rootProgram;
try {
selectedProgram.parse(["node", scriptName, ...argv]);
selectedProgram.parse(['node', scriptName, ...argv]);
} catch (error) {
const commanderError = error as { code?: string; message?: string };
if (commanderError?.code === "commander.helpDisplayed") {
if (commanderError?.code === 'commander.helpDisplayed') {
process.exit(0);
}
fail(commanderError?.message || String(error));
}
const options = selectedProgram.opts<Record<string, unknown>>();
if (typeof options.backend === "string") {
if (typeof options.backend === 'string') {
parsed.backend = parseBackend(options.backend);
}
if (typeof options.directory === "string") {
if (typeof options.directory === 'string') {
parsed.directory = options.directory;
}
if (options.recursive === true) parsed.recursive = true;
if (typeof options.profile === "string") {
if (typeof options.profile === 'string') {
parsed.profile = options.profile;
}
if (options.start === true) parsed.startOverlay = true;
if (typeof options.logLevel === "string") {
if (typeof options.logLevel === 'string') {
parsed.logLevel = parseLogLevel(options.logLevel);
}
if (options.rofi === true) parsed.useRofi = true;
@@ -611,7 +556,7 @@ export function parseArgs(
if (options.texthooker === false) parsed.useTexthooker = false;
const rootTarget = rootProgram.processedArgs[0];
if (typeof rootTarget === "string" && rootTarget) {
if (typeof rootTarget === 'string' && rootTarget) {
ensureTarget(rootTarget, parsed);
}
@@ -619,23 +564,29 @@ export function parseArgs(
if (jellyfinInvocation.logLevel) {
parsed.logLevel = parseLogLevel(jellyfinInvocation.logLevel);
}
const action = (jellyfinInvocation.action || "").toLowerCase();
if (action && !["setup", "discovery", "play", "login", "logout"].includes(action)) {
const action = (jellyfinInvocation.action || '').toLowerCase();
if (action && !['setup', 'discovery', 'play', 'login', 'logout'].includes(action)) {
fail(`Unknown jellyfin action: ${jellyfinInvocation.action}`);
}
parsed.jellyfinServer = jellyfinInvocation.server || "";
parsed.jellyfinUsername = jellyfinInvocation.username || "";
parsed.jellyfinPassword = jellyfinInvocation.password || "";
parsed.jellyfinServer = jellyfinInvocation.server || '';
parsed.jellyfinUsername = jellyfinInvocation.username || '';
parsed.jellyfinPassword = jellyfinInvocation.password || '';
const modeFlags = {
setup: jellyfinInvocation.setup || action === "setup",
discovery: jellyfinInvocation.discovery || action === "discovery",
play: jellyfinInvocation.play || action === "play",
login: jellyfinInvocation.login || action === "login",
logout: jellyfinInvocation.logout || action === "logout",
setup: jellyfinInvocation.setup || action === 'setup',
discovery: jellyfinInvocation.discovery || action === 'discovery',
play: jellyfinInvocation.play || action === 'play',
login: jellyfinInvocation.login || action === 'login',
logout: jellyfinInvocation.logout || action === 'logout',
};
if (!modeFlags.setup && !modeFlags.discovery && !modeFlags.play && !modeFlags.login && !modeFlags.logout) {
if (
!modeFlags.setup &&
!modeFlags.discovery &&
!modeFlags.play &&
!modeFlags.login &&
!modeFlags.logout
) {
modeFlags.setup = true;
}
@@ -679,9 +630,9 @@ export function parseArgs(
if (configInvocation.logLevel) {
parsed.logLevel = parseLogLevel(configInvocation.logLevel);
}
const action = (configInvocation.action || "path").toLowerCase();
if (action === "path") parsed.configPath = true;
else if (action === "show") parsed.configShow = true;
const action = (configInvocation.action || 'path').toLowerCase();
if (action === 'path') parsed.configPath = true;
else if (action === 'show') parsed.configShow = true;
else fail(`Unknown config action: ${configInvocation.action}`);
}
@@ -689,10 +640,10 @@ export function parseArgs(
if (mpvInvocation.logLevel) {
parsed.logLevel = parseLogLevel(mpvInvocation.logLevel);
}
const action = (mpvInvocation.action || "status").toLowerCase();
if (action === "status") parsed.mpvStatus = true;
else if (action === "socket") parsed.mpvSocket = true;
else if (action === "idle" || action === "start") parsed.mpvIdle = true;
const action = (mpvInvocation.action || 'status').toLowerCase();
if (action === 'status') parsed.mpvStatus = true;
else if (action === 'socket') parsed.mpvSocket = true;
else if (action === 'idle' || action === 'start') parsed.mpvIdle = true;
else fail(`Unknown mpv action: ${mpvInvocation.action}`);
}

View File

@@ -1,20 +1,31 @@
import path from "node:path";
import fs from "node:fs";
import { spawnSync } from "node:child_process";
import type { Args, JellyfinSessionConfig, JellyfinLibraryEntry, JellyfinItemEntry, JellyfinGroupEntry } from "./types.js";
import { log, fail } from "./log.js";
import { commandExists, resolvePathMaybe } from "./util.js";
import path from 'node:path';
import fs from 'node:fs';
import { spawnSync } from 'node:child_process';
import type {
Args,
JellyfinSessionConfig,
JellyfinLibraryEntry,
JellyfinItemEntry,
JellyfinGroupEntry,
} from './types.js';
import { log, fail } from './log.js';
import { commandExists, resolvePathMaybe } from './util.js';
import {
pickLibrary, pickItem, pickGroup, promptOptionalJellyfinSearch,
pickLibrary,
pickItem,
pickGroup,
promptOptionalJellyfinSearch,
findRofiTheme,
} from "./picker.js";
import { loadLauncherJellyfinConfig } from "./config.js";
} from './picker.js';
import { loadLauncherJellyfinConfig } from './config.js';
import {
runAppCommandWithInheritLogged, launchMpvIdleDetached, waitForUnixSocketReady,
} from "./mpv.js";
runAppCommandWithInheritLogged,
launchMpvIdleDetached,
waitForUnixSocketReady,
} from './mpv.js';
export function sanitizeServerUrl(value: string): string {
return value.trim().replace(/\/+$/, "");
return value.trim().replace(/\/+$/, '');
}
export async function jellyfinApiRequest<T>(
@@ -24,12 +35,12 @@ export async function jellyfinApiRequest<T>(
const url = `${session.serverUrl}${requestPath}`;
const response = await fetch(url, {
headers: {
"X-Emby-Token": session.accessToken,
'X-Emby-Token': session.accessToken,
Authorization: `MediaBrowser Token="${session.accessToken}"`,
},
});
if (response.status === 401 || response.status === 403) {
fail("Jellyfin token invalid/expired. Run --jellyfin-login or --jellyfin.");
fail('Jellyfin token invalid/expired. Run --jellyfin-login or --jellyfin.');
}
if (!response.ok) {
fail(`Jellyfin API failed: ${response.status} ${response.statusText}`);
@@ -42,24 +53,21 @@ function itemPreviewUrl(session: JellyfinSessionConfig, id: string): string {
}
function jellyfinIconCacheDir(session: JellyfinSessionConfig): string {
const serverKey = session.serverUrl.replace(/[^a-zA-Z0-9]+/g, "_").slice(0, 96);
const userKey = session.userId.replace(/[^a-zA-Z0-9]+/g, "_").slice(0, 96);
const serverKey = session.serverUrl.replace(/[^a-zA-Z0-9]+/g, '_').slice(0, 96);
const userKey = session.userId.replace(/[^a-zA-Z0-9]+/g, '_').slice(0, 96);
const baseDir = session.iconCacheDir
? resolvePathMaybe(session.iconCacheDir)
: path.join("/tmp", "subminer-jellyfin-icons");
: path.join('/tmp', 'subminer-jellyfin-icons');
return path.join(baseDir, serverKey, userKey);
}
function jellyfinIconPath(session: JellyfinSessionConfig, id: string): string {
const safeId = id.replace(/[^a-zA-Z0-9._-]+/g, "_");
const safeId = id.replace(/[^a-zA-Z0-9._-]+/g, '_');
return path.join(jellyfinIconCacheDir(session), `${safeId}.jpg`);
}
function ensureJellyfinIcon(
session: JellyfinSessionConfig,
id: string,
): string | null {
if (!session.pullPictures || !id || !commandExists("curl")) return null;
function ensureJellyfinIcon(session: JellyfinSessionConfig, id: string): string | null {
if (!session.pullPictures || !id || !commandExists('curl')) return null;
const iconPath = jellyfinIconPath(session, id);
try {
if (fs.existsSync(iconPath) && fs.statSync(iconPath).size > 0) {
@@ -75,11 +83,9 @@ function ensureJellyfinIcon(
return null;
}
const result = spawnSync(
"curl",
["-fsSL", "-o", iconPath, itemPreviewUrl(session, id)],
{ stdio: "ignore" },
);
const result = spawnSync('curl', ['-fsSL', '-o', iconPath, itemPreviewUrl(session, id)], {
stdio: 'ignore',
});
if (result.error || result.status !== 0) return null;
try {
@@ -93,18 +99,16 @@ function ensureJellyfinIcon(
}
export function formatJellyfinItemDisplay(item: Record<string, unknown>): string {
const type = typeof item.Type === "string" ? item.Type : "Item";
const name = typeof item.Name === "string" ? item.Name : "Untitled";
if (type === "Episode") {
const series = typeof item.SeriesName === "string" ? item.SeriesName : "";
const type = typeof item.Type === 'string' ? item.Type : 'Item';
const name = typeof item.Name === 'string' ? item.Name : 'Untitled';
if (type === 'Episode') {
const series = typeof item.SeriesName === 'string' ? item.SeriesName : '';
const season =
typeof item.ParentIndexNumber === "number"
? String(item.ParentIndexNumber).padStart(2, "0")
: "00";
typeof item.ParentIndexNumber === 'number'
? String(item.ParentIndexNumber).padStart(2, '0')
: '00';
const episode =
typeof item.IndexNumber === "number"
? String(item.IndexNumber).padStart(2, "0")
: "00";
typeof item.IndexNumber === 'number' ? String(item.IndexNumber).padStart(2, '0') : '00';
return `${series} S${season}E${episode} ${name}`.trim();
}
return `${name} (${type})`;
@@ -116,11 +120,11 @@ export async function resolveJellyfinSelection(
themePath: string | null = null,
): Promise<string> {
const asNumberOrNull = (value: unknown): number | null => {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
return value;
};
const compareByName = (left: string, right: string): number =>
left.localeCompare(right, undefined, { sensitivity: "base", numeric: true });
left.localeCompare(right, undefined, { sensitivity: 'base', numeric: true });
const sortEntries = (
entries: Array<{
type: string;
@@ -131,7 +135,7 @@ export async function resolveJellyfinSelection(
}>,
) =>
entries.sort((left, right) => {
if (left.type === "Episode" && right.type === "Episode") {
if (left.type === 'Episode' && right.type === 'Episode') {
const leftSeason = left.parentIndex ?? Number.MAX_SAFE_INTEGER;
const rightSeason = right.parentIndex ?? Number.MAX_SAFE_INTEGER;
if (leftSeason !== rightSeason) return leftSeason - rightSeason;
@@ -140,8 +144,8 @@ export async function resolveJellyfinSelection(
if (leftEpisode !== rightEpisode) return leftEpisode - rightEpisode;
}
if (left.type !== right.type) {
const leftEpisodeLike = left.type === "Episode";
const rightEpisodeLike = right.type === "Episode";
const leftEpisodeLike = left.type === 'Episode';
const rightEpisodeLike = right.type === 'Episode';
if (leftEpisodeLike && !rightEpisodeLike) return -1;
if (!leftEpisodeLike && rightEpisodeLike) return 1;
}
@@ -154,28 +158,21 @@ export async function resolveJellyfinSelection(
);
const libraries: JellyfinLibraryEntry[] = (libsPayload.Items || [])
.map((item) => ({
id: typeof item.Id === "string" ? item.Id : "",
name: typeof item.Name === "string" ? item.Name : "Untitled",
id: typeof item.Id === 'string' ? item.Id : '',
name: typeof item.Name === 'string' ? item.Name : 'Untitled',
kind:
typeof item.CollectionType === "string"
typeof item.CollectionType === 'string'
? item.CollectionType
: typeof item.Type === "string"
: typeof item.Type === 'string'
? item.Type
: "unknown",
: 'unknown',
}))
.filter((item) => item.id.length > 0);
let libraryId = session.defaultLibraryId;
if (!libraryId) {
libraryId = pickLibrary(
session,
libraries,
args.useRofi,
ensureJellyfinIcon,
"",
themePath,
);
if (!libraryId) fail("No Jellyfin library selected.");
libraryId = pickLibrary(session, libraries, args.useRofi, ensureJellyfinIcon, '', themePath);
if (!libraryId) fail('No Jellyfin library selected.');
}
const searchTerm = await promptOptionalJellyfinSearch(args.useRofi, themePath);
@@ -194,9 +191,7 @@ export async function resolveJellyfinSelection(
if (page.length === 0) break;
out.push(...page);
startIndex += page.length;
const total = typeof payload.TotalRecordCount === "number"
? payload.TotalRecordCount
: null;
const total = typeof payload.TotalRecordCount === 'number' ? payload.TotalRecordCount : null;
if (total !== null && startIndex >= total) break;
if (page.length < 500) break;
}
@@ -206,19 +201,16 @@ export async function resolveJellyfinSelection(
const topLevelEntries = await fetchItemsPaged(libraryId);
const groups: JellyfinGroupEntry[] = topLevelEntries
.filter((item) => {
const type = typeof item.Type === "string" ? item.Type : "";
const type = typeof item.Type === 'string' ? item.Type : '';
return (
type === "Series" ||
type === "Folder" ||
type === "CollectionFolder" ||
type === "Season"
type === 'Series' || type === 'Folder' || type === 'CollectionFolder' || type === 'Season'
);
})
.map((item) => {
const type = typeof item.Type === "string" ? item.Type : "Folder";
const name = typeof item.Name === "string" ? item.Name : "Untitled";
const type = typeof item.Type === 'string' ? item.Type : 'Folder';
const name = typeof item.Name === 'string' ? item.Name : 'Untitled';
return {
id: typeof item.Id === "string" ? item.Id : "",
id: typeof item.Id === 'string' ? item.Id : '',
name,
type,
display: `${name} (${type})`,
@@ -241,14 +233,14 @@ export async function resolveJellyfinSelection(
const nextLevelEntries = await fetchItemsPaged(selectedGroupId);
const seasons: JellyfinGroupEntry[] = nextLevelEntries
.filter((item) => {
const type = typeof item.Type === "string" ? item.Type : "";
return type === "Season" || type === "Folder";
const type = typeof item.Type === 'string' ? item.Type : '';
return type === 'Season' || type === 'Folder';
})
.map((item) => {
const type = typeof item.Type === "string" ? item.Type : "Season";
const name = typeof item.Name === "string" ? item.Name : "Untitled";
const type = typeof item.Type === 'string' ? item.Type : 'Season';
const name = typeof item.Name === 'string' ? item.Name : 'Untitled';
return {
id: typeof item.Id === "string" ? item.Id : "",
id: typeof item.Id === 'string' ? item.Id : '',
name,
type,
display: `${name} (${type})`,
@@ -262,13 +254,13 @@ export async function resolveJellyfinSelection(
seasons,
args.useRofi,
ensureJellyfinIcon,
"",
'',
themePath,
);
if (!selectedSeasonId) fail("No Jellyfin season selected.");
if (!selectedSeasonId) fail('No Jellyfin season selected.');
contentParentId = selectedSeasonId;
const selectedSeason = seasonsById.get(selectedSeasonId);
if (selectedSeason?.type === "Season") {
if (selectedSeason?.type === 'Season') {
contentRecursive = false;
}
}
@@ -280,7 +272,7 @@ export async function resolveJellyfinSelection(
TotalRecordCount?: number;
}>(
session,
`/Users/${session.userId}/Items?ParentId=${encodeURIComponent(contentParentId)}&Recursive=${contentRecursive ? "true" : "false"}&SortBy=SortName&SortOrder=Ascending&Limit=500&StartIndex=${startIndex}`,
`/Users/${session.userId}/Items?ParentId=${encodeURIComponent(contentParentId)}&Recursive=${contentRecursive ? 'true' : 'false'}&SortBy=SortName&SortOrder=Ascending&Limit=500&StartIndex=${startIndex}`,
);
const allEntries: Array<Record<string, unknown>> = [];
@@ -291,28 +283,26 @@ export async function resolveJellyfinSelection(
if (page.length === 0) break;
allEntries.push(...page);
startIndex += page.length;
const total = typeof payload.TotalRecordCount === "number"
? payload.TotalRecordCount
: null;
const total = typeof payload.TotalRecordCount === 'number' ? payload.TotalRecordCount : null;
if (total !== null && startIndex >= total) break;
if (page.length < 500) break;
}
let items: JellyfinItemEntry[] = sortEntries(
allEntries
.filter((item) => {
const type = typeof item.Type === "string" ? item.Type : "";
return type === "Movie" || type === "Episode" || type === "Audio";
})
.map((item) => ({
id: typeof item.Id === "string" ? item.Id : "",
name: typeof item.Name === "string" ? item.Name : "",
type: typeof item.Type === "string" ? item.Type : "Item",
parentIndex: asNumberOrNull(item.ParentIndexNumber),
index: asNumberOrNull(item.IndexNumber),
display: formatJellyfinItemDisplay(item),
}))
.filter((item) => item.id.length > 0),
.filter((item) => {
const type = typeof item.Type === 'string' ? item.Type : '';
return type === 'Movie' || type === 'Episode' || type === 'Audio';
})
.map((item) => ({
id: typeof item.Id === 'string' ? item.Id : '',
name: typeof item.Name === 'string' ? item.Name : '',
type: typeof item.Type === 'string' ? item.Type : 'Item',
parentIndex: asNumberOrNull(item.ParentIndexNumber),
index: asNumberOrNull(item.IndexNumber),
display: formatJellyfinItemDisplay(item),
}))
.filter((item) => item.id.length > 0),
).map(({ id, name, type, display }) => ({
id,
name,
@@ -323,29 +313,28 @@ export async function resolveJellyfinSelection(
if (items.length === 0) {
items = sortEntries(
allEntries
.filter((item) => {
const type = typeof item.Type === "string" ? item.Type : "";
if (type === "Folder" || type === "CollectionFolder") return false;
const mediaType =
typeof item.MediaType === "string" ? item.MediaType.toLowerCase() : "";
if (mediaType === "video" || mediaType === "audio") return true;
return (
type === "Movie" ||
type === "Episode" ||
type === "Audio" ||
type === "Video" ||
type === "MusicVideo"
);
})
.map((item) => ({
id: typeof item.Id === "string" ? item.Id : "",
name: typeof item.Name === "string" ? item.Name : "",
type: typeof item.Type === "string" ? item.Type : "Item",
parentIndex: asNumberOrNull(item.ParentIndexNumber),
index: asNumberOrNull(item.IndexNumber),
display: formatJellyfinItemDisplay(item),
}))
.filter((item) => item.id.length > 0),
.filter((item) => {
const type = typeof item.Type === 'string' ? item.Type : '';
if (type === 'Folder' || type === 'CollectionFolder') return false;
const mediaType = typeof item.MediaType === 'string' ? item.MediaType.toLowerCase() : '';
if (mediaType === 'video' || mediaType === 'audio') return true;
return (
type === 'Movie' ||
type === 'Episode' ||
type === 'Audio' ||
type === 'Video' ||
type === 'MusicVideo'
);
})
.map((item) => ({
id: typeof item.Id === 'string' ? item.Id : '',
name: typeof item.Name === 'string' ? item.Name : '',
type: typeof item.Type === 'string' ? item.Type : 'Item',
parentIndex: asNumberOrNull(item.ParentIndexNumber),
index: asNumberOrNull(item.IndexNumber),
display: formatJellyfinItemDisplay(item),
}))
.filter((item) => item.id.length > 0),
).map(({ id, name, type, display }) => ({
id,
name,
@@ -354,8 +343,8 @@ export async function resolveJellyfinSelection(
}));
}
const itemId = pickItem(session, items, args.useRofi, ensureJellyfinIcon, "", themePath);
if (!itemId) fail("No Jellyfin item selected.");
const itemId = pickItem(session, items, args.useRofi, ensureJellyfinIcon, '', themePath);
if (!itemId) fail('No Jellyfin item selected.');
return itemId;
}
@@ -367,32 +356,28 @@ export async function runJellyfinPlayMenu(
): Promise<never> {
const config = loadLauncherJellyfinConfig();
const session: JellyfinSessionConfig = {
serverUrl: sanitizeServerUrl(args.jellyfinServer || config.serverUrl || ""),
accessToken: config.accessToken || "",
userId: config.userId || "",
defaultLibraryId: config.defaultLibraryId || "",
serverUrl: sanitizeServerUrl(args.jellyfinServer || config.serverUrl || ''),
accessToken: config.accessToken || '',
userId: config.userId || '',
defaultLibraryId: config.defaultLibraryId || '',
pullPictures: config.pullPictures === true,
iconCacheDir: config.iconCacheDir || "",
iconCacheDir: config.iconCacheDir || '',
};
if (!session.serverUrl || !session.accessToken || !session.userId) {
fail(
"Missing Jellyfin session config. Run `subminer --jellyfin` or `subminer --jellyfin-login` first.",
'Missing Jellyfin session config. Run `subminer --jellyfin` or `subminer --jellyfin-login` first.',
);
}
const rofiTheme = args.useRofi ? findRofiTheme(scriptPath) : null;
if (args.useRofi && !rofiTheme) {
log(
"warn",
args.logLevel,
"Rofi theme not found for Jellyfin picker; using rofi defaults.",
);
log('warn', args.logLevel, 'Rofi theme not found for Jellyfin picker; using rofi defaults.');
}
const itemId = await resolveJellyfinSelection(args, session, rofiTheme);
log("debug", args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
log("debug", args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
let mpvReady = false;
if (fs.existsSync(mpvSocketPath)) {
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 250);
@@ -401,15 +386,11 @@ export async function runJellyfinPlayMenu(
await launchMpvIdleDetached(mpvSocketPath, appPath, args);
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000);
}
log(
"debug",
args.logLevel,
`MPV socket ready check result: ${mpvReady ? "ready" : "not ready"}`,
);
log('debug', args.logLevel, `MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`);
if (!mpvReady) {
fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
}
const forwarded = ["--start", "--jellyfin-play", "--jellyfin-item-id", itemId];
if (args.logLevel !== "info") forwarded.push("--log-level", args.logLevel);
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, "jellyfin-play");
const forwarded = ['--start', '--jellyfin-play', '--jellyfin-item-id', itemId];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
}

View File

@@ -1,11 +1,11 @@
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";
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;
@@ -34,13 +34,9 @@ interface JimakuApiError {
retryAfter?: number;
}
type JimakuApiResponse<T> =
| { ok: true; data: T }
| { ok: false; error: JimakuApiError };
type JimakuApiResponse<T> = { ok: true; data: T } | { ok: false; error: JimakuApiError };
type JimakuDownloadResult =
| { ok: true; path: string }
| { ok: false; error: JimakuApiError };
type JimakuDownloadResult = { ok: true; path: string } | { ok: false; error: JimakuApiError };
interface JimakuConfig {
apiKey: string;
@@ -54,13 +50,13 @@ interface JimakuMediaInfo {
title: string;
season: number | null;
episode: number | null;
confidence: "high" | "medium" | "low";
confidence: 'high' | 'medium' | 'low';
filename: string;
rawTitle: string;
}
function getRetryAfter(headers: http.IncomingHttpHeaders): number | undefined {
const value = headers["x-ratelimit-reset-after"];
const value = headers['x-ratelimit-reset-after'];
if (!value) return undefined;
const raw = Array.isArray(value) ? value[0] : value;
const parsed = Number.parseFloat(raw);
@@ -72,7 +68,7 @@ export function matchEpisodeFromName(name: string): {
season: number | null;
episode: number | null;
index: number | null;
confidence: "high" | "medium" | "low";
confidence: 'high' | 'medium' | 'low';
} {
const seasonEpisode = name.match(/S(\d{1,2})E(\d{1,3})/i);
if (seasonEpisode && seasonEpisode.index !== undefined) {
@@ -80,7 +76,7 @@ export function matchEpisodeFromName(name: string): {
season: Number.parseInt(seasonEpisode[1], 10),
episode: Number.parseInt(seasonEpisode[2], 10),
index: seasonEpisode.index,
confidence: "high",
confidence: 'high',
};
}
@@ -90,7 +86,7 @@ export function matchEpisodeFromName(name: string): {
season: Number.parseInt(alt[1], 10),
episode: Number.parseInt(alt[2], 10),
index: alt.index,
confidence: "high",
confidence: 'high',
};
}
@@ -100,7 +96,7 @@ export function matchEpisodeFromName(name: string): {
season: null,
episode: Number.parseInt(epOnly[1], 10),
index: epOnly.index,
confidence: "medium",
confidence: 'medium',
};
}
@@ -110,11 +106,11 @@ export function matchEpisodeFromName(name: string): {
season: null,
episode: Number.parseInt(numeric[1], 10),
index: numeric.index,
confidence: "medium",
confidence: 'medium',
};
}
return { season: null, episode: null, index: null, confidence: "low" };
return { season: null, episode: null, index: null, confidence: 'low' };
}
function detectSeasonFromDir(mediaPath: string): number | null {
@@ -125,10 +121,7 @@ function detectSeasonFromDir(mediaPath: string): number | null {
return Number.isFinite(parsed) ? parsed : null;
}
function parseGuessitOutput(
mediaPath: string,
stdout: string,
): JimakuMediaInfo | null {
function parseGuessitOutput(mediaPath: string, stdout: string): JimakuMediaInfo | null {
const payload = stdout.trim();
if (!payload) return null;
@@ -142,15 +135,15 @@ function parseGuessitOutput(
episode_list?: Array<number | string>;
};
const season =
typeof parsed.season === "number"
typeof parsed.season === 'number'
? parsed.season
: typeof parsed.season === "string"
: typeof parsed.season === 'string'
? Number.parseInt(parsed.season, 10)
: null;
const directEpisode =
typeof parsed.episode === "number"
typeof parsed.episode === 'number'
? parsed.episode
: typeof parsed.episode === "string"
: typeof parsed.episode === 'string'
? Number.parseInt(parsed.episode, 10)
: null;
const episodeFromList =
@@ -158,29 +151,23 @@ function parseGuessitOutput(
? 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();
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);
title.length > 0 ||
Number.isFinite(season as number) ||
Number.isFinite(episodeValue as number);
if (!hasStructuredData) return null;
return {
title: title || "",
title: title || '',
season: Number.isFinite(season as number) ? season : detectSeasonFromDir(mediaPath),
episode: episode,
confidence: "high",
confidence: 'high',
filename: path.basename(mediaPath),
rawTitle: path.basename(mediaPath).replace(/\.[^/.]+$/, ""),
rawTitle: path.basename(mediaPath).replace(/\.[^/.]+$/, ''),
};
} catch {
return null;
@@ -188,18 +175,18 @@ function parseGuessitOutput(
}
function parseMediaInfoWithGuessit(mediaPath: string): JimakuMediaInfo | null {
if (!commandExists("guessit")) return null;
if (!commandExists('guessit')) return null;
try {
const fileName = path.basename(mediaPath);
const result = spawnSync("guessit", ["--json", fileName], {
const result = spawnSync('guessit', ['--json', fileName], {
cwd: path.dirname(mediaPath),
encoding: "utf8",
encoding: 'utf8',
maxBuffer: 2_000_000,
windowsHide: true,
});
if (result.error || result.status !== 0) return null;
return parseGuessitOutput(mediaPath, result.stdout || "");
return parseGuessitOutput(mediaPath, result.stdout || '');
} catch {
return null;
}
@@ -207,27 +194,27 @@ function parseMediaInfoWithGuessit(mediaPath: string): JimakuMediaInfo | null {
function cleanupTitle(value: string): string {
return value
.replace(/^[\s-–—]+/, "")
.replace(/[\s-–—]+$/, "")
.replace(/\s+/g, " ")
.replace(/^[\s-–—]+/, '')
.replace(/[\s-–—]+$/, '')
.replace(/\s+/g, ' ')
.trim();
}
function formatLangScore(name: string, pref: JimakuLanguagePreference): number {
if (pref === "none") return 0;
if (pref === 'none') return 0;
const upper = name.toUpperCase();
const hasJa =
/(^|[\W_])JA([\W_]|$)/.test(upper) ||
/(^|[\W_])JPN([\W_]|$)/.test(upper) ||
upper.includes(".JA.");
upper.includes('.JA.');
const hasEn =
/(^|[\W_])EN([\W_]|$)/.test(upper) ||
/(^|[\W_])ENG([\W_]|$)/.test(upper) ||
upper.includes(".EN.");
if (pref === "ja") {
upper.includes('.EN.');
if (pref === 'ja') {
if (hasJa) return 2;
if (hasEn) return 1;
} else if (pref === "en") {
} else if (pref === 'en') {
if (hasEn) return 2;
if (hasJa) return 1;
}
@@ -242,11 +229,11 @@ export async function resolveJimakuApiKey(config: JimakuConfig): Promise<string
try {
const commandResult = spawnSync(config.apiKeyCommand, {
shell: true,
encoding: "utf8",
encoding: 'utf8',
timeout: 10000,
});
if (commandResult.error) return null;
const key = (commandResult.stdout || "").trim();
const key = (commandResult.stdout || '').trim();
return key.length > 0 ? key : null;
} catch {
return null;
@@ -268,22 +255,22 @@ export function jimakuFetchJson<T>(
return new Promise((resolve) => {
const requestUrl = new URL(url.toString());
const transport = requestUrl.protocol === "https:" ? https : http;
const transport = requestUrl.protocol === 'https:' ? https : http;
const req = transport.request(
requestUrl,
{
method: "GET",
method: 'GET',
headers: {
Authorization: options.apiKey,
"User-Agent": "SubMiner",
'User-Agent': 'SubMiner',
},
},
(res) => {
let data = "";
res.on("data", (chunk) => {
let data = '';
res.on('data', (chunk) => {
data += chunk.toString();
});
res.on("end", () => {
res.on('end', () => {
const status = res.statusCode || 0;
if (status >= 200 && status < 300) {
try {
@@ -292,7 +279,7 @@ export function jimakuFetchJson<T>(
} catch {
resolve({
ok: false,
error: { error: "Failed to parse Jimaku response JSON." },
error: { error: 'Failed to parse Jimaku response JSON.' },
});
}
return;
@@ -313,15 +300,14 @@ export function jimakuFetchJson<T>(
error: {
error: errorMessage,
code: status || undefined,
retryAfter:
status === 429 ? getRetryAfter(res.headers) : undefined,
retryAfter: status === 429 ? getRetryAfter(res.headers) : undefined,
},
});
});
},
);
req.on("error", (error) => {
req.on('error', (error) => {
resolve({
ok: false,
error: { error: `Jimaku request failed: ${(error as Error).message}` },
@@ -335,12 +321,12 @@ export function jimakuFetchJson<T>(
export function parseMediaInfo(mediaPath: string | null): JimakuMediaInfo {
if (!mediaPath) {
return {
title: "",
title: '',
season: null,
episode: null,
confidence: "low",
filename: "",
rawTitle: "",
confidence: 'low',
filename: '',
rawTitle: '',
};
}
@@ -348,12 +334,12 @@ export function parseMediaInfo(mediaPath: string | null): JimakuMediaInfo {
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();
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;
@@ -378,7 +364,7 @@ export function sortJimakuFiles(
files: JimakuFileEntry[],
pref: JimakuLanguagePreference,
): JimakuFileEntry[] {
if (pref === "none") return files;
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;
@@ -395,22 +381,20 @@ export async function downloadToFile(
if (redirectCount > 3) {
return {
ok: false,
error: { error: "Too many redirects while downloading subtitle." },
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 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,
);
downloadToFile(redirectUrl, destPath, headers, redirectCount + 1).then(resolve);
return;
}
@@ -428,12 +412,12 @@ export async function downloadToFile(
const fileStream = fs.createWriteStream(destPath);
res.pipe(fileStream);
fileStream.on("finish", () => {
fileStream.on('finish', () => {
fileStream.close(() => {
resolve({ ok: true, path: destPath });
});
});
fileStream.on("error", (err: Error) => {
fileStream.on('error', (err: Error) => {
resolve({
ok: false,
error: { error: `Failed to save subtitle: ${err.message}` },
@@ -441,7 +425,7 @@ export async function downloadToFile(
});
});
req.on("error", (err) => {
req.on('error', (err) => {
resolve({
ok: false,
error: {
@@ -454,45 +438,34 @@ export async function downloadToFile(
export function isValidSubtitleCandidateFile(filename: string): boolean {
const ext = path.extname(filename).toLowerCase();
return (
ext === ".srt" ||
ext === ".vtt" ||
ext === ".ass" ||
ext === ".ssa" ||
ext === ".sub"
);
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"];
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 "";
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");
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 pathParts = url.pathname.split('/').filter(Boolean).reverse();
const candidate = pathParts.find((part) => {
const decoded = decodeURIComponent(part || "").replace(/\.[^/.]+$/, "");
const decoded = decodeURIComponent(part || '').replace(/\.[^/.]+$/, '');
const lowered = decoded.toLowerCase();
return (
lowered.length > 2 &&
!/^[0-9.]+$/.test(lowered) &&
!/^[a-f0-9]{16,}$/i.test(lowered)
);
return lowered.length > 2 && !/^[0-9.]+$/.test(lowered) && !/^[a-f0-9]{16,}$/i.test(lowered);
});
const fallback = candidate || url.hostname.replace(/^www\./, "");
const fallback = candidate || url.hostname.replace(/^www\./, '');
return sanitizeJimakuQueryInput(decodeURIComponent(fallback));
} catch {
return trimmed;
@@ -501,9 +474,9 @@ export function normalizeJimakuSearchInput(mediaPath: string): string {
export function sanitizeJimakuQueryInput(value: string): string {
return value
.replace(/^\s*-\s*/, "")
.replace(/[^\w\s\-'".:(),]/g, " ")
.replace(/\s+/g, " ")
.replace(/^\s*-\s*/, '')
.replace(/[^\w\s\-'".:(),]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}

View File

@@ -1,14 +1,14 @@
import fs from "node:fs";
import path from "node:path";
import type { LogLevel } from "./types.js";
import { DEFAULT_MPV_LOG_FILE } from "./types.js";
import fs from 'node:fs';
import path from 'node:path';
import type { LogLevel } from './types.js';
import { DEFAULT_MPV_LOG_FILE } from './types.js';
export const COLORS = {
red: "\x1b[0;31m",
green: "\x1b[0;32m",
yellow: "\x1b[0;33m",
cyan: "\x1b[0;36m",
reset: "\x1b[0m",
red: '\x1b[0;31m',
green: '\x1b[0;32m',
yellow: '\x1b[0;33m',
cyan: '\x1b[0;36m',
reset: '\x1b[0m',
};
export const LOG_PRI: Record<LogLevel, number> = {
@@ -32,11 +32,7 @@ export function appendToMpvLog(message: string): void {
const logPath = getMpvLogPath();
try {
fs.mkdirSync(path.dirname(logPath), { recursive: true });
fs.appendFileSync(
logPath,
`[${new Date().toISOString()}] ${message}\n`,
{ encoding: "utf8" },
);
fs.appendFileSync(logPath, `[${new Date().toISOString()}] ${message}\n`, { encoding: 'utf8' });
} catch {
// ignore logging failures
}
@@ -45,16 +41,14 @@ export function appendToMpvLog(message: string): void {
export function log(level: LogLevel, configured: LogLevel, message: string): void {
if (!shouldLog(level, configured)) return;
const color =
level === "info"
level === 'info'
? COLORS.green
: level === "warn"
: level === 'warn'
? COLORS.yellow
: level === "error"
: level === 'error'
? COLORS.red
: COLORS.cyan;
process.stdout.write(
`${color}[${level.toUpperCase()}]${COLORS.reset} ${message}\n`,
);
process.stdout.write(`${color}[${level.toUpperCase()}]${COLORS.reset} ${message}\n`);
appendToMpvLog(`[${level.toUpperCase()}] ${message}`);
}

View File

@@ -1,64 +1,68 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import type { Args } from "./types.js";
import { log, fail } from "./log.js";
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import type { Args } from './types.js';
import { log, fail } from './log.js';
import { commandExists, isYoutubeTarget, resolvePathMaybe, realpathMaybe } from './util.js';
import {
commandExists, isYoutubeTarget, resolvePathMaybe, realpathMaybe,
} from "./util.js";
import {
parseArgs, loadLauncherYoutubeSubgenConfig, loadLauncherJellyfinConfig,
parseArgs,
loadLauncherYoutubeSubgenConfig,
loadLauncherJellyfinConfig,
readPluginRuntimeConfig,
} from "./config.js";
import { showRofiMenu, showFzfMenu, collectVideos } from "./picker.js";
} from './config.js';
import { showRofiMenu, showFzfMenu, collectVideos } from './picker.js';
import {
state, startMpv, startOverlay, stopOverlay, launchTexthookerOnly,
findAppBinary, waitForSocket, loadSubtitleIntoMpv, runAppCommandWithInherit,
launchMpvIdleDetached, waitForUnixSocketReady,
} from "./mpv.js";
import { generateYoutubeSubtitles } from "./youtube.js";
import { runJellyfinPlayMenu } from "./jellyfin.js";
state,
startMpv,
startOverlay,
stopOverlay,
launchTexthookerOnly,
findAppBinary,
waitForSocket,
loadSubtitleIntoMpv,
runAppCommandWithInherit,
launchMpvIdleDetached,
waitForUnixSocketReady,
} from './mpv.js';
import { generateYoutubeSubtitles } from './youtube.js';
import { runJellyfinPlayMenu } from './jellyfin.js';
function checkDependencies(args: Args): void {
const missing: string[] = [];
if (!commandExists("mpv")) missing.push("mpv");
if (!commandExists('mpv')) missing.push('mpv');
if (
args.targetKind === "url" &&
isYoutubeTarget(args.target) &&
!commandExists("yt-dlp")
) {
missing.push("yt-dlp");
if (args.targetKind === 'url' && isYoutubeTarget(args.target) && !commandExists('yt-dlp')) {
missing.push('yt-dlp');
}
if (
args.targetKind === "url" &&
args.targetKind === 'url' &&
isYoutubeTarget(args.target) &&
args.youtubeSubgenMode !== "off" &&
!commandExists("ffmpeg")
args.youtubeSubgenMode !== 'off' &&
!commandExists('ffmpeg')
) {
missing.push("ffmpeg");
missing.push('ffmpeg');
}
if (missing.length > 0) fail(`Missing dependencies: ${missing.join(" ")}`);
if (missing.length > 0) fail(`Missing dependencies: ${missing.join(' ')}`);
}
function checkPickerDependencies(args: Args): void {
if (args.useRofi) {
if (!commandExists("rofi")) fail("Missing dependency: rofi");
if (!commandExists('rofi')) fail('Missing dependency: rofi');
return;
}
if (!commandExists("fzf")) fail("Missing dependency: fzf");
if (!commandExists('fzf')) fail('Missing dependency: fzf');
}
async function chooseTarget(
args: Args,
scriptPath: string,
): Promise<{ target: string; kind: "file" | "url" } | null> {
): Promise<{ target: string; kind: 'file' | 'url' } | null> {
if (args.target) {
return { target: args.target, kind: args.targetKind as "file" | "url" };
return { target: args.target, kind: args.targetKind as 'file' | 'url' };
}
const searchDir = realpathMaybe(resolvePathMaybe(args.directory));
@@ -71,104 +75,98 @@ async function chooseTarget(
fail(`No video files found in: ${searchDir}`);
}
log(
"info",
args.logLevel,
`Browsing: ${searchDir} (${videos.length} videos found)`,
);
log('info', args.logLevel, `Browsing: ${searchDir} (${videos.length} videos found)`);
const selected = args.useRofi
? showRofiMenu(videos, searchDir, args.recursive, scriptPath, args.logLevel)
: showFzfMenu(videos);
if (!selected) return null;
return { target: selected, kind: "file" };
return { target: selected, kind: 'file' };
}
function registerCleanup(args: Args): void {
process.on("SIGINT", () => {
process.on('SIGINT', () => {
stopOverlay(args);
process.exit(130);
});
process.on("SIGTERM", () => {
process.on('SIGTERM', () => {
stopOverlay(args);
process.exit(143);
});
}
function resolveMainConfigPath(): string {
const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
const baseDirs = Array.from(new Set([xdgConfigHome, path.join(os.homedir(), ".config")]));
const appNames = ["SubMiner", "subminer"];
const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
const baseDirs = Array.from(new Set([xdgConfigHome, path.join(os.homedir(), '.config')]));
const appNames = ['SubMiner', 'subminer'];
for (const baseDir of baseDirs) {
for (const appName of appNames) {
const jsoncPath = path.join(baseDir, appName, "config.jsonc");
const jsoncPath = path.join(baseDir, appName, 'config.jsonc');
if (fs.existsSync(jsoncPath)) return jsoncPath;
const jsonPath = path.join(baseDir, appName, "config.json");
const jsonPath = path.join(baseDir, appName, 'config.json');
if (fs.existsSync(jsonPath)) return jsonPath;
}
}
return path.join(baseDirs[0], "SubMiner", "config.jsonc");
return path.join(baseDirs[0], 'SubMiner', 'config.jsonc');
}
function runDoctor(args: Args, appPath: string | null, mpvSocketPath: string): never {
const checks: Array<{ label: string; ok: boolean; detail: string }> = [
{
label: "app binary",
label: 'app binary',
ok: Boolean(appPath),
detail: appPath || "not found (set SUBMINER_APPIMAGE_PATH)",
detail: appPath || 'not found (set SUBMINER_APPIMAGE_PATH)',
},
{
label: "mpv",
ok: commandExists("mpv"),
detail: commandExists("mpv") ? "found" : "missing",
label: 'mpv',
ok: commandExists('mpv'),
detail: commandExists('mpv') ? 'found' : 'missing',
},
{
label: "yt-dlp",
ok: commandExists("yt-dlp"),
detail: commandExists("yt-dlp") ? "found" : "missing (optional unless YouTube URLs)",
label: 'yt-dlp',
ok: commandExists('yt-dlp'),
detail: commandExists('yt-dlp') ? 'found' : 'missing (optional unless YouTube URLs)',
},
{
label: "ffmpeg",
ok: commandExists("ffmpeg"),
detail: commandExists("ffmpeg") ? "found" : "missing (optional unless subtitle generation)",
label: 'ffmpeg',
ok: commandExists('ffmpeg'),
detail: commandExists('ffmpeg') ? 'found' : 'missing (optional unless subtitle generation)',
},
{
label: "fzf",
ok: commandExists("fzf"),
detail: commandExists("fzf") ? "found" : "missing (optional if using rofi)",
label: 'fzf',
ok: commandExists('fzf'),
detail: commandExists('fzf') ? 'found' : 'missing (optional if using rofi)',
},
{
label: "rofi",
ok: commandExists("rofi"),
detail: commandExists("rofi") ? "found" : "missing (optional if using fzf)",
label: 'rofi',
ok: commandExists('rofi'),
detail: commandExists('rofi') ? 'found' : 'missing (optional if using fzf)',
},
{
label: "config",
label: 'config',
ok: fs.existsSync(resolveMainConfigPath()),
detail: resolveMainConfigPath(),
},
{
label: "mpv socket path",
label: 'mpv socket path',
ok: true,
detail: mpvSocketPath,
},
];
const hasHardFailure = checks.some(
(entry) => entry.label === "app binary" || entry.label === "mpv"
? !entry.ok
: false,
const hasHardFailure = checks.some((entry) =>
entry.label === 'app binary' || entry.label === 'mpv' ? !entry.ok : false,
);
for (const check of checks) {
log(check.ok ? "info" : "warn", args.logLevel, `[doctor] ${check.label}: ${check.detail}`);
log(check.ok ? 'info' : 'warn', args.logLevel, `[doctor] ${check.label}: ${check.detail}`);
}
process.exit(hasHardFailure ? 1 : 0);
}
async function main(): Promise<void> {
const scriptPath = process.argv[1] || "subminer";
const scriptPath = process.argv[1] || 'subminer';
const scriptName = path.basename(scriptPath);
const launcherConfig = loadLauncherYoutubeSubgenConfig();
const launcherJellyfinConfig = loadLauncherJellyfinConfig();
@@ -176,9 +174,9 @@ async function main(): Promise<void> {
const pluginRuntimeConfig = readPluginRuntimeConfig(args.logLevel);
const mpvSocketPath = pluginRuntimeConfig.socketPath;
log("debug", args.logLevel, `Wrapper log level set to: ${args.logLevel}`);
log('debug', args.logLevel, `Wrapper log level set to: ${args.logLevel}`);
const appPath = findAppBinary(process.argv[1] || "subminer");
const appPath = findAppBinary(process.argv[1] || 'subminer');
if (args.doctor) {
runDoctor(args, appPath, mpvSocketPath);
}
@@ -193,10 +191,10 @@ async function main(): Promise<void> {
if (!fs.existsSync(configPath)) {
fail(`Config file not found: ${configPath}`);
}
const contents = fs.readFileSync(configPath, "utf8");
const contents = fs.readFileSync(configPath, 'utf8');
process.stdout.write(contents);
if (!contents.endsWith("\n")) {
process.stdout.write("\n");
if (!contents.endsWith('\n')) {
process.stdout.write('\n');
}
return;
}
@@ -209,22 +207,20 @@ async function main(): Promise<void> {
if (args.mpvStatus) {
const ready = await waitForUnixSocketReady(mpvSocketPath, 500);
log(
ready ? "info" : "warn",
ready ? 'info' : 'warn',
args.logLevel,
`[mpv] socket ${ready ? "ready" : "not ready"}: ${mpvSocketPath}`,
`[mpv] socket ${ready ? 'ready' : 'not ready'}: ${mpvSocketPath}`,
);
process.exit(ready ? 0 : 1);
}
if (!appPath) {
if (process.platform === "darwin") {
if (process.platform === 'darwin') {
fail(
"SubMiner app binary not found. Install SubMiner.app to /Applications or ~/Applications, or set SUBMINER_APPIMAGE_PATH.",
'SubMiner app binary not found. Install SubMiner.app to /Applications or ~/Applications, or set SUBMINER_APPIMAGE_PATH.',
);
}
fail(
"SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.",
);
fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
}
state.appPath = appPath;
@@ -234,7 +230,7 @@ async function main(): Promise<void> {
if (!ready) {
fail(`MPV IPC socket not ready after idle launch: ${mpvSocketPath}`);
}
log("info", args.logLevel, `[mpv] idle instance ready on ${mpvSocketPath}`);
log('info', args.logLevel, `[mpv] idle instance ready on ${mpvSocketPath}`);
return;
}
@@ -243,52 +239,52 @@ async function main(): Promise<void> {
}
if (args.jellyfin) {
const forwarded = ["--jellyfin"];
if (args.logLevel !== "info") forwarded.push("--log-level", args.logLevel);
const forwarded = ['--jellyfin'];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
runAppCommandWithInherit(appPath, forwarded);
}
if (args.jellyfinLogin) {
const serverUrl = args.jellyfinServer || launcherJellyfinConfig.serverUrl || "";
const username = args.jellyfinUsername || launcherJellyfinConfig.username || "";
const password = args.jellyfinPassword || "";
const serverUrl = args.jellyfinServer || launcherJellyfinConfig.serverUrl || '';
const username = args.jellyfinUsername || launcherJellyfinConfig.username || '';
const password = args.jellyfinPassword || '';
if (!serverUrl || !username || !password) {
fail(
"--jellyfin-login requires server, username, and password. Pass flags or run `subminer --jellyfin`.",
'--jellyfin-login requires server, username, and password. Pass flags or run `subminer --jellyfin`.',
);
}
const forwarded = [
"--jellyfin-login",
"--jellyfin-server",
'--jellyfin-login',
'--jellyfin-server',
serverUrl,
"--jellyfin-username",
'--jellyfin-username',
username,
"--jellyfin-password",
'--jellyfin-password',
password,
];
if (args.logLevel !== "info") forwarded.push("--log-level", args.logLevel);
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
runAppCommandWithInherit(appPath, forwarded);
}
if (args.jellyfinLogout) {
const forwarded = ["--jellyfin-logout"];
if (args.logLevel !== "info") forwarded.push("--log-level", args.logLevel);
const forwarded = ['--jellyfin-logout'];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
runAppCommandWithInherit(appPath, forwarded);
}
if (args.jellyfinPlay) {
if (!args.useRofi && !commandExists("fzf")) {
fail("fzf not found. Install fzf or use -R for rofi.");
if (!args.useRofi && !commandExists('fzf')) {
fail('fzf not found. Install fzf or use -R for rofi.');
}
if (args.useRofi && !commandExists("rofi")) {
fail("rofi not found. Install rofi or omit -R for fzf.");
if (args.useRofi && !commandExists('rofi')) {
fail('rofi not found. Install rofi or omit -R for fzf.');
}
await runJellyfinPlayMenu(appPath, args, scriptPath, mpvSocketPath);
}
if (args.jellyfinDiscovery) {
const forwarded = ["--start"];
if (args.logLevel !== "info") forwarded.push("--log-level", args.logLevel);
const forwarded = ['--start'];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
runAppCommandWithInherit(appPath, forwarded);
}
@@ -296,16 +292,16 @@ async function main(): Promise<void> {
checkPickerDependencies(args);
}
const targetChoice = await chooseTarget(args, process.argv[1] || "subminer");
const targetChoice = await chooseTarget(args, process.argv[1] || 'subminer');
if (!targetChoice) {
log("info", args.logLevel, "No video selected, exiting");
log('info', args.logLevel, 'No video selected, exiting');
process.exit(0);
}
checkDependencies({
...args,
target: targetChoice ? targetChoice.target : args.target,
targetKind: targetChoice ? targetChoice.kind : "url",
targetKind: targetChoice ? targetChoice.kind : 'url',
});
registerCleanup(args);
@@ -313,35 +309,29 @@ async function main(): Promise<void> {
let selectedTarget = targetChoice
? {
target: targetChoice.target,
kind: targetChoice.kind as "file" | "url",
kind: targetChoice.kind as 'file' | 'url',
}
: { target: args.target, kind: "url" as const };
: { target: args.target, kind: 'url' as const };
const isYoutubeUrl =
selectedTarget.kind === "url" && isYoutubeTarget(selectedTarget.target);
let preloadedSubtitles:
| { primaryPath?: string; secondaryPath?: string }
| undefined;
const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target);
let preloadedSubtitles: { primaryPath?: string; secondaryPath?: string } | undefined;
if (isYoutubeUrl && args.youtubeSubgenMode === "preprocess") {
log("info", args.logLevel, "YouTube subtitle mode: preprocess");
const generated = await generateYoutubeSubtitles(
selectedTarget.target,
args,
);
if (isYoutubeUrl && args.youtubeSubgenMode === 'preprocess') {
log('info', args.logLevel, 'YouTube subtitle mode: preprocess');
const generated = await generateYoutubeSubtitles(selectedTarget.target, args);
preloadedSubtitles = {
primaryPath: generated.primaryPath,
secondaryPath: generated.secondaryPath,
};
log(
"info",
'info',
args.logLevel,
`YouTube preprocess result: primary=${generated.primaryPath ? "ready" : "missing"}, secondary=${generated.secondaryPath ? "ready" : "missing"}`,
`YouTube preprocess result: primary=${generated.primaryPath ? 'ready' : 'missing'}, secondary=${generated.secondaryPath ? 'ready' : 'missing'}`,
);
} else if (isYoutubeUrl && args.youtubeSubgenMode === "automatic") {
log("info", args.logLevel, "YouTube subtitle mode: automatic (background)");
} else if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') {
log('info', args.logLevel, 'YouTube subtitle mode: automatic (background)');
} else if (isYoutubeUrl) {
log("info", args.logLevel, "YouTube subtitle mode: off");
log('info', args.logLevel, 'YouTube subtitle mode: off');
}
startMpv(
@@ -353,28 +343,20 @@ async function main(): Promise<void> {
preloadedSubtitles,
);
if (isYoutubeUrl && args.youtubeSubgenMode === "automatic") {
void generateYoutubeSubtitles(
selectedTarget.target,
args,
async (lang, subtitlePath) => {
if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') {
void generateYoutubeSubtitles(selectedTarget.target, args, async (lang, subtitlePath) => {
try {
await loadSubtitleIntoMpv(
mpvSocketPath,
subtitlePath,
lang === "primary",
args.logLevel,
);
await loadSubtitleIntoMpv(mpvSocketPath, subtitlePath, lang === 'primary', args.logLevel);
} catch (error) {
log(
"warn",
'warn',
args.logLevel,
`Generated subtitle ready but failed to load in mpv: ${(error as Error).message}`,
);
}
}).catch((error) => {
log(
"warn",
'warn',
args.logLevel,
`Background subtitle generation failed: ${(error as Error).message}`,
);
@@ -386,30 +368,26 @@ async function main(): Promise<void> {
args.startOverlay || args.autoStartOverlay || pluginRuntimeConfig.autoStartOverlay;
if (shouldStartOverlay) {
if (ready) {
log(
"info",
args.logLevel,
"MPV IPC socket ready, starting SubMiner overlay",
);
log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay');
} else {
log(
"info",
'info',
args.logLevel,
"MPV IPC socket not ready after timeout, starting SubMiner overlay anyway",
'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway',
);
}
await startOverlay(appPath, args, mpvSocketPath);
} else if (ready) {
log(
"info",
'info',
args.logLevel,
"MPV IPC socket ready, overlay auto-start disabled (use y-s to start)",
'MPV IPC socket ready, overlay auto-start disabled (use y-s to start)',
);
} else {
log(
"info",
'info',
args.logLevel,
"MPV IPC socket not ready yet, overlay auto-start disabled (use y-s to start)",
'MPV IPC socket not ready yet, overlay auto-start disabled (use y-s to start)',
);
}
@@ -419,7 +397,7 @@ async function main(): Promise<void> {
resolve();
return;
}
state.mpvProc.on("exit", (code) => {
state.mpvProc.on('exit', (code) => {
stopOverlay(args);
process.exitCode = code ?? 0;
resolve();

View File

@@ -1,33 +1,36 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import net from "node:net";
import { spawn, spawnSync } from "node:child_process";
import type { LogLevel, Backend, Args, MpvTrack } from "./types.js";
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from "./types.js";
import { log, fail, getMpvLogPath } from "./log.js";
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process';
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
import { log, fail, getMpvLogPath } from './log.js';
import {
commandExists, isExecutable, resolveBinaryPathCandidate,
realpathMaybe, isYoutubeTarget, uniqueNormalizedLangCodes, sleep, normalizeLangCode,
} from "./util.js";
commandExists,
isExecutable,
resolveBinaryPathCandidate,
realpathMaybe,
isYoutubeTarget,
uniqueNormalizedLangCodes,
sleep,
normalizeLangCode,
} from './util.js';
export const state = {
overlayProc: null as ReturnType<typeof spawn> | null,
mpvProc: null as ReturnType<typeof spawn> | null,
youtubeSubgenChildren: new Set<ReturnType<typeof spawn>>(),
appPath: "" as string,
appPath: '' as string,
overlayManagedByLauncher: false,
stopRequested: false,
};
const DETACHED_IDLE_MPV_PID_FILE = path.join(
os.tmpdir(),
"subminer-idle-mpv.pid",
);
const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid');
function readTrackedDetachedMpvPid(): number | null {
try {
const raw = fs.readFileSync(DETACHED_IDLE_MPV_PID_FILE, "utf8").trim();
const raw = fs.readFileSync(DETACHED_IDLE_MPV_PID_FILE, 'utf8').trim();
const pid = Number.parseInt(raw, 10);
return Number.isInteger(pid) && pid > 0 ? pid : null;
} catch {
@@ -45,7 +48,7 @@ function clearTrackedDetachedMpvPid(): void {
function trackDetachedMpvPid(pid: number): void {
try {
fs.writeFileSync(DETACHED_IDLE_MPV_PID_FILE, String(pid), "utf8");
fs.writeFileSync(DETACHED_IDLE_MPV_PID_FILE, String(pid), 'utf8');
} catch {
// ignore
}
@@ -61,10 +64,10 @@ function isProcessAlive(pid: number): boolean {
}
function processLooksLikeMpv(pid: number): boolean {
if (process.platform !== "linux") return true;
if (process.platform !== 'linux') return true;
try {
const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8");
return cmdline.includes("mpv");
const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
return cmdline.includes('mpv');
} catch {
return false;
}
@@ -83,7 +86,7 @@ async function terminateTrackedDetachedMpv(logLevel: LogLevel): Promise<void> {
}
try {
process.kill(pid, "SIGTERM");
process.kill(pid, 'SIGTERM');
} catch {
clearTrackedDetachedMpvPid();
return;
@@ -99,62 +102,62 @@ async function terminateTrackedDetachedMpv(logLevel: LogLevel): Promise<void> {
}
try {
process.kill(pid, "SIGKILL");
process.kill(pid, 'SIGKILL');
} catch {
// ignore
}
clearTrackedDetachedMpvPid();
log("debug", logLevel, `Terminated stale detached mpv pid=${pid}`);
log('debug', logLevel, `Terminated stale detached mpv pid=${pid}`);
}
export function makeTempDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
export function detectBackend(backend: Backend): Exclude<Backend, "auto"> {
if (backend !== "auto") return backend;
if (process.platform === "darwin") return "macos";
const xdgCurrentDesktop = (process.env.XDG_CURRENT_DESKTOP || "").toLowerCase();
const xdgSessionDesktop = (process.env.XDG_SESSION_DESKTOP || "").toLowerCase();
const xdgSessionType = (process.env.XDG_SESSION_TYPE || "").toLowerCase();
const hasWayland = Boolean(process.env.WAYLAND_DISPLAY) || xdgSessionType === "wayland";
export function detectBackend(backend: Backend): Exclude<Backend, 'auto'> {
if (backend !== 'auto') return backend;
if (process.platform === 'darwin') return 'macos';
const xdgCurrentDesktop = (process.env.XDG_CURRENT_DESKTOP || '').toLowerCase();
const xdgSessionDesktop = (process.env.XDG_SESSION_DESKTOP || '').toLowerCase();
const xdgSessionType = (process.env.XDG_SESSION_TYPE || '').toLowerCase();
const hasWayland = Boolean(process.env.WAYLAND_DISPLAY) || xdgSessionType === 'wayland';
if (
process.env.HYPRLAND_INSTANCE_SIGNATURE ||
xdgCurrentDesktop.includes("hyprland") ||
xdgSessionDesktop.includes("hyprland")
xdgCurrentDesktop.includes('hyprland') ||
xdgSessionDesktop.includes('hyprland')
) {
return "hyprland";
return 'hyprland';
}
if (hasWayland && commandExists("hyprctl")) return "hyprland";
if (process.env.DISPLAY) return "x11";
fail("Could not detect display backend");
if (hasWayland && commandExists('hyprctl')) return 'hyprland';
if (process.env.DISPLAY) return 'x11';
fail('Could not detect display backend');
}
function resolveMacAppBinaryCandidate(candidate: string): string {
const direct = resolveBinaryPathCandidate(candidate);
if (!direct) return "";
if (!direct) return '';
if (process.platform !== "darwin") {
return isExecutable(direct) ? direct : "";
if (process.platform !== 'darwin') {
return isExecutable(direct) ? direct : '';
}
if (isExecutable(direct)) {
return direct;
}
const appIndex = direct.indexOf(".app/");
const appIndex = direct.indexOf('.app/');
const appPath =
direct.endsWith(".app") && direct.includes(".app")
direct.endsWith('.app') && direct.includes('.app')
? direct
: appIndex >= 0
? direct.slice(0, appIndex + ".app".length)
: "";
if (!appPath) return "";
? direct.slice(0, appIndex + '.app'.length)
: '';
if (!appPath) return '';
const candidates = [
path.join(appPath, "Contents", "MacOS", "SubMiner"),
path.join(appPath, "Contents", "MacOS", "subminer"),
path.join(appPath, 'Contents', 'MacOS', 'SubMiner'),
path.join(appPath, 'Contents', 'MacOS', 'subminer'),
];
for (const candidateBinary of candidates) {
@@ -163,14 +166,13 @@ function resolveMacAppBinaryCandidate(candidate: string): string {
}
}
return "";
return '';
}
export function findAppBinary(selfPath: string): string | null {
const envPaths = [
process.env.SUBMINER_APPIMAGE_PATH,
process.env.SUBMINER_BINARY_PATH,
].filter((candidate): candidate is string => Boolean(candidate));
const envPaths = [process.env.SUBMINER_APPIMAGE_PATH, process.env.SUBMINER_BINARY_PATH].filter(
(candidate): candidate is string => Boolean(candidate),
);
for (const envPath of envPaths) {
const resolved = resolveMacAppBinaryCandidate(envPath);
@@ -180,32 +182,22 @@ export function findAppBinary(selfPath: string): string | null {
}
const candidates: string[] = [];
if (process.platform === "darwin") {
candidates.push("/Applications/SubMiner.app/Contents/MacOS/SubMiner");
candidates.push("/Applications/SubMiner.app/Contents/MacOS/subminer");
candidates.push(
path.join(
os.homedir(),
"Applications/SubMiner.app/Contents/MacOS/SubMiner",
),
);
candidates.push(
path.join(
os.homedir(),
"Applications/SubMiner.app/Contents/MacOS/subminer",
),
);
if (process.platform === 'darwin') {
candidates.push('/Applications/SubMiner.app/Contents/MacOS/SubMiner');
candidates.push('/Applications/SubMiner.app/Contents/MacOS/subminer');
candidates.push(path.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/SubMiner'));
candidates.push(path.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/subminer'));
}
candidates.push(path.join(os.homedir(), ".local/bin/SubMiner.AppImage"));
candidates.push("/opt/SubMiner/SubMiner.AppImage");
candidates.push(path.join(os.homedir(), '.local/bin/SubMiner.AppImage'));
candidates.push('/opt/SubMiner/SubMiner.AppImage');
for (const candidate of candidates) {
if (isExecutable(candidate)) return candidate;
}
const fromPath = process.env.PATH?.split(path.delimiter)
.map((dir) => path.join(dir, "subminer"))
.map((dir) => path.join(dir, 'subminer'))
.find((candidate) => isExecutable(candidate));
if (fromPath) {
@@ -220,12 +212,12 @@ export function findAppBinary(selfPath: string): string | null {
export function sendMpvCommand(socketPath: string, command: unknown[]): Promise<void> {
return new Promise((resolve, reject) => {
const socket = net.createConnection(socketPath);
socket.once("connect", () => {
socket.once('connect', () => {
socket.write(`${JSON.stringify({ command })}\n`);
socket.end();
resolve();
});
socket.once("error", (error) => {
socket.once('error', (error) => {
reject(error);
});
});
@@ -245,7 +237,7 @@ export function sendMpvCommandWithResponse(
return new Promise((resolve, reject) => {
const requestId = Date.now() + Math.floor(Math.random() * 1000);
const socket = net.createConnection(socketPath);
let buffer = "";
let buffer = '';
const cleanup = (): void => {
try {
@@ -266,15 +258,15 @@ export function sendMpvCommandWithResponse(
resolve(value);
};
socket.once("connect", () => {
socket.once('connect', () => {
const message = JSON.stringify({ command, request_id: requestId });
socket.write(`${message}\n`);
});
socket.on("data", (chunk: Buffer) => {
socket.on('data', (chunk: Buffer) => {
buffer += chunk.toString();
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? "";
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.trim()) continue;
let parsed: MpvResponseEnvelope;
@@ -284,7 +276,7 @@ export function sendMpvCommandWithResponse(
continue;
}
if (parsed.request_id !== requestId) continue;
if (parsed.error && parsed.error !== "success") {
if (parsed.error && parsed.error !== 'success') {
reject(new Error(`MPV error: ${parsed.error}`));
cleanup();
clearTimeout(timer);
@@ -295,7 +287,7 @@ export function sendMpvCommandWithResponse(
}
});
socket.once("error", (error) => {
socket.once('error', (error) => {
clearTimeout(timer);
cleanup();
reject(error);
@@ -306,32 +298,29 @@ export function sendMpvCommandWithResponse(
export async function getMpvTracks(socketPath: string): Promise<MpvTrack[]> {
const response = await sendMpvCommandWithResponse(
socketPath,
["get_property", "track-list"],
['get_property', 'track-list'],
8000,
);
if (!Array.isArray(response)) return [];
return response
.filter((track): track is MpvTrack => {
if (!track || typeof track !== "object") return false;
if (!track || typeof track !== 'object') return false;
const candidate = track as Record<string, unknown>;
return candidate.type === "sub";
return candidate.type === 'sub';
})
.map((track) => {
const candidate = track as Record<string, unknown>;
return {
type:
typeof candidate.type === "string" ? candidate.type : undefined,
type: typeof candidate.type === 'string' ? candidate.type : undefined,
id:
typeof candidate.id === "number"
typeof candidate.id === 'number'
? candidate.id
: typeof candidate.id === "string"
: typeof candidate.id === 'string'
? Number.parseInt(candidate.id, 10)
: undefined,
lang:
typeof candidate.lang === "string" ? candidate.lang : undefined,
title:
typeof candidate.title === "string" ? candidate.title : undefined,
lang: typeof candidate.lang === 'string' ? candidate.lang : undefined,
title: typeof candidate.title === 'string' ? candidate.title : undefined,
};
});
}
@@ -340,10 +329,10 @@ function isPreferredStreamLang(candidate: string, preferred: string[]): boolean
const normalized = normalizeLangCode(candidate);
if (!normalized) return false;
if (preferred.includes(normalized)) return true;
if (normalized === "ja" && preferred.includes("jpn")) return true;
if (normalized === "jpn" && preferred.includes("ja")) return true;
if (normalized === "en" && preferred.includes("eng")) return true;
if (normalized === "eng" && preferred.includes("en")) return true;
if (normalized === 'ja' && preferred.includes('jpn')) return true;
if (normalized === 'jpn' && preferred.includes('ja')) return true;
if (normalized === 'en' && preferred.includes('eng')) return true;
if (normalized === 'eng' && preferred.includes('en')) return true;
return false;
}
@@ -352,7 +341,7 @@ export function findPreferredSubtitleTrack(
preferredLanguages: string[],
): MpvTrack | null {
const normalizedPreferred = uniqueNormalizedLangCodes(preferredLanguages);
const subtitleTracks = tracks.filter((track) => track.type === "sub");
const subtitleTracks = tracks.filter((track) => track.type === 'sub');
if (normalizedPreferred.length === 0) return subtitleTracks[0] ?? null;
for (const lang of normalizedPreferred) {
@@ -374,11 +363,7 @@ export async function waitForSubtitleTrackList(
const tracks = await getMpvTracks(socketPath).catch(() => [] as MpvTrack[]);
if (tracks.length > 0) return tracks;
if (attempt % 10 === 0) {
log(
"debug",
logLevel,
`Waiting for mpv tracks (${attempt}/${maxAttempts})`,
);
log('debug', logLevel, `Waiting for mpv tracks (${attempt}/${maxAttempts})`);
}
await sleep(250);
}
@@ -403,7 +388,7 @@ export async function loadSubtitleIntoMpv(
if (!fs.existsSync(socketPath)) {
if (attempt % 20 === 0) {
log(
"debug",
'debug',
logLevel,
`Waiting for mpv socket before loading subtitle (${attempt} attempts): ${path.basename(subtitlePath)}`,
);
@@ -414,18 +399,14 @@ export async function loadSubtitleIntoMpv(
try {
await sendMpvCommand(
socketPath,
select ? ["sub-add", subtitlePath, "select"] : ["sub-add", subtitlePath],
);
log(
"info",
logLevel,
`Loaded generated subtitle into mpv: ${path.basename(subtitlePath)}`,
select ? ['sub-add', subtitlePath, 'select'] : ['sub-add', subtitlePath],
);
log('info', logLevel, `Loaded generated subtitle into mpv: ${path.basename(subtitlePath)}`);
return;
} catch {
if (attempt % 20 === 0) {
log(
"debug",
'debug',
logLevel,
`Retrying subtitle load into mpv (${attempt} attempts): ${path.basename(subtitlePath)}`,
);
@@ -435,10 +416,7 @@ export async function loadSubtitleIntoMpv(
}
}
export function waitForSocket(
socketPath: string,
timeoutMs = 10000,
): Promise<boolean> {
export function waitForSocket(socketPath: string, timeoutMs = 10000): Promise<boolean> {
const start = Date.now();
return new Promise((resolve) => {
const timer = setInterval(() => {
@@ -457,54 +435,48 @@ export function waitForSocket(
export function startMpv(
target: string,
targetKind: "file" | "url",
targetKind: 'file' | 'url',
args: Args,
socketPath: string,
appPath: string,
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
): void {
if (
targetKind === "file" &&
(!fs.existsSync(target) || !fs.statSync(target).isFile())
) {
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
fail(`Video file not found: ${target}`);
}
if (targetKind === "url") {
log("info", args.logLevel, `Playing URL: ${target}`);
if (targetKind === 'url') {
log('info', args.logLevel, `Playing URL: ${target}`);
} else {
log("info", args.logLevel, `Playing: ${path.basename(target)}`);
log('info', args.logLevel, `Playing: ${path.basename(target)}`);
}
const mpvArgs: string[] = [];
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
if (targetKind === "url" && isYoutubeTarget(target)) {
log("info", args.logLevel, "Applying URL playback options");
mpvArgs.push("--ytdl=yes", "--ytdl-raw-options=");
if (targetKind === 'url' && isYoutubeTarget(target)) {
log('info', args.logLevel, 'Applying URL playback options');
mpvArgs.push('--ytdl=yes', '--ytdl-raw-options=');
if (isYoutubeTarget(target)) {
const subtitleLangs = uniqueNormalizedLangCodes([
...args.youtubePrimarySubLangs,
...args.youtubeSecondarySubLangs,
]).join(",");
const audioLangs = uniqueNormalizedLangCodes(args.youtubeAudioLangs).join(",");
log("info", args.logLevel, "Applying YouTube playback options");
log("debug", args.logLevel, `YouTube subtitle langs: ${subtitleLangs}`);
log("debug", args.logLevel, `YouTube audio langs: ${audioLangs}`);
mpvArgs.push(
`--ytdl-format=${DEFAULT_YOUTUBE_YTDL_FORMAT}`,
`--alang=${audioLangs}`,
);
]).join(',');
const audioLangs = uniqueNormalizedLangCodes(args.youtubeAudioLangs).join(',');
log('info', args.logLevel, 'Applying YouTube playback options');
log('debug', args.logLevel, `YouTube subtitle langs: ${subtitleLangs}`);
log('debug', args.logLevel, `YouTube audio langs: ${audioLangs}`);
mpvArgs.push(`--ytdl-format=${DEFAULT_YOUTUBE_YTDL_FORMAT}`, `--alang=${audioLangs}`);
if (args.youtubeSubgenMode === "off") {
if (args.youtubeSubgenMode === 'off') {
mpvArgs.push(
"--sub-auto=fuzzy",
'--sub-auto=fuzzy',
`--slang=${subtitleLangs}`,
"--ytdl-raw-options-append=write-auto-subs=",
"--ytdl-raw-options-append=write-subs=",
"--ytdl-raw-options-append=sub-format=vtt/best",
'--ytdl-raw-options-append=write-auto-subs=',
'--ytdl-raw-options-append=write-subs=',
'--ytdl-raw-options-append=sub-format=vtt/best',
`--ytdl-raw-options-append=sub-langs=${subtitleLangs}`,
);
}
@@ -517,9 +489,7 @@ export function startMpv(
if (preloadedSubtitles?.secondaryPath) {
mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`);
}
mpvArgs.push(
`--script-opts=subminer-binary_path=${appPath},subminer-socket_path=${socketPath}`,
);
mpvArgs.push(`--script-opts=subminer-binary_path=${appPath},subminer-socket_path=${socketPath}`);
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
try {
@@ -531,28 +501,19 @@ export function startMpv(
mpvArgs.push(`--input-ipc-server=${socketPath}`);
mpvArgs.push(target);
state.mpvProc = spawn("mpv", mpvArgs, { stdio: "inherit" });
state.mpvProc = spawn('mpv', mpvArgs, { stdio: 'inherit' });
}
export function startOverlay(
appPath: string,
args: Args,
socketPath: string,
): Promise<void> {
export function startOverlay(appPath: string, args: Args, socketPath: string): Promise<void> {
const backend = detectBackend(args.backend);
log(
"info",
args.logLevel,
`Starting SubMiner overlay (backend: ${backend})...`,
);
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
const overlayArgs = ["--start", "--backend", backend, "--socket", socketPath];
if (args.logLevel !== "info")
overlayArgs.push("--log-level", args.logLevel);
if (args.useTexthooker) overlayArgs.push("--texthooker");
const overlayArgs = ['--start', '--backend', backend, '--socket', socketPath];
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
if (args.useTexthooker) overlayArgs.push('--texthooker');
state.overlayProc = spawn(appPath, overlayArgs, {
stdio: "inherit",
stdio: 'inherit',
env: { ...process.env, SUBMINER_MPV_LOG: getMpvLogPath() },
});
state.overlayManagedByLauncher = true;
@@ -563,12 +524,11 @@ export function startOverlay(
}
export function launchTexthookerOnly(appPath: string, args: Args): never {
const overlayArgs = ["--texthooker"];
if (args.logLevel !== "info")
overlayArgs.push("--log-level", args.logLevel);
const overlayArgs = ['--texthooker'];
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
log("info", args.logLevel, "Launching texthooker mode...");
const result = spawnSync(appPath, overlayArgs, { stdio: "inherit" });
log('info', args.logLevel, 'Launching texthooker mode...');
const result = spawnSync(appPath, overlayArgs, { stdio: 'inherit' });
process.exit(result.status ?? 0);
}
@@ -577,17 +537,16 @@ export function stopOverlay(args: Args): void {
state.stopRequested = true;
if (state.overlayManagedByLauncher && state.appPath) {
log("info", args.logLevel, "Stopping SubMiner overlay...");
log('info', args.logLevel, 'Stopping SubMiner overlay...');
const stopArgs = ["--stop"];
if (args.logLevel !== "info")
stopArgs.push("--log-level", args.logLevel);
const stopArgs = ['--stop'];
if (args.logLevel !== 'info') stopArgs.push('--log-level', args.logLevel);
spawnSync(state.appPath, stopArgs, { stdio: "ignore" });
spawnSync(state.appPath, stopArgs, { stdio: 'ignore' });
if (state.overlayProc && !state.overlayProc.killed) {
try {
state.overlayProc.kill("SIGTERM");
state.overlayProc.kill('SIGTERM');
} catch {
// ignore
}
@@ -596,7 +555,7 @@ export function stopOverlay(args: Args): void {
if (state.mpvProc && !state.mpvProc.killed) {
try {
state.mpvProc.kill("SIGTERM");
state.mpvProc.kill('SIGTERM');
} catch {
// ignore
}
@@ -605,7 +564,7 @@ export function stopOverlay(args: Args): void {
for (const child of state.youtubeSubgenChildren) {
if (!child.killed) {
try {
child.kill("SIGTERM");
child.kill('SIGTERM');
} catch {
// ignore
}
@@ -617,15 +576,18 @@ export function stopOverlay(args: Args): void {
}
function buildAppEnv(): NodeJS.ProcessEnv {
const env: Record<string, string | undefined> = { ...process.env, SUBMINER_MPV_LOG: getMpvLogPath() };
const env: Record<string, string | undefined> = {
...process.env,
SUBMINER_MPV_LOG: getMpvLogPath(),
};
const layers = env.VK_INSTANCE_LAYERS;
if (typeof layers === "string" && layers.trim().length > 0) {
if (typeof layers === 'string' && layers.trim().length > 0) {
const filtered = layers
.split(":")
.split(':')
.map((part) => part.trim())
.filter((part) => part.length > 0 && !/lsfg/i.test(part));
if (filtered.length > 0) {
env.VK_INSTANCE_LAYERS = filtered.join(":");
env.VK_INSTANCE_LAYERS = filtered.join(':');
} else {
delete env.VK_INSTANCE_LAYERS;
}
@@ -633,12 +595,9 @@ function buildAppEnv(): NodeJS.ProcessEnv {
return env;
}
export function runAppCommandWithInherit(
appPath: string,
appArgs: string[],
): never {
export function runAppCommandWithInherit(appPath: string, appArgs: string[]): never {
const result = spawnSync(appPath, appArgs, {
stdio: "inherit",
stdio: 'inherit',
env: buildAppEnv(),
});
if (result.error) {
@@ -653,27 +612,23 @@ export function runAppCommandWithInheritLogged(
logLevel: LogLevel,
label: string,
): never {
log("debug", logLevel, `${label}: launching app with args: ${appArgs.join(" ")}`);
log('debug', logLevel, `${label}: launching app with args: ${appArgs.join(' ')}`);
const result = spawnSync(appPath, appArgs, {
stdio: "inherit",
stdio: 'inherit',
env: buildAppEnv(),
});
if (result.error) {
fail(`Failed to run app command: ${result.error.message}`);
}
log(
"debug",
logLevel,
`${label}: app command exited with status ${result.status ?? 0}`,
);
log('debug', logLevel, `${label}: app command exited with status ${result.status ?? 0}`);
process.exit(result.status ?? 0);
}
export function launchAppStartDetached(appPath: string, logLevel: LogLevel): void {
const startArgs = ["--start"];
if (logLevel !== "info") startArgs.push("--log-level", logLevel);
const startArgs = ['--start'];
if (logLevel !== 'info') startArgs.push('--log-level', logLevel);
const proc = spawn(appPath, startArgs, {
stdio: "ignore",
stdio: 'ignore',
detached: true,
env: buildAppEnv(),
});
@@ -693,23 +648,23 @@ export function launchMpvIdleDetached(
// ignore
}
const mpvArgs: string[] = [];
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
mpvArgs.push("--idle=yes");
mpvArgs.push(
`--script-opts=subminer-binary_path=${appPath},subminer-socket_path=${socketPath}`,
);
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
mpvArgs.push(`--input-ipc-server=${socketPath}`);
const proc = spawn("mpv", mpvArgs, {
stdio: "ignore",
detached: true,
});
if (typeof proc.pid === "number" && proc.pid > 0) {
trackDetachedMpvPid(proc.pid);
}
proc.unref();
const mpvArgs: string[] = [];
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
mpvArgs.push('--idle=yes');
mpvArgs.push(
`--script-opts=subminer-binary_path=${appPath},subminer-socket_path=${socketPath}`,
);
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
mpvArgs.push(`--input-ipc-server=${socketPath}`);
const proc = spawn('mpv', mpvArgs, {
stdio: 'ignore',
detached: true,
});
if (typeof proc.pid === 'number' && proc.pid > 0) {
trackDetachedMpvPid(proc.pid);
}
proc.unref();
})();
}
@@ -717,10 +672,7 @@ async function sleepMs(ms: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, ms));
}
async function waitForPathExists(
filePath: string,
timeoutMs: number,
): Promise<boolean> {
async function waitForPathExists(filePath: string, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
@@ -749,8 +701,8 @@ async function canConnectUnixSocket(socketPath: string): Promise<boolean> {
resolve(value);
};
socket.once("connect", () => finish(true));
socket.once("error", () => finish(false));
socket.once('connect', () => finish(true));
socket.once('error', () => finish(false));
socket.setTimeout(400, () => finish(false));
});
}

View File

@@ -1,11 +1,17 @@
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";
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, `'\\''`)}'`;
@@ -14,87 +20,69 @@ export function escapeShellSingle(value: string): string {
export function showRofiFlatMenu(
items: string[],
prompt: string,
initialQuery = "",
initialQuery = '',
themePath: string | null = null,
): string {
const args = [
"-dmenu",
"-i",
"-matching",
"fuzzy",
"-p",
prompt,
];
const args = ['-dmenu', '-i', '-matching', 'fuzzy', '-p', prompt];
if (themePath) {
args.push("-theme", themePath);
args.push('-theme', themePath);
} else {
args.push(
"-theme-str",
'configuration { font: "Noto Sans CJK JP Regular 8";}',
);
args.push('-theme-str', 'configuration { font: "Noto Sans CJK JP Regular 8";}');
}
if (initialQuery.trim().length > 0) {
args.push("-filter", initialQuery.trim());
args.push('-filter', initialQuery.trim());
}
const result = spawnSync(
"rofi",
args,
{
input: `${items.join("\n")}\n`,
encoding: "utf8",
stdio: ["pipe", "pipe", "ignore"],
},
);
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));
fail(formatPickerLaunchError('rofi', result.error as NodeJS.ErrnoException));
}
return (result.stdout || "").trim();
return (result.stdout || '').trim();
}
export function showFzfFlatMenu(
lines: string[],
prompt: string,
previewCommand: string,
initialQuery = "",
initialQuery = '',
): string {
const args = [
"--ansi",
"--reverse",
"--ignore-case",
'--ansi',
'--reverse',
'--ignore-case',
`--prompt=${prompt}`,
"--delimiter=\t",
"--with-nth=2",
"--preview-window=right:50%:wrap",
"--preview",
'--delimiter=\t',
'--with-nth=2',
'--preview-window=right:50%:wrap',
'--preview',
previewCommand,
];
if (initialQuery.trim().length > 0) {
args.push("--query", initialQuery.trim());
args.push('--query', initialQuery.trim());
}
const result = spawnSync(
"fzf",
args,
{
input: `${lines.join("\n")}\n`,
encoding: "utf8",
stdio: ["pipe", "pipe", "inherit"],
},
);
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));
fail(formatPickerLaunchError('fzf', result.error as NodeJS.ErrnoException));
}
return (result.stdout || "").trim();
return (result.stdout || '').trim();
}
export function parseSelectionId(selection: string): string {
if (!selection) return "";
const tab = selection.indexOf("\t");
if (tab === -1) return "";
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");
const tab = selection.indexOf('\t');
if (tab === -1) return selection;
return selection.slice(tab + 1);
}
@@ -121,51 +109,39 @@ 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 (useRofi && commandExists('rofi')) {
const rofiArgs = ['-dmenu', '-i', '-p', 'Jellyfin Search (optional)'];
if (themePath) {
rofiArgs.push("-theme", themePath);
rofiArgs.push('-theme', themePath);
} else {
rofiArgs.push(
"-theme-str",
'configuration { font: "Noto Sans CJK JP Regular 8";}',
);
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();
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 "";
if (!process.stdin.isTTY || !process.stdout.isTTY) return '';
process.stdout.write("Jellyfin search term (optional, press Enter to skip): ");
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();
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);
process.stdin.on('data', onData);
});
}
@@ -177,41 +153,35 @@ interface RofiIconEntry {
function showRofiIconMenu(
entries: RofiIconEntry[],
prompt: string,
initialQuery = "",
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);
const rofiArgs = ['-dmenu', '-i', '-show-icons', '-format', 'i', '-p', prompt];
if (initialQuery) rofiArgs.push('-filter', initialQuery);
if (themePath) {
rofiArgs.push("-theme", themePath);
rofiArgs.push("-theme-str", "configuration { show-icons: true; }");
rofiArgs.push("-theme-str", "element-icon { enabled: true; size: 3em; }");
rofiArgs.push('-theme', themePath);
rofiArgs.push('-theme-str', 'configuration { show-icons: true; }');
rofiArgs.push('-theme-str', 'element-icon { enabled: true; size: 3em; }');
} else {
rofiArgs.push(
"-theme-str",
'-theme-str',
'configuration { font: "Noto Sans CJK JP Regular 8"; show-icons: true; }',
);
rofiArgs.push("-theme-str", "element-icon { enabled: true; size: 3em; }");
rofiArgs.push('-theme-str', 'element-icon { enabled: true; size: 3em; }');
}
const lines = entries.map((entry) =>
entry.iconPath
? `${entry.label}\u0000icon\u001f${entry.iconPath}`
: entry.label
);
const input = Buffer.from(`${lines.join("\n")}\n`, "utf8");
const result = spawnSync(
"rofi",
rofiArgs,
{
input,
encoding: "utf8",
stdio: ["pipe", "pipe", "ignore"],
},
entry.iconPath ? `${entry.label}\u0000icon\u001f${entry.iconPath}` : entry.label,
);
const input = Buffer.from(`${lines.join('\n')}\n`, 'utf8');
const result = spawnSync('rofi', rofiArgs, {
input,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
});
if (result.error) return -1;
const out = (result.stdout || "").trim();
const out = (result.stdout || '').trim();
if (!out) return -1;
const idx = Number.parseInt(out, 10);
return Number.isFinite(idx) ? idx : -1;
@@ -222,47 +192,35 @@ export function pickLibrary(
libraries: JellyfinLibraryEntry[],
useRofi: boolean,
ensureIcon: (session: JellyfinSessionConfig, id: string) => string | null,
initialQuery = "",
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.");
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 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")
? `
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
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"';
: 'echo "Install curl + chafa for image preview"';
const picked = showFzfFlatMenu(
lines,
"Jellyfin Library: ",
preview,
initialQuery,
);
const picked = showFzfFlatMenu(lines, 'Jellyfin Library: ', preview, initialQuery);
return parseSelectionId(picked);
}
@@ -271,38 +229,35 @@ export function pickItem(
items: JellyfinItemEntry[],
useRofi: boolean,
ensureIcon: (session: JellyfinSessionConfig, id: string) => string | null,
initialQuery = "",
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.");
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 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")
? `
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
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"';
: 'echo "Install curl + chafa for image preview"';
const picked = showFzfFlatMenu(lines, "Jellyfin Item: ", preview, initialQuery);
const picked = showFzfFlatMenu(lines, 'Jellyfin Item: ', preview, initialQuery);
return parseSelectionId(picked);
}
@@ -311,54 +266,46 @@ export function pickGroup(
groups: JellyfinGroupEntry[],
useRofi: boolean,
ensureIcon: (session: JellyfinSessionConfig, id: string) => string | null,
initialQuery = "",
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 "";
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 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")
? `
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
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"';
: 'echo "Install curl + chafa for image preview"';
const picked = showFzfFlatMenu(
lines,
"Jellyfin Anime/Folder: ",
preview,
initialQuery,
);
const picked = showFzfFlatMenu(lines, 'Jellyfin Anime/Folder: ', preview, initialQuery);
return parseSelectionId(picked);
}
export function formatPickerLaunchError(
picker: "rofi" | "fzf",
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.";
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}`;
}
@@ -388,23 +335,15 @@ export function collectVideos(dir: string, recursive: boolean): string[] {
};
walk(root);
return out.sort((a, b) =>
a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }),
);
return out.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
}
export function buildRofiMenu(
videos: string[],
dir: string,
recursive: boolean,
): Buffer {
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 display = recursive ? path.relative(dir, video) : path.basename(video);
const line = `${display}\0icon\x1fthumbnail://${video}\n`;
chunks.push(Buffer.from(line, "utf8"));
chunks.push(Buffer.from(line, 'utf8'));
}
return Buffer.concat(chunks);
}
@@ -416,22 +355,15 @@ export function findRofiTheme(scriptPath: string): string | null {
const scriptDir = path.dirname(realpathMaybe(scriptPath));
const candidates: string[] = [];
if (process.platform === "darwin") {
if (process.platform === 'darwin') {
candidates.push(
path.join(
os.homedir(),
"Library/Application Support/SubMiner/themes",
ROFI_THEME_FILE,
),
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));
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));
@@ -451,52 +383,50 @@ export function showRofiMenu(
logLevel: LogLevel,
): string {
const args = [
"-dmenu",
"-i",
"-p",
"Select Video ",
"-show-icons",
"-theme-str",
'-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);
args.push('-theme', theme);
} else {
log(
"warn",
'warn',
logLevel,
"Rofi theme not found; using rofi defaults (set SUBMINER_ROFI_THEME to override)",
'Rofi theme not found; using rofi defaults (set SUBMINER_ROFI_THEME to override)',
);
}
const result = spawnSync("rofi", args, {
const result = spawnSync('rofi', args, {
input: buildRofiMenu(videos, dir, recursive),
encoding: "utf8",
stdio: ["pipe", "pipe", "ignore"],
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
});
if (result.error) {
fail(
formatPickerLaunchError("rofi", result.error as NodeJS.ErrnoException),
);
fail(formatPickerLaunchError('rofi', result.error as NodeJS.ErrnoException));
}
const selection = (result.stdout || "").trim();
if (!selection) return "";
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");
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";
? '--format=symbols --symbols=vhalf+wide --color-space=din99d'
: '--format=kitty';
const previewCmd = commandExists("chafa")
const previewCmd = commandExists('chafa')
? `
video={2}
thumb_dir="$HOME/.cache/thumbnails/large"
@@ -521,35 +451,35 @@ get_thumb() {
}
thumb=$(get_thumb)
[[ -n "$thumb" ]] && chafa ${chafaFormat} --size=${"${FZF_PREVIEW_COLUMNS}"}x${"${FZF_PREVIEW_LINES}"} "$thumb" 2>/dev/null
[[ -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",
'fzf',
[
"--ansi",
"--reverse",
"--prompt=Select Video: ",
"--delimiter=\t",
"--with-nth=1",
"--preview-window=right:50%:wrap",
"--preview",
'--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"],
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'inherit'],
},
);
if (result.error) {
fail(formatPickerLaunchError("fzf", result.error as NodeJS.ErrnoException));
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 "";
const selection = (result.stdout || '').trim();
if (!selection) return '';
const tabIndex = selection.indexOf('\t');
if (tabIndex === -1) return '';
return selection.slice(tabIndex + 1);
}

View File

@@ -1,61 +1,56 @@
import path from "node:path";
import os from "node:os";
import path from 'node:path';
import os from 'node:os';
export const VIDEO_EXTENSIONS = new Set([
"mkv",
"mp4",
"avi",
"webm",
"mov",
"flv",
"wmv",
"m4v",
"ts",
"m2ts",
'mkv',
'mp4',
'avi',
'webm',
'mov',
'flv',
'wmv',
'm4v',
'ts',
'm2ts',
]);
export const ROFI_THEME_FILE = "subminer.rasi";
export const DEFAULT_SOCKET_PATH = "/tmp/subminer-socket";
export const DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS = ["ja", "jpn"];
export const DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS = ["en", "eng"];
export const YOUTUBE_SUB_EXTENSIONS = new Set([".srt", ".vtt", ".ass"]);
export const ROFI_THEME_FILE = 'subminer.rasi';
export const DEFAULT_SOCKET_PATH = '/tmp/subminer-socket';
export const DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS = ['ja', 'jpn'];
export const DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS = ['en', 'eng'];
export const YOUTUBE_SUB_EXTENSIONS = new Set(['.srt', '.vtt', '.ass']);
export const YOUTUBE_AUDIO_EXTENSIONS = new Set([
".m4a",
".mp3",
".webm",
".opus",
".wav",
".aac",
".flac",
'.m4a',
'.mp3',
'.webm',
'.opus',
'.wav',
'.aac',
'.flac',
]);
export const DEFAULT_YOUTUBE_SUBGEN_OUT_DIR = path.join(
os.homedir(),
".cache",
"subminer",
"youtube-subs",
'.cache',
'subminer',
'youtube-subs',
);
export const DEFAULT_MPV_LOG_FILE = path.join(
os.homedir(),
".cache",
"SubMiner",
"mp.log",
);
export const DEFAULT_YOUTUBE_YTDL_FORMAT = "bestvideo*+bestaudio/best";
export const DEFAULT_JIMAKU_API_BASE_URL = "https://jimaku.cc";
export const DEFAULT_MPV_LOG_FILE = path.join(os.homedir(), '.cache', 'SubMiner', 'mp.log');
export const DEFAULT_YOUTUBE_YTDL_FORMAT = 'bestvideo*+bestaudio/best';
export const DEFAULT_JIMAKU_API_BASE_URL = 'https://jimaku.cc';
export const DEFAULT_MPV_SUBMINER_ARGS = [
"--sub-auto=fuzzy",
"--sub-file-paths=.;subs;subtitles",
"--sid=auto",
"--secondary-sid=auto",
"--secondary-sub-visibility=no",
"--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us",
"--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us",
'--sub-auto=fuzzy',
'--sub-file-paths=.;subs;subtitles',
'--sid=auto',
'--secondary-sid=auto',
'--secondary-sub-visibility=no',
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
] as const;
export type LogLevel = "debug" | "info" | "warn" | "error";
export type YoutubeSubgenMode = "automatic" | "preprocess" | "off";
export type Backend = "auto" | "hyprland" | "x11" | "macos";
export type JimakuLanguagePreference = "ja" | "en" | "none";
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export type YoutubeSubgenMode = 'automatic' | 'preprocess' | 'off';
export type Backend = 'auto' | 'hyprland' | 'x11' | 'macos';
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
export interface Args {
backend: Backend;
@@ -79,7 +74,7 @@ export interface Args {
useRofi: boolean;
logLevel: LogLevel;
target: string;
targetKind: "" | "file" | "url";
targetKind: '' | 'file' | 'url';
jimakuApiKey: string;
jimakuApiKeyCommand: string;
jimakuApiBaseUrl: string;
@@ -147,10 +142,10 @@ export interface CommandExecResult {
export interface SubtitleCandidate {
path: string;
lang: "primary" | "secondary";
lang: 'primary' | 'secondary';
ext: string;
size: number;
source: "manual" | "auto" | "whisper" | "whisper-translate";
source: 'manual' | 'auto' | 'whisper' | 'whisper-translate';
}
export interface YoutubeSubgenOutputs {

View File

@@ -1,9 +1,9 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { spawn } from "node:child_process";
import type { LogLevel, CommandExecOptions, CommandExecResult } from "./types.js";
import { log } from "./log.js";
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { spawn } from 'node:child_process';
import type { LogLevel, CommandExecOptions, CommandExecResult } from './types.js';
import { log } from './log.js';
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -19,7 +19,7 @@ export function isExecutable(filePath: string): boolean {
}
export function commandExists(command: string): boolean {
const pathEnv = process.env.PATH ?? "";
const pathEnv = process.env.PATH ?? '';
for (const dir of pathEnv.split(path.delimiter)) {
if (!dir) continue;
const full = path.join(dir, command);
@@ -29,7 +29,7 @@ export function commandExists(command: string): boolean {
}
export function resolvePathMaybe(input: string): string {
if (input.startsWith("~")) {
if (input.startsWith('~')) {
return path.join(os.homedir(), input.slice(1));
}
return input;
@@ -37,8 +37,8 @@ export function resolvePathMaybe(input: string): string {
export function resolveBinaryPathCandidate(input: string): string {
const trimmed = input.trim();
if (!trimmed) return "";
const unquoted = trimmed.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1");
if (!trimmed) return '';
const unquoted = trimmed.replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1');
return resolvePathMaybe(unquoted);
}
@@ -55,22 +55,19 @@ export function isUrlTarget(target: string): boolean {
}
export function isYoutubeTarget(target: string): boolean {
return (
/^ytsearch:/.test(target) ||
/^https?:\/\/(www\.)?(youtube\.com|youtu\.be)\//.test(target)
);
return /^ytsearch:/.test(target) || /^https?:\/\/(www\.)?(youtube\.com|youtu\.be)\//.test(target);
}
export function sanitizeToken(value: string): string {
return String(value)
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
export function normalizeBasename(value: string, fallback: string): string {
const safe = sanitizeToken(value.replace(/[\\/]+/g, "-"));
const safe = sanitizeToken(value.replace(/[\\/]+/g, '-'));
if (safe) return safe;
const fallbackSafe = sanitizeToken(fallback);
if (fallbackSafe) return fallbackSafe;
@@ -78,7 +75,10 @@ export function normalizeBasename(value: string, fallback: string): string {
}
export function normalizeLangCode(value: string): string {
return value.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "");
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '');
}
export function uniqueNormalizedLangCodes(values: string[]): string[] {
@@ -94,25 +94,15 @@ export function uniqueNormalizedLangCodes(values: string[]): string[] {
}
export function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function parseBoolLike(value: string): boolean | null {
const normalized = value.trim().toLowerCase();
if (
normalized === "yes" ||
normalized === "true" ||
normalized === "1" ||
normalized === "on"
) {
if (normalized === 'yes' || normalized === 'true' || normalized === '1' || normalized === 'on') {
return true;
}
if (
normalized === "no" ||
normalized === "false" ||
normalized === "0" ||
normalized === "off"
) {
if (normalized === 'no' || normalized === 'false' || normalized === '0' || normalized === 'off') {
return false;
}
return null;
@@ -120,7 +110,7 @@ export function parseBoolLike(value: string): boolean | null {
export function inferWhisperLanguage(langCodes: string[], fallback: string): string {
for (const lang of uniqueNormalizedLangCodes(langCodes)) {
if (lang === "jpn") return "ja";
if (lang === 'jpn') return 'ja';
if (lang.length >= 2) return lang.slice(0, 2);
}
return fallback;
@@ -134,29 +124,29 @@ export function runExternalCommand(
): Promise<CommandExecResult> {
const allowFailure = opts.allowFailure === true;
const captureStdout = opts.captureStdout === true;
const configuredLogLevel = opts.logLevel ?? "info";
const configuredLogLevel = opts.logLevel ?? 'info';
const commandLabel = opts.commandLabel || executable;
const streamOutput = opts.streamOutput === true;
return new Promise((resolve, reject) => {
log("debug", configuredLogLevel, `[${commandLabel}] spawn: ${executable} ${args.join(" ")}`);
log('debug', configuredLogLevel, `[${commandLabel}] spawn: ${executable} ${args.join(' ')}`);
const child = spawn(executable, args, {
stdio: ["ignore", "pipe", "pipe"],
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ...opts.env },
});
childTracker?.add(child);
let stdout = "";
let stderr = "";
let stdoutBuffer = "";
let stderrBuffer = "";
let stdout = '';
let stderr = '';
let stdoutBuffer = '';
let stderrBuffer = '';
const flushLines = (
buffer: string,
level: LogLevel,
sink: (remaining: string) => void,
): void => {
const lines = buffer.split(/\r?\n/);
const remaining = lines.pop() ?? "";
const remaining = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.length > 0) {
@@ -166,56 +156,54 @@ export function runExternalCommand(
sink(remaining);
};
child.stdout.on("data", (chunk: Buffer) => {
child.stdout.on('data', (chunk: Buffer) => {
const text = chunk.toString();
if (captureStdout) stdout += text;
if (streamOutput) {
stdoutBuffer += text;
flushLines(stdoutBuffer, "debug", (remaining) => {
flushLines(stdoutBuffer, 'debug', (remaining) => {
stdoutBuffer = remaining;
});
}
});
child.stderr.on("data", (chunk: Buffer) => {
child.stderr.on('data', (chunk: Buffer) => {
const text = chunk.toString();
stderr += text;
if (streamOutput) {
stderrBuffer += text;
flushLines(stderrBuffer, "debug", (remaining) => {
flushLines(stderrBuffer, 'debug', (remaining) => {
stderrBuffer = remaining;
});
}
});
child.on("error", (error) => {
child.on('error', (error) => {
childTracker?.delete(child);
reject(new Error(`Failed to start "${executable}": ${error.message}`));
});
child.on("close", (code) => {
child.on('close', (code) => {
childTracker?.delete(child);
if (streamOutput) {
const trailingOut = stdoutBuffer.trim();
if (trailingOut.length > 0) {
log("debug", configuredLogLevel, `[${commandLabel}] ${trailingOut}`);
log('debug', configuredLogLevel, `[${commandLabel}] ${trailingOut}`);
}
const trailingErr = stderrBuffer.trim();
if (trailingErr.length > 0) {
log("debug", configuredLogLevel, `[${commandLabel}] ${trailingErr}`);
log('debug', configuredLogLevel, `[${commandLabel}] ${trailingErr}`);
}
}
log(
code === 0 ? "debug" : "warn",
code === 0 ? 'debug' : 'warn',
configuredLogLevel,
`[${commandLabel}] exit code ${code ?? 1}`,
);
if (code !== 0 && !allowFailure) {
const commandString = `${executable} ${args.join(" ")}`;
const commandString = `${executable} ${args.join(' ')}`;
reject(
new Error(
`Command failed (${commandString}): ${stderr.trim() || `exit code ${code}`}`,
),
new Error(`Command failed (${commandString}): ${stderr.trim() || `exit code ${code}`}`),
);
return;
}

View File

@@ -1,17 +1,21 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import type { Args, SubtitleCandidate, YoutubeSubgenOutputs } from "./types.js";
import { YOUTUBE_SUB_EXTENSIONS, YOUTUBE_AUDIO_EXTENSIONS } from "./types.js";
import { log } from "./log.js";
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import type { Args, SubtitleCandidate, YoutubeSubgenOutputs } from './types.js';
import { YOUTUBE_SUB_EXTENSIONS, YOUTUBE_AUDIO_EXTENSIONS } from './types.js';
import { log } from './log.js';
import {
resolvePathMaybe, uniqueNormalizedLangCodes,
escapeRegExp, normalizeBasename, runExternalCommand, commandExists,
} from "./util.js";
import { state } from "./mpv.js";
resolvePathMaybe,
uniqueNormalizedLangCodes,
escapeRegExp,
normalizeBasename,
runExternalCommand,
commandExists,
} from './util.js';
import { state } from './mpv.js';
function toYtdlpLangPattern(langCodes: string[]): string {
return langCodes.map((lang) => `${lang}.*`).join(",");
return langCodes.map((lang) => `${lang}.*`).join(',');
}
function filenameHasLanguageTag(filenameLower: string, langCode: string): boolean {
@@ -24,16 +28,12 @@ function classifyLanguage(
filename: string,
primaryLangCodes: string[],
secondaryLangCodes: string[],
): "primary" | "secondary" | null {
): 'primary' | 'secondary' | null {
const lower = filename.toLowerCase();
const primary = primaryLangCodes.some((code) =>
filenameHasLanguageTag(lower, code),
);
const secondary = secondaryLangCodes.some((code) =>
filenameHasLanguageTag(lower, code),
);
if (primary && !secondary) return "primary";
if (secondary && !primary) return "secondary";
const primary = primaryLangCodes.some((code) => filenameHasLanguageTag(lower, code));
const secondary = secondaryLangCodes.some((code) => filenameHasLanguageTag(lower, code));
if (primary && !secondary) return 'primary';
if (secondary && !primary) return 'secondary';
return null;
}
@@ -41,20 +41,20 @@ function preferredLangLabel(langCodes: string[], fallback: string): string {
return uniqueNormalizedLangCodes(langCodes)[0] || fallback;
}
function sourceTag(source: SubtitleCandidate["source"]): string {
if (source === "manual" || source === "auto") return `ytdlp-${source}`;
if (source === "whisper-translate") return "whisper-translate";
return "whisper";
function sourceTag(source: SubtitleCandidate['source']): string {
if (source === 'manual' || source === 'auto') return `ytdlp-${source}`;
if (source === 'whisper-translate') return 'whisper-translate';
return 'whisper';
}
function pickBestCandidate(candidates: SubtitleCandidate[]): SubtitleCandidate | null {
if (candidates.length === 0) return null;
const scored = [...candidates].sort((a, b) => {
const sourceA = a.source === "manual" ? 1 : 0;
const sourceB = b.source === "manual" ? 1 : 0;
const sourceA = a.source === 'manual' ? 1 : 0;
const sourceB = b.source === 'manual' ? 1 : 0;
if (sourceA !== sourceB) return sourceB - sourceA;
const srtA = a.ext === ".srt" ? 1 : 0;
const srtB = b.ext === ".srt" ? 1 : 0;
const srtA = a.ext === '.srt' ? 1 : 0;
const srtB = b.ext === '.srt' ? 1 : 0;
if (srtA !== srtB) return srtB - srtA;
return b.size - a.size;
});
@@ -64,7 +64,7 @@ function pickBestCandidate(candidates: SubtitleCandidate[]): SubtitleCandidate |
function scanSubtitleCandidates(
tempDir: string,
knownSet: Set<string>,
source: "manual" | "auto",
source: 'manual' | 'auto',
primaryLangCodes: string[],
secondaryLangCodes: string[],
): SubtitleCandidate[] {
@@ -94,9 +94,9 @@ async function convertToSrt(
tempDir: string,
langLabel: string,
): Promise<string> {
if (path.extname(inputPath).toLowerCase() === ".srt") return inputPath;
if (path.extname(inputPath).toLowerCase() === '.srt') return inputPath;
const outputPath = path.join(tempDir, `converted.${langLabel}.srt`);
await runExternalCommand("ffmpeg", ["-y", "-loglevel", "error", "-i", inputPath, outputPath]);
await runExternalCommand('ffmpeg', ['-y', '-loglevel', 'error', '-i', inputPath, outputPath]);
return outputPath;
}
@@ -132,19 +132,19 @@ async function runWhisper(
outputPrefix: string,
): Promise<string> {
const args = [
"-m",
'-m',
modelPath,
"-f",
'-f',
audioPath,
"--output-srt",
"--output-file",
'--output-srt',
'--output-file',
outputPrefix,
"--language",
'--language',
language,
];
if (translate) args.push("--translate");
if (translate) args.push('--translate');
await runExternalCommand(whisperBin, args, {
commandLabel: "whisper",
commandLabel: 'whisper',
streamOutput: true,
});
const outputPath = `${outputPrefix}.srt`;
@@ -155,19 +155,19 @@ async function runWhisper(
}
async function convertAudioForWhisper(inputPath: string, tempDir: string): Promise<string> {
const wavPath = path.join(tempDir, "whisper-input.wav");
await runExternalCommand("ffmpeg", [
"-y",
"-loglevel",
"error",
"-i",
const wavPath = path.join(tempDir, 'whisper-input.wav');
await runExternalCommand('ffmpeg', [
'-y',
'-loglevel',
'error',
'-i',
inputPath,
"-ar",
"16000",
"-ac",
"1",
"-c:a",
"pcm_s16le",
'-ar',
'16000',
'-ac',
'1',
'-c:a',
'pcm_s16le',
wavPath,
]);
if (!fs.existsSync(wavPath)) {
@@ -179,65 +179,55 @@ async function convertAudioForWhisper(inputPath: string, tempDir: string): Promi
export function resolveWhisperBinary(args: Args): string | null {
const explicit = args.whisperBin.trim();
if (explicit) return resolvePathMaybe(explicit);
if (commandExists("whisper-cli")) return "whisper-cli";
if (commandExists('whisper-cli')) return 'whisper-cli';
return null;
}
export async function generateYoutubeSubtitles(
target: string,
args: Args,
onReady?: (lang: "primary" | "secondary", pathToLoad: string) => Promise<void>,
onReady?: (lang: 'primary' | 'secondary', pathToLoad: string) => Promise<void>,
): Promise<YoutubeSubgenOutputs> {
const outDir = path.resolve(resolvePathMaybe(args.youtubeSubgenOutDir));
fs.mkdirSync(outDir, { recursive: true });
const primaryLangCodes = uniqueNormalizedLangCodes(args.youtubePrimarySubLangs);
const secondaryLangCodes = uniqueNormalizedLangCodes(args.youtubeSecondarySubLangs);
const primaryLabel = preferredLangLabel(primaryLangCodes, "primary");
const secondaryLabel = preferredLangLabel(secondaryLangCodes, "secondary");
const primaryLabel = preferredLangLabel(primaryLangCodes, 'primary');
const secondaryLabel = preferredLangLabel(secondaryLangCodes, 'secondary');
const secondaryCanUseWhisperTranslate =
secondaryLangCodes.includes("en") || secondaryLangCodes.includes("eng");
const ytdlpManualLangs = toYtdlpLangPattern([
...primaryLangCodes,
...secondaryLangCodes,
]);
secondaryLangCodes.includes('en') || secondaryLangCodes.includes('eng');
const ytdlpManualLangs = toYtdlpLangPattern([...primaryLangCodes, ...secondaryLangCodes]);
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "subminer-yt-subgen-"));
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yt-subgen-'));
const knownFiles = new Set<string>();
let keepTemp = args.youtubeSubgenKeepTemp;
const publishTrack = async (
lang: "primary" | "secondary",
source: SubtitleCandidate["source"],
lang: 'primary' | 'secondary',
source: SubtitleCandidate['source'],
selectedPath: string,
basename: string,
): Promise<string> => {
const langLabel = lang === "primary" ? primaryLabel : secondaryLabel;
const taggedPath = path.join(
outDir,
`${basename}.${langLabel}.${sourceTag(source)}.srt`,
);
const langLabel = lang === 'primary' ? primaryLabel : secondaryLabel;
const taggedPath = path.join(outDir, `${basename}.${langLabel}.${sourceTag(source)}.srt`);
const aliasPath = path.join(outDir, `${basename}.${langLabel}.srt`);
fs.copyFileSync(selectedPath, taggedPath);
fs.copyFileSync(taggedPath, aliasPath);
log(
"info",
args.logLevel,
`Generated subtitle (${langLabel}, ${source}) -> ${aliasPath}`,
);
log('info', args.logLevel, `Generated subtitle (${langLabel}, ${source}) -> ${aliasPath}`);
if (onReady) await onReady(lang, aliasPath);
return aliasPath;
};
try {
log("debug", args.logLevel, `YouTube subtitle temp dir: ${tempDir}`);
log('debug', args.logLevel, `YouTube subtitle temp dir: ${tempDir}`);
const meta = await runExternalCommand(
"yt-dlp",
["--dump-single-json", "--no-warnings", target],
'yt-dlp',
['--dump-single-json', '--no-warnings', target],
{
captureStdout: true,
logLevel: args.logLevel,
commandLabel: "yt-dlp:meta",
commandLabel: 'yt-dlp:meta',
},
state.youtubeSubgenChildren,
);
@@ -246,23 +236,23 @@ export async function generateYoutubeSubtitles(
const basename = normalizeBasename(videoId, videoId);
await runExternalCommand(
"yt-dlp",
'yt-dlp',
[
"--skip-download",
"--no-warnings",
"--write-subs",
"--sub-format",
"srt/vtt/best",
"--sub-langs",
'--skip-download',
'--no-warnings',
'--write-subs',
'--sub-format',
'srt/vtt/best',
'--sub-langs',
ytdlpManualLangs,
"-o",
path.join(tempDir, "%(id)s.%(ext)s"),
'-o',
path.join(tempDir, '%(id)s.%(ext)s'),
target,
],
{
allowFailure: true,
logLevel: args.logLevel,
commandLabel: "yt-dlp:manual-subs",
commandLabel: 'yt-dlp:manual-subs',
streamOutput: true,
},
state.youtubeSubgenChildren,
@@ -271,41 +261,37 @@ export async function generateYoutubeSubtitles(
const manualSubs = scanSubtitleCandidates(
tempDir,
knownFiles,
"manual",
'manual',
primaryLangCodes,
secondaryLangCodes,
);
for (const sub of manualSubs) knownFiles.add(sub.path);
let primaryCandidates = manualSubs.filter((entry) => entry.lang === "primary");
let secondaryCandidates = manualSubs.filter(
(entry) => entry.lang === "secondary",
);
let primaryCandidates = manualSubs.filter((entry) => entry.lang === 'primary');
let secondaryCandidates = manualSubs.filter((entry) => entry.lang === 'secondary');
const missingAuto: string[] = [];
if (primaryCandidates.length === 0)
missingAuto.push(toYtdlpLangPattern(primaryLangCodes));
if (secondaryCandidates.length === 0)
missingAuto.push(toYtdlpLangPattern(secondaryLangCodes));
if (primaryCandidates.length === 0) missingAuto.push(toYtdlpLangPattern(primaryLangCodes));
if (secondaryCandidates.length === 0) missingAuto.push(toYtdlpLangPattern(secondaryLangCodes));
if (missingAuto.length > 0) {
await runExternalCommand(
"yt-dlp",
'yt-dlp',
[
"--skip-download",
"--no-warnings",
"--write-auto-subs",
"--sub-format",
"srt/vtt/best",
"--sub-langs",
missingAuto.join(","),
"-o",
path.join(tempDir, "%(id)s.%(ext)s"),
'--skip-download',
'--no-warnings',
'--write-auto-subs',
'--sub-format',
'srt/vtt/best',
'--sub-langs',
missingAuto.join(','),
'-o',
path.join(tempDir, '%(id)s.%(ext)s'),
target,
],
{
allowFailure: true,
logLevel: args.logLevel,
commandLabel: "yt-dlp:auto-subs",
commandLabel: 'yt-dlp:auto-subs',
streamOutput: true,
},
state.youtubeSubgenChildren,
@@ -314,45 +300,31 @@ export async function generateYoutubeSubtitles(
const autoSubs = scanSubtitleCandidates(
tempDir,
knownFiles,
"auto",
'auto',
primaryLangCodes,
secondaryLangCodes,
);
for (const sub of autoSubs) knownFiles.add(sub.path);
primaryCandidates = primaryCandidates.concat(
autoSubs.filter((entry) => entry.lang === "primary"),
autoSubs.filter((entry) => entry.lang === 'primary'),
);
secondaryCandidates = secondaryCandidates.concat(
autoSubs.filter((entry) => entry.lang === "secondary"),
autoSubs.filter((entry) => entry.lang === 'secondary'),
);
}
let primaryAlias = "";
let secondaryAlias = "";
let primaryAlias = '';
let secondaryAlias = '';
const selectedPrimary = pickBestCandidate(primaryCandidates);
const selectedSecondary = pickBestCandidate(secondaryCandidates);
if (selectedPrimary) {
const srt = await convertToSrt(selectedPrimary.path, tempDir, primaryLabel);
primaryAlias = await publishTrack(
"primary",
selectedPrimary.source,
srt,
basename,
);
primaryAlias = await publishTrack('primary', selectedPrimary.source, srt, basename);
}
if (selectedSecondary) {
const srt = await convertToSrt(
selectedSecondary.path,
tempDir,
secondaryLabel,
);
secondaryAlias = await publishTrack(
"secondary",
selectedSecondary.source,
srt,
basename,
);
const srt = await convertToSrt(selectedSecondary.path, tempDir, secondaryLabel);
secondaryAlias = await publishTrack('secondary', selectedSecondary.source, srt, basename);
}
const needsPrimaryWhisper = !selectedPrimary;
@@ -361,40 +333,40 @@ export async function generateYoutubeSubtitles(
const whisperBin = resolveWhisperBinary(args);
const modelPath = args.whisperModel.trim()
? path.resolve(resolvePathMaybe(args.whisperModel.trim()))
: "";
: '';
const hasWhisperFallback = !!whisperBin && !!modelPath && fs.existsSync(modelPath);
if (!hasWhisperFallback) {
log(
"warn",
'warn',
args.logLevel,
"Whisper fallback is not configured; continuing with available subtitle tracks.",
'Whisper fallback is not configured; continuing with available subtitle tracks.',
);
} else {
try {
await runExternalCommand(
"yt-dlp",
'yt-dlp',
[
"-f",
"bestaudio/best",
"--extract-audio",
"--audio-format",
'-f',
'bestaudio/best',
'--extract-audio',
'--audio-format',
args.youtubeSubgenAudioFormat,
"--no-warnings",
"-o",
path.join(tempDir, "%(id)s.%(ext)s"),
'--no-warnings',
'-o',
path.join(tempDir, '%(id)s.%(ext)s'),
target,
],
{
logLevel: args.logLevel,
commandLabel: "yt-dlp:audio",
commandLabel: 'yt-dlp:audio',
streamOutput: true,
},
state.youtubeSubgenChildren,
);
const audioPath = findAudioFile(tempDir, args.youtubeSubgenAudioFormat);
if (!audioPath) {
throw new Error("Audio extraction succeeded, but no audio file was found.");
throw new Error('Audio extraction succeeded, but no audio file was found.');
}
const whisperAudioPath = await convertAudioForWhisper(audioPath, tempDir);
@@ -409,15 +381,10 @@ export async function generateYoutubeSubtitles(
false,
primaryPrefix,
);
primaryAlias = await publishTrack(
"primary",
"whisper",
primarySrt,
basename,
);
primaryAlias = await publishTrack('primary', 'whisper', primarySrt, basename);
} catch (error) {
log(
"warn",
'warn',
args.logLevel,
`Failed to generate primary subtitle via whisper fallback: ${(error as Error).message}`,
);
@@ -426,10 +393,7 @@ export async function generateYoutubeSubtitles(
if (needsSecondaryWhisper) {
try {
const secondaryPrefix = path.join(
tempDir,
`${basename}.${secondaryLabel}`,
);
const secondaryPrefix = path.join(tempDir, `${basename}.${secondaryLabel}`);
const secondarySrt = await runWhisper(
whisperBin!,
modelPath,
@@ -439,14 +403,14 @@ export async function generateYoutubeSubtitles(
secondaryPrefix,
);
secondaryAlias = await publishTrack(
"secondary",
"whisper-translate",
'secondary',
'whisper-translate',
secondarySrt,
basename,
);
} catch (error) {
log(
"warn",
'warn',
args.logLevel,
`Failed to generate secondary subtitle via whisper fallback: ${(error as Error).message}`,
);
@@ -454,7 +418,7 @@ export async function generateYoutubeSubtitles(
}
} catch (error) {
log(
"warn",
'warn',
args.logLevel,
`Whisper fallback pipeline failed: ${(error as Error).message}`,
);
@@ -464,20 +428,20 @@ export async function generateYoutubeSubtitles(
if (!secondaryCanUseWhisperTranslate && !selectedSecondary) {
log(
"warn",
'warn',
args.logLevel,
`Secondary subtitle language (${secondaryLabel}) has no whisper translate fallback; relying on yt-dlp subtitles only.`,
);
}
if (!primaryAlias && !secondaryAlias) {
throw new Error("Failed to generate any subtitle tracks.");
throw new Error('Failed to generate any subtitle tracks.');
}
if (!primaryAlias || !secondaryAlias) {
log(
"warn",
'warn',
args.logLevel,
`Generated partial subtitle result: primary=${primaryAlias ? "ok" : "missing"}, secondary=${secondaryAlias ? "ok" : "missing"}`,
`Generated partial subtitle result: primary=${primaryAlias ? 'ok' : 'missing'}, secondary=${secondaryAlias ? 'ok' : 'missing'}`,
);
}
@@ -491,7 +455,7 @@ export async function generateYoutubeSubtitles(
throw error;
} finally {
if (keepTemp) {
log("warn", args.logLevel, `Keeping subtitle temp dir: ${tempDir}`);
log('warn', args.logLevel, `Keeping subtitle temp dir: ${tempDir}`);
} else {
try {
fs.rmSync(tempDir, { recursive: true, force: true });