mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
434 lines
12 KiB
TypeScript
434 lines
12 KiB
TypeScript
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 {
|
|
parseArgs, loadLauncherYoutubeSubgenConfig, loadLauncherJellyfinConfig,
|
|
readPluginRuntimeConfig,
|
|
} 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";
|
|
|
|
function checkDependencies(args: Args): void {
|
|
const missing: string[] = [];
|
|
|
|
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) &&
|
|
args.youtubeSubgenMode !== "off" &&
|
|
!commandExists("ffmpeg")
|
|
) {
|
|
missing.push("ffmpeg");
|
|
}
|
|
|
|
if (missing.length > 0) fail(`Missing dependencies: ${missing.join(" ")}`);
|
|
}
|
|
|
|
function checkPickerDependencies(args: Args): void {
|
|
if (args.useRofi) {
|
|
if (!commandExists("rofi")) fail("Missing dependency: rofi");
|
|
return;
|
|
}
|
|
|
|
if (!commandExists("fzf")) fail("Missing dependency: fzf");
|
|
}
|
|
|
|
async function chooseTarget(
|
|
args: Args,
|
|
scriptPath: string,
|
|
): Promise<{ target: string; kind: "file" | "url" } | null> {
|
|
if (args.target) {
|
|
return { target: args.target, kind: args.targetKind as "file" | "url" };
|
|
}
|
|
|
|
const searchDir = realpathMaybe(resolvePathMaybe(args.directory));
|
|
if (!fs.existsSync(searchDir) || !fs.statSync(searchDir).isDirectory()) {
|
|
fail(`Directory not found: ${searchDir}`);
|
|
}
|
|
|
|
const videos = collectVideos(searchDir, args.recursive);
|
|
if (videos.length === 0) {
|
|
fail(`No video files found in: ${searchDir}`);
|
|
}
|
|
|
|
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" };
|
|
}
|
|
|
|
function registerCleanup(args: Args): void {
|
|
process.on("SIGINT", () => {
|
|
stopOverlay(args);
|
|
process.exit(130);
|
|
});
|
|
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"];
|
|
for (const baseDir of baseDirs) {
|
|
for (const appName of appNames) {
|
|
const jsoncPath = path.join(baseDir, appName, "config.jsonc");
|
|
if (fs.existsSync(jsoncPath)) return jsoncPath;
|
|
const jsonPath = path.join(baseDir, appName, "config.json");
|
|
if (fs.existsSync(jsonPath)) return jsonPath;
|
|
}
|
|
}
|
|
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",
|
|
ok: Boolean(appPath),
|
|
detail: appPath || "not found (set SUBMINER_APPIMAGE_PATH)",
|
|
},
|
|
{
|
|
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: "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: "rofi",
|
|
ok: commandExists("rofi"),
|
|
detail: commandExists("rofi") ? "found" : "missing (optional if using fzf)",
|
|
},
|
|
{
|
|
label: "config",
|
|
ok: fs.existsSync(resolveMainConfigPath()),
|
|
detail: resolveMainConfigPath(),
|
|
},
|
|
{
|
|
label: "mpv socket path",
|
|
ok: true,
|
|
detail: mpvSocketPath,
|
|
},
|
|
];
|
|
|
|
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}`);
|
|
}
|
|
process.exit(hasHardFailure ? 1 : 0);
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const scriptPath = process.argv[1] || "subminer";
|
|
const scriptName = path.basename(scriptPath);
|
|
const launcherConfig = loadLauncherYoutubeSubgenConfig();
|
|
const launcherJellyfinConfig = loadLauncherJellyfinConfig();
|
|
const args = parseArgs(process.argv.slice(2), scriptName, launcherConfig);
|
|
const pluginRuntimeConfig = readPluginRuntimeConfig(args.logLevel);
|
|
const mpvSocketPath = pluginRuntimeConfig.socketPath;
|
|
|
|
log("debug", args.logLevel, `Wrapper log level set to: ${args.logLevel}`);
|
|
|
|
const appPath = findAppBinary(process.argv[1] || "subminer");
|
|
if (args.doctor) {
|
|
runDoctor(args, appPath, mpvSocketPath);
|
|
}
|
|
|
|
if (args.configPath) {
|
|
process.stdout.write(`${resolveMainConfigPath()}\n`);
|
|
return;
|
|
}
|
|
|
|
if (args.configShow) {
|
|
const configPath = resolveMainConfigPath();
|
|
if (!fs.existsSync(configPath)) {
|
|
fail(`Config file not found: ${configPath}`);
|
|
}
|
|
const contents = fs.readFileSync(configPath, "utf8");
|
|
process.stdout.write(contents);
|
|
if (!contents.endsWith("\n")) {
|
|
process.stdout.write("\n");
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (args.mpvSocket) {
|
|
process.stdout.write(`${mpvSocketPath}\n`);
|
|
return;
|
|
}
|
|
|
|
if (args.mpvStatus) {
|
|
const ready = await waitForUnixSocketReady(mpvSocketPath, 500);
|
|
log(
|
|
ready ? "info" : "warn",
|
|
args.logLevel,
|
|
`[mpv] socket ${ready ? "ready" : "not ready"}: ${mpvSocketPath}`,
|
|
);
|
|
process.exit(ready ? 0 : 1);
|
|
}
|
|
|
|
if (!appPath) {
|
|
if (process.platform === "darwin") {
|
|
fail(
|
|
"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.",
|
|
);
|
|
}
|
|
state.appPath = appPath;
|
|
|
|
if (args.mpvIdle) {
|
|
await launchMpvIdleDetached(mpvSocketPath, appPath, args);
|
|
const ready = await waitForUnixSocketReady(mpvSocketPath, 8000);
|
|
if (!ready) {
|
|
fail(`MPV IPC socket not ready after idle launch: ${mpvSocketPath}`);
|
|
}
|
|
log("info", args.logLevel, `[mpv] idle instance ready on ${mpvSocketPath}`);
|
|
return;
|
|
}
|
|
|
|
if (args.texthookerOnly) {
|
|
launchTexthookerOnly(appPath, args);
|
|
}
|
|
|
|
if (args.jellyfin) {
|
|
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 || "";
|
|
if (!serverUrl || !username || !password) {
|
|
fail(
|
|
"--jellyfin-login requires server, username, and password. Pass flags or run `subminer --jellyfin`.",
|
|
);
|
|
}
|
|
const forwarded = [
|
|
"--jellyfin-login",
|
|
"--jellyfin-server",
|
|
serverUrl,
|
|
"--jellyfin-username",
|
|
username,
|
|
"--jellyfin-password",
|
|
password,
|
|
];
|
|
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);
|
|
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("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);
|
|
runAppCommandWithInherit(appPath, forwarded);
|
|
}
|
|
|
|
if (!args.target) {
|
|
checkPickerDependencies(args);
|
|
}
|
|
|
|
const targetChoice = await chooseTarget(args, process.argv[1] || "subminer");
|
|
if (!targetChoice) {
|
|
log("info", args.logLevel, "No video selected, exiting");
|
|
process.exit(0);
|
|
}
|
|
|
|
checkDependencies({
|
|
...args,
|
|
target: targetChoice ? targetChoice.target : args.target,
|
|
targetKind: targetChoice ? targetChoice.kind : "url",
|
|
});
|
|
|
|
registerCleanup(args);
|
|
|
|
let selectedTarget = targetChoice
|
|
? {
|
|
target: targetChoice.target,
|
|
kind: targetChoice.kind as "file" | "url",
|
|
}
|
|
: { target: args.target, kind: "url" as const };
|
|
|
|
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,
|
|
);
|
|
preloadedSubtitles = {
|
|
primaryPath: generated.primaryPath,
|
|
secondaryPath: generated.secondaryPath,
|
|
};
|
|
log(
|
|
"info",
|
|
args.logLevel,
|
|
`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) {
|
|
log("info", args.logLevel, "YouTube subtitle mode: off");
|
|
}
|
|
|
|
startMpv(
|
|
selectedTarget.target,
|
|
selectedTarget.kind,
|
|
args,
|
|
mpvSocketPath,
|
|
appPath,
|
|
preloadedSubtitles,
|
|
);
|
|
|
|
if (isYoutubeUrl && args.youtubeSubgenMode === "automatic") {
|
|
void generateYoutubeSubtitles(
|
|
selectedTarget.target,
|
|
args,
|
|
async (lang, subtitlePath) => {
|
|
try {
|
|
await loadSubtitleIntoMpv(
|
|
mpvSocketPath,
|
|
subtitlePath,
|
|
lang === "primary",
|
|
args.logLevel,
|
|
);
|
|
} catch (error) {
|
|
log(
|
|
"warn",
|
|
args.logLevel,
|
|
`Generated subtitle ready but failed to load in mpv: ${(error as Error).message}`,
|
|
);
|
|
}
|
|
}).catch((error) => {
|
|
log(
|
|
"warn",
|
|
args.logLevel,
|
|
`Background subtitle generation failed: ${(error as Error).message}`,
|
|
);
|
|
});
|
|
}
|
|
|
|
const ready = await waitForSocket(mpvSocketPath);
|
|
const shouldStartOverlay =
|
|
args.startOverlay || args.autoStartOverlay || pluginRuntimeConfig.autoStartOverlay;
|
|
if (shouldStartOverlay) {
|
|
if (ready) {
|
|
log(
|
|
"info",
|
|
args.logLevel,
|
|
"MPV IPC socket ready, starting SubMiner overlay",
|
|
);
|
|
} else {
|
|
log(
|
|
"info",
|
|
args.logLevel,
|
|
"MPV IPC socket not ready after timeout, starting SubMiner overlay anyway",
|
|
);
|
|
}
|
|
await startOverlay(appPath, args, mpvSocketPath);
|
|
} else if (ready) {
|
|
log(
|
|
"info",
|
|
args.logLevel,
|
|
"MPV IPC socket ready, overlay auto-start disabled (use y-s to start)",
|
|
);
|
|
} else {
|
|
log(
|
|
"info",
|
|
args.logLevel,
|
|
"MPV IPC socket not ready yet, overlay auto-start disabled (use y-s to start)",
|
|
);
|
|
}
|
|
|
|
await new Promise<void>((resolve) => {
|
|
if (!state.mpvProc) {
|
|
stopOverlay(args);
|
|
resolve();
|
|
return;
|
|
}
|
|
state.mpvProc.on("exit", (code) => {
|
|
stopOverlay(args);
|
|
process.exitCode = code ?? 0;
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
main().catch((error: unknown) => {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
fail(message);
|
|
});
|