mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat(spotify): add go-librespot binary resolver + Linux support gate
Mirror youtube.ts findYtDlp/checkYtDlpAvailable (bin/ then PATH, cache-positive-only availability, reset test hook) and add isGoLibrespotSupported() Linux gate for the Stage 2 audio backend. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
978e6ee7f2
commit
32718f0118
2 files changed
+202
No files matched your search
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import {
|
||||||
|
isGoLibrespotSupported,
|
||||||
|
pickGoLibrespotPath,
|
||||||
|
findGoLibrespot,
|
||||||
|
checkGoLibrespotAvailable,
|
||||||
|
resetGoLibrespotBinaryCache,
|
||||||
|
__setGoLibrespotVersionProbe,
|
||||||
|
} from "./binary.js";
|
||||||
|
|
||||||
|
const origPlatform = process.platform;
|
||||||
|
function setPlatform(p: NodeJS.Platform): void {
|
||||||
|
Object.defineProperty(process, "platform", { value: p, configurable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setPlatform(origPlatform);
|
||||||
|
__setGoLibrespotVersionProbe(null);
|
||||||
|
resetGoLibrespotBinaryCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isGoLibrespotSupported", () => {
|
||||||
|
it("is true only on linux", () => {
|
||||||
|
setPlatform("linux");
|
||||||
|
expect(isGoLibrespotSupported()).toBe(true);
|
||||||
|
setPlatform("win32");
|
||||||
|
expect(isGoLibrespotSupported()).toBe(false);
|
||||||
|
setPlatform("darwin");
|
||||||
|
expect(isGoLibrespotSupported()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pickGoLibrespotPath (bin/ then PATH ordering)", () => {
|
||||||
|
const binPath = join("some", "root", "bin", "go-librespot");
|
||||||
|
|
||||||
|
it("prefers the bin/ path when the file exists", () => {
|
||||||
|
expect(
|
||||||
|
pickGoLibrespotPath([binPath, "go-librespot"], (p) => p === binPath),
|
||||||
|
).toBe(binPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls through to the bare PATH name when the bin/ file is missing", () => {
|
||||||
|
expect(pickGoLibrespotPath([binPath, "go-librespot"], () => false)).toBe(
|
||||||
|
"go-librespot",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns bare command names without touching the filesystem", () => {
|
||||||
|
const exists = vi.fn(() => false);
|
||||||
|
expect(pickGoLibrespotPath(["go-librespot"], exists)).toBe("go-librespot");
|
||||||
|
expect(exists).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findGoLibrespot", () => {
|
||||||
|
it("returns the bare command name when bin/go-librespot is absent", () => {
|
||||||
|
// No go-librespot binary is committed under bin/, so resolution must
|
||||||
|
// fall back to the bare PATH name (execFile resolves it at run time).
|
||||||
|
expect(findGoLibrespot()).toBe("go-librespot");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkGoLibrespotAvailable", () => {
|
||||||
|
it("returns false immediately on unsupported platforms without probing", async () => {
|
||||||
|
setPlatform("darwin");
|
||||||
|
const probe = vi.fn(async () => {});
|
||||||
|
__setGoLibrespotVersionProbe(probe);
|
||||||
|
expect(await checkGoLibrespotAvailable()).toBe(false);
|
||||||
|
expect(probe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when the binary responds to --version on linux", async () => {
|
||||||
|
setPlatform("linux");
|
||||||
|
__setGoLibrespotVersionProbe(async () => {});
|
||||||
|
expect(await checkGoLibrespotAvailable()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caches only positive results and probes once", async () => {
|
||||||
|
setPlatform("linux");
|
||||||
|
const probe = vi.fn(async () => {});
|
||||||
|
__setGoLibrespotVersionProbe(probe);
|
||||||
|
expect(await checkGoLibrespotAvailable()).toBe(true);
|
||||||
|
expect(await checkGoLibrespotAvailable()).toBe(true);
|
||||||
|
expect(probe).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not cache a failed probe (retries on the next call)", async () => {
|
||||||
|
setPlatform("linux");
|
||||||
|
__setGoLibrespotVersionProbe(async () => {
|
||||||
|
throw new Error("ENOENT");
|
||||||
|
});
|
||||||
|
expect(await checkGoLibrespotAvailable()).toBe(false);
|
||||||
|
// A later successful probe must now succeed — negatives are not cached.
|
||||||
|
__setGoLibrespotVersionProbe(async () => {});
|
||||||
|
expect(await checkGoLibrespotAvailable()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resetGoLibrespotBinaryCache clears a cached positive", async () => {
|
||||||
|
setPlatform("linux");
|
||||||
|
__setGoLibrespotVersionProbe(async () => {});
|
||||||
|
expect(await checkGoLibrespotAvailable()).toBe(true);
|
||||||
|
resetGoLibrespotBinaryCache();
|
||||||
|
__setGoLibrespotVersionProbe(async () => {
|
||||||
|
throw new Error("gone");
|
||||||
|
});
|
||||||
|
expect(await checkGoLibrespotAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True only on Linux. go-librespot ships Linux-only release binaries and the
|
||||||
|
* sidecar relies on a POSIX FIFO (mkfifo), so the Spotify audio backend is
|
||||||
|
* gated to Linux/Docker. Everywhere else the caller falls back to the Stage-1
|
||||||
|
* sentinel-skip message.
|
||||||
|
*/
|
||||||
|
export function isGoLibrespotSupported(): boolean {
|
||||||
|
return process.platform === "linux";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure resolver core behind findGoLibrespot(). Returns the first candidate
|
||||||
|
* that is either a bare command name (left for execFile to resolve via PATH)
|
||||||
|
* or an existing bin/ file. Exported so tests can inject candidates + a fake
|
||||||
|
* existence predicate and need no real binary on disk.
|
||||||
|
*/
|
||||||
|
export function pickGoLibrespotPath(
|
||||||
|
candidates: string[],
|
||||||
|
exists: (p: string) => boolean,
|
||||||
|
): string {
|
||||||
|
for (const c of candidates) {
|
||||||
|
// bin/ paths only count when the file is actually present; bare names are
|
||||||
|
// returned unconditionally and resolved later via PATH.
|
||||||
|
const isBinPath = c.includes(join("bin", "go-librespot"));
|
||||||
|
if (!isBinPath || exists(c)) return c;
|
||||||
|
}
|
||||||
|
return "go-librespot";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the go-librespot binary path: project bin/ dir first, then PATH. */
|
||||||
|
export function findGoLibrespot(): string {
|
||||||
|
// src/music/spotify -> ../../../bin (one level deeper than youtube.ts).
|
||||||
|
const binPath = join(__dirname, "..", "..", "..", "bin", "go-librespot");
|
||||||
|
return pickGoLibrespotPath([binPath, "go-librespot"], existsSync);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Injectable `--version` probe. Defaults to the real execFile call; tests
|
||||||
|
// override it so checkGoLibrespotAvailable() needs no real binary. Keeps the
|
||||||
|
// public checkGoLibrespotAvailable() signature param-free per the contract.
|
||||||
|
type VersionProbe = (bin: string) => Promise<void>;
|
||||||
|
const realProbe: VersionProbe = async (bin) => {
|
||||||
|
await execFileAsync(bin, ["--version"], { timeout: 5_000, maxBuffer: 1024 });
|
||||||
|
};
|
||||||
|
let versionProbe: VersionProbe = realProbe;
|
||||||
|
|
||||||
|
/** Test hook: override the `--version` probe, or restore the default with null. */
|
||||||
|
export function __setGoLibrespotVersionProbe(
|
||||||
|
probe: VersionProbe | null,
|
||||||
|
): void {
|
||||||
|
versionProbe = probe ?? realProbe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Availability check for go-librespot. Returns false immediately on non-Linux
|
||||||
|
* platforms (unsupported). Otherwise runs `go-librespot --version` (5s timeout)
|
||||||
|
* and caches ONLY the positive result — a missing binary is retried on the
|
||||||
|
* next call so the operator can install it without restarting the server.
|
||||||
|
*/
|
||||||
|
let cachedAvailable = false;
|
||||||
|
let pendingCheck: Promise<boolean> | null = null;
|
||||||
|
export async function checkGoLibrespotAvailable(): Promise<boolean> {
|
||||||
|
if (!isGoLibrespotSupported()) return false;
|
||||||
|
if (cachedAvailable) return true;
|
||||||
|
if (pendingCheck) return pendingCheck;
|
||||||
|
pendingCheck = (async () => {
|
||||||
|
try {
|
||||||
|
await versionProbe(findGoLibrespot());
|
||||||
|
cachedAvailable = true;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
pendingCheck = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return pendingCheck;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force re-detection on the next call (for tests). */
|
||||||
|
export function resetGoLibrespotBinaryCache(): void {
|
||||||
|
cachedAvailable = false;
|
||||||
|
pendingCheck = null;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user