mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
refactor(launcher): extract mpv, youtube, and jellyfin modules
- launcher/mpv.ts: state object, mpv IPC, process management, socket helpers - launcher/youtube.ts: YouTube subtitle generation pipeline and helpers - launcher/jellyfin.ts: Jellyfin API client, icon caching, play menu - runAppCommandWithInherit and related functions placed in mpv.ts - buildAppEnv deduplicated into single helper in mpv.ts
This commit is contained in:
354
launcher/jellyfin.ts
Normal file
354
launcher/jellyfin.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import type { Args, JellyfinSessionConfig, JellyfinLibraryEntry, JellyfinItemEntry, JellyfinGroupEntry } from "./types.js";
|
||||
import { log, fail } from "./log.js";
|
||||
import { commandExists, resolvePathMaybe } from "./util.js";
|
||||
import {
|
||||
pickLibrary, pickItem, pickGroup, promptOptionalJellyfinSearch,
|
||||
findRofiTheme,
|
||||
} from "./picker.js";
|
||||
import { loadLauncherJellyfinConfig } from "./config.js";
|
||||
import {
|
||||
runAppCommandWithInheritLogged, launchMpvIdleDetached, waitForUnixSocketReady,
|
||||
} from "./mpv.js";
|
||||
|
||||
export function sanitizeServerUrl(value: string): string {
|
||||
return value.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export async function jellyfinApiRequest<T>(
|
||||
session: JellyfinSessionConfig,
|
||||
requestPath: string,
|
||||
): Promise<T> {
|
||||
const url = `${session.serverUrl}${requestPath}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"X-Emby-Token": session.accessToken,
|
||||
Authorization: `MediaBrowser Token="${session.accessToken}"`,
|
||||
},
|
||||
});
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
fail("Jellyfin token invalid/expired. Run --jellyfin-login or --jellyfin.");
|
||||
}
|
||||
if (!response.ok) {
|
||||
fail(`Jellyfin API failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
function itemPreviewUrl(session: JellyfinSessionConfig, id: string): string {
|
||||
return `${session.serverUrl}/Items/${id}/Images/Primary?maxHeight=720&quality=85&api_key=${encodeURIComponent(session.accessToken)}`;
|
||||
}
|
||||
|
||||
function jellyfinIconCacheDir(session: JellyfinSessionConfig): string {
|
||||
const serverKey = session.serverUrl.replace(/[^a-zA-Z0-9]+/g, "_").slice(0, 96);
|
||||
const userKey = session.userId.replace(/[^a-zA-Z0-9]+/g, "_").slice(0, 96);
|
||||
const baseDir = session.iconCacheDir
|
||||
? resolvePathMaybe(session.iconCacheDir)
|
||||
: path.join("/tmp", "subminer-jellyfin-icons");
|
||||
return path.join(baseDir, serverKey, userKey);
|
||||
}
|
||||
|
||||
function jellyfinIconPath(session: JellyfinSessionConfig, id: string): string {
|
||||
const safeId = id.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
||||
return path.join(jellyfinIconCacheDir(session), `${safeId}.jpg`);
|
||||
}
|
||||
|
||||
function ensureJellyfinIcon(
|
||||
session: JellyfinSessionConfig,
|
||||
id: string,
|
||||
): string | null {
|
||||
if (!session.pullPictures || !id || !commandExists("curl")) return null;
|
||||
const iconPath = jellyfinIconPath(session, id);
|
||||
try {
|
||||
if (fs.existsSync(iconPath) && fs.statSync(iconPath).size > 0) {
|
||||
return iconPath;
|
||||
}
|
||||
} catch {
|
||||
// continue to download
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(iconPath), { recursive: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
"curl",
|
||||
["-fsSL", "-o", iconPath, itemPreviewUrl(session, id)],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
if (result.error || result.status !== 0) return null;
|
||||
|
||||
try {
|
||||
if (fs.existsSync(iconPath) && fs.statSync(iconPath).size > 0) {
|
||||
return iconPath;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatJellyfinItemDisplay(item: Record<string, unknown>): string {
|
||||
const type = typeof item.Type === "string" ? item.Type : "Item";
|
||||
const name = typeof item.Name === "string" ? item.Name : "Untitled";
|
||||
if (type === "Episode") {
|
||||
const series = typeof item.SeriesName === "string" ? item.SeriesName : "";
|
||||
const season =
|
||||
typeof item.ParentIndexNumber === "number"
|
||||
? String(item.ParentIndexNumber).padStart(2, "0")
|
||||
: "00";
|
||||
const episode =
|
||||
typeof item.IndexNumber === "number"
|
||||
? String(item.IndexNumber).padStart(2, "0")
|
||||
: "00";
|
||||
return `${series} S${season}E${episode} ${name}`.trim();
|
||||
}
|
||||
return `${name} (${type})`;
|
||||
}
|
||||
|
||||
export async function resolveJellyfinSelection(
|
||||
args: Args,
|
||||
session: JellyfinSessionConfig,
|
||||
themePath: string | null = null,
|
||||
): Promise<string> {
|
||||
const libsPayload = await jellyfinApiRequest<{ Items?: Array<Record<string, unknown>> }>(
|
||||
session,
|
||||
`/Users/${session.userId}/Views`,
|
||||
);
|
||||
const libraries: JellyfinLibraryEntry[] = (libsPayload.Items || [])
|
||||
.map((item) => ({
|
||||
id: typeof item.Id === "string" ? item.Id : "",
|
||||
name: typeof item.Name === "string" ? item.Name : "Untitled",
|
||||
kind:
|
||||
typeof item.CollectionType === "string"
|
||||
? item.CollectionType
|
||||
: typeof item.Type === "string"
|
||||
? item.Type
|
||||
: "unknown",
|
||||
}))
|
||||
.filter((item) => item.id.length > 0);
|
||||
|
||||
let libraryId = session.defaultLibraryId;
|
||||
if (!libraryId) {
|
||||
libraryId = pickLibrary(
|
||||
session,
|
||||
libraries,
|
||||
args.useRofi,
|
||||
ensureJellyfinIcon,
|
||||
"",
|
||||
themePath,
|
||||
);
|
||||
if (!libraryId) fail("No Jellyfin library selected.");
|
||||
}
|
||||
const searchTerm = await promptOptionalJellyfinSearch(args.useRofi, themePath);
|
||||
|
||||
const fetchItemsPaged = async (parentId: string) => {
|
||||
const out: Array<Record<string, unknown>> = [];
|
||||
let startIndex = 0;
|
||||
while (true) {
|
||||
const payload = await jellyfinApiRequest<{
|
||||
Items?: Array<Record<string, unknown>>;
|
||||
TotalRecordCount?: number;
|
||||
}>(
|
||||
session,
|
||||
`/Users/${session.userId}/Items?ParentId=${encodeURIComponent(parentId)}&Recursive=false&SortBy=SortName&SortOrder=Ascending&Limit=500&StartIndex=${startIndex}`,
|
||||
);
|
||||
const page = payload.Items || [];
|
||||
if (page.length === 0) break;
|
||||
out.push(...page);
|
||||
startIndex += page.length;
|
||||
const total = typeof payload.TotalRecordCount === "number"
|
||||
? payload.TotalRecordCount
|
||||
: null;
|
||||
if (total !== null && startIndex >= total) break;
|
||||
if (page.length < 500) break;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const topLevelEntries = await fetchItemsPaged(libraryId);
|
||||
const groups: JellyfinGroupEntry[] = topLevelEntries
|
||||
.filter((item) => {
|
||||
const type = typeof item.Type === "string" ? item.Type : "";
|
||||
return (
|
||||
type === "Series" ||
|
||||
type === "Folder" ||
|
||||
type === "CollectionFolder" ||
|
||||
type === "Season"
|
||||
);
|
||||
})
|
||||
.map((item) => {
|
||||
const type = typeof item.Type === "string" ? item.Type : "Folder";
|
||||
const name = typeof item.Name === "string" ? item.Name : "Untitled";
|
||||
return {
|
||||
id: typeof item.Id === "string" ? item.Id : "",
|
||||
name,
|
||||
type,
|
||||
display: `${name} (${type})`,
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.id.length > 0);
|
||||
|
||||
let contentParentId = libraryId;
|
||||
const selectedGroupId = pickGroup(
|
||||
session,
|
||||
groups,
|
||||
args.useRofi,
|
||||
ensureJellyfinIcon,
|
||||
searchTerm,
|
||||
themePath,
|
||||
);
|
||||
if (selectedGroupId) {
|
||||
contentParentId = selectedGroupId;
|
||||
const nextLevelEntries = await fetchItemsPaged(selectedGroupId);
|
||||
const seasons: JellyfinGroupEntry[] = nextLevelEntries
|
||||
.filter((item) => {
|
||||
const type = typeof item.Type === "string" ? item.Type : "";
|
||||
return type === "Season" || type === "Folder";
|
||||
})
|
||||
.map((item) => {
|
||||
const type = typeof item.Type === "string" ? item.Type : "Season";
|
||||
const name = typeof item.Name === "string" ? item.Name : "Untitled";
|
||||
return {
|
||||
id: typeof item.Id === "string" ? item.Id : "",
|
||||
name,
|
||||
type,
|
||||
display: `${name} (${type})`,
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.id.length > 0);
|
||||
if (seasons.length > 0) {
|
||||
const selectedSeasonId = pickGroup(
|
||||
session,
|
||||
seasons,
|
||||
args.useRofi,
|
||||
ensureJellyfinIcon,
|
||||
"",
|
||||
themePath,
|
||||
);
|
||||
if (!selectedSeasonId) fail("No Jellyfin season selected.");
|
||||
contentParentId = selectedSeasonId;
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPage = async (startIndex: number) =>
|
||||
jellyfinApiRequest<{
|
||||
Items?: Array<Record<string, unknown>>;
|
||||
TotalRecordCount?: number;
|
||||
}>(
|
||||
session,
|
||||
`/Users/${session.userId}/Items?ParentId=${encodeURIComponent(contentParentId)}&Recursive=true&SortBy=SortName&SortOrder=Ascending&Limit=500&StartIndex=${startIndex}`,
|
||||
);
|
||||
|
||||
const allEntries: Array<Record<string, unknown>> = [];
|
||||
let startIndex = 0;
|
||||
while (true) {
|
||||
const payload = await fetchPage(startIndex);
|
||||
const page = payload.Items || [];
|
||||
if (page.length === 0) break;
|
||||
allEntries.push(...page);
|
||||
startIndex += page.length;
|
||||
const total = typeof payload.TotalRecordCount === "number"
|
||||
? payload.TotalRecordCount
|
||||
: null;
|
||||
if (total !== null && startIndex >= total) break;
|
||||
if (page.length < 500) break;
|
||||
}
|
||||
|
||||
let items: JellyfinItemEntry[] = allEntries
|
||||
.filter((item) => {
|
||||
const type = typeof item.Type === "string" ? item.Type : "";
|
||||
return type === "Movie" || type === "Episode" || type === "Audio";
|
||||
})
|
||||
.map((item) => ({
|
||||
id: typeof item.Id === "string" ? item.Id : "",
|
||||
name: typeof item.Name === "string" ? item.Name : "",
|
||||
type: typeof item.Type === "string" ? item.Type : "Item",
|
||||
display: formatJellyfinItemDisplay(item),
|
||||
}))
|
||||
.filter((item) => item.id.length > 0);
|
||||
|
||||
if (items.length === 0) {
|
||||
items = allEntries
|
||||
.filter((item) => {
|
||||
const type = typeof item.Type === "string" ? item.Type : "";
|
||||
if (type === "Folder" || type === "CollectionFolder") return false;
|
||||
const mediaType =
|
||||
typeof item.MediaType === "string" ? item.MediaType.toLowerCase() : "";
|
||||
if (mediaType === "video" || mediaType === "audio") return true;
|
||||
return (
|
||||
type === "Movie" ||
|
||||
type === "Episode" ||
|
||||
type === "Audio" ||
|
||||
type === "Video" ||
|
||||
type === "MusicVideo"
|
||||
);
|
||||
})
|
||||
.map((item) => ({
|
||||
id: typeof item.Id === "string" ? item.Id : "",
|
||||
name: typeof item.Name === "string" ? item.Name : "",
|
||||
type: typeof item.Type === "string" ? item.Type : "Item",
|
||||
display: formatJellyfinItemDisplay(item),
|
||||
}))
|
||||
.filter((item) => item.id.length > 0);
|
||||
}
|
||||
|
||||
const itemId = pickItem(session, items, args.useRofi, ensureJellyfinIcon, "", themePath);
|
||||
if (!itemId) fail("No Jellyfin item selected.");
|
||||
return itemId;
|
||||
}
|
||||
|
||||
export async function runJellyfinPlayMenu(
|
||||
appPath: string,
|
||||
args: Args,
|
||||
scriptPath: string,
|
||||
mpvSocketPath: string,
|
||||
): Promise<never> {
|
||||
const config = loadLauncherJellyfinConfig();
|
||||
const session: JellyfinSessionConfig = {
|
||||
serverUrl: sanitizeServerUrl(args.jellyfinServer || config.serverUrl || ""),
|
||||
accessToken: config.accessToken || "",
|
||||
userId: config.userId || "",
|
||||
defaultLibraryId: config.defaultLibraryId || "",
|
||||
pullPictures: config.pullPictures === true,
|
||||
iconCacheDir: config.iconCacheDir || "",
|
||||
};
|
||||
|
||||
if (!session.serverUrl || !session.accessToken || !session.userId) {
|
||||
fail(
|
||||
"Missing Jellyfin session config. Run `subminer --jellyfin` or `subminer --jellyfin-login` first.",
|
||||
);
|
||||
}
|
||||
|
||||
const rofiTheme = args.useRofi ? findRofiTheme(scriptPath) : null;
|
||||
if (args.useRofi && !rofiTheme) {
|
||||
log(
|
||||
"warn",
|
||||
args.logLevel,
|
||||
"Rofi theme not found for Jellyfin picker; using rofi defaults.",
|
||||
);
|
||||
}
|
||||
|
||||
const itemId = await resolveJellyfinSelection(args, session, rofiTheme);
|
||||
log("debug", args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
|
||||
try {
|
||||
fs.rmSync(mpvSocketPath, { force: true });
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
log("debug", args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
|
||||
launchMpvIdleDetached(mpvSocketPath, appPath, args);
|
||||
const mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000);
|
||||
log(
|
||||
"debug",
|
||||
args.logLevel,
|
||||
`MPV socket ready check result: ${mpvReady ? "ready" : "not ready"}`,
|
||||
);
|
||||
const forwarded = ["--start", "--jellyfin-play", "--jellyfin-item-id", itemId];
|
||||
if (args.logLevel !== "info") forwarded.push("--log-level", args.logLevel);
|
||||
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, "jellyfin-play");
|
||||
}
|
||||
671
launcher/mpv.ts
Normal file
671
launcher/mpv.ts
Normal file
@@ -0,0 +1,671 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import net from "node:net";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import type { LogLevel, Backend, Args, MpvTrack } from "./types.js";
|
||||
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from "./types.js";
|
||||
import { log, fail, getMpvLogPath } from "./log.js";
|
||||
import {
|
||||
commandExists, isExecutable, resolveBinaryPathCandidate,
|
||||
realpathMaybe, isYoutubeTarget, uniqueNormalizedLangCodes, sleep, normalizeLangCode,
|
||||
} from "./util.js";
|
||||
|
||||
export const state = {
|
||||
overlayProc: null as ReturnType<typeof spawn> | null,
|
||||
mpvProc: null as ReturnType<typeof spawn> | null,
|
||||
youtubeSubgenChildren: new Set<ReturnType<typeof spawn>>(),
|
||||
appPath: "" as string,
|
||||
overlayManagedByLauncher: false,
|
||||
stopRequested: false,
|
||||
};
|
||||
|
||||
export function makeTempDir(prefix: string): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
export function detectBackend(backend: Backend): Exclude<Backend, "auto"> {
|
||||
if (backend !== "auto") return backend;
|
||||
if (process.platform === "darwin") return "macos";
|
||||
const xdgCurrentDesktop = (process.env.XDG_CURRENT_DESKTOP || "").toLowerCase();
|
||||
const xdgSessionDesktop = (process.env.XDG_SESSION_DESKTOP || "").toLowerCase();
|
||||
const xdgSessionType = (process.env.XDG_SESSION_TYPE || "").toLowerCase();
|
||||
const hasWayland = Boolean(process.env.WAYLAND_DISPLAY) || xdgSessionType === "wayland";
|
||||
|
||||
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 resolveMacAppBinaryCandidate(candidate: string): string {
|
||||
const direct = resolveBinaryPathCandidate(candidate);
|
||||
if (!direct) return "";
|
||||
|
||||
if (process.platform !== "darwin") {
|
||||
return isExecutable(direct) ? direct : "";
|
||||
}
|
||||
|
||||
if (isExecutable(direct)) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const appIndex = direct.indexOf(".app/");
|
||||
const appPath =
|
||||
direct.endsWith(".app") && direct.includes(".app")
|
||||
? direct
|
||||
: appIndex >= 0
|
||||
? direct.slice(0, appIndex + ".app".length)
|
||||
: "";
|
||||
if (!appPath) return "";
|
||||
|
||||
const candidates = [
|
||||
path.join(appPath, "Contents", "MacOS", "SubMiner"),
|
||||
path.join(appPath, "Contents", "MacOS", "subminer"),
|
||||
];
|
||||
|
||||
for (const candidateBinary of candidates) {
|
||||
if (isExecutable(candidateBinary)) {
|
||||
return candidateBinary;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function findAppBinary(selfPath: string): string | null {
|
||||
const envPaths = [
|
||||
process.env.SUBMINER_APPIMAGE_PATH,
|
||||
process.env.SUBMINER_BINARY_PATH,
|
||||
].filter((candidate): candidate is string => Boolean(candidate));
|
||||
|
||||
for (const envPath of envPaths) {
|
||||
const resolved = resolveMacAppBinaryCandidate(envPath);
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface MpvResponseEnvelope {
|
||||
request_id?: number;
|
||||
error?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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 [];
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export 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);
|
||||
});
|
||||
}
|
||||
|
||||
export function startMpv(
|
||||
target: string,
|
||||
targetKind: "file" | "url",
|
||||
args: Args,
|
||||
socketPath: string,
|
||||
appPath: string,
|
||||
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
|
||||
): void {
|
||||
if (
|
||||
targetKind === "file" &&
|
||||
(!fs.existsSync(target) || !fs.statSync(target).isFile())
|
||||
) {
|
||||
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(
|
||||
`--script-opts=subminer-binary_path=${appPath},subminer-socket_path=${socketPath}`,
|
||||
);
|
||||
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" });
|
||||
}
|
||||
|
||||
export function startOverlay(
|
||||
appPath: string,
|
||||
args: Args,
|
||||
socketPath: string,
|
||||
): Promise<void> {
|
||||
const backend = detectBackend(args.backend);
|
||||
log(
|
||||
"info",
|
||||
args.logLevel,
|
||||
`Starting SubMiner overlay (backend: ${backend})...`,
|
||||
);
|
||||
|
||||
const overlayArgs = ["--start", "--backend", backend, "--socket", socketPath];
|
||||
if (args.logLevel !== "info")
|
||||
overlayArgs.push("--log-level", args.logLevel);
|
||||
if (args.useTexthooker) overlayArgs.push("--texthooker");
|
||||
|
||||
state.overlayProc = spawn(appPath, overlayArgs, {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, SUBMINER_MPV_LOG: getMpvLogPath() },
|
||||
});
|
||||
state.overlayManagedByLauncher = true;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
export function launchTexthookerOnly(appPath: string, args: Args): never {
|
||||
const overlayArgs = ["--texthooker"];
|
||||
if (args.logLevel !== "info")
|
||||
overlayArgs.push("--log-level", args.logLevel);
|
||||
|
||||
log("info", args.logLevel, "Launching texthooker mode...");
|
||||
const result = spawnSync(appPath, overlayArgs, { stdio: "inherit" });
|
||||
process.exit(result.status ?? 0);
|
||||
}
|
||||
|
||||
export 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 !== "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 buildAppEnv(): NodeJS.ProcessEnv {
|
||||
const env: Record<string, string | undefined> = { ...process.env, SUBMINER_MPV_LOG: getMpvLogPath() };
|
||||
const layers = env.VK_INSTANCE_LAYERS;
|
||||
if (typeof layers === "string" && layers.trim().length > 0) {
|
||||
const filtered = layers
|
||||
.split(":")
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0 && !/lsfg/i.test(part));
|
||||
if (filtered.length > 0) {
|
||||
env.VK_INSTANCE_LAYERS = filtered.join(":");
|
||||
} else {
|
||||
delete env.VK_INSTANCE_LAYERS;
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export function runAppCommandWithInherit(
|
||||
appPath: string,
|
||||
appArgs: string[],
|
||||
): never {
|
||||
const result = spawnSync(appPath, appArgs, {
|
||||
stdio: "inherit",
|
||||
env: buildAppEnv(),
|
||||
});
|
||||
if (result.error) {
|
||||
fail(`Failed to run app command: ${result.error.message}`);
|
||||
}
|
||||
process.exit(result.status ?? 0);
|
||||
}
|
||||
|
||||
export function runAppCommandWithInheritLogged(
|
||||
appPath: string,
|
||||
appArgs: string[],
|
||||
logLevel: LogLevel,
|
||||
label: string,
|
||||
): never {
|
||||
log("debug", logLevel, `${label}: launching app with args: ${appArgs.join(" ")}`);
|
||||
const result = spawnSync(appPath, appArgs, {
|
||||
stdio: "inherit",
|
||||
env: buildAppEnv(),
|
||||
});
|
||||
if (result.error) {
|
||||
fail(`Failed to run app command: ${result.error.message}`);
|
||||
}
|
||||
log(
|
||||
"debug",
|
||||
logLevel,
|
||||
`${label}: app command exited with status ${result.status ?? 0}`,
|
||||
);
|
||||
process.exit(result.status ?? 0);
|
||||
}
|
||||
|
||||
export function launchAppStartDetached(appPath: string, logLevel: LogLevel): void {
|
||||
const startArgs = ["--start"];
|
||||
if (logLevel !== "info") startArgs.push("--log-level", logLevel);
|
||||
const proc = spawn(appPath, startArgs, {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
env: buildAppEnv(),
|
||||
});
|
||||
proc.unref();
|
||||
}
|
||||
|
||||
export function launchMpvIdleDetached(
|
||||
socketPath: string,
|
||||
appPath: string,
|
||||
args: Args,
|
||||
): void {
|
||||
const mpvArgs: string[] = [];
|
||||
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
|
||||
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
|
||||
mpvArgs.push("--idle=yes");
|
||||
mpvArgs.push(
|
||||
`--script-opts=subminer-binary_path=${appPath},subminer-socket_path=${socketPath}`,
|
||||
);
|
||||
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
|
||||
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
||||
const proc = spawn("mpv", mpvArgs, {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
});
|
||||
proc.unref();
|
||||
}
|
||||
|
||||
async function sleepMs(ms: number): Promise<void> {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForPathExists(
|
||||
filePath: string,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) return true;
|
||||
} catch {
|
||||
// ignore transient fs errors
|
||||
}
|
||||
await sleepMs(150);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function canConnectUnixSocket(socketPath: string): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const socket = net.createConnection(socketPath);
|
||||
let settled = false;
|
||||
|
||||
const finish = (value: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
socket.once("connect", () => finish(true));
|
||||
socket.once("error", () => finish(false));
|
||||
socket.setTimeout(400, () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForUnixSocketReady(
|
||||
socketPath: string,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const exists = await waitForPathExists(socketPath, 300);
|
||||
if (exists) {
|
||||
const ready = await canConnectUnixSocket(socketPath);
|
||||
if (ready) return true;
|
||||
}
|
||||
await sleepMs(150);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
503
launcher/youtube.ts
Normal file
503
launcher/youtube.ts
Normal file
@@ -0,0 +1,503 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import type { Args, SubtitleCandidate, YoutubeSubgenOutputs } from "./types.js";
|
||||
import { YOUTUBE_SUB_EXTENSIONS, YOUTUBE_AUDIO_EXTENSIONS } from "./types.js";
|
||||
import { log } from "./log.js";
|
||||
import {
|
||||
resolvePathMaybe, uniqueNormalizedLangCodes,
|
||||
escapeRegExp, normalizeBasename, runExternalCommand, commandExists,
|
||||
} from "./util.js";
|
||||
import { state } from "./mpv.js";
|
||||
|
||||
function toYtdlpLangPattern(langCodes: string[]): string {
|
||||
return langCodes.map((lang) => `${lang}.*`).join(",");
|
||||
}
|
||||
|
||||
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 sourceTag(source: SubtitleCandidate["source"]): string {
|
||||
if (source === "manual" || source === "auto") return `ytdlp-${source}`;
|
||||
if (source === "whisper-translate") return "whisper-translate";
|
||||
return "whisper";
|
||||
}
|
||||
|
||||
function pickBestCandidate(candidates: SubtitleCandidate[]): SubtitleCandidate | null {
|
||||
if (candidates.length === 0) return null;
|
||||
const scored = [...candidates].sort((a, b) => {
|
||||
const sourceA = a.source === "manual" ? 1 : 0;
|
||||
const sourceB = b.source === "manual" ? 1 : 0;
|
||||
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;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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",
|
||||
},
|
||||
state.youtubeSubgenChildren,
|
||||
);
|
||||
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,
|
||||
},
|
||||
state.youtubeSubgenChildren,
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
state.youtubeSubgenChildren,
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
state.youtubeSubgenChildren,
|
||||
);
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user