diff --git a/src/music/spotify/go-librespot-api.test.ts b/src/music/spotify/go-librespot-api.test.ts new file mode 100644 index 0000000..47247ae --- /dev/null +++ b/src/music/spotify/go-librespot-api.test.ts @@ -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>) { + 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(); + }); +}); diff --git a/src/music/spotify/go-librespot-api.ts b/src/music/spotify/go-librespot-api.ts new file mode 100644 index 0000000..d516644 --- /dev/null +++ b/src/music/spotify/go-librespot-api.ts @@ -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 { + try { + const res = await this.http.get("/"); + return res.status === 200; + } catch { + return false; + } + } + + async playTrack(uri: string): Promise { + await this.http.post("/player/play", { uri }); + } + + async pause(): Promise { + await this.http.post("/player/pause"); + } + + async resume(): Promise { + await this.http.post("/player/resume"); + } + + async stop(): Promise { + await this.http.post("/player/stop"); + } + + async seek(ms: number): Promise { + await this.http.post("/player/seek", { position: ms, relative: false }); + } + + async getStatus(): Promise { + 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 | 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); + } +}