feat(spotify): add go-librespot REST client + WS event client (Stage 2)

GoLibrespotRestClient wraps axios (injectable via deps.http) for
/player/play|pause|resume|stop|seek, GET /status, GET / ping; ping/getStatus
swallow errors to false/null, mutating ops reject. GoLibrespotEventClient
(EventEmitter) parses {type,data} /events frames and re-emits type with data,
reconnects on close with capped backoff, stop() tears down. TDD with a mock
AxiosInstance and a fake WebSocket (no real binary/network).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.8 committed 2026-07-02 20:47:44 +08:00
1 parent 470a62129a
commit f2b14b5f00
2 files changed
+405

No files matched your search

+218
View File
@@ -0,0 +1,218 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { EventEmitter } from "node:events";
import type { AxiosInstance } from "axios";
import { GoLibrespotRestClient, GoLibrespotEventClient } from "./go-librespot-api.js";
/** Minimal axios stub: only get/post are exercised by the client. */
function makeHttp(overrides?: Partial<Record<"get" | "post", any>>) {
return {
get: vi.fn().mockResolvedValue({ status: 200, data: {} }),
post: vi.fn().mockResolvedValue({ status: 200, data: {} }),
...overrides,
} as unknown as AxiosInstance;
}
describe("GoLibrespotRestClient", () => {
it("ping() returns true on GET / -> 200", async () => {
const http = makeHttp();
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
await expect(client.ping()).resolves.toBe(true);
expect(http.get).toHaveBeenCalledWith("/");
});
it("ping() returns false when GET / rejects (daemon not up)", async () => {
const http = makeHttp({ get: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")) });
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
await expect(client.ping()).resolves.toBe(false);
});
it("playTrack() POSTs /player/play with the uri body", async () => {
const http = makeHttp();
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
await client.playTrack("spotify:track:abc123");
expect(http.post).toHaveBeenCalledWith("/player/play", { uri: "spotify:track:abc123" });
});
it("pause/resume/stop POST their bodyless endpoints", async () => {
const http = makeHttp();
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
await client.pause();
await client.resume();
await client.stop();
expect(http.post).toHaveBeenNthCalledWith(1, "/player/pause");
expect(http.post).toHaveBeenNthCalledWith(2, "/player/resume");
expect(http.post).toHaveBeenNthCalledWith(3, "/player/stop");
});
it("seek() POSTs /player/seek with position(ms) and relative:false", async () => {
const http = makeHttp();
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
await client.seek(42000);
expect(http.post).toHaveBeenCalledWith("/player/seek", { position: 42000, relative: false });
});
it("playTrack() rejects when the POST fails (surfaced to caller)", async () => {
const http = makeHttp({ post: vi.fn().mockRejectedValue(new Error("boom")) });
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
await expect(client.playTrack("spotify:track:x")).rejects.toThrow("boom");
});
it("getStatus() normalizes the /status shape (ms position/duration)", async () => {
const http = makeHttp({
get: vi.fn().mockResolvedValue({
status: 200,
data: {
stopped: false,
paused: false,
buffering: false,
track: {
uri: "spotify:track:abc",
name: "Song",
artist_names: ["A", "B"],
album_name: "Alb",
album_cover_url: "https://i.scdn.co/c.jpg",
position: 12345,
duration: 200000,
},
},
}),
});
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
const status = await client.getStatus();
expect(http.get).toHaveBeenCalledWith("/status");
expect(status).toEqual({
stopped: false,
paused: false,
buffering: false,
track: {
uri: "spotify:track:abc",
name: "Song",
artist_names: ["A", "B"],
album_name: "Alb",
album_cover_url: "https://i.scdn.co/c.jpg",
position: 12345,
duration: 200000,
},
});
});
it("getStatus() returns null with a null track when nothing is loaded", async () => {
const http = makeHttp({ get: vi.fn().mockResolvedValue({ status: 200, data: { stopped: true, paused: false, buffering: false, track: null } }) });
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
const status = await client.getStatus();
expect(status).toEqual({ stopped: true, paused: false, buffering: false, track: null });
});
it("getStatus() returns null when GET /status rejects", async () => {
const http = makeHttp({ get: vi.fn().mockRejectedValue(new Error("down")) });
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
await expect(client.getStatus()).resolves.toBeNull();
});
});
/** Fake ws: records instances, lets tests drive open/message/close/error. */
class FakeWebSocket extends EventEmitter {
static instances: FakeWebSocket[] = [];
closed = false;
constructor(public url: string) {
super();
FakeWebSocket.instances.push(this);
}
close() {
this.closed = true;
this.emit("close");
}
}
function frame(type: string, data: unknown): Buffer {
return Buffer.from(JSON.stringify({ type, data }));
}
describe("GoLibrespotEventClient", () => {
beforeEach(() => {
FakeWebSocket.instances = [];
});
it("emits 'not_playing' (track-end) with its data payload", () => {
const client = new GoLibrespotEventClient("ws://127.0.0.1:3678/events", {
WebSocketCtor: FakeWebSocket as any,
});
const onEnded = vi.fn();
client.on("not_playing", onEnded);
client.start();
const ws = FakeWebSocket.instances[0];
expect(ws.url).toBe("ws://127.0.0.1:3678/events");
ws.emit("message", frame("not_playing", { uri: "spotify:track:abc", play_origin: "go-librespot" }));
expect(onEnded).toHaveBeenCalledTimes(1);
expect(onEnded).toHaveBeenCalledWith({ uri: "spotify:track:abc", play_origin: "go-librespot" });
client.stop();
});
it("emits 'metadata' with the now-playing object", () => {
const client = new GoLibrespotEventClient("ws://127.0.0.1:3678/events", {
WebSocketCtor: FakeWebSocket as any,
});
const onMeta = vi.fn();
client.on("metadata", onMeta);
client.start();
FakeWebSocket.instances[0].emit(
"message",
frame("metadata", { uri: "spotify:track:xyz", name: "Song", artist_names: ["Q"], duration: 200000 }),
);
expect(onMeta).toHaveBeenCalledWith({ uri: "spotify:track:xyz", name: "Song", artist_names: ["Q"], duration: 200000 });
client.stop();
});
it("ignores non-JSON frames without throwing", () => {
const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any });
const onAny = vi.fn();
client.on("metadata", onAny);
client.start();
expect(() => FakeWebSocket.instances[0].emit("message", Buffer.from("not json"))).not.toThrow();
expect(onAny).not.toHaveBeenCalled();
client.stop();
});
it("reconnects with backoff after the socket closes", () => {
vi.useFakeTimers();
try {
const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any });
client.start();
expect(FakeWebSocket.instances).toHaveLength(1);
FakeWebSocket.instances[0].emit("close");
expect(FakeWebSocket.instances).toHaveLength(1); // not immediate
vi.advanceTimersByTime(500);
expect(FakeWebSocket.instances).toHaveLength(2); // reconnected
client.stop();
} finally {
vi.useRealTimers();
}
});
it("stop() closes the socket and prevents reconnect", () => {
vi.useFakeTimers();
try {
const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any });
client.start();
const ws = FakeWebSocket.instances[0];
client.stop();
expect(ws.closed).toBe(true);
vi.advanceTimersByTime(60000);
expect(FakeWebSocket.instances).toHaveLength(1); // no new socket
} finally {
vi.useRealTimers();
}
});
it("does not throw on socket 'error' when no error listener is attached", () => {
const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any });
client.start();
expect(() => FakeWebSocket.instances[0].emit("error", new Error("net"))).not.toThrow();
client.stop();
});
});
+187
View File
@@ -0,0 +1,187 @@
import { EventEmitter } from "node:events";
import axios, { type AxiosInstance } from "axios";
import WebSocket from "ws";
export interface GoLibrespotStatusTrack {
uri: string;
name: string;
artist_names: string[];
album_name: string;
album_cover_url: string | null;
position: number;
duration: number;
}
export interface GoLibrespotStatus {
stopped: boolean;
paused: boolean;
buffering: boolean;
track: GoLibrespotStatusTrack | null;
}
export class GoLibrespotRestClient {
private http: AxiosInstance;
constructor(baseUrl: string, deps?: { http?: AxiosInstance }) {
this.http =
deps?.http ??
axios.create({
baseURL: baseUrl,
timeout: 10000,
headers: { "Content-Type": "application/json" },
});
}
async ping(): Promise<boolean> {
try {
const res = await this.http.get("/");
return res.status === 200;
} catch {
return false;
}
}
async playTrack(uri: string): Promise<void> {
await this.http.post("/player/play", { uri });
}
async pause(): Promise<void> {
await this.http.post("/player/pause");
}
async resume(): Promise<void> {
await this.http.post("/player/resume");
}
async stop(): Promise<void> {
await this.http.post("/player/stop");
}
async seek(ms: number): Promise<void> {
await this.http.post("/player/seek", { position: ms, relative: false });
}
async getStatus(): Promise<GoLibrespotStatus | null> {
try {
const res = await this.http.get("/status");
const d = res.data ?? {};
const t = d.track;
return {
stopped: Boolean(d.stopped),
paused: Boolean(d.paused),
buffering: Boolean(d.buffering),
track: t
? {
uri: t.uri ?? "",
name: t.name ?? "",
artist_names: Array.isArray(t.artist_names) ? t.artist_names : [],
album_name: t.album_name ?? "",
album_cover_url: t.album_cover_url ?? null,
position: t.position ?? 0,
duration: t.duration ?? 0,
}
: null,
};
} catch {
return null;
}
}
}
export type GoLibrespotEventType =
| "metadata"
| "playing"
| "paused"
| "not_playing"
| "stopped"
| "will_play"
| "seek"
| "active"
| "inactive"
| "volume"
| "playback_ready";
interface WsLike {
on(event: string, cb: (...args: any[]) => void): void;
close(): void;
}
type WebSocketCtor = new (url: string) => WsLike;
const INITIAL_RECONNECT_MS = 500;
const MAX_RECONNECT_MS = 10000;
export class GoLibrespotEventClient extends EventEmitter {
private wsUrl: string;
private WebSocketCtor: WebSocketCtor;
private ws: WsLike | null = null;
private stopped = false;
private reconnectDelay = INITIAL_RECONNECT_MS;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
constructor(wsUrl: string, deps?: { WebSocketCtor?: WebSocketCtor }) {
super();
this.wsUrl = wsUrl;
this.WebSocketCtor = deps?.WebSocketCtor ?? (WebSocket as unknown as WebSocketCtor);
}
start(): void {
this.stopped = false;
this.connect();
}
stop(): void {
this.stopped = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
private connect(): void {
if (this.stopped) return;
const ws = new this.WebSocketCtor(this.wsUrl);
this.ws = ws;
ws.on("open", () => {
this.reconnectDelay = INITIAL_RECONNECT_MS;
});
ws.on("message", (buf: unknown) => this.handleMessage(buf));
ws.on("close", () => {
this.ws = null;
this.scheduleReconnect();
});
ws.on("error", (err: unknown) => {
if (this.listenerCount("error") > 0) this.emit("error", err);
});
}
private handleMessage(buf: unknown): void {
let parsed: unknown;
try {
const text = Buffer.isBuffer(buf)
? buf.toString("utf8")
: typeof buf === "string"
? buf
: String(buf);
parsed = JSON.parse(text);
} catch {
return;
}
if (parsed && typeof (parsed as any).type === "string") {
this.emit((parsed as any).type, (parsed as any).data ?? {});
}
}
private scheduleReconnect(): void {
if (this.stopped || this.reconnectTimer) return;
const delay = this.reconnectDelay;
this.reconnectDelay = Math.min(delay * 2, MAX_RECONNECT_MS);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, delay);
}
}