From 9c542b57e0907849cb6fb4721f855557fa62d9e1 Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 13 Feb 2026 22:53:47 -0800 Subject: [PATCH] Use SubMiner rofi theme for ani-cli stream mode and vendor ani-cli binary path --- .gitmodules | 4 + Makefile | 16 + subminer | 1078 +++++++++++++++++++++++++++++++++++++++++++++++- vendor/ani-cli | 1 + 4 files changed, 1088 insertions(+), 11 deletions(-) create mode 160000 vendor/ani-cli diff --git a/.gitmodules b/.gitmodules index 31ab7ff..60ca904 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,3 +2,7 @@ path = vendor/texthooker-ui url = https://github.com/ksyasuda/texthooker-ui.git branch = subminer + +[submodule "vendor/ani-cli"] + path = vendor/ani-cli + url = https://github.com/pystardust/ani-cli diff --git a/Makefile b/Makefile index 663ee32..2dde697 100644 --- a/Makefile +++ b/Makefile @@ -166,6 +166,13 @@ install-linux: @printf '%s\n' "[INFO] Installing Linux wrapper/theme artifacts" @install -d "$(BINDIR)" @install -m 0755 "./$(APP_NAME)" "$(BINDIR)/$(APP_NAME)" + @if [ -f "vendor/ani-cli/ani-cli" ]; then \ + install -d "$(BINDIR)/ani-cli"; \ + install -m 0755 "vendor/ani-cli/ani-cli" "$(BINDIR)/ani-cli/ani-cli"; \ + printf '%s\n' "[INFO] Installed vendored ani-cli to $(BINDIR)/ani-cli"; \ + else \ + printf '%s\n' "[WARN] vendored ani-cli not found at vendor/ani-cli (stream mode will require system ani-cli)"; \ + fi @install -d "$(LINUX_DATA_DIR)/themes" @install -m 0644 "./$(THEME_FILE)" "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)" @if [ -n "$(APPIMAGE_SRC)" ]; then \ @@ -180,6 +187,13 @@ install-macos: @printf '%s\n' "[INFO] Installing macOS wrapper/theme/app artifacts" @install -d "$(BINDIR)" @install -m 0755 "./$(APP_NAME)" "$(BINDIR)/$(APP_NAME)" + @if [ -f "vendor/ani-cli/ani-cli" ]; then \ + install -d "$(BINDIR)/ani-cli"; \ + install -m 0755 "vendor/ani-cli/ani-cli" "$(BINDIR)/ani-cli/ani-cli"; \ + printf '%s\n' "[INFO] Installed vendored ani-cli to $(BINDIR)/ani-cli"; \ + else \ + printf '%s\n' "[WARN] vendored ani-cli not found at vendor/ani-cli (stream mode will require system ani-cli)"; \ + fi @install -d "$(MACOS_DATA_DIR)/themes" @install -m 0644 "./$(THEME_FILE)" "$(MACOS_DATA_DIR)/themes/$(THEME_FILE)" @install -d "$(MACOS_APP_DIR)" @@ -210,11 +224,13 @@ uninstall: uninstall-linux uninstall-linux: @rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage" + @rm -rf "$(BINDIR)/ani-cli" @rm -f "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)" @printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(BINDIR)/SubMiner.AppImage" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" uninstall-macos: @rm -f "$(BINDIR)/subminer" + @rm -rf "$(BINDIR)/ani-cli" @rm -f "$(MACOS_DATA_DIR)/themes/$(THEME_FILE)" @rm -rf "$(MACOS_APP_DEST)" @printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_APP_DEST)" diff --git a/subminer b/subminer index 6394569..d346e7f 100755 --- a/subminer +++ b/subminer @@ -10,6 +10,8 @@ import path from "node:path"; import os from "node:os"; import net from "node:net"; import { spawn, spawnSync } from "node:child_process"; +import http from "node:http"; +import https from "node:https"; import { parse as parseJsonc } from "jsonc-parser"; const VIDEO_EXTENSIONS = new Set([ @@ -46,6 +48,8 @@ const DEFAULT_YOUTUBE_SUBGEN_OUT_DIR = path.join( "youtube-subs", ); const DEFAULT_YOUTUBE_YTDL_FORMAT = "bestvideo*+bestaudio/best"; +const DEFAULT_JIMAKU_API_BASE_URL = "https://jimaku.cc"; +const DEFAULT_STREAM_PRIMARY_SUB_LANGS = ["ja", "jpn"]; const DEFAULT_MPV_SUBMINER_ARGS = [ "--sub-auto=fuzzy", "--sub-file-paths=.;subs;subtitles", @@ -60,6 +64,453 @@ type YoutubeSubgenMode = "automatic" | "preprocess" | "off"; type Backend = "auto" | "hyprland" | "x11" | "macos"; +type JimakuLanguagePreference = "ja" | "en" | "none"; + +interface JimakuEntry { + id: number; + name: string; + english_name?: string | null; + japanese_name?: string | null; + flags?: { + anime?: boolean; + movie?: boolean; + adult?: boolean; + external?: boolean; + unverified?: boolean; + }; +} + +interface JimakuFileEntry { + name: string; + url: string; + size: number; + last_modified: string; +} + +interface JimakuApiError { + error: string; + code?: number; + retryAfter?: number; +} + +type JimakuApiResponse = + | { ok: true; data: T } + | { ok: false; error: JimakuApiError }; + +type JimakuDownloadResult = + | { ok: true; path: string } + | { ok: false; error: JimakuApiError }; + +interface JimakuConfig { + apiKey: string; + apiKeyCommand: string; + apiBaseUrl: string; + languagePreference: JimakuLanguagePreference; + maxEntryResults: number; +} + +interface JimakuMediaInfo { + title: string; + season: number | null; + episode: number | null; + confidence: "high" | "medium" | "low"; + filename: string; + rawTitle: string; +} + +function getRetryAfter(headers: http.IncomingHttpHeaders): number | undefined { + const value = headers["x-ratelimit-reset-after"]; + if (!value) return undefined; + const raw = Array.isArray(value) ? value[0] : value; + const parsed = Number.parseFloat(raw); + if (!Number.isFinite(parsed)) return undefined; + return parsed; +} + +function matchEpisodeFromName(name: string): { + season: number | null; + episode: number | null; + index: number | null; + confidence: "high" | "medium" | "low"; +} { + const seasonEpisode = name.match(/S(\d{1,2})E(\d{1,3})/i); + if (seasonEpisode && seasonEpisode.index !== undefined) { + return { + season: Number.parseInt(seasonEpisode[1], 10), + episode: Number.parseInt(seasonEpisode[2], 10), + index: seasonEpisode.index, + confidence: "high", + }; + } + + const alt = name.match(/(\d{1,2})x(\d{1,3})/i); + if (alt && alt.index !== undefined) { + return { + season: Number.parseInt(alt[1], 10), + episode: Number.parseInt(alt[2], 10), + index: alt.index, + confidence: "high", + }; + } + + const epOnly = name.match(/(?:^|[\s._-])E(?:P)?(\d{1,3})(?:\b|[\s._-])/i); + if (epOnly && epOnly.index !== undefined) { + return { + season: null, + episode: Number.parseInt(epOnly[1], 10), + index: epOnly.index, + confidence: "medium", + }; + } + + const numeric = name.match(/(?:^|[-–—]\s*)(\d{1,3})\s*[-–—]/); + if (numeric && numeric.index !== undefined) { + return { + season: null, + episode: Number.parseInt(numeric[1], 10), + index: numeric.index, + confidence: "medium", + }; + } + + return { season: null, episode: null, index: null, confidence: "low" }; +} + +function detectSeasonFromDir(mediaPath: string): number | null { + const parent = path.basename(path.dirname(mediaPath)); + const match = parent.match(/(?:Season|S)\s*(\d{1,2})/i); + if (!match) return null; + const parsed = Number.parseInt(match[1], 10); + return Number.isFinite(parsed) ? parsed : null; +} + +function parseGuessitOutput( + mediaPath: string, + stdout: string, +): JimakuMediaInfo | null { + const payload = stdout.trim(); + if (!payload) return null; + + try { + const parsed = JSON.parse(payload) as { + title?: string; + title_original?: string; + series?: string; + season?: number | string; + episode?: number | string; + episode_list?: Array; + }; + const season = + typeof parsed.season === "number" + ? parsed.season + : typeof parsed.season === "string" + ? Number.parseInt(parsed.season, 10) + : null; + const directEpisode = + typeof parsed.episode === "number" + ? parsed.episode + : typeof parsed.episode === "string" + ? Number.parseInt(parsed.episode, 10) + : null; + const episodeFromList = + parsed.episode_list && parsed.episode_list.length > 0 + ? Number.parseInt(String(parsed.episode_list[0]), 10) + : null; + const episodeValue = + directEpisode !== null && Number.isFinite(directEpisode) + ? directEpisode + : episodeFromList; + const episode = + Number.isFinite(episodeValue as number) ? (episodeValue as number) : null; + const title = ( + parsed.title || + parsed.title_original || + parsed.series || + "" + ).trim(); + const hasStructuredData = + title.length > 0 || Number.isFinite(season as number) || Number.isFinite(episodeValue as number); + + if (!hasStructuredData) return null; + + return { + title: title || "", + season: Number.isFinite(season as number) ? season : detectSeasonFromDir(mediaPath), + episode: episode, + confidence: "high", + filename: path.basename(mediaPath), + rawTitle: path.basename(mediaPath).replace(/\.[^/.]+$/, ""), + }; + } catch { + return null; + } +} + +function parseMediaInfoWithGuessit(mediaPath: string): JimakuMediaInfo | null { + if (!commandExists("guessit")) return null; + + try { + const fileName = path.basename(mediaPath); + const result = spawnSync("guessit", ["--json", fileName], { + cwd: path.dirname(mediaPath), + encoding: "utf8", + maxBuffer: 2_000_000, + windowsHide: true, + }); + if (result.error || result.status !== 0) return null; + return parseGuessitOutput(mediaPath, result.stdout || ""); + } catch { + return null; + } +} + +function cleanupTitle(value: string): string { + return value + .replace(/^[\s-–—]+/, "") + .replace(/[\s-–—]+$/, "") + .replace(/\s+/g, " ") + .trim(); +} + +function formatLangScore(name: string, pref: JimakuLanguagePreference): number { + if (pref === "none") return 0; + const upper = name.toUpperCase(); + const hasJa = + /(^|[\W_])JA([\W_]|$)/.test(upper) || + /(^|[\W_])JPN([\W_]|$)/.test(upper) || + upper.includes(".JA."); + const hasEn = + /(^|[\W_])EN([\W_]|$)/.test(upper) || + /(^|[\W_])ENG([\W_]|$)/.test(upper) || + upper.includes(".EN."); + if (pref === "ja") { + if (hasJa) return 2; + if (hasEn) return 1; + } else if (pref === "en") { + if (hasEn) return 2; + if (hasJa) return 1; + } + return 0; +} + +async function resolveJimakuApiKey(config: JimakuConfig): Promise { + if (config.apiKey && config.apiKey.trim()) { + return config.apiKey.trim(); + } + if (config.apiKeyCommand && config.apiKeyCommand.trim()) { + try { + const commandResult = spawnSync(config.apiKeyCommand, { + shell: true, + encoding: "utf8", + timeout: 10000, + }); + if (commandResult.error) return null; + const key = (commandResult.stdout || "").trim(); + return key.length > 0 ? key : null; + } catch { + return null; + } + } + return null; +} + +function jimakuFetchJson( + endpoint: string, + query: Record, + options: { baseUrl: string; apiKey: string }, +): Promise> { + const url = new URL(endpoint, options.baseUrl); + for (const [key, value] of Object.entries(query)) { + if (value === null || value === undefined) continue; + url.searchParams.set(key, String(value)); + } + + return new Promise((resolve) => { + const requestUrl = new URL(url.toString()); + const transport = requestUrl.protocol === "https:" ? https : http; + const req = transport.request( + requestUrl, + { + method: "GET", + headers: { + Authorization: options.apiKey, + "User-Agent": "SubMiner", + }, + }, + (res) => { + let data = ""; + res.on("data", (chunk) => { + data += chunk.toString(); + }); + res.on("end", () => { + const status = res.statusCode || 0; + if (status >= 200 && status < 300) { + try { + const parsed = JSON.parse(data) as T; + resolve({ ok: true, data: parsed }); + } catch { + resolve({ + ok: false, + error: { error: "Failed to parse Jimaku response JSON." }, + }); + } + return; + } + + let errorMessage = `Jimaku API error (HTTP ${status})`; + try { + const parsed = JSON.parse(data) as { error?: string }; + if (parsed && parsed.error) { + errorMessage = parsed.error; + } + } catch { + // ignore parse errors + } + + resolve({ + ok: false, + error: { + error: errorMessage, + code: status || undefined, + retryAfter: + status === 429 ? getRetryAfter(res.headers) : undefined, + }, + }); + }); + }, + ); + + req.on("error", (error) => { + resolve({ + ok: false, + error: { error: `Jimaku request failed: ${(error as Error).message}` }, + }); + }); + + req.end(); + }); +} + +function parseMediaInfo(mediaPath: string | null): JimakuMediaInfo { + if (!mediaPath) { + return { + title: "", + season: null, + episode: null, + confidence: "low", + filename: "", + rawTitle: "", + }; + } + + const guessitInfo = parseMediaInfoWithGuessit(mediaPath); + if (guessitInfo) return guessitInfo; + + const filename = path.basename(mediaPath); + let name = filename.replace(/\.[^/.]+$/, ""); + name = name.replace(/\[[^\]]*]/g, " "); + name = name.replace(/\(\d{4}\)/g, " "); + name = name.replace(/[._]/g, " "); + name = name.replace(/[–—]/g, "-"); + name = name.replace(/\s+/g, " ").trim(); + + const parsed = matchEpisodeFromName(name); + let titlePart = name; + if (parsed.index !== null) { + titlePart = name.slice(0, parsed.index); + } + + const seasonFromDir = parsed.season ?? detectSeasonFromDir(mediaPath); + const title = cleanupTitle(titlePart || name); + + return { + title, + season: seasonFromDir, + episode: parsed.episode, + confidence: parsed.confidence, + filename, + rawTitle: name, + }; +} + +function sortJimakuFiles( + files: JimakuFileEntry[], + pref: JimakuLanguagePreference, +): JimakuFileEntry[] { + if (pref === "none") return files; + return [...files].sort((a, b) => { + const scoreDiff = formatLangScore(b.name, pref) - formatLangScore(a.name, pref); + if (scoreDiff !== 0) return scoreDiff; + return a.name.localeCompare(b.name); + }); +} + +async function downloadToFile( + url: string, + destPath: string, + headers: Record, + redirectCount = 0, +): Promise { + if (redirectCount > 3) { + return { + ok: false, + error: { error: "Too many redirects while downloading subtitle." }, + }; + } + + return new Promise((resolve) => { + const parsedUrl = new URL(url); + const transport = parsedUrl.protocol === "https:" ? https : http; + + const req = transport.get(parsedUrl, { headers }, (res) => { + const status = res.statusCode || 0; + if ([301, 302, 303, 307, 308].includes(status) && res.headers.location) { + const redirectUrl = new URL(res.headers.location, parsedUrl).toString(); + res.resume(); + downloadToFile(redirectUrl, destPath, headers, redirectCount + 1).then( + resolve, + ); + return; + } + + if (status < 200 || status >= 300) { + res.resume(); + resolve({ + ok: false, + error: { + error: `Failed to download subtitle (HTTP ${status}).`, + code: status, + }, + }); + return; + } + + const fileStream = fs.createWriteStream(destPath); + res.pipe(fileStream); + fileStream.on("finish", () => { + fileStream.close(() => { + resolve({ ok: true, path: destPath }); + }); + }); + fileStream.on("error", (err: Error) => { + resolve({ + ok: false, + error: { error: `Failed to save subtitle: ${err.message}` }, + }); + }); + }); + + req.on("error", (err) => { + resolve({ + ok: false, + error: { + error: `Download request failed: ${(err as Error).message}`, + }, + }); + }); + }); +} + interface Args { backend: Backend; directory: string; @@ -83,6 +534,14 @@ interface Args { logLevel: LogLevel; target: string; targetKind: "" | "file" | "url"; + streamMode: boolean; + aniCliPath: string | null; + streamPrimarySubLangs: string[]; + jimakuApiKey: string; + jimakuApiKeyCommand: string; + jimakuApiBaseUrl: string; + jimakuLanguagePreference: JimakuLanguagePreference; + jimakuMaxEntryResults: number; } interface LauncherYoutubeSubgenConfig { @@ -91,6 +550,11 @@ interface LauncherYoutubeSubgenConfig { whisperModel?: string; primarySubLanguages?: string[]; secondarySubLanguages?: string[]; + jimakuApiKey?: string; + jimakuApiKeyCommand?: string; + jimakuApiBaseUrl?: string; + jimakuLanguagePreference?: JimakuLanguagePreference; + jimakuMaxEntryResults?: number; } interface PluginRuntimeConfig { @@ -120,8 +584,21 @@ const state = { appPath: "" as string, overlayManagedByLauncher: false, stopRequested: false, + streamSubtitleFiles: [] as string[], }; +interface ResolvedStreamTarget { + streamUrl: string; + subtitleUrl?: string; +} + +interface MpvTrack { + type?: string; + id?: number; + lang?: string; + title?: string; +} + function usage(scriptName: string): string { return `subminer - Launch MPV with SubMiner sentence mining overlay @@ -132,6 +609,7 @@ Options: -d, --directory DIR Directory to browse for videos (default: current directory) -r, --recursive Search for videos recursively -p, --profile PROFILE MPV profile to use (default: subminer) + -s, --stream Resolve stream URL via ani-cli using the given query --start Explicitly start SubMiner overlay --yt-subgen-mode MODE YouTube subtitle generation mode: automatic, preprocess, off (default: automatic) @@ -168,6 +646,7 @@ Examples: ${scriptName} video.mkv # Play specific file ${scriptName} https://youtu.be/... # Play a YouTube URL ${scriptName} ytsearch:query # Play first YouTube search result + ${scriptName} -s \"blue lock\" # Resolve and play Blue Lock stream via ani-cli ${scriptName} --yt-subgen-mode preprocess --whisper-bin /path/whisper-cli --whisper-model /path/model.bin https://youtu.be/... ${scriptName} video.mkv # Play with subminer profile ${scriptName} -p gpu-hq video.mkv # Play with gpu-hq profile @@ -247,6 +726,7 @@ function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig { const root = parsed as { youtubeSubgen?: unknown; secondarySub?: { secondarySubLanguages?: unknown }; + jimaku?: unknown; }; const youtubeSubgen = root.youtubeSubgen; const mode = @@ -276,6 +756,38 @@ function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig { (value): value is string => typeof value === "string", ) : undefined; + const jimaku = root.jimaku; + const jimakuApiKey = + jimaku && typeof jimaku === "object" + ? (jimaku as { apiKey?: unknown }).apiKey + : undefined; + const jimakuApiKeyCommand = + jimaku && typeof jimaku === "object" + ? (jimaku as { apiKeyCommand?: unknown }).apiKeyCommand + : undefined; + const jimakuApiBaseUrl = + jimaku && typeof jimaku === "object" + ? (jimaku as { apiBaseUrl?: unknown }).apiBaseUrl + : undefined; + const jimakuLanguagePreference = jimaku && typeof jimaku === "object" + ? (jimaku as { languagePreference?: unknown }).languagePreference + : undefined; + const jimakuMaxEntryResults = + jimaku && typeof jimaku === "object" + ? (jimaku as { maxEntryResults?: unknown }).maxEntryResults + : undefined; + const resolvedJimakuLanguagePreference = + jimakuLanguagePreference === "ja" || + jimakuLanguagePreference === "en" || + jimakuLanguagePreference === "none" + ? jimakuLanguagePreference + : undefined; + const resolvedJimakuMaxEntryResults = + typeof jimakuMaxEntryResults === "number" && + Number.isFinite(jimakuMaxEntryResults) && + jimakuMaxEntryResults > 0 + ? Math.floor(jimakuMaxEntryResults) + : undefined; return { mode: @@ -286,6 +798,17 @@ function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig { whisperModel: typeof whisperModel === "string" ? whisperModel : undefined, primarySubLanguages, secondarySubLanguages, + jimakuApiKey: typeof jimakuApiKey === "string" ? jimakuApiKey : undefined, + jimakuApiKeyCommand: + typeof jimakuApiKeyCommand === "string" + ? jimakuApiKeyCommand + : undefined, + jimakuApiBaseUrl: + typeof jimakuApiBaseUrl === "string" + ? jimakuApiBaseUrl + : undefined, + jimakuLanguagePreference: resolvedJimakuLanguagePreference, + jimakuMaxEntryResults: resolvedJimakuMaxEntryResults, }; } catch { return {}; @@ -317,6 +840,7 @@ interface CommandExecOptions { logLevel?: LogLevel; commandLabel?: string; streamOutput?: boolean; + env?: NodeJS.ProcessEnv; } interface CommandExecResult { @@ -439,6 +963,7 @@ function runExternalCommand( log("debug", configuredLogLevel, `[${commandLabel}] spawn: ${executable} ${args.join(" ")}`); const child = spawn(executable, args, { stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, ...opts.env }, }); state.youtubeSubgenChildren.add(child); @@ -713,6 +1238,181 @@ async function loadSubtitleIntoMpv( } } +interface MpvResponseEnvelope { + request_id?: number; + error?: string; + data?: unknown; +} + +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); + }); + }); +} + +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; +} + +function findPreferredSubtitleTrack( + tracks: MpvTrack[], + preferredLanguages: string[], +): MpvTrack | null { + const normalizedPreferred = uniqueNormalizedLangCodes(preferredLanguages); + const subtitleTracks = tracks.filter((track) => track.type === "sub"); + if (normalizedPreferred.length === 0) return subtitleTracks[0] ?? null; + + for (const lang of normalizedPreferred) { + const matched = subtitleTracks.find( + (track) => track.lang && isPreferredStreamLang(track.lang, [lang]), + ); + if (matched) return matched; + } + + return null; +} + +async function waitForSubtitleTrackList( + socketPath: string, + logLevel: LogLevel, +): Promise { + const maxAttempts = 40; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const tracks = await getMpvTracks(socketPath).catch(() => [] as MpvTrack[]); + if (tracks.length > 0) return tracks; + if (attempt % 10 === 0) { + log( + "debug", + logLevel, + `Waiting for mpv tracks (${attempt}/${maxAttempts})`, + ); + } + await sleep(250); + } + return []; +} + +function isValidSubtitleCandidateFile(filename: string): boolean { + const ext = path.extname(filename).toLowerCase(); + return ( + ext === ".srt" || + ext === ".vtt" || + ext === ".ass" || + ext === ".ssa" || + ext === ".sub" + ); +} + +function mapPreferenceToLanguages(preference: JimakuLanguagePreference): string[] { + if (preference === "en") return ["en", "eng"]; + if (preference === "none") return []; + return ["ja", "jpn"]; +} + +function makeTempDir(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + function detectBackend(backend: Backend): Exclude { if (backend !== "auto") return backend; if (process.platform === "darwin") return "macos"; @@ -978,11 +1678,255 @@ function findAppBinary(selfPath: string): string | null { return null; } +function resolveAniCliPath(scriptPath: string): string | null { + const envPath = process.env.SUBMINER_ANI_CLI_PATH; + if (envPath && isExecutable(envPath)) return envPath; + + const candidates: string[] = []; + candidates.push(path.resolve(path.dirname(realpathMaybe(scriptPath)), "ani-cli/ani-cli")); + + for (const candidate of candidates) { + if (isExecutable(candidate)) return candidate; + } + + if (commandExists("ani-cli")) return "ani-cli"; + + return null; +} + +function buildAniCliRofiConfig( + scriptPath: string, + logLevel: LogLevel, +): { env: NodeJS.ProcessEnv; cleanup: () => void } | null { + const themePath = findRofiTheme(scriptPath); + if (!themePath) { + return null; + } + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "subminer-ani-cli-rofi-")); + const configPath = path.join(tempDir, "rofi", "config.rasi"); + try { + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, `@theme "${themePath}"\n`); + } catch (error) { + fs.rmSync(tempDir, { force: true, recursive: true }); + throw new Error( + `Failed to prepare temporary ani-cli rofi theme config: ${(error as Error).message}`, + ); + } + + log("debug", logLevel, `Using Subminer rofi theme for ani-cli via ${configPath}`); + + return { + env: { + XDG_CONFIG_HOME: tempDir, + }, + cleanup: () => { + fs.rmSync(tempDir, { force: true, recursive: true }); + }, + }; +} + +function parseAniCliOutput(output: string): ResolvedStreamTarget { + const lines = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const selectedIndex = lines.findIndex((line) => + line.startsWith("Selected link:"), + ); + const selectedBlock = + selectedIndex >= 0 + ? lines.slice(selectedIndex + 1) + : lines.slice(); + const targetCandidate = selectedBlock + .flatMap((line) => line.match(/https?:\/\/\S+/g) ?? []) + .find((value) => value.length > 0); + if (!targetCandidate) { + throw new Error("Could not parse ani-cli stream URL from output."); + } + + const subtitleCandidate = lines + .find((line) => line.startsWith("subtitle >") || line.includes("subtitle >")) + ?.match(/https?:\/\/\S+/)?.[0]; + + return { + streamUrl: targetCandidate, + subtitleUrl: subtitleCandidate, + }; +} + +async function resolveStreamTarget( + query: string, + args: Args, + scriptPath: string, +): Promise { + const aniCliThemeConfig = buildAniCliRofiConfig(scriptPath, args.logLevel); + try { + const result = await runExternalCommand(args.aniCliPath as string, [query], { + captureStdout: true, + logLevel: args.logLevel, + commandLabel: "ani-cli", + streamOutput: false, + env: { + ANI_CLI_PLAYER: "debug", + ...aniCliThemeConfig?.env, + }, + }); + + const parsed = parseAniCliOutput(result.stdout); + if (!parsed.streamUrl.startsWith("http://") && !parsed.streamUrl.startsWith("https://")) { + throw new Error( + `Ani-cli output stream URL is invalid: ${parsed.streamUrl}`, + ); + } + log("info", args.logLevel, `Resolved stream target: ${parsed.streamUrl}`); + if (parsed.subtitleUrl) { + log( + "debug", + args.logLevel, + `Resolved stream subtitle URL: ${parsed.subtitleUrl}`, + ); + } + return parsed; + } finally { + aniCliThemeConfig?.cleanup(); + } +} + +function buildJimakuConfig(args: Args): { + apiKey: string; + apiKeyCommand: string; + apiBaseUrl: string; + languagePreference: JimakuLanguagePreference; + maxEntryResults: number; +} { + return { + apiKey: args.jimakuApiKey, + apiKeyCommand: args.jimakuApiKeyCommand, + apiBaseUrl: args.jimakuApiBaseUrl || DEFAULT_JIMAKU_API_BASE_URL, + languagePreference: args.jimakuLanguagePreference, + maxEntryResults: args.jimakuMaxEntryResults || 10, + }; +} + +async function resolveJimakuSubtitle( + args: Args, + mediaQuery: string, +): Promise { + const config = buildJimakuConfig(args); + if (!config.apiKey && !config.apiKeyCommand) return null; + const mediaInfo = parseMediaInfo(`${mediaQuery}.mkv`); + const searchQuery = mediaInfo.title || mediaQuery || "anime episode"; + const apiKey = await resolveJimakuApiKey(config); + if (!apiKey) return null; + + const searchResponse = await jimakuFetchJson( + "/api/entries/search", + { + anime: true, + query: searchQuery, + limit: config.maxEntryResults, + }, + { + baseUrl: config.apiBaseUrl || DEFAULT_JIMAKU_API_BASE_URL, + apiKey, + }, + ); + + if (!searchResponse.ok || searchResponse.data.length === 0) return null; + + const filesResponse = await jimakuFetchJson( + `/api/entries/${searchResponse.data[0].id}/files`, + { + episode: mediaInfo.episode, + }, + { + baseUrl: config.apiBaseUrl || DEFAULT_JIMAKU_API_BASE_URL, + apiKey, + }, + ); + if (!filesResponse.ok || filesResponse.data.length === 0) return null; + + const sortedFiles = sortJimakuFiles( + filesResponse.data, + config.languagePreference, + ); + const selectedFile = + sortedFiles.find((entry) => isValidSubtitleCandidateFile(entry.name)) ?? + sortedFiles[0]; + if (!selectedFile) return null; + + const extension = path.extname(selectedFile.name).toLowerCase() || ".srt"; + const tempFile = path.join( + makeTempDir("subminer-jimaku-stream-"), + `${Date.now()}-stream-subtitle${extension}`, + ); + + const result = await downloadToFile( + selectedFile.url, + tempFile, + { Authorization: apiKey }, + ); + if (!result.ok) return null; + state.streamSubtitleFiles.push(result.path); + return result.path; +} + +async function ensurePrimaryStreamSubtitle( + socketPath: string, + args: Args, + mediaQuery: string, +): Promise { + const preferredLanguages = uniqueNormalizedLangCodes( + args.streamPrimarySubLangs.length > 0 + ? args.streamPrimarySubLangs + : mapPreferenceToLanguages(args.jimakuLanguagePreference), + ); + const tracks = await waitForSubtitleTrackList(socketPath, args.logLevel); + const preferredTrack = findPreferredSubtitleTrack(tracks, preferredLanguages); + + if (preferredTrack?.id !== undefined) { + await sendMpvCommand( + socketPath, + ["set_property", "sid", preferredTrack.id], + ); + log( + "info", + args.logLevel, + `Selected existing stream subtitle track: ${preferredTrack.lang || preferredTrack.title || preferredTrack.id}`, + ); + return; + } + + const jimakuPath = await resolveJimakuSubtitle(args, mediaQuery); + if (!jimakuPath) { + log( + "warn", + args.logLevel, + "No matching stream subtitle track found and no Jimaku fallback available.", + ); + return; + } + + try { + await loadSubtitleIntoMpv(socketPath, jimakuPath, true, args.logLevel); + log("info", args.logLevel, `Loaded Jimaku subtitle fallback: ${path.basename(jimakuPath)}`); + } catch (error) { + log( + "warn", + args.logLevel, + `Failed to load Jimaku fallback subtitle: ${(error as Error).message}`, + ); + } +} + function checkDependencies(args: Args): void { const missing: string[] = []; if (!commandExists("mpv")) missing.push("mpv"); + if (args.streamMode) { + if (!args.aniCliPath) missing.push("ani-cli"); + } + if ( args.targetKind === "url" && isYoutubeTarget(args.target) && @@ -1352,6 +2296,12 @@ function parseArgs( const configuredPrimaryLangs = uniqueNormalizedLangCodes( launcherConfig.primarySubLanguages ?? [], ); + const envStreamPrimaryLangs = uniqueNormalizedLangCodes( + (process.env.SUBMINER_STREAM_PRIMARY_SUB_LANGS || "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean), + ); const primarySubLangs = configuredPrimaryLangs.length > 0 ? configuredPrimaryLangs @@ -1378,6 +2328,19 @@ function parseArgs( process.env.SUBMINER_YT_SUBGEN_OUT_DIR || DEFAULT_YOUTUBE_SUBGEN_OUT_DIR, youtubeSubgenAudioFormat: process.env.SUBMINER_YT_SUBGEN_AUDIO_FORMAT || "m4a", youtubeSubgenKeepTemp: process.env.SUBMINER_YT_SUBGEN_KEEP_TEMP === "1", + streamMode: false, + aniCliPath: "", + streamPrimarySubLangs: + envStreamPrimaryLangs.length > 0 + ? envStreamPrimaryLangs + : [...DEFAULT_STREAM_PRIMARY_SUB_LANGS], + jimakuApiKey: process.env.SUBMINER_JIMAKU_API_KEY || "", + jimakuApiKeyCommand: process.env.SUBMINER_JIMAKU_API_KEY_COMMAND || "", + jimakuApiBaseUrl: + process.env.SUBMINER_JIMAKU_API_BASE_URL || DEFAULT_JIMAKU_API_BASE_URL, + jimakuLanguagePreference: + launcherConfig.jimakuLanguagePreference || "ja", + jimakuMaxEntryResults: launcherConfig.jimakuMaxEntryResults || 10, youtubePrimarySubLangs: primarySubLangs, youtubeSecondarySubLangs: secondarySubLangs, youtubeAudioLangs, @@ -1391,6 +2354,21 @@ function parseArgs( targetKind: "", }; + if (launcherConfig.jimakuApiKey) parsed.jimakuApiKey = launcherConfig.jimakuApiKey; + if (launcherConfig.jimakuApiKeyCommand) + parsed.jimakuApiKeyCommand = launcherConfig.jimakuApiKeyCommand; + if (launcherConfig.jimakuApiBaseUrl) + parsed.jimakuApiBaseUrl = launcherConfig.jimakuApiBaseUrl; + if (launcherConfig.jimakuLanguagePreference) + parsed.jimakuLanguagePreference = launcherConfig.jimakuLanguagePreference; + if (launcherConfig.jimakuMaxEntryResults !== undefined) + parsed.jimakuMaxEntryResults = launcherConfig.jimakuMaxEntryResults; + + parsed.streamPrimarySubLangs = uniqueNormalizedLangCodes([ + ...parsed.streamPrimarySubLangs, + ...mapPreferenceToLanguages(parsed.jimakuLanguagePreference), + ]); + const isValidLogLevel = (value: string): value is LogLevel => value === "debug" || value === "info" || @@ -1438,6 +2416,12 @@ function parseArgs( continue; } + if (arg === "-s" || arg === "--stream") { + parsed.streamMode = true; + i += 1; + continue; + } + if (arg === "--start") { parsed.startOverlay = true; i += 1; @@ -1615,7 +2599,10 @@ function parseArgs( const positional = argv.slice(i); if (positional.length > 0) { const target = positional[0]; - if (isUrlTarget(target)) { + if (parsed.streamMode) { + parsed.target = target; + parsed.targetKind = "url"; + } else if (isUrlTarget(target)) { parsed.target = target; parsed.targetKind = "url"; } else { @@ -1715,6 +2702,19 @@ function stopOverlay(args: Args): void { } } state.youtubeSubgenChildren.clear(); + + for (const subtitleFile of state.streamSubtitleFiles) { + try { + fs.rmSync(subtitleFile, { force: true }); + fs.rmSync(path.dirname(subtitleFile), { + force: true, + recursive: true, + }); + } catch { + // ignore + } + } + state.streamSubtitleFiles = []; } function parseBoolLike(value: string): boolean | null { @@ -1939,7 +2939,8 @@ function registerCleanup(args: Args): void { } async function main(): Promise { - const scriptName = path.basename(process.argv[1] || "subminer"); + const scriptPath = process.argv[1] || "subminer"; + const scriptName = path.basename(scriptPath); const launcherConfig = loadLauncherYoutubeSubgenConfig(); const args = parseArgs(process.argv.slice(2), scriptName, launcherConfig); const pluginRuntimeConfig = readPluginRuntimeConfig(args.logLevel); @@ -1965,32 +2966,73 @@ async function main(): Promise { } if (!args.target) { + if (args.streamMode) { + fail("Stream mode requires a search query argument."); + } checkPickerDependencies(args); } - const targetChoice = await chooseTarget(args, process.argv[1] || "subminer"); - if (!targetChoice) { + const targetChoice = args.streamMode + ? null + : await chooseTarget(args, process.argv[1] || "subminer"); + if (!targetChoice && !args.streamMode) { log("info", args.logLevel, "No video selected, exiting"); process.exit(0); } + if (args.streamMode) { + args.aniCliPath = resolveAniCliPath(scriptPath); + } + checkDependencies({ ...args, - target: targetChoice.target, - targetKind: targetChoice.kind, + target: targetChoice ? targetChoice.target : args.target, + targetKind: targetChoice ? targetChoice.kind : "url", }); registerCleanup(args); + let selectedTarget = targetChoice + ? { + target: targetChoice.target, + kind: targetChoice.kind as "file" | "url", + } + : { target: args.target, kind: "url" as const }; + let resolvedStreamTarget: ResolvedStreamTarget | null = null; + const streamSource = selectedTarget.target; + + if (args.streamMode) { + log("info", args.logLevel, `Resolving stream target via ani-cli for "${selectedTarget.target}"`); + resolvedStreamTarget = await resolveStreamTarget( + selectedTarget.target, + args, + scriptPath, + ); + selectedTarget = { + target: resolvedStreamTarget.streamUrl, + kind: "url", + }; + if (resolvedStreamTarget.subtitleUrl) { + log( + "debug", + args.logLevel, + `ani-cli provided subtitle URL: ${resolvedStreamTarget.subtitleUrl}`, + ); + } + } + const isYoutubeUrl = - targetChoice.kind === "url" && isYoutubeTarget(targetChoice.target); + selectedTarget.kind === "url" && isYoutubeTarget(selectedTarget.target); let preloadedSubtitles: | { primaryPath?: string; secondaryPath?: string } | undefined; if (isYoutubeUrl && args.youtubeSubgenMode === "preprocess") { log("info", args.logLevel, "YouTube subtitle mode: preprocess"); - const generated = await generateYoutubeSubtitles(targetChoice.target, args); + const generated = await generateYoutubeSubtitles( + selectedTarget.target, + args, + ); preloadedSubtitles = { primaryPath: generated.primaryPath, secondaryPath: generated.secondaryPath, @@ -2007,15 +3049,18 @@ async function main(): Promise { } startMpv( - targetChoice.target, - targetChoice.kind, + selectedTarget.target, + selectedTarget.kind, args, mpvSocketPath, preloadedSubtitles, ); if (isYoutubeUrl && args.youtubeSubgenMode === "automatic") { - void generateYoutubeSubtitles(targetChoice.target, args, async (lang, subtitlePath) => { + void generateYoutubeSubtitles( + selectedTarget.target, + args, + async (lang, subtitlePath) => { try { await loadSubtitleIntoMpv( mpvSocketPath, @@ -2040,6 +3085,17 @@ async function main(): Promise { } const ready = await waitForSocket(mpvSocketPath); + if (args.streamMode && ready) { + await ensurePrimaryStreamSubtitle(mpvSocketPath, args, streamSource).catch( + (error) => { + log( + "warn", + args.logLevel, + `Stream subtitle setup failed: ${(error as Error).message}`, + ); + }, + ); + } const shouldStartOverlay = args.startOverlay || args.autoStartOverlay || pluginRuntimeConfig.autoStartOverlay; if (shouldStartOverlay) { diff --git a/vendor/ani-cli b/vendor/ani-cli new file mode 160000 index 0000000..c8aa791 --- /dev/null +++ b/vendor/ani-cli @@ -0,0 +1 @@ +Subproject commit c8aa791982cd4c5d72234c590a2834cfa5a712e6