mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
2903 lines
82 KiB
TypeScript
Executable File
2903 lines
82 KiB
TypeScript
Executable File
#!/usr/bin/env bun
|
|
|
|
/**
|
|
* SubMiner launcher (Bun runtime)
|
|
* Local-only wrapper for mpv + SubMiner overlay orchestration.
|
|
*/
|
|
|
|
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 http from "node:http";
|
|
import https from "node:https";
|
|
import { parse as parseJsonc } from "jsonc-parser";
|
|
|
|
const VIDEO_EXTENSIONS = new Set([
|
|
"mkv",
|
|
"mp4",
|
|
"avi",
|
|
"webm",
|
|
"mov",
|
|
"flv",
|
|
"wmv",
|
|
"m4v",
|
|
"ts",
|
|
"m2ts",
|
|
]);
|
|
|
|
const ROFI_THEME_FILE = "subminer.rasi";
|
|
const DEFAULT_SOCKET_PATH = "/tmp/subminer-socket";
|
|
const DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS = ["ja", "jpn"];
|
|
const DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS = ["en", "eng"];
|
|
const YOUTUBE_SUB_EXTENSIONS = new Set([".srt", ".vtt", ".ass"]);
|
|
const YOUTUBE_AUDIO_EXTENSIONS = new Set([
|
|
".m4a",
|
|
".mp3",
|
|
".webm",
|
|
".opus",
|
|
".wav",
|
|
".aac",
|
|
".flac",
|
|
]);
|
|
const DEFAULT_YOUTUBE_SUBGEN_OUT_DIR = path.join(
|
|
os.homedir(),
|
|
".cache",
|
|
"subminer",
|
|
"youtube-subs",
|
|
);
|
|
const DEFAULT_MPV_LOG_FILE = path.join(
|
|
os.homedir(),
|
|
".cache",
|
|
"SubMiner",
|
|
"mp.log",
|
|
);
|
|
const DEFAULT_YOUTUBE_YTDL_FORMAT = "bestvideo*+bestaudio/best";
|
|
const DEFAULT_JIMAKU_API_BASE_URL = "https://jimaku.cc";
|
|
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;
|
|
|
|
type LogLevel = "debug" | "info" | "warn" | "error";
|
|
type YoutubeSubgenMode = "automatic" | "preprocess" | "off";
|
|
|
|
type Backend = "auto" | "hyprland" | "x11" | "macos";
|
|
|
|
type JimakuLanguagePreference = "ja" | "en" | "none";
|
|
|
|
interface JimakuEntry {
|
|
id: number;
|
|
name: string;
|
|
english_name?: string | null;
|
|
japanese_name?: string | null;
|
|
flags?: {
|
|
anime?: boolean;
|
|
movie?: boolean;
|
|
adult?: boolean;
|
|
external?: boolean;
|
|
unverified?: boolean;
|
|
};
|
|
}
|
|
|
|
interface JimakuFileEntry {
|
|
name: string;
|
|
url: string;
|
|
size: number;
|
|
last_modified: string;
|
|
}
|
|
|
|
interface JimakuApiError {
|
|
error: string;
|
|
code?: number;
|
|
retryAfter?: number;
|
|
}
|
|
|
|
type JimakuApiResponse<T> =
|
|
| { ok: true; data: T }
|
|
| { ok: false; error: JimakuApiError };
|
|
|
|
type JimakuDownloadResult =
|
|
| { ok: true; path: string }
|
|
| { ok: false; error: JimakuApiError };
|
|
|
|
interface JimakuConfig {
|
|
apiKey: string;
|
|
apiKeyCommand: string;
|
|
apiBaseUrl: string;
|
|
languagePreference: JimakuLanguagePreference;
|
|
maxEntryResults: number;
|
|
}
|
|
|
|
interface JimakuMediaInfo {
|
|
title: string;
|
|
season: number | null;
|
|
episode: number | null;
|
|
confidence: "high" | "medium" | "low";
|
|
filename: string;
|
|
rawTitle: string;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 parseGuessitOutput(
|
|
mediaPath: string,
|
|
stdout: string,
|
|
): JimakuMediaInfo | null {
|
|
const payload = stdout.trim();
|
|
if (!payload) return null;
|
|
|
|
try {
|
|
const parsed = JSON.parse(payload) as {
|
|
title?: string;
|
|
title_original?: string;
|
|
series?: string;
|
|
season?: number | string;
|
|
episode?: number | string;
|
|
episode_list?: Array<number | string>;
|
|
};
|
|
const season =
|
|
typeof parsed.season === "number"
|
|
? parsed.season
|
|
: typeof parsed.season === "string"
|
|
? Number.parseInt(parsed.season, 10)
|
|
: null;
|
|
const directEpisode =
|
|
typeof parsed.episode === "number"
|
|
? parsed.episode
|
|
: typeof parsed.episode === "string"
|
|
? Number.parseInt(parsed.episode, 10)
|
|
: null;
|
|
const episodeFromList =
|
|
parsed.episode_list && parsed.episode_list.length > 0
|
|
? 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();
|
|
const hasStructuredData =
|
|
title.length > 0 || Number.isFinite(season as number) || Number.isFinite(episodeValue as number);
|
|
|
|
if (!hasStructuredData) return null;
|
|
|
|
return {
|
|
title: title || "",
|
|
season: Number.isFinite(season as number) ? season : detectSeasonFromDir(mediaPath),
|
|
episode: episode,
|
|
confidence: "high",
|
|
filename: path.basename(mediaPath),
|
|
rawTitle: path.basename(mediaPath).replace(/\.[^/.]+$/, ""),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseMediaInfoWithGuessit(mediaPath: string): JimakuMediaInfo | null {
|
|
if (!commandExists("guessit")) return null;
|
|
|
|
try {
|
|
const fileName = path.basename(mediaPath);
|
|
const result = spawnSync("guessit", ["--json", fileName], {
|
|
cwd: path.dirname(mediaPath),
|
|
encoding: "utf8",
|
|
maxBuffer: 2_000_000,
|
|
windowsHide: true,
|
|
});
|
|
if (result.error || result.status !== 0) return null;
|
|
return parseGuessitOutput(mediaPath, result.stdout || "");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function cleanupTitle(value: string): string {
|
|
return value
|
|
.replace(/^[\s-–—]+/, "")
|
|
.replace(/[\s-–—]+$/, "")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function formatLangScore(name: string, pref: JimakuLanguagePreference): number {
|
|
if (pref === "none") return 0;
|
|
const upper = name.toUpperCase();
|
|
const hasJa =
|
|
/(^|[\W_])JA([\W_]|$)/.test(upper) ||
|
|
/(^|[\W_])JPN([\W_]|$)/.test(upper) ||
|
|
upper.includes(".JA.");
|
|
const hasEn =
|
|
/(^|[\W_])EN([\W_]|$)/.test(upper) ||
|
|
/(^|[\W_])ENG([\W_]|$)/.test(upper) ||
|
|
upper.includes(".EN.");
|
|
if (pref === "ja") {
|
|
if (hasJa) return 2;
|
|
if (hasEn) return 1;
|
|
} else if (pref === "en") {
|
|
if (hasEn) return 2;
|
|
if (hasJa) return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
async function resolveJimakuApiKey(config: JimakuConfig): Promise<string | null> {
|
|
if (config.apiKey && config.apiKey.trim()) {
|
|
return config.apiKey.trim();
|
|
}
|
|
if (config.apiKeyCommand && config.apiKeyCommand.trim()) {
|
|
try {
|
|
const commandResult = spawnSync(config.apiKeyCommand, {
|
|
shell: true,
|
|
encoding: "utf8",
|
|
timeout: 10000,
|
|
});
|
|
if (commandResult.error) return null;
|
|
const key = (commandResult.stdout || "").trim();
|
|
return key.length > 0 ? key : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
const requestUrl = new URL(url.toString());
|
|
const transport = requestUrl.protocol === "https:" ? https : http;
|
|
const req = transport.request(
|
|
requestUrl,
|
|
{
|
|
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;
|
|
if (status >= 200 && status < 300) {
|
|
try {
|
|
const parsed = JSON.parse(data) as T;
|
|
resolve({ ok: true, data: parsed });
|
|
} catch {
|
|
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
|
|
}
|
|
|
|
resolve({
|
|
ok: false,
|
|
error: {
|
|
error: errorMessage,
|
|
code: status || undefined,
|
|
retryAfter:
|
|
status === 429 ? getRetryAfter(res.headers) : undefined,
|
|
},
|
|
});
|
|
});
|
|
},
|
|
);
|
|
|
|
req.on("error", (error) => {
|
|
resolve({
|
|
ok: false,
|
|
error: { error: `Jimaku request failed: ${(error as Error).message}` },
|
|
});
|
|
});
|
|
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function parseMediaInfo(mediaPath: string | null): JimakuMediaInfo {
|
|
if (!mediaPath) {
|
|
return {
|
|
title: "",
|
|
season: null,
|
|
episode: null,
|
|
confidence: "low",
|
|
filename: "",
|
|
rawTitle: "",
|
|
};
|
|
}
|
|
|
|
const guessitInfo = parseMediaInfoWithGuessit(mediaPath);
|
|
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();
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
function sortJimakuFiles(
|
|
files: JimakuFileEntry[],
|
|
pref: JimakuLanguagePreference,
|
|
): JimakuFileEntry[] {
|
|
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;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
}
|
|
|
|
async function downloadToFile(
|
|
url: string,
|
|
destPath: string,
|
|
headers: Record<string, string>,
|
|
redirectCount = 0,
|
|
): Promise<JimakuDownloadResult> {
|
|
if (redirectCount > 3) {
|
|
return {
|
|
ok: false,
|
|
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 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,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (status < 200 || status >= 300) {
|
|
res.resume();
|
|
resolve({
|
|
ok: false,
|
|
error: {
|
|
error: `Failed to download subtitle (HTTP ${status}).`,
|
|
code: status,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
const fileStream = fs.createWriteStream(destPath);
|
|
res.pipe(fileStream);
|
|
fileStream.on("finish", () => {
|
|
fileStream.close(() => {
|
|
resolve({ ok: true, path: destPath });
|
|
});
|
|
});
|
|
fileStream.on("error", (err: Error) => {
|
|
resolve({
|
|
ok: false,
|
|
error: { error: `Failed to save subtitle: ${err.message}` },
|
|
});
|
|
});
|
|
});
|
|
|
|
req.on("error", (err) => {
|
|
resolve({
|
|
ok: false,
|
|
error: {
|
|
error: `Download request failed: ${(err as Error).message}`,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
interface LauncherYoutubeSubgenConfig {
|
|
mode?: YoutubeSubgenMode;
|
|
whisperBin?: string;
|
|
whisperModel?: string;
|
|
primarySubLanguages?: string[];
|
|
secondarySubLanguages?: string[];
|
|
jimakuApiKey?: string;
|
|
jimakuApiKeyCommand?: string;
|
|
jimakuApiBaseUrl?: string;
|
|
jimakuLanguagePreference?: JimakuLanguagePreference;
|
|
jimakuMaxEntryResults?: number;
|
|
}
|
|
|
|
interface PluginRuntimeConfig {
|
|
autoStartOverlay: boolean;
|
|
socketPath: string;
|
|
}
|
|
|
|
const COLORS = {
|
|
red: "\x1b[0;31m",
|
|
green: "\x1b[0;32m",
|
|
yellow: "\x1b[0;33m",
|
|
cyan: "\x1b[0;36m",
|
|
reset: "\x1b[0m",
|
|
};
|
|
|
|
const LOG_PRI: Record<LogLevel, number> = {
|
|
debug: 10,
|
|
info: 20,
|
|
warn: 30,
|
|
error: 40,
|
|
};
|
|
|
|
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,
|
|
overlayManagedByLauncher: false,
|
|
stopRequested: false,
|
|
};
|
|
|
|
interface MpvTrack {
|
|
type?: string;
|
|
id?: number;
|
|
lang?: string;
|
|
title?: string;
|
|
}
|
|
|
|
function usage(scriptName: string): string {
|
|
return `subminer - Launch MPV with SubMiner sentence mining overlay
|
|
|
|
Usage: ${scriptName} [OPTIONS] [FILE|DIRECTORY|URL]
|
|
|
|
Options:
|
|
-b, --backend BACKEND Display backend to use: auto, hyprland, x11, macos (default: auto)
|
|
-d, --directory DIR Directory to browse for videos (default: current directory)
|
|
-r, --recursive Search for videos recursively
|
|
-p, --profile PROFILE MPV profile to use (default: subminer)
|
|
--start Explicitly start SubMiner overlay
|
|
--yt-subgen-mode MODE
|
|
YouTube subtitle generation mode: automatic, preprocess, off (default: automatic)
|
|
--whisper-bin PATH whisper.cpp CLI binary (used for fallback transcription)
|
|
--whisper-model PATH
|
|
whisper model file path (used for fallback transcription)
|
|
--yt-subgen-out-dir DIR
|
|
Output directory for generated YouTube subtitles (default: ${DEFAULT_YOUTUBE_SUBGEN_OUT_DIR})
|
|
--yt-subgen-audio-format FORMAT
|
|
Audio format for extraction (default: m4a)
|
|
--yt-subgen-keep-temp
|
|
Keep YouTube subtitle temp directory
|
|
-v, --verbose Enable verbose/debug logging
|
|
--log-level LEVEL Set log level: debug, info, warn, error
|
|
-R, --rofi Use rofi file browser instead of fzf for video selection
|
|
-S, --start-overlay Auto-start SubMiner overlay after MPV socket is ready
|
|
-T, --no-texthooker Disable texthooker-ui server
|
|
--texthooker Launch only texthooker page (no MPV/overlay workflow)
|
|
-h, --help Show this help message
|
|
|
|
Environment:
|
|
SUBMINER_APPIMAGE_PATH Path to SubMiner AppImage/binary (optional override)
|
|
SUBMINER_ROFI_THEME Path to rofi theme file (optional override)
|
|
SUBMINER_YT_SUBGEN_MODE automatic, preprocess, off (optional default)
|
|
SUBMINER_WHISPER_BIN whisper.cpp binary path (optional fallback)
|
|
SUBMINER_WHISPER_MODEL whisper model path (optional fallback)
|
|
SUBMINER_YT_SUBGEN_OUT_DIR Generated subtitle output directory
|
|
|
|
Examples:
|
|
${scriptName} # Browse current directory with fzf
|
|
${scriptName} -R # Browse current directory with rofi
|
|
${scriptName} -d ~/Videos # Browse ~/Videos
|
|
${scriptName} -r -d ~/Anime # Recursively browse ~/Anime
|
|
${scriptName} video.mkv # Play specific file
|
|
${scriptName} https://youtu.be/... # Play a YouTube URL
|
|
${scriptName} ytsearch:query # Play first YouTube search result
|
|
${scriptName} --yt-subgen-mode preprocess --whisper-bin /path/whisper-cli --whisper-model /path/model.bin https://youtu.be/...
|
|
${scriptName} video.mkv # Play with subminer profile
|
|
${scriptName} -p gpu-hq video.mkv # Play with gpu-hq profile
|
|
${scriptName} -b x11 video.mkv # Force x11 backend
|
|
${scriptName} -S video.mkv # Start overlay immediately after MPV launch
|
|
${scriptName} --texthooker # Launch only texthooker page
|
|
`;
|
|
}
|
|
|
|
function shouldLog(level: LogLevel, configured: LogLevel): boolean {
|
|
return LOG_PRI[level] >= LOG_PRI[configured];
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
|
|
function getMpvLogPath(): string {
|
|
const envPath = process.env.SUBMINER_MPV_LOG?.trim();
|
|
if (envPath) return envPath;
|
|
return DEFAULT_MPV_LOG_FILE;
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
function fail(message: string): never {
|
|
process.stderr.write(`${COLORS.red}[ERROR]${COLORS.reset} ${message}\n`);
|
|
appendToMpvLog(`[ERROR] ${message}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
function isExecutable(filePath: string): boolean {
|
|
try {
|
|
fs.accessSync(filePath, fs.constants.X_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function resolvePathMaybe(input: string): string {
|
|
if (input.startsWith("~")) {
|
|
return path.join(os.homedir(), input.slice(1));
|
|
}
|
|
return input;
|
|
}
|
|
|
|
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
|
|
: "";
|
|
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 root = parsed as {
|
|
youtubeSubgen?: unknown;
|
|
secondarySub?: { secondarySubLanguages?: unknown };
|
|
jimaku?: unknown;
|
|
};
|
|
const youtubeSubgen = root.youtubeSubgen;
|
|
const mode =
|
|
youtubeSubgen && typeof youtubeSubgen === "object"
|
|
? (youtubeSubgen as { mode?: unknown }).mode
|
|
: undefined;
|
|
const whisperBin =
|
|
youtubeSubgen && typeof youtubeSubgen === "object"
|
|
? (youtubeSubgen as { whisperBin?: unknown }).whisperBin
|
|
: undefined;
|
|
const whisperModel =
|
|
youtubeSubgen && typeof youtubeSubgen === "object"
|
|
? (youtubeSubgen as { whisperModel?: unknown }).whisperModel
|
|
: undefined;
|
|
const primarySubLanguagesRaw =
|
|
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",
|
|
)
|
|
: undefined;
|
|
const secondarySubLanguages = Array.isArray(secondarySubLanguagesRaw)
|
|
? 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;
|
|
const jimakuApiKeyCommand =
|
|
jimaku && typeof jimaku === "object"
|
|
? (jimaku as { apiKeyCommand?: unknown }).apiKeyCommand
|
|
: undefined;
|
|
const jimakuApiBaseUrl =
|
|
jimaku && typeof jimaku === "object"
|
|
? (jimaku as { apiBaseUrl?: unknown }).apiBaseUrl
|
|
: undefined;
|
|
const jimakuLanguagePreference = jimaku && typeof jimaku === "object"
|
|
? (jimaku as { languagePreference?: unknown }).languagePreference
|
|
: undefined;
|
|
const jimakuMaxEntryResults =
|
|
jimaku && typeof jimaku === "object"
|
|
? (jimaku as { maxEntryResults?: unknown }).maxEntryResults
|
|
: undefined;
|
|
const resolvedJimakuLanguagePreference =
|
|
jimakuLanguagePreference === "ja" ||
|
|
jimakuLanguagePreference === "en" ||
|
|
jimakuLanguagePreference === "none"
|
|
? jimakuLanguagePreference
|
|
: undefined;
|
|
const resolvedJimakuMaxEntryResults =
|
|
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,
|
|
primarySubLanguages,
|
|
secondarySubLanguages,
|
|
jimakuApiKey: typeof jimakuApiKey === "string" ? jimakuApiKey : undefined,
|
|
jimakuApiKeyCommand:
|
|
typeof jimakuApiKeyCommand === "string"
|
|
? jimakuApiKeyCommand
|
|
: undefined,
|
|
jimakuApiBaseUrl:
|
|
typeof jimakuApiBaseUrl === "string"
|
|
? jimakuApiBaseUrl
|
|
: undefined,
|
|
jimakuLanguagePreference: resolvedJimakuLanguagePreference,
|
|
jimakuMaxEntryResults: resolvedJimakuMaxEntryResults,
|
|
};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function realpathMaybe(filePath: string): string {
|
|
try {
|
|
return fs.realpathSync(filePath);
|
|
} catch {
|
|
return path.resolve(filePath);
|
|
}
|
|
}
|
|
|
|
function isUrlTarget(target: string): boolean {
|
|
return /^https?:\/\//.test(target) || /^ytsearch:/.test(target);
|
|
}
|
|
|
|
function isYoutubeTarget(target: string): boolean {
|
|
return (
|
|
/^ytsearch:/.test(target) ||
|
|
/^https?:\/\/(www\.)?(youtube\.com|youtu\.be)\//.test(target)
|
|
);
|
|
}
|
|
|
|
interface CommandExecOptions {
|
|
allowFailure?: boolean;
|
|
captureStdout?: boolean;
|
|
logLevel?: LogLevel;
|
|
commandLabel?: string;
|
|
streamOutput?: boolean;
|
|
env?: NodeJS.ProcessEnv;
|
|
}
|
|
|
|
interface CommandExecResult {
|
|
code: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
interface SubtitleCandidate {
|
|
path: string;
|
|
lang: "primary" | "secondary";
|
|
ext: string;
|
|
size: number;
|
|
source: "manual" | "auto" | "whisper" | "whisper-translate";
|
|
}
|
|
|
|
interface YoutubeSubgenOutputs {
|
|
basename: string;
|
|
primaryPath?: string;
|
|
secondaryPath?: string;
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function sanitizeToken(value: string): string {
|
|
return String(value)
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._-]+/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.replace(/^-|-$/g, "");
|
|
}
|
|
|
|
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()}`;
|
|
}
|
|
|
|
function normalizeLangCode(value: string): string {
|
|
return value.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "");
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function toYtdlpLangPattern(langCodes: string[]): string {
|
|
return langCodes.map((lang) => `${lang}.*`).join(",");
|
|
}
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function filenameHasLanguageTag(filenameLower: string, langCode: string): boolean {
|
|
const escaped = escapeRegExp(langCode);
|
|
const pattern = new RegExp(`(^|[._-])${escaped}([._-]|$)`);
|
|
return pattern.test(filenameLower);
|
|
}
|
|
|
|
function classifyLanguage(
|
|
filename: string,
|
|
primaryLangCodes: string[],
|
|
secondaryLangCodes: string[],
|
|
): "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";
|
|
return null;
|
|
}
|
|
|
|
function preferredLangLabel(langCodes: string[], fallback: string): string {
|
|
return uniqueNormalizedLangCodes(langCodes)[0] || fallback;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function sourceTag(source: SubtitleCandidate["source"]): string {
|
|
if (source === "manual" || source === "auto") return `ytdlp-${source}`;
|
|
if (source === "whisper-translate") return "whisper-translate";
|
|
return "whisper";
|
|
}
|
|
|
|
function runExternalCommand(
|
|
executable: string,
|
|
args: string[],
|
|
opts: CommandExecOptions = {},
|
|
): 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 },
|
|
});
|
|
state.youtubeSubgenChildren.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) => {
|
|
state.youtubeSubgenChildren.delete(child);
|
|
reject(new Error(`Failed to start "${executable}": ${error.message}`));
|
|
});
|
|
|
|
child.on("close", (code) => {
|
|
state.youtubeSubgenChildren.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 });
|
|
});
|
|
});
|
|
}
|
|
|
|
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;
|
|
if (sourceA !== sourceB) return sourceB - sourceA;
|
|
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;
|
|
});
|
|
return scored[0];
|
|
}
|
|
|
|
function scanSubtitleCandidates(
|
|
tempDir: string,
|
|
knownSet: Set<string>,
|
|
source: "manual" | "auto",
|
|
primaryLangCodes: string[],
|
|
secondaryLangCodes: string[],
|
|
): SubtitleCandidate[] {
|
|
const entries = fs.readdirSync(tempDir);
|
|
const out: SubtitleCandidate[] = [];
|
|
for (const name of entries) {
|
|
const fullPath = path.join(tempDir, name);
|
|
if (knownSet.has(fullPath)) continue;
|
|
let stat: fs.Stats;
|
|
try {
|
|
stat = fs.statSync(fullPath);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (!stat.isFile()) continue;
|
|
const ext = path.extname(fullPath).toLowerCase();
|
|
if (!YOUTUBE_SUB_EXTENSIONS.has(ext)) continue;
|
|
const lang = classifyLanguage(name, primaryLangCodes, secondaryLangCodes);
|
|
if (!lang) continue;
|
|
out.push({ path: fullPath, lang, ext, size: stat.size, source });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function convertToSrt(
|
|
inputPath: string,
|
|
tempDir: string,
|
|
langLabel: string,
|
|
): Promise<string> {
|
|
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]);
|
|
return outputPath;
|
|
}
|
|
|
|
function findAudioFile(tempDir: string, preferredExt: string): string | null {
|
|
const entries = fs.readdirSync(tempDir);
|
|
const audioFiles: Array<{ path: string; ext: string; mtimeMs: number }> = [];
|
|
for (const name of entries) {
|
|
const fullPath = path.join(tempDir, name);
|
|
let stat: fs.Stats;
|
|
try {
|
|
stat = fs.statSync(fullPath);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (!stat.isFile()) continue;
|
|
const ext = path.extname(name).toLowerCase();
|
|
if (!YOUTUBE_AUDIO_EXTENSIONS.has(ext)) continue;
|
|
audioFiles.push({ path: fullPath, ext, mtimeMs: stat.mtimeMs });
|
|
}
|
|
if (audioFiles.length === 0) return null;
|
|
const preferred = audioFiles.find((entry) => entry.ext === `.${preferredExt.toLowerCase()}`);
|
|
if (preferred) return preferred.path;
|
|
audioFiles.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
return audioFiles[0].path;
|
|
}
|
|
|
|
async function runWhisper(
|
|
whisperBin: string,
|
|
modelPath: string,
|
|
audioPath: string,
|
|
language: string,
|
|
translate: boolean,
|
|
outputPrefix: string,
|
|
): Promise<string> {
|
|
const args = [
|
|
"-m",
|
|
modelPath,
|
|
"-f",
|
|
audioPath,
|
|
"--output-srt",
|
|
"--output-file",
|
|
outputPrefix,
|
|
"--language",
|
|
language,
|
|
];
|
|
if (translate) args.push("--translate");
|
|
await runExternalCommand(whisperBin, args, {
|
|
commandLabel: "whisper",
|
|
streamOutput: true,
|
|
});
|
|
const outputPath = `${outputPrefix}.srt`;
|
|
if (!fs.existsSync(outputPath)) {
|
|
throw new Error(`whisper output not found: ${outputPath}`);
|
|
}
|
|
return outputPath;
|
|
}
|
|
|
|
async function convertAudioForWhisper(inputPath: string, tempDir: string): Promise<string> {
|
|
const wavPath = path.join(tempDir, "whisper-input.wav");
|
|
await runExternalCommand("ffmpeg", [
|
|
"-y",
|
|
"-loglevel",
|
|
"error",
|
|
"-i",
|
|
inputPath,
|
|
"-ar",
|
|
"16000",
|
|
"-ac",
|
|
"1",
|
|
"-c:a",
|
|
"pcm_s16le",
|
|
wavPath,
|
|
]);
|
|
if (!fs.existsSync(wavPath)) {
|
|
throw new Error(`Failed to prepare whisper audio input: ${wavPath}`);
|
|
}
|
|
return wavPath;
|
|
}
|
|
|
|
function sendMpvCommand(socketPath: string, command: unknown[]): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const socket = net.createConnection(socketPath);
|
|
socket.once("connect", () => {
|
|
socket.write(`${JSON.stringify({ command })}\n`);
|
|
socket.end();
|
|
resolve();
|
|
});
|
|
socket.once("error", (error) => {
|
|
reject(error);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function loadSubtitleIntoMpv(
|
|
socketPath: string,
|
|
subtitlePath: string,
|
|
select: boolean,
|
|
logLevel: LogLevel,
|
|
): Promise<void> {
|
|
for (let attempt = 1; ; attempt += 1) {
|
|
const mpvExited =
|
|
state.mpvProc !== null &&
|
|
state.mpvProc.exitCode !== null &&
|
|
state.mpvProc.exitCode !== undefined;
|
|
if (mpvExited) {
|
|
throw new Error(`mpv exited before subtitle could be loaded: ${subtitlePath}`);
|
|
}
|
|
|
|
if (!fs.existsSync(socketPath)) {
|
|
if (attempt % 20 === 0) {
|
|
log(
|
|
"debug",
|
|
logLevel,
|
|
`Waiting for mpv socket before loading subtitle (${attempt} attempts): ${path.basename(subtitlePath)}`,
|
|
);
|
|
}
|
|
await sleep(250);
|
|
continue;
|
|
}
|
|
try {
|
|
await sendMpvCommand(
|
|
socketPath,
|
|
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",
|
|
logLevel,
|
|
`Retrying subtitle load into mpv (${attempt} attempts): ${path.basename(subtitlePath)}`,
|
|
);
|
|
}
|
|
await sleep(250);
|
|
}
|
|
}
|
|
}
|
|
|
|
interface MpvResponseEnvelope {
|
|
request_id?: number;
|
|
error?: string;
|
|
data?: unknown;
|
|
}
|
|
|
|
function sendMpvCommandWithResponse(
|
|
socketPath: string,
|
|
command: unknown[],
|
|
timeoutMs = 5000,
|
|
): Promise<unknown> {
|
|
return new Promise((resolve, reject) => {
|
|
const requestId = Date.now() + Math.floor(Math.random() * 1000);
|
|
const socket = net.createConnection(socketPath);
|
|
let buffer = "";
|
|
|
|
const cleanup = (): void => {
|
|
try {
|
|
socket.destroy();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
const timer = setTimeout(() => {
|
|
cleanup();
|
|
reject(new Error(`MPV command timed out after ${timeoutMs}ms`));
|
|
}, timeoutMs);
|
|
|
|
const finish = (value: unknown): void => {
|
|
clearTimeout(timer);
|
|
cleanup();
|
|
resolve(value);
|
|
};
|
|
|
|
socket.once("connect", () => {
|
|
const message = JSON.stringify({ command, request_id: requestId });
|
|
socket.write(`${message}\n`);
|
|
});
|
|
|
|
socket.on("data", (chunk: Buffer) => {
|
|
buffer += chunk.toString();
|
|
const lines = buffer.split(/\r?\n/);
|
|
buffer = lines.pop() ?? "";
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
let parsed: MpvResponseEnvelope;
|
|
try {
|
|
parsed = JSON.parse(line);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (parsed.request_id !== requestId) continue;
|
|
if (parsed.error && parsed.error !== "success") {
|
|
reject(new Error(`MPV error: ${parsed.error}`));
|
|
cleanup();
|
|
clearTimeout(timer);
|
|
return;
|
|
}
|
|
finish(parsed.data);
|
|
return;
|
|
}
|
|
});
|
|
|
|
socket.once("error", (error) => {
|
|
clearTimeout(timer);
|
|
cleanup();
|
|
reject(error);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function getMpvTracks(socketPath: string): Promise<MpvTrack[]> {
|
|
const response = await sendMpvCommandWithResponse(
|
|
socketPath,
|
|
["get_property", "track-list"],
|
|
8000,
|
|
);
|
|
if (!Array.isArray(response)) return [];
|
|
|
|
return response
|
|
.filter((track): track is MpvTrack => {
|
|
if (!track || typeof track !== "object") return false;
|
|
const candidate = track as Record<string, unknown>;
|
|
return candidate.type === "sub";
|
|
})
|
|
.map((track) => {
|
|
const candidate = track as Record<string, unknown>;
|
|
return {
|
|
type:
|
|
typeof candidate.type === "string" ? candidate.type : undefined,
|
|
id:
|
|
typeof candidate.id === "number"
|
|
? candidate.id
|
|
: 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,
|
|
};
|
|
});
|
|
}
|
|
|
|
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;
|
|
return false;
|
|
}
|
|
|
|
function findPreferredSubtitleTrack(
|
|
tracks: MpvTrack[],
|
|
preferredLanguages: string[],
|
|
): MpvTrack | null {
|
|
const normalizedPreferred = uniqueNormalizedLangCodes(preferredLanguages);
|
|
const subtitleTracks = tracks.filter((track) => track.type === "sub");
|
|
if (normalizedPreferred.length === 0) return subtitleTracks[0] ?? null;
|
|
|
|
for (const lang of normalizedPreferred) {
|
|
const matched = subtitleTracks.find(
|
|
(track) => track.lang && isPreferredStreamLang(track.lang, [lang]),
|
|
);
|
|
if (matched) return matched;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function waitForSubtitleTrackList(
|
|
socketPath: string,
|
|
logLevel: LogLevel,
|
|
): Promise<MpvTrack[]> {
|
|
const maxAttempts = 40;
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
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})`,
|
|
);
|
|
}
|
|
await sleep(250);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function isValidSubtitleCandidateFile(filename: string): boolean {
|
|
const ext = path.extname(filename).toLowerCase();
|
|
return (
|
|
ext === ".srt" ||
|
|
ext === ".vtt" ||
|
|
ext === ".ass" ||
|
|
ext === ".ssa" ||
|
|
ext === ".sub"
|
|
);
|
|
}
|
|
|
|
function mapPreferenceToLanguages(preference: JimakuLanguagePreference): string[] {
|
|
if (preference === "en") return ["en", "eng"];
|
|
if (preference === "none") return [];
|
|
return ["ja", "jpn"];
|
|
}
|
|
|
|
function makeTempDir(prefix: string): string {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
}
|
|
|
|
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")
|
|
) {
|
|
return "hyprland";
|
|
}
|
|
if (hasWayland && commandExists("hyprctl")) return "hyprland";
|
|
if (process.env.DISPLAY) return "x11";
|
|
fail("Could not detect display backend");
|
|
}
|
|
|
|
function findRofiTheme(scriptPath: string): string | null {
|
|
const envTheme = process.env.SUBMINER_ROFI_THEME;
|
|
if (envTheme && fs.existsSync(envTheme)) return envTheme;
|
|
|
|
const scriptDir = path.dirname(realpathMaybe(scriptPath));
|
|
const candidates: string[] = [];
|
|
|
|
if (process.platform === "darwin") {
|
|
candidates.push(
|
|
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));
|
|
}
|
|
|
|
candidates.push(path.join(scriptDir, ROFI_THEME_FILE));
|
|
|
|
for (const candidate of candidates) {
|
|
if (fs.existsSync(candidate)) return candidate;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function collectVideos(dir: string, recursive: boolean): string[] {
|
|
const root = path.resolve(dir);
|
|
const out: string[] = [];
|
|
|
|
const walk = (current: string): void => {
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
const full = path.join(current, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (recursive) walk(full);
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) continue;
|
|
const ext = path.extname(entry.name).slice(1).toLowerCase();
|
|
if (VIDEO_EXTENSIONS.has(ext)) out.push(full);
|
|
}
|
|
};
|
|
|
|
walk(root);
|
|
return out.sort((a, b) =>
|
|
a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }),
|
|
);
|
|
}
|
|
|
|
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 line = `${display}\0icon\x1fthumbnail://${video}\n`;
|
|
chunks.push(Buffer.from(line, "utf8"));
|
|
}
|
|
return Buffer.concat(chunks);
|
|
}
|
|
|
|
function showRofiMenu(
|
|
videos: string[],
|
|
dir: string,
|
|
recursive: boolean,
|
|
scriptPath: string,
|
|
logLevel: LogLevel,
|
|
): string {
|
|
const args = [
|
|
"-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);
|
|
} else {
|
|
log(
|
|
"warn",
|
|
logLevel,
|
|
"Rofi theme not found; using rofi defaults (set SUBMINER_ROFI_THEME to override)",
|
|
);
|
|
}
|
|
|
|
const result = spawnSync("rofi", args, {
|
|
input: buildRofiMenu(videos, dir, recursive),
|
|
encoding: "utf8",
|
|
stdio: ["pipe", "pipe", "ignore"],
|
|
});
|
|
if (result.error) {
|
|
fail(
|
|
formatPickerLaunchError("rofi", result.error as NodeJS.ErrnoException),
|
|
);
|
|
}
|
|
|
|
const selection = (result.stdout || "").trim();
|
|
if (!selection) return "";
|
|
return path.join(dir, selection);
|
|
}
|
|
|
|
function buildFzfMenu(videos: string[]): string {
|
|
return videos.map((video) => `${path.basename(video)}\t${video}`).join("\n");
|
|
}
|
|
|
|
function showFzfMenu(videos: string[]): string {
|
|
const chafaFormat = process.env.TMUX
|
|
? "--format=symbols --symbols=vhalf+wide --color-space=din99d"
|
|
: "--format=kitty";
|
|
|
|
const previewCmd = commandExists("chafa")
|
|
? `
|
|
video={2}
|
|
thumb_dir="$HOME/.cache/thumbnails/large"
|
|
video_uri="file://$(realpath "$video")"
|
|
if command -v md5sum >/dev/null 2>&1; then
|
|
thumb_hash=$(echo -n "$video_uri" | md5sum | cut -d' ' -f1)
|
|
else
|
|
thumb_hash=$(echo -n "$video_uri" | md5 -q)
|
|
fi
|
|
thumb_path="$thumb_dir/$thumb_hash.png"
|
|
|
|
get_thumb() {
|
|
if [[ -f "$thumb_path" ]]; then
|
|
echo "$thumb_path"
|
|
elif command -v ffmpegthumbnailer >/dev/null 2>&1; then
|
|
tmp="/tmp/subminer-preview.jpg"
|
|
ffmpegthumbnailer -i "$video" -o "$tmp" -s 512 -q 5 2>/dev/null && echo "$tmp"
|
|
elif command -v ffmpeg >/dev/null 2>&1; then
|
|
tmp="/tmp/subminer-preview.jpg"
|
|
ffmpeg -y -i "$video" -ss 00:00:05 -vframes 1 -vf "scale=512:-1" "$tmp" 2>/dev/null && echo "$tmp"
|
|
fi
|
|
}
|
|
|
|
thumb=$(get_thumb)
|
|
[[ -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",
|
|
[
|
|
"--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"],
|
|
},
|
|
);
|
|
if (result.error) {
|
|
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 "";
|
|
return selection.slice(tabIndex + 1);
|
|
}
|
|
|
|
function formatPickerLaunchError(
|
|
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.";
|
|
}
|
|
return `Failed to launch ${picker}: ${error.message}`;
|
|
}
|
|
|
|
function findAppBinary(selfPath: string): string | null {
|
|
const envPath = process.env.SUBMINER_APPIMAGE_PATH;
|
|
if (envPath && isExecutable(envPath)) return envPath;
|
|
|
|
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",
|
|
),
|
|
);
|
|
}
|
|
|
|
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"))
|
|
.find((candidate) => isExecutable(candidate));
|
|
|
|
if (fromPath) {
|
|
const resolvedSelf = realpathMaybe(selfPath);
|
|
const resolvedCandidate = realpathMaybe(fromPath);
|
|
if (resolvedSelf !== resolvedCandidate) return fromPath;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function normalizeJimakuSearchInput(mediaPath: string): string {
|
|
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");
|
|
if (titleParam && titleParam.trim()) return titleParam.trim();
|
|
|
|
const pathParts = url.pathname.split("/").filter(Boolean).reverse();
|
|
const candidate = pathParts.find((part) => {
|
|
const decoded = decodeURIComponent(part || "").replace(/\.[^/.]+$/, "");
|
|
const lowered = decoded.toLowerCase();
|
|
return (
|
|
lowered.length > 2 &&
|
|
!/^[0-9.]+$/.test(lowered) &&
|
|
!/^[a-f0-9]{16,}$/i.test(lowered)
|
|
);
|
|
});
|
|
|
|
const fallback = candidate || url.hostname.replace(/^www\./, "");
|
|
return sanitizeJimakuQueryInput(decodeURIComponent(fallback));
|
|
} catch {
|
|
return trimmed;
|
|
}
|
|
}
|
|
|
|
function sanitizeJimakuQueryInput(value: string): string {
|
|
return value
|
|
.replace(/^\s*-\s*/, "")
|
|
.replace(/[^\w\s\-'".:(),]/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function buildJimakuConfig(args: Args): {
|
|
apiKey: string;
|
|
apiKeyCommand: string;
|
|
apiBaseUrl: string;
|
|
languagePreference: JimakuLanguagePreference;
|
|
maxEntryResults: number;
|
|
} {
|
|
return {
|
|
apiKey: args.jimakuApiKey,
|
|
apiKeyCommand: args.jimakuApiKeyCommand,
|
|
apiBaseUrl: args.jimakuApiBaseUrl || DEFAULT_JIMAKU_API_BASE_URL,
|
|
languagePreference: args.jimakuLanguagePreference,
|
|
maxEntryResults: args.jimakuMaxEntryResults || 10,
|
|
};
|
|
}
|
|
|
|
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 resolveWhisperBinary(args: Args): string | null {
|
|
const explicit = args.whisperBin.trim();
|
|
if (explicit) return resolvePathMaybe(explicit);
|
|
if (commandExists("whisper-cli")) return "whisper-cli";
|
|
return null;
|
|
}
|
|
|
|
async function generateYoutubeSubtitles(
|
|
target: string,
|
|
args: Args,
|
|
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 secondaryCanUseWhisperTranslate =
|
|
secondaryLangCodes.includes("en") || secondaryLangCodes.includes("eng");
|
|
const ytdlpManualLangs = toYtdlpLangPattern([
|
|
...primaryLangCodes,
|
|
...secondaryLangCodes,
|
|
]);
|
|
|
|
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"],
|
|
selectedPath: string,
|
|
basename: string,
|
|
): Promise<string> => {
|
|
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}`,
|
|
);
|
|
if (onReady) await onReady(lang, aliasPath);
|
|
return aliasPath;
|
|
};
|
|
|
|
try {
|
|
log("debug", args.logLevel, `YouTube subtitle temp dir: ${tempDir}`);
|
|
const meta = await runExternalCommand(
|
|
"yt-dlp",
|
|
["--dump-single-json", "--no-warnings", target],
|
|
{
|
|
captureStdout: true,
|
|
logLevel: args.logLevel,
|
|
commandLabel: "yt-dlp:meta",
|
|
},
|
|
);
|
|
const metadata = JSON.parse(meta.stdout) as { id?: string };
|
|
const videoId = metadata.id || `${Date.now()}`;
|
|
const basename = normalizeBasename(videoId, videoId);
|
|
|
|
await runExternalCommand(
|
|
"yt-dlp",
|
|
[
|
|
"--skip-download",
|
|
"--no-warnings",
|
|
"--write-subs",
|
|
"--sub-format",
|
|
"srt/vtt/best",
|
|
"--sub-langs",
|
|
ytdlpManualLangs,
|
|
"-o",
|
|
path.join(tempDir, "%(id)s.%(ext)s"),
|
|
target,
|
|
],
|
|
{
|
|
allowFailure: true,
|
|
logLevel: args.logLevel,
|
|
commandLabel: "yt-dlp:manual-subs",
|
|
streamOutput: true,
|
|
},
|
|
);
|
|
|
|
const manualSubs = scanSubtitleCandidates(
|
|
tempDir,
|
|
knownFiles,
|
|
"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",
|
|
);
|
|
|
|
const missingAuto: string[] = [];
|
|
if (primaryCandidates.length === 0)
|
|
missingAuto.push(toYtdlpLangPattern(primaryLangCodes));
|
|
if (secondaryCandidates.length === 0)
|
|
missingAuto.push(toYtdlpLangPattern(secondaryLangCodes));
|
|
|
|
if (missingAuto.length > 0) {
|
|
await runExternalCommand(
|
|
"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"),
|
|
target,
|
|
],
|
|
{
|
|
allowFailure: true,
|
|
logLevel: args.logLevel,
|
|
commandLabel: "yt-dlp:auto-subs",
|
|
streamOutput: true,
|
|
},
|
|
);
|
|
|
|
const autoSubs = scanSubtitleCandidates(
|
|
tempDir,
|
|
knownFiles,
|
|
"auto",
|
|
primaryLangCodes,
|
|
secondaryLangCodes,
|
|
);
|
|
for (const sub of autoSubs) knownFiles.add(sub.path);
|
|
primaryCandidates = primaryCandidates.concat(
|
|
autoSubs.filter((entry) => entry.lang === "primary"),
|
|
);
|
|
secondaryCandidates = secondaryCandidates.concat(
|
|
autoSubs.filter((entry) => entry.lang === "secondary"),
|
|
);
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
if (selectedSecondary) {
|
|
const srt = await convertToSrt(
|
|
selectedSecondary.path,
|
|
tempDir,
|
|
secondaryLabel,
|
|
);
|
|
secondaryAlias = await publishTrack(
|
|
"secondary",
|
|
selectedSecondary.source,
|
|
srt,
|
|
basename,
|
|
);
|
|
}
|
|
|
|
const needsPrimaryWhisper = !selectedPrimary;
|
|
const needsSecondaryWhisper = !selectedSecondary && secondaryCanUseWhisperTranslate;
|
|
if (needsPrimaryWhisper || needsSecondaryWhisper) {
|
|
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",
|
|
args.logLevel,
|
|
"Whisper fallback is not configured; continuing with available subtitle tracks.",
|
|
);
|
|
} else {
|
|
try {
|
|
await runExternalCommand(
|
|
"yt-dlp",
|
|
[
|
|
"-f",
|
|
"bestaudio/best",
|
|
"--extract-audio",
|
|
"--audio-format",
|
|
args.youtubeSubgenAudioFormat,
|
|
"--no-warnings",
|
|
"-o",
|
|
path.join(tempDir, "%(id)s.%(ext)s"),
|
|
target,
|
|
],
|
|
{
|
|
logLevel: args.logLevel,
|
|
commandLabel: "yt-dlp:audio",
|
|
streamOutput: true,
|
|
},
|
|
);
|
|
const audioPath = findAudioFile(tempDir, args.youtubeSubgenAudioFormat);
|
|
if (!audioPath) {
|
|
throw new Error("Audio extraction succeeded, but no audio file was found.");
|
|
}
|
|
const whisperAudioPath = await convertAudioForWhisper(audioPath, tempDir);
|
|
|
|
if (needsPrimaryWhisper) {
|
|
try {
|
|
const primaryPrefix = path.join(tempDir, `${basename}.${primaryLabel}`);
|
|
const primarySrt = await runWhisper(
|
|
whisperBin!,
|
|
modelPath,
|
|
whisperAudioPath,
|
|
args.youtubeWhisperSourceLanguage,
|
|
false,
|
|
primaryPrefix,
|
|
);
|
|
primaryAlias = await publishTrack(
|
|
"primary",
|
|
"whisper",
|
|
primarySrt,
|
|
basename,
|
|
);
|
|
} catch (error) {
|
|
log(
|
|
"warn",
|
|
args.logLevel,
|
|
`Failed to generate primary subtitle via whisper fallback: ${(error as Error).message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (needsSecondaryWhisper) {
|
|
try {
|
|
const secondaryPrefix = path.join(
|
|
tempDir,
|
|
`${basename}.${secondaryLabel}`,
|
|
);
|
|
const secondarySrt = await runWhisper(
|
|
whisperBin!,
|
|
modelPath,
|
|
whisperAudioPath,
|
|
args.youtubeWhisperSourceLanguage,
|
|
true,
|
|
secondaryPrefix,
|
|
);
|
|
secondaryAlias = await publishTrack(
|
|
"secondary",
|
|
"whisper-translate",
|
|
secondarySrt,
|
|
basename,
|
|
);
|
|
} catch (error) {
|
|
log(
|
|
"warn",
|
|
args.logLevel,
|
|
`Failed to generate secondary subtitle via whisper fallback: ${(error as Error).message}`,
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
log(
|
|
"warn",
|
|
args.logLevel,
|
|
`Whisper fallback pipeline failed: ${(error as Error).message}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!secondaryCanUseWhisperTranslate && !selectedSecondary) {
|
|
log(
|
|
"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.");
|
|
}
|
|
if (!primaryAlias || !secondaryAlias) {
|
|
log(
|
|
"warn",
|
|
args.logLevel,
|
|
`Generated partial subtitle result: primary=${primaryAlias ? "ok" : "missing"}, secondary=${secondaryAlias ? "ok" : "missing"}`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
basename,
|
|
primaryPath: primaryAlias || undefined,
|
|
secondaryPath: secondaryAlias || undefined,
|
|
};
|
|
} catch (error) {
|
|
keepTemp = true;
|
|
throw error;
|
|
} finally {
|
|
if (keepTemp) {
|
|
log("warn", args.logLevel, `Keeping subtitle temp dir: ${tempDir}`);
|
|
} else {
|
|
try {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
} catch {
|
|
// ignore cleanup failures
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function checkPickerDependencies(args: Args): void {
|
|
if (args.useRofi) {
|
|
if (!commandExists("rofi")) fail("Missing dependency: rofi");
|
|
return;
|
|
}
|
|
|
|
if (!commandExists("fzf")) fail("Missing dependency: fzf");
|
|
}
|
|
|
|
function parseArgs(
|
|
argv: string[],
|
|
scriptName: string,
|
|
launcherConfig: LauncherYoutubeSubgenConfig,
|
|
): Args {
|
|
const envMode = (process.env.SUBMINER_YT_SUBGEN_MODE || "").toLowerCase();
|
|
const defaultMode: YoutubeSubgenMode =
|
|
envMode === "preprocess" || envMode === "off" || envMode === "automatic"
|
|
? (envMode as YoutubeSubgenMode)
|
|
: launcherConfig.mode
|
|
? launcherConfig.mode
|
|
: "automatic";
|
|
const configuredSecondaryLangs = uniqueNormalizedLangCodes(
|
|
launcherConfig.secondarySubLanguages ?? [],
|
|
);
|
|
const configuredPrimaryLangs = uniqueNormalizedLangCodes(
|
|
launcherConfig.primarySubLanguages ?? [],
|
|
);
|
|
const primarySubLangs =
|
|
configuredPrimaryLangs.length > 0
|
|
? configuredPrimaryLangs
|
|
: [...DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS];
|
|
const secondarySubLangs =
|
|
configuredSecondaryLangs.length > 0
|
|
? configuredSecondaryLangs
|
|
: [...DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS];
|
|
const youtubeAudioLangs = uniqueNormalizedLangCodes([
|
|
...primarySubLangs,
|
|
...secondarySubLangs,
|
|
]);
|
|
const parsed: Args = {
|
|
backend: "auto",
|
|
directory: ".",
|
|
recursive: false,
|
|
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",
|
|
jimakuMaxEntryResults: launcherConfig.jimakuMaxEntryResults || 10,
|
|
youtubePrimarySubLangs: primarySubLangs,
|
|
youtubeSecondarySubLangs: secondarySubLangs,
|
|
youtubeAudioLangs,
|
|
youtubeWhisperSourceLanguage: inferWhisperLanguage(primarySubLangs, "ja"),
|
|
useTexthooker: true,
|
|
autoStartOverlay: false,
|
|
texthookerOnly: false,
|
|
useRofi: false,
|
|
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.jimakuLanguagePreference)
|
|
parsed.jimakuLanguagePreference = launcherConfig.jimakuLanguagePreference;
|
|
if (launcherConfig.jimakuMaxEntryResults !== undefined)
|
|
parsed.jimakuMaxEntryResults = launcherConfig.jimakuMaxEntryResults;
|
|
|
|
const isValidLogLevel = (value: string): value is LogLevel =>
|
|
value === "debug" ||
|
|
value === "info" ||
|
|
value === "warn" ||
|
|
value === "error";
|
|
const isValidYoutubeSubgenMode = (value: string): value is YoutubeSubgenMode =>
|
|
value === "automatic" || value === "preprocess" || value === "off";
|
|
|
|
let i = 0;
|
|
while (i < argv.length) {
|
|
const arg = argv[i];
|
|
|
|
if (arg === "-b" || arg === "--backend") {
|
|
const value = argv[i + 1];
|
|
if (!value) fail("--backend requires a value");
|
|
if (!["auto", "hyprland", "x11", "macos"].includes(value)) {
|
|
fail(
|
|
`Invalid backend: ${value} (must be auto, hyprland, x11, or macos)`,
|
|
);
|
|
}
|
|
parsed.backend = value as Backend;
|
|
i += 2;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "-d" || arg === "--directory") {
|
|
const value = argv[i + 1];
|
|
if (!value) fail("--directory requires a value");
|
|
parsed.directory = value;
|
|
i += 2;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "-r" || arg === "--recursive") {
|
|
parsed.recursive = true;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "-p" || arg === "--profile") {
|
|
const value = argv[i + 1];
|
|
if (!value) fail("--profile requires a value");
|
|
parsed.profile = value;
|
|
i += 2;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--start") {
|
|
parsed.startOverlay = true;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--yt-subgen-mode" || arg === "--youtube-subgen-mode") {
|
|
const value = (argv[i + 1] || "").toLowerCase();
|
|
if (!isValidYoutubeSubgenMode(value)) {
|
|
fail(
|
|
`Invalid yt-subgen mode: ${value || ""} (must be automatic, preprocess, or off)`,
|
|
);
|
|
}
|
|
parsed.youtubeSubgenMode = value;
|
|
i += 2;
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
arg.startsWith("--yt-subgen-mode=") ||
|
|
arg.startsWith("--youtube-subgen-mode=")
|
|
) {
|
|
const value = arg.split("=", 2)[1]?.toLowerCase() || "";
|
|
if (!isValidYoutubeSubgenMode(value)) {
|
|
fail(
|
|
`Invalid yt-subgen mode: ${value || ""} (must be automatic, preprocess, or off)`,
|
|
);
|
|
}
|
|
parsed.youtubeSubgenMode = value;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--whisper-bin") {
|
|
const value = argv[i + 1];
|
|
if (!value) fail("--whisper-bin requires a value");
|
|
parsed.whisperBin = value;
|
|
i += 2;
|
|
continue;
|
|
}
|
|
|
|
if (arg.startsWith("--whisper-bin=")) {
|
|
const value = arg.slice("--whisper-bin=".length);
|
|
if (!value) fail("--whisper-bin requires a value");
|
|
parsed.whisperBin = value;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--whisper-model") {
|
|
const value = argv[i + 1];
|
|
if (!value) fail("--whisper-model requires a value");
|
|
parsed.whisperModel = value;
|
|
i += 2;
|
|
continue;
|
|
}
|
|
|
|
if (arg.startsWith("--whisper-model=")) {
|
|
const value = arg.slice("--whisper-model=".length);
|
|
if (!value) fail("--whisper-model requires a value");
|
|
parsed.whisperModel = value;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--yt-subgen-out-dir") {
|
|
const value = argv[i + 1];
|
|
if (!value) fail("--yt-subgen-out-dir requires a value");
|
|
parsed.youtubeSubgenOutDir = value;
|
|
i += 2;
|
|
continue;
|
|
}
|
|
|
|
if (arg.startsWith("--yt-subgen-out-dir=")) {
|
|
const value = arg.slice("--yt-subgen-out-dir=".length);
|
|
if (!value) fail("--yt-subgen-out-dir requires a value");
|
|
parsed.youtubeSubgenOutDir = value;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--yt-subgen-audio-format") {
|
|
const value = argv[i + 1];
|
|
if (!value) fail("--yt-subgen-audio-format requires a value");
|
|
parsed.youtubeSubgenAudioFormat = value;
|
|
i += 2;
|
|
continue;
|
|
}
|
|
|
|
if (arg.startsWith("--yt-subgen-audio-format=")) {
|
|
const value = arg.slice("--yt-subgen-audio-format=".length);
|
|
if (!value) fail("--yt-subgen-audio-format requires a value");
|
|
parsed.youtubeSubgenAudioFormat = value;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--yt-subgen-keep-temp") {
|
|
parsed.youtubeSubgenKeepTemp = true;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "-v" || arg === "--verbose") {
|
|
parsed.logLevel = "debug";
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--log-level") {
|
|
const value = argv[i + 1];
|
|
if (!value || !isValidLogLevel(value)) {
|
|
fail(
|
|
`Invalid log level: ${value ?? ""} (must be debug, info, warn, or error)`,
|
|
);
|
|
}
|
|
parsed.logLevel = value;
|
|
i += 2;
|
|
continue;
|
|
}
|
|
|
|
if (arg.startsWith("--log-level=")) {
|
|
const value = arg.slice("--log-level=".length);
|
|
if (!isValidLogLevel(value)) {
|
|
fail(
|
|
`Invalid log level: ${value} (must be debug, info, warn, or error)`,
|
|
);
|
|
}
|
|
parsed.logLevel = value;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "-R" || arg === "--rofi") {
|
|
parsed.useRofi = true;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "-S" || arg === "--start-overlay") {
|
|
parsed.autoStartOverlay = true;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "-T" || arg === "--no-texthooker") {
|
|
parsed.useTexthooker = false;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--texthooker") {
|
|
parsed.texthookerOnly = true;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "-h" || arg === "--help") {
|
|
process.stdout.write(usage(scriptName));
|
|
process.exit(0);
|
|
}
|
|
|
|
if (arg === "--") {
|
|
i += 1;
|
|
break;
|
|
}
|
|
|
|
if (arg.startsWith("-")) {
|
|
fail(`Unknown option: ${arg}`);
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
const positional = argv.slice(i);
|
|
if (positional.length > 0) {
|
|
const target = positional[0];
|
|
if (isUrlTarget(target)) {
|
|
parsed.target = target;
|
|
parsed.targetKind = "url";
|
|
} else {
|
|
const resolved = resolvePathMaybe(target);
|
|
if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
|
|
parsed.target = resolved;
|
|
parsed.targetKind = "file";
|
|
} else if (
|
|
fs.existsSync(resolved) &&
|
|
fs.statSync(resolved).isDirectory()
|
|
) {
|
|
parsed.directory = resolved;
|
|
} else {
|
|
fail(`Not a file, directory, or supported URL: ${target}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function startOverlay(
|
|
appPath: string,
|
|
args: Args,
|
|
socketPath: string,
|
|
): Promise<void> {
|
|
const backend = detectBackend(args.backend);
|
|
log(
|
|
"info",
|
|
args.logLevel,
|
|
`Starting SubMiner overlay (backend: ${backend})...`,
|
|
);
|
|
|
|
const overlayArgs = ["--start", "--backend", backend, "--socket", socketPath];
|
|
if (args.logLevel === "debug") overlayArgs.push("--verbose");
|
|
else if (args.logLevel !== "info")
|
|
overlayArgs.push("--log-level", args.logLevel);
|
|
if (args.useTexthooker) overlayArgs.push("--texthooker");
|
|
|
|
state.overlayProc = spawn(appPath, overlayArgs, {
|
|
stdio: "inherit",
|
|
env: { ...process.env, SUBMINER_MPV_LOG: getMpvLogPath() },
|
|
});
|
|
state.overlayManagedByLauncher = true;
|
|
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, 2000);
|
|
});
|
|
}
|
|
|
|
function launchTexthookerOnly(appPath: string, args: Args): never {
|
|
const overlayArgs = ["--texthooker"];
|
|
if (args.logLevel === "debug") overlayArgs.push("--verbose");
|
|
else if (args.logLevel !== "info")
|
|
overlayArgs.push("--log-level", args.logLevel);
|
|
|
|
log("info", args.logLevel, "Launching texthooker mode...");
|
|
const result = spawnSync(appPath, overlayArgs, { stdio: "inherit" });
|
|
process.exit(result.status ?? 0);
|
|
}
|
|
|
|
function stopOverlay(args: Args): void {
|
|
if (state.stopRequested) return;
|
|
state.stopRequested = true;
|
|
|
|
if (state.overlayManagedByLauncher && state.appPath) {
|
|
log("info", args.logLevel, "Stopping SubMiner overlay...");
|
|
|
|
const stopArgs = ["--stop"];
|
|
if (args.logLevel === "debug") stopArgs.push("--verbose");
|
|
else if (args.logLevel !== "info")
|
|
stopArgs.push("--log-level", args.logLevel);
|
|
|
|
spawnSync(state.appPath, stopArgs, { stdio: "ignore" });
|
|
|
|
if (state.overlayProc && !state.overlayProc.killed) {
|
|
try {
|
|
state.overlayProc.kill("SIGTERM");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
if (state.mpvProc && !state.mpvProc.killed) {
|
|
try {
|
|
state.mpvProc.kill("SIGTERM");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
for (const child of state.youtubeSubgenChildren) {
|
|
if (!child.killed) {
|
|
try {
|
|
child.kill("SIGTERM");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
state.youtubeSubgenChildren.clear();
|
|
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function getPluginConfigCandidates(): string[] {
|
|
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"),
|
|
]),
|
|
);
|
|
}
|
|
|
|
function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
|
const runtimeConfig: PluginRuntimeConfig = {
|
|
autoStartOverlay: false,
|
|
socketPath: DEFAULT_SOCKET_PATH,
|
|
};
|
|
const candidates = getPluginConfigCandidates();
|
|
|
|
for (const configPath of candidates) {
|
|
if (!fs.existsSync(configPath)) continue;
|
|
try {
|
|
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;
|
|
const autoStartMatch = trimmed.match(/^auto_start\s*=\s*(.+)$/i);
|
|
if (autoStartMatch) {
|
|
const value = (autoStartMatch[1] || "").split("#", 1)[0]?.trim() || "";
|
|
const parsed = parseBoolLike(value);
|
|
if (parsed !== null) {
|
|
runtimeConfig.autoStartOverlay = parsed;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const socketMatch = trimmed.match(/^socket_path\s*=\s*(.+)$/i);
|
|
if (socketMatch) {
|
|
const value = (socketMatch[1] || "").split("#", 1)[0]?.trim() || "";
|
|
if (value) runtimeConfig.socketPath = value;
|
|
}
|
|
}
|
|
log(
|
|
"debug",
|
|
logLevel,
|
|
`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`,
|
|
);
|
|
return runtimeConfig;
|
|
}
|
|
}
|
|
|
|
log(
|
|
"debug",
|
|
logLevel,
|
|
`No mpv subminer.conf found; using launcher defaults (auto_start=no socket_path=${runtimeConfig.socketPath})`,
|
|
);
|
|
return runtimeConfig;
|
|
}
|
|
|
|
function waitForSocket(
|
|
socketPath: string,
|
|
timeoutMs = 10000,
|
|
): Promise<boolean> {
|
|
const start = Date.now();
|
|
return new Promise((resolve) => {
|
|
const timer = setInterval(() => {
|
|
if (fs.existsSync(socketPath)) {
|
|
clearInterval(timer);
|
|
resolve(true);
|
|
return;
|
|
}
|
|
if (Date.now() - start >= timeoutMs) {
|
|
clearInterval(timer);
|
|
resolve(false);
|
|
}
|
|
}, 100);
|
|
});
|
|
}
|
|
|
|
function startMpv(
|
|
target: string,
|
|
targetKind: "file" | "url",
|
|
args: Args,
|
|
socketPath: string,
|
|
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
|
|
): void {
|
|
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}`);
|
|
} else {
|
|
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 (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}`,
|
|
);
|
|
|
|
if (args.youtubeSubgenMode === "off") {
|
|
mpvArgs.push(
|
|
"--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=sub-langs=${subtitleLangs}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (preloadedSubtitles?.primaryPath) {
|
|
mpvArgs.push(`--sub-file=${preloadedSubtitles.primaryPath}`);
|
|
}
|
|
if (preloadedSubtitles?.secondaryPath) {
|
|
mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`);
|
|
}
|
|
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
|
|
|
|
try {
|
|
fs.rmSync(socketPath, { force: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
|
mpvArgs.push(target);
|
|
|
|
state.mpvProc = spawn("mpv", mpvArgs, { stdio: "inherit" });
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const scriptPath = process.argv[1] || "subminer";
|
|
const scriptName = path.basename(scriptPath);
|
|
const launcherConfig = loadLauncherYoutubeSubgenConfig();
|
|
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 (!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.texthookerOnly) {
|
|
launchTexthookerOnly(appPath, args);
|
|
}
|
|
|
|
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,
|
|
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);
|
|
});
|
|
|
|
// vim: ft=typescript
|