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:
2026-02-17 03:31:39 -08:00
parent b4df3f8295
commit ba94a33b46
3 changed files with 1528 additions and 0 deletions

354
launcher/jellyfin.ts Normal file
View 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
View 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
View 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
}
}
}
}