diff --git a/src/music/spotify/binary.test.ts b/src/music/spotify/binary.test.ts new file mode 100644 index 0000000..47a8231 --- /dev/null +++ b/src/music/spotify/binary.test.ts @@ -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); + }); +}); diff --git a/src/music/spotify/binary.ts b/src/music/spotify/binary.ts new file mode 100644 index 0000000..c20e05b --- /dev/null +++ b/src/music/spotify/binary.ts @@ -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; +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 | null = null; +export async function checkGoLibrespotAvailable(): Promise { + 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; +}