Revert "Use lobster as stream resolver fallback for -s query flow"

This reverts commit 022f4e972c.
This commit is contained in:
2026-02-13 22:58:37 -08:00
parent c16bc26a58
commit 3dfa713b29

138
subminer
View File

@@ -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/lobster using the given query
-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)
@@ -633,7 +633,6 @@ 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)
@@ -647,7 +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/lobster
${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
@@ -1680,19 +1679,16 @@ function findAppBinary(selfPath: string): string | null {
}
function resolveAniCliPath(scriptPath: string): string | null {
const envPath =
process.env.SUBMINER_STREAM_TOOL_PATH || process.env.SUBMINER_ANI_CLI_PATH;
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"));
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;
@@ -1715,11 +1711,11 @@ function buildAniCliRofiConfig(
} catch (error) {
fs.rmSync(tempDir, { force: true, recursive: true });
throw new Error(
`Failed to prepare temporary stream resolver rofi theme config: ${(error as Error).message}`,
`Failed to prepare temporary ani-cli rofi theme config: ${(error as Error).message}`,
);
}
log("debug", logLevel, `Using Subminer rofi theme for stream resolver via ${configPath}`);
log("debug", logLevel, `Using Subminer rofi theme for ani-cli via ${configPath}`);
return {
env: {
@@ -1731,84 +1727,7 @@ 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<string, unknown>)) {
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:"),
@@ -1817,14 +1736,16 @@ function parseAniCliOutput(output: string): ResolvedStreamTarget {
selectedIndex >= 0
? lines.slice(selectedIndex + 1)
: lines.slice();
const urls = selectedBlock
.flatMap((line) => line.match(/https?:\/\/\S+/g) ?? []);
const targetCandidate = pickStreamUrl(urls);
const targetCandidate = selectedBlock
.flatMap((line) => line.match(/https?:\/\/\S+/g) ?? [])
.find((value) => value.length > 0);
if (!targetCandidate) {
throw new Error("Could not parse stream URL from output.");
throw new Error("Could not parse ani-cli stream URL from output.");
}
const subtitleCandidate = pickSubtitleUrl(urls, targetCandidate);
const subtitleCandidate = lines
.find((line) => line.startsWith("subtitle >") || line.includes("subtitle >"))
?.match(/https?:\/\/\S+/)?.[0];
return {
streamUrl: targetCandidate,
@@ -1838,31 +1759,22 @@ async function resolveStreamTarget(
scriptPath: string,
): Promise<ResolvedStreamTarget> {
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,
commandArgs,
{
captureStdout: true,
logLevel: args.logLevel,
commandLabel: isLobster ? "lobster" : "ani-cli",
streamOutput: false,
env: {
ANI_CLI_PLAYER: "debug",
...aniCliThemeConfig?.env,
},
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(
`Stream resolver output URL is invalid: ${parsed.streamUrl}`,
`Ani-cli output stream URL is invalid: ${parsed.streamUrl}`,
);
}
log("info", args.logLevel, `Resolved stream target: ${parsed.streamUrl}`);
@@ -2012,7 +1924,7 @@ function checkDependencies(args: Args): void {
if (!commandExists("mpv")) missing.push("mpv");
if (args.streamMode) {
if (!args.aniCliPath) missing.push("lobster or ani-cli");
if (!args.aniCliPath) missing.push("ani-cli");
}
if (
@@ -3090,7 +3002,7 @@ async function main(): Promise<void> {
const streamSource = selectedTarget.target;
if (args.streamMode) {
log("info", args.logLevel, `Resolving stream target via streamer for "${selectedTarget.target}"`);
log("info", args.logLevel, `Resolving stream target via ani-cli for "${selectedTarget.target}"`);
resolvedStreamTarget = await resolveStreamTarget(
selectedTarget.target,
args,
@@ -3104,7 +3016,7 @@ async function main(): Promise<void> {
log(
"debug",
args.logLevel,
`${path.basename(args.aniCliPath || "streamer")} provided subtitle URL: ${resolvedStreamTarget.subtitleUrl}`,
`ani-cli provided subtitle URL: ${resolvedStreamTarget.subtitleUrl}`,
);
}
}