From c16bc26a586643b27c505eff35c1770ca358970d Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 13 Feb 2026 22:56:05 -0800 Subject: [PATCH] Use lobster as stream resolver fallback for -s query flow --- subminer | 138 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 113 insertions(+), 25 deletions(-) diff --git a/subminer b/subminer index d346e7f..3337ceb 100755 --- a/subminer +++ b/subminer @@ -609,7 +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 + -s, --stream Resolve stream URL via ani-cli/lobster using the given query --start Explicitly start SubMiner overlay --yt-subgen-mode MODE YouTube subtitle generation mode: automatic, preprocess, off (default: automatic) @@ -633,6 +633,7 @@ Options: Environment: SUBMINER_APPIMAGE_PATH Path to SubMiner AppImage/binary (optional override) SUBMINER_ROFI_THEME Path to rofi theme file (optional override) + SUBMINER_STREAM_TOOL_PATH Path override for stream resolver binary (ani-cli or lobster) SUBMINER_YT_SUBGEN_MODE automatic, preprocess, off (optional default) SUBMINER_WHISPER_BIN whisper.cpp binary path (optional fallback) SUBMINER_WHISPER_MODEL whisper model path (optional fallback) @@ -646,7 +647,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} -s \"blue lock\" # Resolve and play Blue Lock stream via ani-cli/lobster ${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 @@ -1679,16 +1680,19 @@ function findAppBinary(selfPath: string): string | null { } function resolveAniCliPath(scriptPath: string): string | null { - const envPath = process.env.SUBMINER_ANI_CLI_PATH; + const envPath = + process.env.SUBMINER_STREAM_TOOL_PATH || 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")); + candidates.push(path.resolve(path.dirname(realpathMaybe(scriptPath)), "lobster/lobster.sh")); for (const candidate of candidates) { if (isExecutable(candidate)) return candidate; } + if (commandExists("lobster")) return "lobster"; if (commandExists("ani-cli")) return "ani-cli"; return null; @@ -1711,11 +1715,11 @@ function buildAniCliRofiConfig( } 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}`, + `Failed to prepare temporary stream resolver rofi theme config: ${(error as Error).message}`, ); } - log("debug", logLevel, `Using Subminer rofi theme for ani-cli via ${configPath}`); + log("debug", logLevel, `Using Subminer rofi theme for stream resolver via ${configPath}`); return { env: { @@ -1727,7 +1731,84 @@ function buildAniCliRofiConfig( }; } +function extractUrls(value: unknown, out: string[] = []): string[] { + if (typeof value === "string") { + if (/^https?:\/\//i.test(value)) out.push(value); + return out; + } + if (Array.isArray(value)) { + for (const item of value) extractUrls(item, out); + return out; + } + if (value && typeof value === "object") { + for (const item of Object.values(value as Record)) { + extractUrls(item, out); + } + } + return out; +} + +function extractFirstJsonObject(text: string): string | null { + let depth = 0; + let start = -1; + + for (let i = 0; i < text.length; i++) { + const char = text[i]; + if (char === "{") { + if (depth === 0) start = i; + depth += 1; + } else if (char === "}") { + if (depth > 0) depth -= 1; + if (depth === 0 && start >= 0) return text.slice(start, i + 1); + } + } + + return null; +} + +function pickStreamUrl(urls: string[]): string | null { + const prioritized = urls.filter((url) => { + const lower = url.toLowerCase(); + return lower.includes(".m3u8") || lower.includes(".mp4") || lower.includes("/playlist"); + }); + return ( + prioritized[0] ?? + urls[0] ?? + null + ); +} + +function pickSubtitleUrl(urls: string[], streamUrl: string): string | undefined { + const candidates = urls.filter((url) => url !== streamUrl); + const explicit = candidates.find((url) => { + const lower = url.toLowerCase(); + return lower.endsWith(".vtt") || lower.endsWith(".srt") || lower.endsWith(".ass") || + lower.endsWith(".ssa") || lower.includes("subtitle"); + }); + return explicit ?? candidates[0]; +} + function parseAniCliOutput(output: string): ResolvedStreamTarget { + const trimmedOutput = output.trim(); + if (trimmedOutput) { + const jsonBlock = extractFirstJsonObject(trimmedOutput); + if (jsonBlock) { + try { + const parsed = JSON.parse(jsonBlock); + const urls = [...new Set(extractUrls(parsed))]; + const streamUrl = pickStreamUrl(urls); + if (streamUrl) { + return { + streamUrl, + subtitleUrl: pickSubtitleUrl(urls, streamUrl), + }; + } + } catch { + // fall back to legacy text parsing + } + } + } + const lines = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); const selectedIndex = lines.findIndex((line) => line.startsWith("Selected link:"), @@ -1736,16 +1817,14 @@ function parseAniCliOutput(output: string): ResolvedStreamTarget { selectedIndex >= 0 ? lines.slice(selectedIndex + 1) : lines.slice(); - const targetCandidate = selectedBlock - .flatMap((line) => line.match(/https?:\/\/\S+/g) ?? []) - .find((value) => value.length > 0); + const urls = selectedBlock + .flatMap((line) => line.match(/https?:\/\/\S+/g) ?? []); + const targetCandidate = pickStreamUrl(urls); if (!targetCandidate) { - throw new Error("Could not parse ani-cli stream URL from output."); + throw new Error("Could not parse stream URL from output."); } - const subtitleCandidate = lines - .find((line) => line.startsWith("subtitle >") || line.includes("subtitle >")) - ?.match(/https?:\/\/\S+/)?.[0]; + const subtitleCandidate = pickSubtitleUrl(urls, targetCandidate); return { streamUrl: targetCandidate, @@ -1759,22 +1838,31 @@ async function resolveStreamTarget( scriptPath: string, ): Promise { const aniCliThemeConfig = buildAniCliRofiConfig(scriptPath, args.logLevel); + const isLobster = args.aniCliPath + ? path.basename(args.aniCliPath) === "lobster.sh" + : false; + const commandArgs = isLobster ? ["-j", query] : [query]; + 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 result = await runExternalCommand( + args.aniCliPath as string, + commandArgs, + { + captureStdout: true, + logLevel: args.logLevel, + commandLabel: isLobster ? "lobster" : "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}`, + `Stream resolver output URL is invalid: ${parsed.streamUrl}`, ); } log("info", args.logLevel, `Resolved stream target: ${parsed.streamUrl}`); @@ -1924,7 +2012,7 @@ function checkDependencies(args: Args): void { if (!commandExists("mpv")) missing.push("mpv"); if (args.streamMode) { - if (!args.aniCliPath) missing.push("ani-cli"); + if (!args.aniCliPath) missing.push("lobster or ani-cli"); } if ( @@ -3002,7 +3090,7 @@ async function main(): Promise { const streamSource = selectedTarget.target; if (args.streamMode) { - log("info", args.logLevel, `Resolving stream target via ani-cli for "${selectedTarget.target}"`); + log("info", args.logLevel, `Resolving stream target via streamer for "${selectedTarget.target}"`); resolvedStreamTarget = await resolveStreamTarget( selectedTarget.target, args, @@ -3016,7 +3104,7 @@ async function main(): Promise { log( "debug", args.logLevel, - `ani-cli provided subtitle URL: ${resolvedStreamTarget.subtitleUrl}`, + `${path.basename(args.aniCliPath || "streamer")} provided subtitle URL: ${resolvedStreamTarget.subtitleUrl}`, ); } }