From 8c84090b63b8ea60e5abcbedece1aff62110ac27 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 23:04:16 +0800 Subject: [PATCH] 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) --- src/music/spotify/binary.test.ts | 127 +++++++++++++++++++++++++++++++ src/music/spotify/binary.ts | 99 ++++++++++++++++++++++++ 2 files changed, 226 insertions(+) diff --git a/src/music/spotify/binary.test.ts b/src/music/spotify/binary.test.ts index 47a8231..a19f51f 100644 --- a/src/music/spotify/binary.test.ts +++ b/src/music/spotify/binary.test.ts @@ -7,6 +7,12 @@ import { checkGoLibrespotAvailable, resetGoLibrespotBinaryCache, __setGoLibrespotVersionProbe, + isRustLibrespotSupported, + pickLibrespotPath, + findLibrespot, + checkLibrespotAvailable, + resetLibrespotBinaryCache, + __setLibrespotVersionProbe, } from "./binary.js"; const origPlatform = process.platform; @@ -18,6 +24,8 @@ afterEach(() => { setPlatform(origPlatform); __setGoLibrespotVersionProbe(null); resetGoLibrespotBinaryCache(); + __setLibrespotVersionProbe(null); + resetLibrespotBinaryCache(); }); describe("isGoLibrespotSupported", () => { @@ -107,3 +115,122 @@ describe("checkGoLibrespotAvailable", () => { 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); + }); +}); diff --git a/src/music/spotify/binary.ts b/src/music/spotify/binary.ts index c20e05b..c33c409 100644 --- a/src/music/spotify/binary.ts +++ b/src/music/spotify/binary.ts @@ -91,3 +91,102 @@ export function resetGoLibrespotBinaryCache(): void { cachedAvailable = false; 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; +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 | null = null; +export async function checkLibrespotAvailable(): Promise { + 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; +}