mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
refactor(launcher): extract types, logging, and utilities
- launcher/types.ts: shared types, interfaces, and constants - launcher/log.ts: logging infrastructure (COLORS, log, fail, etc.) - launcher/util.ts: pure utilities, lang helpers, and child process runner - runExternalCommand accepts childTracker param instead of referencing state - inferWhisperLanguage placed in util.ts to avoid circular deps
This commit is contained in:
65
launcher/log.ts
Normal file
65
launcher/log.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LOG_PRI: Record<LogLevel, number> = {
|
||||||
|
debug: 10,
|
||||||
|
info: 20,
|
||||||
|
warn: 30,
|
||||||
|
error: 40,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function shouldLog(level: LogLevel, configured: LogLevel): boolean {
|
||||||
|
return LOG_PRI[level] >= LOG_PRI[configured];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMpvLogPath(): string {
|
||||||
|
const envPath = process.env.SUBMINER_MPV_LOG?.trim();
|
||||||
|
if (envPath) return envPath;
|
||||||
|
return DEFAULT_MPV_LOG_FILE;
|
||||||
|
}
|
||||||
|
|
||||||
|
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" },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ignore logging failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function log(level: LogLevel, configured: LogLevel, message: string): void {
|
||||||
|
if (!shouldLog(level, configured)) return;
|
||||||
|
const color =
|
||||||
|
level === "info"
|
||||||
|
? COLORS.green
|
||||||
|
: level === "warn"
|
||||||
|
? COLORS.yellow
|
||||||
|
: level === "error"
|
||||||
|
? COLORS.red
|
||||||
|
: COLORS.cyan;
|
||||||
|
process.stdout.write(
|
||||||
|
`${color}[${level.toUpperCase()}]${COLORS.reset} ${message}\n`,
|
||||||
|
);
|
||||||
|
appendToMpvLog(`[${level.toUpperCase()}] ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fail(message: string): never {
|
||||||
|
process.stderr.write(`${COLORS.red}[ERROR]${COLORS.reset} ${message}\n`);
|
||||||
|
appendToMpvLog(`[ERROR] ${message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
188
launcher/types.ts
Normal file
188
launcher/types.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
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",
|
||||||
|
]);
|
||||||
|
|
||||||
|
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",
|
||||||
|
]);
|
||||||
|
export const DEFAULT_YOUTUBE_SUBGEN_OUT_DIR = path.join(
|
||||||
|
os.homedir(),
|
||||||
|
".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_SUBMINER_ARGS = [
|
||||||
|
"--sub-auto=fuzzy",
|
||||||
|
"--sub-file-paths=.;subs;subtitles",
|
||||||
|
"--sid=auto",
|
||||||
|
"--secondary-sid=auto",
|
||||||
|
"--secondary-sub-visibility=no",
|
||||||
|
"--slang=ja,jpn,en,eng",
|
||||||
|
] 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 interface Args {
|
||||||
|
backend: Backend;
|
||||||
|
directory: string;
|
||||||
|
recursive: boolean;
|
||||||
|
profile: string;
|
||||||
|
startOverlay: boolean;
|
||||||
|
youtubeSubgenMode: YoutubeSubgenMode;
|
||||||
|
whisperBin: string;
|
||||||
|
whisperModel: string;
|
||||||
|
youtubeSubgenOutDir: string;
|
||||||
|
youtubeSubgenAudioFormat: string;
|
||||||
|
youtubeSubgenKeepTemp: boolean;
|
||||||
|
youtubePrimarySubLangs: string[];
|
||||||
|
youtubeSecondarySubLangs: string[];
|
||||||
|
youtubeAudioLangs: string[];
|
||||||
|
youtubeWhisperSourceLanguage: string;
|
||||||
|
useTexthooker: boolean;
|
||||||
|
autoStartOverlay: boolean;
|
||||||
|
texthookerOnly: boolean;
|
||||||
|
useRofi: boolean;
|
||||||
|
logLevel: LogLevel;
|
||||||
|
target: string;
|
||||||
|
targetKind: "" | "file" | "url";
|
||||||
|
jimakuApiKey: string;
|
||||||
|
jimakuApiKeyCommand: string;
|
||||||
|
jimakuApiBaseUrl: string;
|
||||||
|
jimakuLanguagePreference: JimakuLanguagePreference;
|
||||||
|
jimakuMaxEntryResults: number;
|
||||||
|
jellyfin: boolean;
|
||||||
|
jellyfinLogin: boolean;
|
||||||
|
jellyfinLogout: boolean;
|
||||||
|
jellyfinPlay: boolean;
|
||||||
|
jellyfinServer: string;
|
||||||
|
jellyfinUsername: string;
|
||||||
|
jellyfinPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LauncherYoutubeSubgenConfig {
|
||||||
|
mode?: YoutubeSubgenMode;
|
||||||
|
whisperBin?: string;
|
||||||
|
whisperModel?: string;
|
||||||
|
primarySubLanguages?: string[];
|
||||||
|
secondarySubLanguages?: string[];
|
||||||
|
jimakuApiKey?: string;
|
||||||
|
jimakuApiKeyCommand?: string;
|
||||||
|
jimakuApiBaseUrl?: string;
|
||||||
|
jimakuLanguagePreference?: JimakuLanguagePreference;
|
||||||
|
jimakuMaxEntryResults?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LauncherJellyfinConfig {
|
||||||
|
enabled?: boolean;
|
||||||
|
serverUrl?: string;
|
||||||
|
username?: string;
|
||||||
|
accessToken?: string;
|
||||||
|
userId?: string;
|
||||||
|
defaultLibraryId?: string;
|
||||||
|
pullPictures?: boolean;
|
||||||
|
iconCacheDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginRuntimeConfig {
|
||||||
|
autoStartOverlay: boolean;
|
||||||
|
socketPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandExecOptions {
|
||||||
|
allowFailure?: boolean;
|
||||||
|
captureStdout?: boolean;
|
||||||
|
logLevel?: LogLevel;
|
||||||
|
commandLabel?: string;
|
||||||
|
streamOutput?: boolean;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandExecResult {
|
||||||
|
code: number;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleCandidate {
|
||||||
|
path: string;
|
||||||
|
lang: "primary" | "secondary";
|
||||||
|
ext: string;
|
||||||
|
size: number;
|
||||||
|
source: "manual" | "auto" | "whisper" | "whisper-translate";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YoutubeSubgenOutputs {
|
||||||
|
basename: string;
|
||||||
|
primaryPath?: string;
|
||||||
|
secondaryPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MpvTrack {
|
||||||
|
type?: string;
|
||||||
|
id?: number;
|
||||||
|
lang?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JellyfinSessionConfig {
|
||||||
|
serverUrl: string;
|
||||||
|
accessToken: string;
|
||||||
|
userId: string;
|
||||||
|
defaultLibraryId: string;
|
||||||
|
pullPictures: boolean;
|
||||||
|
iconCacheDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JellyfinLibraryEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JellyfinItemEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
display: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JellyfinGroupEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
display: string;
|
||||||
|
}
|
||||||
225
launcher/util.ts
Normal file
225
launcher/util.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExecutable(filePath: string): boolean {
|
||||||
|
try {
|
||||||
|
fs.accessSync(filePath, fs.constants.X_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commandExists(command: string): boolean {
|
||||||
|
const pathEnv = process.env.PATH ?? "";
|
||||||
|
for (const dir of pathEnv.split(path.delimiter)) {
|
||||||
|
if (!dir) continue;
|
||||||
|
const full = path.join(dir, command);
|
||||||
|
if (isExecutable(full)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePathMaybe(input: string): string {
|
||||||
|
if (input.startsWith("~")) {
|
||||||
|
return path.join(os.homedir(), input.slice(1));
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBinaryPathCandidate(input: string): string {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
const unquoted = trimmed.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1");
|
||||||
|
return resolvePathMaybe(unquoted);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function realpathMaybe(filePath: string): string {
|
||||||
|
try {
|
||||||
|
return fs.realpathSync(filePath);
|
||||||
|
} catch {
|
||||||
|
return path.resolve(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUrlTarget(target: string): boolean {
|
||||||
|
return /^https?:\/\//.test(target) || /^ytsearch:/.test(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isYoutubeTarget(target: string): boolean {
|
||||||
|
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, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBasename(value: string, fallback: string): string {
|
||||||
|
const safe = sanitizeToken(value.replace(/[\\/]+/g, "-"));
|
||||||
|
if (safe) return safe;
|
||||||
|
const fallbackSafe = sanitizeToken(fallback);
|
||||||
|
if (fallbackSafe) return fallbackSafe;
|
||||||
|
return `${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLangCode(value: string): string {
|
||||||
|
return value.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uniqueNormalizedLangCodes(values: string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const value of values) {
|
||||||
|
const normalized = normalizeLangCode(value);
|
||||||
|
if (!normalized || seen.has(normalized)) continue;
|
||||||
|
seen.add(normalized);
|
||||||
|
out.push(normalized);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeRegExp(value: string): string {
|
||||||
|
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"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === "no" ||
|
||||||
|
normalized === "false" ||
|
||||||
|
normalized === "0" ||
|
||||||
|
normalized === "off"
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferWhisperLanguage(langCodes: string[], fallback: string): string {
|
||||||
|
for (const lang of uniqueNormalizedLangCodes(langCodes)) {
|
||||||
|
if (lang === "jpn") return "ja";
|
||||||
|
if (lang.length >= 2) return lang.slice(0, 2);
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runExternalCommand(
|
||||||
|
executable: string,
|
||||||
|
args: string[],
|
||||||
|
opts: CommandExecOptions = {},
|
||||||
|
childTracker?: Set<ReturnType<typeof spawn>>,
|
||||||
|
): Promise<CommandExecResult> {
|
||||||
|
const allowFailure = opts.allowFailure === true;
|
||||||
|
const captureStdout = opts.captureStdout === true;
|
||||||
|
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(" ")}`);
|
||||||
|
const child = spawn(executable, args, {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
env: { ...process.env, ...opts.env },
|
||||||
|
});
|
||||||
|
childTracker?.add(child);
|
||||||
|
|
||||||
|
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() ?? "";
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.length > 0) {
|
||||||
|
log(level, configuredLogLevel, `[${commandLabel}] ${trimmed}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sink(remaining);
|
||||||
|
};
|
||||||
|
|
||||||
|
child.stdout.on("data", (chunk: Buffer) => {
|
||||||
|
const text = chunk.toString();
|
||||||
|
if (captureStdout) stdout += text;
|
||||||
|
if (streamOutput) {
|
||||||
|
stdoutBuffer += text;
|
||||||
|
flushLines(stdoutBuffer, "debug", (remaining) => {
|
||||||
|
stdoutBuffer = remaining;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on("data", (chunk: Buffer) => {
|
||||||
|
const text = chunk.toString();
|
||||||
|
stderr += text;
|
||||||
|
if (streamOutput) {
|
||||||
|
stderrBuffer += text;
|
||||||
|
flushLines(stderrBuffer, "debug", (remaining) => {
|
||||||
|
stderrBuffer = remaining;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", (error) => {
|
||||||
|
childTracker?.delete(child);
|
||||||
|
reject(new Error(`Failed to start "${executable}": ${error.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
childTracker?.delete(child);
|
||||||
|
if (streamOutput) {
|
||||||
|
const trailingOut = stdoutBuffer.trim();
|
||||||
|
if (trailingOut.length > 0) {
|
||||||
|
log("debug", configuredLogLevel, `[${commandLabel}] ${trailingOut}`);
|
||||||
|
}
|
||||||
|
const trailingErr = stderrBuffer.trim();
|
||||||
|
if (trailingErr.length > 0) {
|
||||||
|
log("debug", configuredLogLevel, `[${commandLabel}] ${trailingErr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log(
|
||||||
|
code === 0 ? "debug" : "warn",
|
||||||
|
configuredLogLevel,
|
||||||
|
`[${commandLabel}] exit code ${code ?? 1}`,
|
||||||
|
);
|
||||||
|
if (code !== 0 && !allowFailure) {
|
||||||
|
const commandString = `${executable} ${args.join(" ")}`;
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Command failed (${commandString}): ${stderr.trim() || `exit code ${code}`}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({ code: code ?? 1, stdout, stderr });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user