From 6a72833c2a54b48d3760e1422b327dba8804a68f Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Fri, 3 Jul 2026 00:45:55 +0800 Subject: [PATCH] feat(spotify): report resolved backend + binaryAvailable on /status; share backend resolver [S4.2] Co-Authored-By: Claude Opus 4.8 (1M context) --- src/music/spotify/backend-select.test.ts | 12 ++++++++++ src/music/spotify/backend-select.ts | 25 +++++++++++++++++++++ src/music/spotify/controller.ts | 24 +++++++++----------- src/web/api/spotify.test.ts | 4 ++-- src/web/api/spotify.ts | 7 +++++- src/web/server.ts | 28 ++++++++++++++++++++---- 6 files changed, 79 insertions(+), 21 deletions(-) create mode 100644 src/music/spotify/backend-select.test.ts create mode 100644 src/music/spotify/backend-select.ts diff --git a/src/music/spotify/backend-select.test.ts b/src/music/spotify/backend-select.test.ts new file mode 100644 index 0000000..6a1fa22 --- /dev/null +++ b/src/music/spotify/backend-select.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from "vitest"; +import { resolveSpotifyBackendKind as pick } from "./backend-select.js"; +describe("resolveSpotifyBackendKind", () => { + it("auto: go present -> go-librespot", () => expect(pick("auto", true, true)).toBe("go-librespot")); + it("auto: go absent, rust present -> librespot", () => expect(pick("auto", false, true)).toBe("librespot")); + it("auto: neither -> null", () => expect(pick("auto", false, false)).toBeNull()); + it("go-librespot: present -> go-librespot", () => expect(pick("go-librespot", true, true)).toBe("go-librespot")); + it("go-librespot: absent -> null even if rust present", () => expect(pick("go-librespot", false, true)).toBeNull()); + it("librespot: present -> librespot", () => expect(pick("librespot", true, true)).toBe("librespot")); + it("librespot: absent -> null even if go present", () => expect(pick("librespot", true, false)).toBeNull()); + it("auto default fallthrough matches auto", () => expect(pick("auto", true, false)).toBe("go-librespot")); +}); diff --git a/src/music/spotify/backend-select.ts b/src/music/spotify/backend-select.ts new file mode 100644 index 0000000..7a58622 --- /dev/null +++ b/src/music/spotify/backend-select.ts @@ -0,0 +1,25 @@ +/** Which concrete backend runs for a given config + host binary availability. */ +export type SpotifyBackendKind = "go-librespot" | "librespot"; + +/** + * Pure backend selection shared by SpotifyController.chooseBackend() (per-bot) + * and the web /status endpoint (process-wide). Booleans in, no IO — the caller + * supplies platform+binary presence. + */ +export function resolveSpotifyBackendKind( + backend: "auto" | "go-librespot" | "librespot", + goPresent: boolean, + rustPresent: boolean, +): SpotifyBackendKind | null { + switch (backend) { + case "go-librespot": + return goPresent ? "go-librespot" : null; + case "librespot": + return rustPresent ? "librespot" : null; + case "auto": + default: + if (goPresent) return "go-librespot"; + if (rustPresent) return "librespot"; + return null; + } +} diff --git a/src/music/spotify/controller.ts b/src/music/spotify/controller.ts index 5b2d035..496c0e9 100644 --- a/src/music/spotify/controller.ts +++ b/src/music/spotify/controller.ts @@ -29,9 +29,11 @@ import { type OAuthTokenStore, } from "./spotify-oauth.js"; import { SpotifyConnectApi } from "./connect-api.js"; - -/** Which concrete backend the controller will run for this host + config. */ -export type SpotifyBackendKind = "go-librespot" | "librespot"; +import { + resolveSpotifyBackendKind, + type SpotifyBackendKind, +} from "./backend-select.js"; +export type { SpotifyBackendKind }; // keep the name exported for existing importers /** * Minimal file-backed OAuth token store used when the caller does not inject a @@ -156,17 +158,11 @@ export class SpotifyController extends EventEmitter { * librespot present, else null. */ chooseBackend(): SpotifyBackendKind | null { - switch (this.config.backend) { - case "go-librespot": - return this.goPresent() ? "go-librespot" : null; - case "librespot": - return this.rustPresent() ? "librespot" : null; - case "auto": - default: - if (this.goPresent()) return "go-librespot"; - if (this.rustPresent()) return "librespot"; - return null; - } + return resolveSpotifyBackendKind( + this.config.backend, + this.goPresent(), + this.rustPresent(), + ); } /** enabled in config AND a backend is selectable (platform + binary present). */ diff --git a/src/web/api/spotify.test.ts b/src/web/api/spotify.test.ts index 020467e..252bf8b 100644 --- a/src/web/api/spotify.test.ts +++ b/src/web/api/spotify.test.ts @@ -18,7 +18,7 @@ function makeApp(oauth: SpotifyOAuthLike, role: Role = "admin", caps: string[] = createSpotifyRouter({ oauth, logger: pino({ level: "silent" }), - getBackendInfo: () => ({ backend: "librespot", deviceName: "TS-Bot" }), + getBackendInfo: () => ({ backend: "librespot", deviceName: "TS-Bot", binaryAvailable: true }), webUiRedirect: "/", }), ); @@ -84,7 +84,7 @@ describe("spotify OAuth router", () => { const app = makeApp(fakeOauth({ isAuthorized: () => true })); const res = await request(app).get("/api/spotify/status"); expect(res.status).toBe(200); - expect(res.body).toEqual({ authorized: true, backend: "librespot", deviceName: "TS-Bot" }); + expect(res.body).toEqual({ authorized: true, backend: "librespot", deviceName: "TS-Bot", binaryAvailable: true }); }); it("GET /status is 403 for a guest", async () => { diff --git a/src/web/api/spotify.ts b/src/web/api/spotify.ts index 2e4565f..d8c055f 100644 --- a/src/web/api/spotify.ts +++ b/src/web/api/spotify.ts @@ -15,7 +15,11 @@ export interface SpotifyRouterOptions { oauth: SpotifyOAuthLike; logger: Logger; /** Process-wide backend info for /status (single Premium account, Stage 3). */ - getBackendInfo: () => { backend: string; deviceName: string }; + getBackendInfo: () => { + backend: string; + deviceName: string; + binaryAvailable: boolean; + }; /** Web UI page to bounce the browser back to after the OAuth callback. */ webUiRedirect?: string; } @@ -67,6 +71,7 @@ export function createSpotifyRouter(opts: SpotifyRouterOptions): Router { authorized: oauth.isAuthorized(), backend: info.backend, deviceName: info.deviceName, + binaryAvailable: info.binaryAvailable, }); }); diff --git a/src/web/server.ts b/src/web/server.ts index fd47f71..720bbc1 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -21,6 +21,14 @@ import { createAuditRouter } from "./api/audit.js"; import { createFavoritesRouter } from "./api/favorites.js"; import { createSpotifyRouter } from "./api/spotify.js"; import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; +import { resolveSpotifyBackendKind } from "../music/spotify/backend-select.js"; +import { + isGoLibrespotSupported, + findGoLibrespot, + isRustLibrespotSupported, + findLibrespot, +} from "../music/spotify/binary.js"; +import { existsSync } from "node:fs"; import { setupWebSocket } from "./websocket.js"; import { createUserStore } from "../data/users.js"; import { createSessionStore } from "../data/sessions.js"; @@ -147,10 +155,22 @@ export function createWebServer(options: WebServerOptions): WebServer { createSpotifyRouter({ oauth: options.spotifyOAuth, logger, - getBackendInfo: () => ({ - backend: options.config.spotify.backend, - deviceName: options.config.spotify.deviceName, - }), + getBackendInfo: () => { + const goPresent = + isGoLibrespotSupported() && existsSync(findGoLibrespot()); + const rustPresent = + isRustLibrespotSupported() && existsSync(findLibrespot()); + const resolved = resolveSpotifyBackendKind( + options.config.spotify.backend, + goPresent, + rustPresent, + ); + return { + backend: resolved ?? "none", + deviceName: options.config.spotify.deviceName, + binaryAvailable: resolved !== null, + }; + }, webUiRedirect: "/", }), );