diff --git a/launcher/jellyfin.ts b/launcher/jellyfin.ts new file mode 100644 index 0000000..6a6c628 --- /dev/null +++ b/launcher/jellyfin.ts @@ -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( + session: JellyfinSessionConfig, + requestPath: string, +): Promise { + 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 { + 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 { + const libsPayload = await jellyfinApiRequest<{ Items?: Array> }>( + 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> = []; + let startIndex = 0; + while (true) { + const payload = await jellyfinApiRequest<{ + Items?: Array>; + 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>; + TotalRecordCount?: number; + }>( + session, + `/Users/${session.userId}/Items?ParentId=${encodeURIComponent(contentParentId)}&Recursive=true&SortBy=SortName&SortOrder=Ascending&Limit=500&StartIndex=${startIndex}`, + ); + + const allEntries: Array> = []; + 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 { + 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"); +} diff --git a/launcher/mpv.ts b/launcher/mpv.ts new file mode 100644 index 0000000..afcba4b --- /dev/null +++ b/launcher/mpv.ts @@ -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 | null, + mpvProc: null as ReturnType | null, + youtubeSubgenChildren: new Set>(), + 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 { + 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 { + 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 { + 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 { + 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; + return candidate.type === "sub"; + }) + .map((track) => { + const candidate = track as Record; + 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 { + 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 { + 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 { + 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 { + 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 = { ...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 { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForPathExists( + filePath: string, + timeoutMs: number, +): Promise { + 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 { + return await new Promise((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 { + 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; +} diff --git a/launcher/youtube.ts b/launcher/youtube.ts new file mode 100644 index 0000000..f2644a4 --- /dev/null +++ b/launcher/youtube.ts @@ -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, + 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 { + 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 { + 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 { + 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, +): Promise { + 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(); + let keepTemp = args.youtubeSubgenKeepTemp; + + const publishTrack = async ( + lang: "primary" | "secondary", + source: SubtitleCandidate["source"], + selectedPath: string, + basename: string, + ): Promise => { + 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 + } + } + } +}