mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
pretty
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
278
launcher/main.ts
278
launcher/main.ts
@@ -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();
|
||||
|
||||
372
launcher/mpv.ts
372
launcher/mpv.ts
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user