mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat(spotify): add Rust librespot binary resolver (Stage 3 Task 1)
Append isRustLibrespotSupported/pickLibrespotPath/findLibrespot/ checkLibrespotAvailable/resetLibrespotBinaryCache to binary.ts, mirroring the go-librespot resolver. Supported on all platforms (pipe->stdout), resolves librespot.exe on win32, caches only positive --version probes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3277736f5d
commit
8c84090b63
2 files changed
+226
No files matched your search
@@ -7,6 +7,12 @@ import {
|
|||||||
checkGoLibrespotAvailable,
|
checkGoLibrespotAvailable,
|
||||||
resetGoLibrespotBinaryCache,
|
resetGoLibrespotBinaryCache,
|
||||||
__setGoLibrespotVersionProbe,
|
__setGoLibrespotVersionProbe,
|
||||||
|
isRustLibrespotSupported,
|
||||||
|
pickLibrespotPath,
|
||||||
|
findLibrespot,
|
||||||
|
checkLibrespotAvailable,
|
||||||
|
resetLibrespotBinaryCache,
|
||||||
|
__setLibrespotVersionProbe,
|
||||||
} from "./binary.js";
|
} from "./binary.js";
|
||||||
|
|
||||||
const origPlatform = process.platform;
|
const origPlatform = process.platform;
|
||||||
@@ -18,6 +24,8 @@ afterEach(() => {
|
|||||||
setPlatform(origPlatform);
|
setPlatform(origPlatform);
|
||||||
__setGoLibrespotVersionProbe(null);
|
__setGoLibrespotVersionProbe(null);
|
||||||
resetGoLibrespotBinaryCache();
|
resetGoLibrespotBinaryCache();
|
||||||
|
__setLibrespotVersionProbe(null);
|
||||||
|
resetLibrespotBinaryCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isGoLibrespotSupported", () => {
|
describe("isGoLibrespotSupported", () => {
|
||||||
@@ -107,3 +115,122 @@ describe("checkGoLibrespotAvailable", () => {
|
|||||||
expect(await checkGoLibrespotAvailable()).toBe(false);
|
expect(await checkGoLibrespotAvailable()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isRustLibrespotSupported", () => {
|
||||||
|
it("is true on every platform (pipe->stdout works everywhere)", () => {
|
||||||
|
setPlatform("linux");
|
||||||
|
expect(isRustLibrespotSupported()).toBe(true);
|
||||||
|
setPlatform("win32");
|
||||||
|
expect(isRustLibrespotSupported()).toBe(true);
|
||||||
|
setPlatform("darwin");
|
||||||
|
expect(isRustLibrespotSupported()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pickLibrespotPath (bin/ then PATH ordering, win32 exe)", () => {
|
||||||
|
it("prefers the bin/ path when the file exists", () => {
|
||||||
|
const binPath = join("some", "root", "bin", "librespot");
|
||||||
|
expect(
|
||||||
|
pickLibrespotPath([binPath, "librespot"], (p) => p === binPath),
|
||||||
|
).toBe(binPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the bin/librespot.exe path on win32 when it exists", () => {
|
||||||
|
setPlatform("win32");
|
||||||
|
const binExe = join("some", "root", "bin", "librespot.exe");
|
||||||
|
expect(
|
||||||
|
pickLibrespotPath([binExe, "librespot.exe"], (p) => p === binExe),
|
||||||
|
).toBe(binExe);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls through to the bare PATH name (librespot) on posix when bin/ is missing", () => {
|
||||||
|
setPlatform("linux");
|
||||||
|
const binPath = join("some", "root", "bin", "librespot");
|
||||||
|
expect(pickLibrespotPath([binPath, "librespot"], () => false)).toBe(
|
||||||
|
"librespot",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls through to librespot.exe on win32 when bin/ is missing", () => {
|
||||||
|
setPlatform("win32");
|
||||||
|
const binExe = join("some", "root", "bin", "librespot.exe");
|
||||||
|
expect(pickLibrespotPath([binExe], () => false)).toBe("librespot.exe");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns bare command names without touching the filesystem", () => {
|
||||||
|
const exists = vi.fn(() => false);
|
||||||
|
expect(pickLibrespotPath(["librespot"], exists)).toBe("librespot");
|
||||||
|
expect(exists).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findLibrespot", () => {
|
||||||
|
it("returns the bare command name when bin/librespot is absent", () => {
|
||||||
|
// No librespot binary is committed under bin/, so resolution must fall
|
||||||
|
// back to the bare PATH name (execFile resolves it at run time).
|
||||||
|
setPlatform("linux");
|
||||||
|
expect(findLibrespot()).toBe("librespot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns librespot.exe on win32 when bin/librespot.exe is absent", () => {
|
||||||
|
setPlatform("win32");
|
||||||
|
expect(findLibrespot()).toBe("librespot.exe");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkLibrespotAvailable", () => {
|
||||||
|
it("returns true when the binary responds to --version (any platform)", async () => {
|
||||||
|
setPlatform("win32");
|
||||||
|
__setLibrespotVersionProbe(async () => {});
|
||||||
|
expect(await checkLibrespotAvailable()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true on darwin too (no platform gate)", async () => {
|
||||||
|
setPlatform("darwin");
|
||||||
|
__setLibrespotVersionProbe(async () => {});
|
||||||
|
expect(await checkLibrespotAvailable()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caches only positive results and probes once", async () => {
|
||||||
|
setPlatform("linux");
|
||||||
|
const probe = vi.fn(async () => {});
|
||||||
|
__setLibrespotVersionProbe(probe);
|
||||||
|
expect(await checkLibrespotAvailable()).toBe(true);
|
||||||
|
expect(await checkLibrespotAvailable()).toBe(true);
|
||||||
|
expect(probe).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not cache a failed probe (retries on the next call)", async () => {
|
||||||
|
setPlatform("win32");
|
||||||
|
__setLibrespotVersionProbe(async () => {
|
||||||
|
throw new Error("ENOENT");
|
||||||
|
});
|
||||||
|
expect(await checkLibrespotAvailable()).toBe(false);
|
||||||
|
__setLibrespotVersionProbe(async () => {});
|
||||||
|
expect(await checkLibrespotAvailable()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resetLibrespotBinaryCache clears a cached positive", async () => {
|
||||||
|
setPlatform("linux");
|
||||||
|
__setLibrespotVersionProbe(async () => {});
|
||||||
|
expect(await checkLibrespotAvailable()).toBe(true);
|
||||||
|
resetLibrespotBinaryCache();
|
||||||
|
__setLibrespotVersionProbe(async () => {
|
||||||
|
throw new Error("gone");
|
||||||
|
});
|
||||||
|
expect(await checkLibrespotAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("de-dupes concurrent in-flight probes", async () => {
|
||||||
|
setPlatform("linux");
|
||||||
|
const probe = vi.fn(async () => {});
|
||||||
|
__setLibrespotVersionProbe(probe);
|
||||||
|
const [a, b] = await Promise.all([
|
||||||
|
checkLibrespotAvailable(),
|
||||||
|
checkLibrespotAvailable(),
|
||||||
|
]);
|
||||||
|
expect(a).toBe(true);
|
||||||
|
expect(b).toBe(true);
|
||||||
|
expect(probe).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -91,3 +91,102 @@ export function resetGoLibrespotBinaryCache(): void {
|
|||||||
cachedAvailable = false;
|
cachedAvailable = false;
|
||||||
pendingCheck = null;
|
pendingCheck = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rust librespot (librespot-org) resolver — mirrors the go-librespot fns
|
||||||
|
// above. Unlike go-librespot, Rust librespot's `--backend pipe` writes PCM to
|
||||||
|
// *stdout* on every platform (no FIFO, no audio device), so it is supported
|
||||||
|
// on Windows/macOS/Linux alike and the binary is named librespot.exe on win32.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True on ALL platforms. The Rust librespot pipe backend writes raw bytes to
|
||||||
|
* process stdout, which Node's spawned child.stdout receives unmodified on
|
||||||
|
* Windows too — so there is no platform gate here (contrast
|
||||||
|
* isGoLibrespotSupported, which is Linux-only).
|
||||||
|
*/
|
||||||
|
export function isRustLibrespotSupported(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure resolver core behind findLibrespot(). 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. Mirrors
|
||||||
|
* pickGoLibrespotPath but keys off the win32 exe name.
|
||||||
|
*/
|
||||||
|
export function pickLibrespotPath(
|
||||||
|
candidates: string[],
|
||||||
|
exists: (p: string) => boolean,
|
||||||
|
): string {
|
||||||
|
const exe = process.platform === "win32" ? "librespot.exe" : "librespot";
|
||||||
|
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", "librespot"));
|
||||||
|
if (!isBinPath || exists(c)) return c;
|
||||||
|
}
|
||||||
|
return exe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the Rust librespot binary path: project bin/ dir first, then PATH.
|
||||||
|
* On win32 both the bin/librespot.exe candidate and the bare "librespot.exe"
|
||||||
|
* fallback are used so a PATH-installed librespot.exe (scoop/choco) resolves.
|
||||||
|
*/
|
||||||
|
export function findLibrespot(): string {
|
||||||
|
const exe = process.platform === "win32" ? "librespot.exe" : "librespot";
|
||||||
|
// src/music/spotify -> ../../../bin (same depth as findGoLibrespot).
|
||||||
|
const binExe = join(__dirname, "..", "..", "..", "bin", exe);
|
||||||
|
const binBare = join(__dirname, "..", "..", "..", "bin", "librespot");
|
||||||
|
return pickLibrespotPath([binExe, binBare, exe], existsSync);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Injectable `--version` probe. Defaults to the real execFile call; tests
|
||||||
|
// override it so checkLibrespotAvailable() needs no real binary. Keeps the
|
||||||
|
// public checkLibrespotAvailable() signature param-free per the contract.
|
||||||
|
type LibrespotVersionProbe = (bin: string) => Promise<void>;
|
||||||
|
const realLibrespotProbe: LibrespotVersionProbe = async (bin) => {
|
||||||
|
await execFileAsync(bin, ["--version"], { timeout: 5_000, maxBuffer: 1024 });
|
||||||
|
};
|
||||||
|
let librespotVersionProbe: LibrespotVersionProbe = realLibrespotProbe;
|
||||||
|
|
||||||
|
/** Test hook: override the `--version` probe, or restore the default with null. */
|
||||||
|
export function __setLibrespotVersionProbe(
|
||||||
|
probe: LibrespotVersionProbe | null,
|
||||||
|
): void {
|
||||||
|
librespotVersionProbe = probe ?? realLibrespotProbe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Availability check for Rust librespot. No platform gate (supported
|
||||||
|
* everywhere). Runs `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 (cargo/scoop/choco) without restarting the server.
|
||||||
|
*/
|
||||||
|
let rustCachedAvailable = false;
|
||||||
|
let rustPendingCheck: Promise<boolean> | null = null;
|
||||||
|
export async function checkLibrespotAvailable(): Promise<boolean> {
|
||||||
|
if (!isRustLibrespotSupported()) return false;
|
||||||
|
if (rustCachedAvailable) return true;
|
||||||
|
if (rustPendingCheck) return rustPendingCheck;
|
||||||
|
rustPendingCheck = (async () => {
|
||||||
|
try {
|
||||||
|
await librespotVersionProbe(findLibrespot());
|
||||||
|
rustCachedAvailable = true;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
rustPendingCheck = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return rustPendingCheck;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force re-detection on the next call (for tests). */
|
||||||
|
export function resetLibrespotBinaryCache(): void {
|
||||||
|
rustCachedAvailable = false;
|
||||||
|
rustPendingCheck = null;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user