mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
Use lobster as stream resolver fallback for -s query flow
This commit is contained in:
138
subminer
138
subminer
@@ -609,7 +609,7 @@ Options:
|
|||||||
-d, --directory DIR Directory to browse for videos (default: current directory)
|
-d, --directory DIR Directory to browse for videos (default: current directory)
|
||||||
-r, --recursive Search for videos recursively
|
-r, --recursive Search for videos recursively
|
||||||
-p, --profile PROFILE MPV profile to use (default: subminer)
|
-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
|
--start Explicitly start SubMiner overlay
|
||||||
--yt-subgen-mode MODE
|
--yt-subgen-mode MODE
|
||||||
YouTube subtitle generation mode: automatic, preprocess, off (default: automatic)
|
YouTube subtitle generation mode: automatic, preprocess, off (default: automatic)
|
||||||
@@ -633,6 +633,7 @@ Options:
|
|||||||
Environment:
|
Environment:
|
||||||
SUBMINER_APPIMAGE_PATH Path to SubMiner AppImage/binary (optional override)
|
SUBMINER_APPIMAGE_PATH Path to SubMiner AppImage/binary (optional override)
|
||||||
SUBMINER_ROFI_THEME Path to rofi theme file (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_YT_SUBGEN_MODE automatic, preprocess, off (optional default)
|
||||||
SUBMINER_WHISPER_BIN whisper.cpp binary path (optional fallback)
|
SUBMINER_WHISPER_BIN whisper.cpp binary path (optional fallback)
|
||||||
SUBMINER_WHISPER_MODEL whisper model path (optional fallback)
|
SUBMINER_WHISPER_MODEL whisper model path (optional fallback)
|
||||||
@@ -646,7 +647,7 @@ Examples:
|
|||||||
${scriptName} video.mkv # Play specific file
|
${scriptName} video.mkv # Play specific file
|
||||||
${scriptName} https://youtu.be/... # Play a YouTube URL
|
${scriptName} https://youtu.be/... # Play a YouTube URL
|
||||||
${scriptName} ytsearch:query # Play first YouTube search result
|
${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} --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} video.mkv # Play with subminer profile
|
||||||
${scriptName} -p gpu-hq video.mkv # Play with gpu-hq 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 {
|
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;
|
if (envPath && isExecutable(envPath)) return envPath;
|
||||||
|
|
||||||
const candidates: string[] = [];
|
const candidates: string[] = [];
|
||||||
candidates.push(path.resolve(path.dirname(realpathMaybe(scriptPath)), "ani-cli/ani-cli"));
|
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) {
|
for (const candidate of candidates) {
|
||||||
if (isExecutable(candidate)) return candidate;
|
if (isExecutable(candidate)) return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (commandExists("lobster")) return "lobster";
|
||||||
if (commandExists("ani-cli")) return "ani-cli";
|
if (commandExists("ani-cli")) return "ani-cli";
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -1711,11 +1715,11 @@ function buildAniCliRofiConfig(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
fs.rmSync(tempDir, { force: true, recursive: true });
|
fs.rmSync(tempDir, { force: true, recursive: true });
|
||||||
throw new Error(
|
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 {
|
return {
|
||||||
env: {
|
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<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 {
|
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 lines = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||||
const selectedIndex = lines.findIndex((line) =>
|
const selectedIndex = lines.findIndex((line) =>
|
||||||
line.startsWith("Selected link:"),
|
line.startsWith("Selected link:"),
|
||||||
@@ -1736,16 +1817,14 @@ function parseAniCliOutput(output: string): ResolvedStreamTarget {
|
|||||||
selectedIndex >= 0
|
selectedIndex >= 0
|
||||||
? lines.slice(selectedIndex + 1)
|
? lines.slice(selectedIndex + 1)
|
||||||
: lines.slice();
|
: lines.slice();
|
||||||
const targetCandidate = selectedBlock
|
const urls = selectedBlock
|
||||||
.flatMap((line) => line.match(/https?:\/\/\S+/g) ?? [])
|
.flatMap((line) => line.match(/https?:\/\/\S+/g) ?? []);
|
||||||
.find((value) => value.length > 0);
|
const targetCandidate = pickStreamUrl(urls);
|
||||||
if (!targetCandidate) {
|
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
|
const subtitleCandidate = pickSubtitleUrl(urls, targetCandidate);
|
||||||
.find((line) => line.startsWith("subtitle >") || line.includes("subtitle >"))
|
|
||||||
?.match(/https?:\/\/\S+/)?.[0];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
streamUrl: targetCandidate,
|
streamUrl: targetCandidate,
|
||||||
@@ -1759,22 +1838,31 @@ async function resolveStreamTarget(
|
|||||||
scriptPath: string,
|
scriptPath: string,
|
||||||
): Promise<ResolvedStreamTarget> {
|
): Promise<ResolvedStreamTarget> {
|
||||||
const aniCliThemeConfig = buildAniCliRofiConfig(scriptPath, args.logLevel);
|
const aniCliThemeConfig = buildAniCliRofiConfig(scriptPath, args.logLevel);
|
||||||
|
const isLobster = args.aniCliPath
|
||||||
|
? path.basename(args.aniCliPath) === "lobster.sh"
|
||||||
|
: false;
|
||||||
|
const commandArgs = isLobster ? ["-j", query] : [query];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runExternalCommand(args.aniCliPath as string, [query], {
|
const result = await runExternalCommand(
|
||||||
captureStdout: true,
|
args.aniCliPath as string,
|
||||||
logLevel: args.logLevel,
|
commandArgs,
|
||||||
commandLabel: "ani-cli",
|
{
|
||||||
streamOutput: false,
|
captureStdout: true,
|
||||||
env: {
|
logLevel: args.logLevel,
|
||||||
ANI_CLI_PLAYER: "debug",
|
commandLabel: isLobster ? "lobster" : "ani-cli",
|
||||||
...aniCliThemeConfig?.env,
|
streamOutput: false,
|
||||||
|
env: {
|
||||||
|
ANI_CLI_PLAYER: "debug",
|
||||||
|
...aniCliThemeConfig?.env,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
const parsed = parseAniCliOutput(result.stdout);
|
const parsed = parseAniCliOutput(result.stdout);
|
||||||
if (!parsed.streamUrl.startsWith("http://") && !parsed.streamUrl.startsWith("https://")) {
|
if (!parsed.streamUrl.startsWith("http://") && !parsed.streamUrl.startsWith("https://")) {
|
||||||
throw new Error(
|
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}`);
|
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 (!commandExists("mpv")) missing.push("mpv");
|
||||||
|
|
||||||
if (args.streamMode) {
|
if (args.streamMode) {
|
||||||
if (!args.aniCliPath) missing.push("ani-cli");
|
if (!args.aniCliPath) missing.push("lobster or ani-cli");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -3002,7 +3090,7 @@ async function main(): Promise<void> {
|
|||||||
const streamSource = selectedTarget.target;
|
const streamSource = selectedTarget.target;
|
||||||
|
|
||||||
if (args.streamMode) {
|
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(
|
resolvedStreamTarget = await resolveStreamTarget(
|
||||||
selectedTarget.target,
|
selectedTarget.target,
|
||||||
args,
|
args,
|
||||||
@@ -3016,7 +3104,7 @@ async function main(): Promise<void> {
|
|||||||
log(
|
log(
|
||||||
"debug",
|
"debug",
|
||||||
args.logLevel,
|
args.logLevel,
|
||||||
`ani-cli provided subtitle URL: ${resolvedStreamTarget.subtitleUrl}`,
|
`${path.basename(args.aniCliPath || "streamer")} provided subtitle URL: ${resolvedStreamTarget.subtitleUrl}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user