mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
refactor: add main.ts decomposition guardrails and extract core helpers
This commit is contained in:
44
src/cli/args.test.ts
Normal file
44
src/cli/args.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { hasExplicitCommand, parseArgs, shouldStartApp } from "./args";
|
||||
|
||||
test("parseArgs parses booleans and value flags", () => {
|
||||
const args = parseArgs([
|
||||
"--start",
|
||||
"--socket",
|
||||
"/tmp/mpv.sock",
|
||||
"--backend=hyprland",
|
||||
"--port",
|
||||
"6000",
|
||||
"--log-level",
|
||||
"warn",
|
||||
"--verbose",
|
||||
]);
|
||||
|
||||
assert.equal(args.start, true);
|
||||
assert.equal(args.socketPath, "/tmp/mpv.sock");
|
||||
assert.equal(args.backend, "hyprland");
|
||||
assert.equal(args.texthookerPort, 6000);
|
||||
assert.equal(args.logLevel, "warn");
|
||||
assert.equal(args.verbose, true);
|
||||
});
|
||||
|
||||
test("parseArgs ignores missing value after --log-level", () => {
|
||||
const args = parseArgs(["--log-level", "--start"]);
|
||||
assert.equal(args.logLevel, undefined);
|
||||
assert.equal(args.start, true);
|
||||
});
|
||||
|
||||
test("hasExplicitCommand and shouldStartApp preserve command intent", () => {
|
||||
const stopOnly = parseArgs(["--stop"]);
|
||||
assert.equal(hasExplicitCommand(stopOnly), true);
|
||||
assert.equal(shouldStartApp(stopOnly), false);
|
||||
|
||||
const toggle = parseArgs(["--toggle-visible-overlay"]);
|
||||
assert.equal(hasExplicitCommand(toggle), true);
|
||||
assert.equal(shouldStartApp(toggle), true);
|
||||
|
||||
const noCommand = parseArgs(["--verbose"]);
|
||||
assert.equal(hasExplicitCommand(noCommand), false);
|
||||
assert.equal(shouldStartApp(noCommand), false);
|
||||
});
|
||||
241
src/cli/args.ts
Normal file
241
src/cli/args.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
export interface CliArgs {
|
||||
start: boolean;
|
||||
stop: boolean;
|
||||
toggle: boolean;
|
||||
toggleVisibleOverlay: boolean;
|
||||
toggleInvisibleOverlay: boolean;
|
||||
settings: boolean;
|
||||
show: boolean;
|
||||
hide: boolean;
|
||||
showVisibleOverlay: boolean;
|
||||
hideVisibleOverlay: boolean;
|
||||
showInvisibleOverlay: boolean;
|
||||
hideInvisibleOverlay: boolean;
|
||||
copySubtitle: boolean;
|
||||
copySubtitleMultiple: boolean;
|
||||
mineSentence: boolean;
|
||||
mineSentenceMultiple: boolean;
|
||||
updateLastCardFromClipboard: boolean;
|
||||
toggleSecondarySub: boolean;
|
||||
triggerFieldGrouping: boolean;
|
||||
triggerSubsync: boolean;
|
||||
markAudioCard: boolean;
|
||||
openRuntimeOptions: boolean;
|
||||
texthooker: boolean;
|
||||
help: boolean;
|
||||
autoStartOverlay: boolean;
|
||||
generateConfig: boolean;
|
||||
configPath?: string;
|
||||
backupOverwrite: boolean;
|
||||
socketPath?: string;
|
||||
backend?: string;
|
||||
texthookerPort?: number;
|
||||
verbose: boolean;
|
||||
logLevel?: "debug" | "info" | "warn" | "error";
|
||||
}
|
||||
|
||||
export type CliCommandSource = "initial" | "second-instance";
|
||||
|
||||
export function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = {
|
||||
start: false,
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
toggleInvisibleOverlay: false,
|
||||
settings: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
showVisibleOverlay: false,
|
||||
hideVisibleOverlay: false,
|
||||
showInvisibleOverlay: false,
|
||||
hideInvisibleOverlay: false,
|
||||
copySubtitle: false,
|
||||
copySubtitleMultiple: false,
|
||||
mineSentence: false,
|
||||
mineSentenceMultiple: false,
|
||||
updateLastCardFromClipboard: false,
|
||||
toggleSecondarySub: false,
|
||||
triggerFieldGrouping: false,
|
||||
triggerSubsync: false,
|
||||
markAudioCard: false,
|
||||
openRuntimeOptions: false,
|
||||
texthooker: false,
|
||||
help: false,
|
||||
autoStartOverlay: false,
|
||||
generateConfig: false,
|
||||
backupOverwrite: false,
|
||||
verbose: false,
|
||||
};
|
||||
|
||||
const readValue = (value?: string): string | undefined => {
|
||||
if (!value) return undefined;
|
||||
if (value.startsWith("--")) return undefined;
|
||||
return value;
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (!arg.startsWith("--")) continue;
|
||||
|
||||
if (arg === "--start") args.start = true;
|
||||
else if (arg === "--stop") args.stop = true;
|
||||
else if (arg === "--toggle") args.toggle = true;
|
||||
else if (arg === "--toggle-visible-overlay")
|
||||
args.toggleVisibleOverlay = true;
|
||||
else if (arg === "--toggle-invisible-overlay")
|
||||
args.toggleInvisibleOverlay = true;
|
||||
else if (arg === "--settings" || arg === "--yomitan") args.settings = true;
|
||||
else if (arg === "--show") args.show = true;
|
||||
else if (arg === "--hide") args.hide = true;
|
||||
else if (arg === "--show-visible-overlay") args.showVisibleOverlay = true;
|
||||
else if (arg === "--hide-visible-overlay") args.hideVisibleOverlay = true;
|
||||
else if (arg === "--show-invisible-overlay")
|
||||
args.showInvisibleOverlay = true;
|
||||
else if (arg === "--hide-invisible-overlay")
|
||||
args.hideInvisibleOverlay = true;
|
||||
else if (arg === "--copy-subtitle") args.copySubtitle = true;
|
||||
else if (arg === "--copy-subtitle-multiple") args.copySubtitleMultiple = true;
|
||||
else if (arg === "--mine-sentence") args.mineSentence = true;
|
||||
else if (arg === "--mine-sentence-multiple") args.mineSentenceMultiple = true;
|
||||
else if (arg === "--update-last-card-from-clipboard")
|
||||
args.updateLastCardFromClipboard = true;
|
||||
else if (arg === "--toggle-secondary-sub") args.toggleSecondarySub = true;
|
||||
else if (arg === "--trigger-field-grouping")
|
||||
args.triggerFieldGrouping = true;
|
||||
else if (arg === "--trigger-subsync") args.triggerSubsync = true;
|
||||
else if (arg === "--mark-audio-card") args.markAudioCard = true;
|
||||
else if (arg === "--open-runtime-options") args.openRuntimeOptions = true;
|
||||
else if (arg === "--texthooker") args.texthooker = true;
|
||||
else if (arg === "--auto-start-overlay") args.autoStartOverlay = true;
|
||||
else if (arg === "--generate-config") args.generateConfig = true;
|
||||
else if (arg === "--backup-overwrite") args.backupOverwrite = true;
|
||||
else if (arg === "--help") args.help = true;
|
||||
else if (arg === "--verbose") args.verbose = true;
|
||||
else if (arg.startsWith("--log-level=")) {
|
||||
const value = arg.split("=", 2)[1]?.toLowerCase();
|
||||
if (
|
||||
value === "debug" ||
|
||||
value === "info" ||
|
||||
value === "warn" ||
|
||||
value === "error"
|
||||
) {
|
||||
args.logLevel = value;
|
||||
}
|
||||
} else if (arg === "--log-level") {
|
||||
const value = readValue(argv[i + 1])?.toLowerCase();
|
||||
if (
|
||||
value === "debug" ||
|
||||
value === "info" ||
|
||||
value === "warn" ||
|
||||
value === "error"
|
||||
) {
|
||||
args.logLevel = value;
|
||||
}
|
||||
} else if (arg.startsWith("--config-path=")) {
|
||||
const value = arg.split("=", 2)[1];
|
||||
if (value) args.configPath = value;
|
||||
} else if (arg === "--config-path") {
|
||||
const value = readValue(argv[i + 1]);
|
||||
if (value) args.configPath = value;
|
||||
} else if (arg.startsWith("--socket=")) {
|
||||
const value = arg.split("=", 2)[1];
|
||||
if (value) args.socketPath = value;
|
||||
} else if (arg === "--socket") {
|
||||
const value = readValue(argv[i + 1]);
|
||||
if (value) args.socketPath = value;
|
||||
} else if (arg.startsWith("--backend=")) {
|
||||
const value = arg.split("=", 2)[1];
|
||||
if (value) args.backend = value;
|
||||
} else if (arg === "--backend") {
|
||||
const value = readValue(argv[i + 1]);
|
||||
if (value) args.backend = value;
|
||||
} else if (arg.startsWith("--port=")) {
|
||||
const value = Number(arg.split("=", 2)[1]);
|
||||
if (!Number.isNaN(value)) args.texthookerPort = value;
|
||||
} else if (arg === "--port") {
|
||||
const value = Number(readValue(argv[i + 1]));
|
||||
if (!Number.isNaN(value)) args.texthookerPort = value;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
return (
|
||||
args.start ||
|
||||
args.stop ||
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.toggleInvisibleOverlay ||
|
||||
args.settings ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
args.showVisibleOverlay ||
|
||||
args.hideVisibleOverlay ||
|
||||
args.showInvisibleOverlay ||
|
||||
args.hideInvisibleOverlay ||
|
||||
args.copySubtitle ||
|
||||
args.copySubtitleMultiple ||
|
||||
args.mineSentence ||
|
||||
args.mineSentenceMultiple ||
|
||||
args.updateLastCardFromClipboard ||
|
||||
args.toggleSecondarySub ||
|
||||
args.triggerFieldGrouping ||
|
||||
args.triggerSubsync ||
|
||||
args.markAudioCard ||
|
||||
args.openRuntimeOptions ||
|
||||
args.texthooker ||
|
||||
args.generateConfig ||
|
||||
args.help
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldStartApp(args: CliArgs): boolean {
|
||||
if (args.stop && !args.start) return false;
|
||||
if (
|
||||
args.start ||
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.toggleInvisibleOverlay ||
|
||||
args.copySubtitle ||
|
||||
args.copySubtitleMultiple ||
|
||||
args.mineSentence ||
|
||||
args.mineSentenceMultiple ||
|
||||
args.updateLastCardFromClipboard ||
|
||||
args.toggleSecondarySub ||
|
||||
args.triggerFieldGrouping ||
|
||||
args.triggerSubsync ||
|
||||
args.markAudioCard ||
|
||||
args.openRuntimeOptions ||
|
||||
args.texthooker
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
||||
return (
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.toggleInvisibleOverlay ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
args.showVisibleOverlay ||
|
||||
args.hideVisibleOverlay ||
|
||||
args.showInvisibleOverlay ||
|
||||
args.hideInvisibleOverlay ||
|
||||
args.copySubtitle ||
|
||||
args.copySubtitleMultiple ||
|
||||
args.mineSentence ||
|
||||
args.mineSentenceMultiple ||
|
||||
args.updateLastCardFromClipboard ||
|
||||
args.toggleSecondarySub ||
|
||||
args.triggerFieldGrouping ||
|
||||
args.triggerSubsync ||
|
||||
args.markAudioCard ||
|
||||
args.openRuntimeOptions
|
||||
);
|
||||
}
|
||||
20
src/cli/help.test.ts
Normal file
20
src/cli/help.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { printHelp } from "./help";
|
||||
|
||||
test("printHelp includes configured texthooker port", () => {
|
||||
const original = console.log;
|
||||
let output = "";
|
||||
console.log = (value?: unknown) => {
|
||||
output += String(value);
|
||||
};
|
||||
|
||||
try {
|
||||
printHelp(7777);
|
||||
} finally {
|
||||
console.log = original;
|
||||
}
|
||||
|
||||
assert.match(output, /--help\s+Show this help/);
|
||||
assert.match(output, /default: 7777/);
|
||||
});
|
||||
40
src/cli/help.ts
Normal file
40
src/cli/help.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export function printHelp(defaultTexthookerPort: number): void {
|
||||
console.log(`
|
||||
SubMiner CLI commands:
|
||||
--start Start MPV IPC connection and overlay control loop
|
||||
--stop Stop the running overlay app
|
||||
--toggle Toggle visible subtitle overlay visibility (legacy alias)
|
||||
--toggle-visible-overlay Toggle visible subtitle overlay visibility
|
||||
--toggle-invisible-overlay Toggle invisible interactive overlay visibility
|
||||
--settings Open Yomitan settings window
|
||||
--texthooker Launch texthooker only (no overlay window)
|
||||
--show Force show visible overlay (legacy alias)
|
||||
--hide Force hide visible overlay (legacy alias)
|
||||
--show-visible-overlay Force show visible subtitle overlay
|
||||
--hide-visible-overlay Force hide visible subtitle overlay
|
||||
--show-invisible-overlay Force show invisible interactive overlay
|
||||
--hide-invisible-overlay Force hide invisible interactive overlay
|
||||
--copy-subtitle Copy current subtitle text
|
||||
--copy-subtitle-multiple Start multi-copy mode
|
||||
--mine-sentence Mine sentence card from current subtitle
|
||||
--mine-sentence-multiple Start multi-mine sentence mode
|
||||
--update-last-card-from-clipboard Update last card from clipboard
|
||||
--toggle-secondary-sub Cycle secondary subtitle mode
|
||||
--trigger-field-grouping Trigger Kiku field grouping
|
||||
--trigger-subsync Run subtitle sync
|
||||
--mark-audio-card Mark last card as audio card
|
||||
--open-runtime-options Open runtime options palette
|
||||
--auto-start-overlay Auto-hide mpv subtitles on connect (show overlay)
|
||||
--socket PATH Override MPV IPC socket/pipe path
|
||||
--backend BACKEND Override window tracker backend (auto, hyprland, sway, x11, macos)
|
||||
--port PORT Texthooker server port (default: ${defaultTexthookerPort})
|
||||
--verbose Enable debug logging (equivalent to --log-level debug)
|
||||
--log-level LEVEL Set log level: debug, info, warn, error
|
||||
--generate-config Generate default config.jsonc from centralized config registry
|
||||
--config-path PATH Target config path for --generate-config
|
||||
--backup-overwrite With --generate-config, backup and overwrite existing file
|
||||
--dev Run in development mode
|
||||
--debug Alias for --dev
|
||||
--help Show this help
|
||||
`);
|
||||
}
|
||||
31
src/core/utils/coerce.ts
Normal file
31
src/core/utils/coerce.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export function asFiniteNumber(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
min?: number,
|
||||
max?: number,
|
||||
): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
||||
if (min !== undefined && value < min) return min;
|
||||
if (max !== undefined && value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function asString(value: unknown, fallback: string): string {
|
||||
if (typeof value !== "string") return fallback;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : fallback;
|
||||
}
|
||||
|
||||
export function asBoolean(value: unknown, fallback: boolean): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "yes" || normalized === "true" || normalized === "1") {
|
||||
return true;
|
||||
}
|
||||
if (normalized === "no" || normalized === "false" || normalized === "0") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
78
src/core/utils/config-gen.ts
Normal file
78
src/core/utils/config-gen.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as readline from "readline";
|
||||
import { CliArgs } from "../../cli/args";
|
||||
|
||||
function formatBackupTimestamp(date = new Date()): string {
|
||||
const pad = (v: number): string => String(v).padStart(2, "0");
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function promptYesNo(question: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
const normalized = answer.trim().toLowerCase();
|
||||
resolve(normalized === "y" || normalized === "yes");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateDefaultConfigFile(
|
||||
args: CliArgs,
|
||||
options: {
|
||||
configDir: string;
|
||||
defaultConfig: unknown;
|
||||
generateTemplate: (config: unknown) => string;
|
||||
},
|
||||
): Promise<number> {
|
||||
const targetPath = args.configPath
|
||||
? path.resolve(args.configPath)
|
||||
: path.join(options.configDir, "config.jsonc");
|
||||
const template = options.generateTemplate(options.defaultConfig);
|
||||
|
||||
if (fs.existsSync(targetPath)) {
|
||||
if (args.backupOverwrite) {
|
||||
const backupPath = `${targetPath}.bak.${formatBackupTimestamp()}`;
|
||||
fs.copyFileSync(targetPath, backupPath);
|
||||
fs.writeFileSync(targetPath, template, "utf-8");
|
||||
console.log(`Backed up existing config to ${backupPath}`);
|
||||
console.log(`Generated config at ${targetPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||
console.error(
|
||||
`Config exists at ${targetPath}. Re-run with --backup-overwrite to back up and overwrite.`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const confirmed = await promptYesNo(
|
||||
`Config exists at ${targetPath}. Back up and overwrite? [y/N] `,
|
||||
);
|
||||
if (!confirmed) {
|
||||
console.log("Config generation cancelled.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
const backupPath = `${targetPath}.bak.${formatBackupTimestamp()}`;
|
||||
fs.copyFileSync(targetPath, backupPath);
|
||||
fs.writeFileSync(targetPath, template, "utf-8");
|
||||
console.log(`Backed up existing config to ${backupPath}`);
|
||||
console.log(`Generated config at ${targetPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(targetPath);
|
||||
if (!fs.existsSync(parentDir)) {
|
||||
fs.mkdirSync(parentDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(targetPath, template, "utf-8");
|
||||
console.log(`Generated config at ${targetPath}`);
|
||||
return 0;
|
||||
}
|
||||
39
src/core/utils/electron-backend.ts
Normal file
39
src/core/utils/electron-backend.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { CliArgs, shouldStartApp } from "../../cli/args";
|
||||
|
||||
function getElectronOzonePlatformHint(): string | null {
|
||||
const hint = process.env.ELECTRON_OZONE_PLATFORM_HINT?.trim().toLowerCase();
|
||||
if (hint) return hint;
|
||||
const ozone = process.env.OZONE_PLATFORM?.trim().toLowerCase();
|
||||
if (ozone) return ozone;
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldPreferWaylandBackend(): boolean {
|
||||
return Boolean(
|
||||
process.env.HYPRLAND_INSTANCE_SIGNATURE || process.env.SWAYSOCK,
|
||||
);
|
||||
}
|
||||
|
||||
export function forceX11Backend(args: CliArgs): void {
|
||||
if (process.platform !== "linux") return;
|
||||
if (!shouldStartApp(args)) return;
|
||||
if (shouldPreferWaylandBackend()) return;
|
||||
|
||||
const hint = getElectronOzonePlatformHint();
|
||||
if (hint === "x11") return;
|
||||
|
||||
process.env.ELECTRON_OZONE_PLATFORM_HINT = "x11";
|
||||
process.env.OZONE_PLATFORM = "x11";
|
||||
}
|
||||
|
||||
export function enforceUnsupportedWaylandMode(args: CliArgs): void {
|
||||
if (process.platform !== "linux") return;
|
||||
if (!shouldStartApp(args)) return;
|
||||
const hint = getElectronOzonePlatformHint();
|
||||
if (hint !== "wayland") return;
|
||||
|
||||
const message =
|
||||
"Unsupported Electron backend: Wayland. Set ELECTRON_OZONE_PLATFORM_HINT=x11 and restart SubMiner.";
|
||||
console.error(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
30
src/core/utils/keybindings.ts
Normal file
30
src/core/utils/keybindings.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Config, Keybinding } from "../../types";
|
||||
|
||||
export function resolveKeybindings(
|
||||
config: Config,
|
||||
defaultKeybindings: Keybinding[],
|
||||
): Keybinding[] {
|
||||
const userBindings = config.keybindings || [];
|
||||
const bindingMap = new Map<string, (string | number)[] | null>();
|
||||
|
||||
for (const binding of defaultKeybindings) {
|
||||
bindingMap.set(binding.key, binding.command);
|
||||
}
|
||||
|
||||
for (const binding of userBindings) {
|
||||
if (binding.command === null) {
|
||||
bindingMap.delete(binding.key);
|
||||
} else {
|
||||
bindingMap.set(binding.key, binding.command);
|
||||
}
|
||||
}
|
||||
|
||||
const keybindings: Keybinding[] = [];
|
||||
for (const [key, command] of bindingMap) {
|
||||
if (command !== null) {
|
||||
keybindings.push({ key, command });
|
||||
}
|
||||
}
|
||||
|
||||
return keybindings;
|
||||
}
|
||||
247
src/jimaku/utils.ts
Normal file
247
src/jimaku/utils.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import * as http from "http";
|
||||
import * as https from "https";
|
||||
import * as path from "path";
|
||||
import * as childProcess from "child_process";
|
||||
import {
|
||||
JimakuApiResponse,
|
||||
JimakuConfig,
|
||||
JimakuMediaInfo,
|
||||
} from "../types";
|
||||
|
||||
function execCommand(
|
||||
command: string,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
childProcess.exec(command, { timeout: 10000 }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve({ stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveJimakuApiKey(
|
||||
config: JimakuConfig,
|
||||
): Promise<string | null> {
|
||||
if (config.apiKey && config.apiKey.trim()) {
|
||||
console.log("[jimaku] API key found in config");
|
||||
return config.apiKey.trim();
|
||||
}
|
||||
if (config.apiKeyCommand && config.apiKeyCommand.trim()) {
|
||||
try {
|
||||
const { stdout } = await execCommand(config.apiKeyCommand);
|
||||
const key = stdout.trim();
|
||||
console.log(
|
||||
`[jimaku] apiKeyCommand result: ${key.length > 0 ? "key obtained" : "empty output"}`,
|
||||
);
|
||||
return key.length > 0 ? key : null;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"Failed to run jimaku.apiKeyCommand:",
|
||||
(err as Error).message,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
"[jimaku] No API key configured (neither apiKey nor apiKeyCommand set)",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRetryAfter(headers: http.IncomingHttpHeaders): number | undefined {
|
||||
const value = headers["x-ratelimit-reset-after"];
|
||||
if (!value) return undefined;
|
||||
const raw = Array.isArray(value) ? value[0] : value;
|
||||
const parsed = Number.parseFloat(raw);
|
||||
if (!Number.isFinite(parsed)) return undefined;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function jimakuFetchJson<T>(
|
||||
endpoint: string,
|
||||
query: Record<string, string | number | boolean | null | undefined>,
|
||||
options: { baseUrl: string; apiKey: string },
|
||||
): Promise<JimakuApiResponse<T>> {
|
||||
const url = new URL(endpoint, options.baseUrl);
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === null || value === undefined) continue;
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
|
||||
console.log(`[jimaku] GET ${url.toString()}`);
|
||||
const transport = url.protocol === "https:" ? https : http;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const req = transport.request(
|
||||
url,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: options.apiKey,
|
||||
"User-Agent": "SubMiner",
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
res.on("end", () => {
|
||||
const status = res.statusCode || 0;
|
||||
console.log(`[jimaku] Response HTTP ${status} for ${endpoint}`);
|
||||
if (status >= 200 && status < 300) {
|
||||
try {
|
||||
const parsed = JSON.parse(data) as T;
|
||||
resolve({ ok: true, data: parsed });
|
||||
} catch {
|
||||
console.error(`[jimaku] JSON parse error: ${data.slice(0, 200)}`);
|
||||
resolve({
|
||||
ok: false,
|
||||
error: { error: "Failed to parse Jimaku response JSON." },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let errorMessage = `Jimaku API error (HTTP ${status})`;
|
||||
try {
|
||||
const parsed = JSON.parse(data) as { error?: string };
|
||||
if (parsed && parsed.error) {
|
||||
errorMessage = parsed.error;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors.
|
||||
}
|
||||
console.error(`[jimaku] API error: ${errorMessage}`);
|
||||
|
||||
resolve({
|
||||
ok: false,
|
||||
error: {
|
||||
error: errorMessage,
|
||||
code: status || undefined,
|
||||
retryAfter:
|
||||
status === 429 ? getRetryAfter(res.headers) : undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
req.on("error", (err) => {
|
||||
console.error(`[jimaku] Network error: ${(err as Error).message}`);
|
||||
resolve({
|
||||
ok: false,
|
||||
error: { error: `Jimaku request failed: ${(err as Error).message}` },
|
||||
});
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function matchEpisodeFromName(name: string): {
|
||||
season: number | null;
|
||||
episode: number | null;
|
||||
index: number | null;
|
||||
confidence: "high" | "medium" | "low";
|
||||
} {
|
||||
const seasonEpisode = name.match(/S(\d{1,2})E(\d{1,3})/i);
|
||||
if (seasonEpisode && seasonEpisode.index !== undefined) {
|
||||
return {
|
||||
season: Number.parseInt(seasonEpisode[1], 10),
|
||||
episode: Number.parseInt(seasonEpisode[2], 10),
|
||||
index: seasonEpisode.index,
|
||||
confidence: "high",
|
||||
};
|
||||
}
|
||||
|
||||
const alt = name.match(/(\d{1,2})x(\d{1,3})/i);
|
||||
if (alt && alt.index !== undefined) {
|
||||
return {
|
||||
season: Number.parseInt(alt[1], 10),
|
||||
episode: Number.parseInt(alt[2], 10),
|
||||
index: alt.index,
|
||||
confidence: "high",
|
||||
};
|
||||
}
|
||||
|
||||
const epOnly = name.match(/(?:^|[\s._-])E(?:P)?(\d{1,3})(?:\b|[\s._-])/i);
|
||||
if (epOnly && epOnly.index !== undefined) {
|
||||
return {
|
||||
season: null,
|
||||
episode: Number.parseInt(epOnly[1], 10),
|
||||
index: epOnly.index,
|
||||
confidence: "medium",
|
||||
};
|
||||
}
|
||||
|
||||
const numeric = name.match(/(?:^|[-–—]\s*)(\d{1,3})\s*[-–—]/);
|
||||
if (numeric && numeric.index !== undefined) {
|
||||
return {
|
||||
season: null,
|
||||
episode: Number.parseInt(numeric[1], 10),
|
||||
index: numeric.index,
|
||||
confidence: "medium",
|
||||
};
|
||||
}
|
||||
|
||||
return { season: null, episode: null, index: null, confidence: "low" };
|
||||
}
|
||||
|
||||
function detectSeasonFromDir(mediaPath: string): number | null {
|
||||
const parent = path.basename(path.dirname(mediaPath));
|
||||
const match = parent.match(/(?:Season|S)\s*(\d{1,2})/i);
|
||||
if (!match) return null;
|
||||
const parsed = Number.parseInt(match[1], 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function cleanupTitle(value: string): string {
|
||||
return value
|
||||
.replace(/^[\s-–—]+/, "")
|
||||
.replace(/[\s-–—]+$/, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function parseMediaInfo(mediaPath: string | null): JimakuMediaInfo {
|
||||
if (!mediaPath) {
|
||||
return {
|
||||
title: "",
|
||||
season: null,
|
||||
episode: null,
|
||||
confidence: "low",
|
||||
filename: "",
|
||||
rawTitle: "",
|
||||
};
|
||||
}
|
||||
|
||||
const filename = path.basename(mediaPath);
|
||||
let name = filename.replace(/\.[^/.]+$/, "");
|
||||
name = name.replace(/\[[^\]]*]/g, " ");
|
||||
name = name.replace(/\(\d{4}\)/g, " ");
|
||||
name = name.replace(/[._]/g, " ");
|
||||
name = name.replace(/[–—]/g, "-");
|
||||
name = name.replace(/\s+/g, " ").trim();
|
||||
|
||||
const parsed = matchEpisodeFromName(name);
|
||||
let titlePart = name;
|
||||
if (parsed.index !== null) {
|
||||
titlePart = name.slice(0, parsed.index);
|
||||
}
|
||||
|
||||
const seasonFromDir = parsed.season ?? detectSeasonFromDir(mediaPath);
|
||||
const title = cleanupTitle(titlePart || name);
|
||||
|
||||
return {
|
||||
title,
|
||||
season: seasonFromDir,
|
||||
episode: parsed.episode,
|
||||
confidence: parsed.confidence,
|
||||
filename,
|
||||
rawTitle: name,
|
||||
};
|
||||
}
|
||||
1139
src/main.ts
1139
src/main.ts
File diff suppressed because it is too large
Load Diff
150
src/subsync/utils.ts
Normal file
150
src/subsync/utils.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import * as fs from "fs";
|
||||
import * as childProcess from "child_process";
|
||||
import { DEFAULT_CONFIG } from "../config";
|
||||
import { SubsyncConfig, SubsyncMode } from "../types";
|
||||
|
||||
export interface MpvTrack {
|
||||
id?: number;
|
||||
type?: string;
|
||||
selected?: boolean;
|
||||
external?: boolean;
|
||||
lang?: string;
|
||||
title?: string;
|
||||
codec?: string;
|
||||
"ff-index"?: number;
|
||||
"external-filename"?: string;
|
||||
}
|
||||
|
||||
export interface SubsyncResolvedConfig {
|
||||
defaultMode: SubsyncMode;
|
||||
alassPath: string;
|
||||
ffsubsyncPath: string;
|
||||
ffmpegPath: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SUBSYNC_EXECUTABLE_PATHS = {
|
||||
alass: "/usr/bin/alass",
|
||||
ffsubsync: "/usr/bin/ffsubsync",
|
||||
ffmpeg: "/usr/bin/ffmpeg",
|
||||
} as const;
|
||||
|
||||
export interface SubsyncContext {
|
||||
videoPath: string;
|
||||
primaryTrack: MpvTrack;
|
||||
secondaryTrack: MpvTrack | null;
|
||||
sourceTracks: MpvTrack[];
|
||||
audioStreamIndex: number | null;
|
||||
}
|
||||
|
||||
export interface CommandResult {
|
||||
ok: boolean;
|
||||
code: number | null;
|
||||
stderr: string;
|
||||
stdout: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function getSubsyncConfig(
|
||||
config: SubsyncConfig | undefined,
|
||||
): SubsyncResolvedConfig {
|
||||
const resolvePath = (value: string | undefined, fallback: string): string => {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : fallback;
|
||||
};
|
||||
|
||||
return {
|
||||
defaultMode: config?.defaultMode ?? DEFAULT_CONFIG.subsync.defaultMode,
|
||||
alassPath: resolvePath(
|
||||
config?.alass_path,
|
||||
DEFAULT_SUBSYNC_EXECUTABLE_PATHS.alass,
|
||||
),
|
||||
ffsubsyncPath: resolvePath(
|
||||
config?.ffsubsync_path,
|
||||
DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffsubsync,
|
||||
),
|
||||
ffmpegPath: resolvePath(
|
||||
config?.ffmpeg_path,
|
||||
DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffmpeg,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasPathSeparators(value: string): boolean {
|
||||
return value.includes("/") || value.includes("\\");
|
||||
}
|
||||
|
||||
export function fileExists(pathOrEmpty: string): boolean {
|
||||
if (!pathOrEmpty) return false;
|
||||
try {
|
||||
return fs.existsSync(pathOrEmpty);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTrackLabel(track: MpvTrack): string {
|
||||
const trackId = typeof track.id === "number" ? track.id : -1;
|
||||
const source = track.external ? "External" : "Internal";
|
||||
const lang = track.lang || track.title || "unknown";
|
||||
const active = track.selected ? " (active)" : "";
|
||||
return `${source} #${trackId} - ${lang}${active}`;
|
||||
}
|
||||
|
||||
export function getTrackById(
|
||||
tracks: MpvTrack[],
|
||||
trackId: number | null,
|
||||
): MpvTrack | null {
|
||||
if (trackId === null) return null;
|
||||
return tracks.find((track) => track.id === trackId) ?? null;
|
||||
}
|
||||
|
||||
export function codecToExtension(codec: string | undefined): string | null {
|
||||
if (!codec) return null;
|
||||
const normalized = codec.toLowerCase();
|
||||
if (normalized === "subrip" || normalized === "srt") return "srt";
|
||||
if (normalized === "ass" || normalized === "ssa") return "ass";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function runCommand(
|
||||
executable: string,
|
||||
args: string[],
|
||||
timeoutMs = 120000,
|
||||
): Promise<CommandResult> {
|
||||
return new Promise((resolve) => {
|
||||
const child = childProcess.spawn(executable, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("error", (error: Error) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
ok: false,
|
||||
code: null,
|
||||
stderr,
|
||||
stdout,
|
||||
error: error.message,
|
||||
});
|
||||
});
|
||||
child.on("close", (code: number | null) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
ok: code === 0,
|
||||
code,
|
||||
stderr,
|
||||
stdout,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user