From 540bf8c032c70b651edb42c91a94f1b7a9412737 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 23:22:54 +0800 Subject: [PATCH] feat(spotify): add SpotifyConnectApi Web API Connect control client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps an injected axios instance with a live user Bearer token from getToken(): getDevices/findDeviceByName, transfer/play/pause/resume/seek, and getPlaybackState (null on 204). Read-only calls degrade gracefully; mutating calls no-op when unauthorized. Fully unit-tested with a mocked AxiosInstance (no network) — Windows-targeted, not e2e-testable (no Premium). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/music/spotify/connect-api.test.ts | 271 ++++++++++++++++++++++++++ src/music/spotify/connect-api.ts | 150 ++++++++++++++ 2 files changed, 421 insertions(+) create mode 100644 src/music/spotify/connect-api.test.ts create mode 100644 src/music/spotify/connect-api.ts diff --git a/src/music/spotify/connect-api.test.ts b/src/music/spotify/connect-api.test.ts new file mode 100644 index 0000000..8aaee85 --- /dev/null +++ b/src/music/spotify/connect-api.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect, vi } from "vitest"; +import type { AxiosInstance } from "axios"; +import { SpotifyConnectApi } from "./connect-api.js"; + +/** Minimal axios stub: only get/put are exercised by the Connect client. */ +function makeHttp(overrides?: Partial>) { + return { + get: vi.fn().mockResolvedValue({ status: 200, data: {} }), + put: vi.fn().mockResolvedValue({ status: 200, data: {} }), + ...overrides, + } as unknown as AxiosInstance; +} + +const AUTH = { headers: { Authorization: "Bearer tok123" } }; +const token = () => + vi.fn<() => Promise>().mockResolvedValue("tok123"); + +describe("SpotifyConnectApi.getDevices", () => { + it("GETs /v1/me/player/devices with the bearer header and maps the list", async () => { + const http = makeHttp({ + get: vi.fn().mockResolvedValue({ + status: 200, + data: { + devices: [ + { id: "dev-1", name: "TS Bot", is_active: true, type: "Speaker" }, + { id: "dev-2", name: "Phone", is_active: false }, + ], + }, + }), + }); + const api = new SpotifyConnectApi(token(), { http }); + const devices = await api.getDevices(); + expect(http.get).toHaveBeenCalledWith("/v1/me/player/devices", AUTH); + expect(devices).toEqual([ + { id: "dev-1", name: "TS Bot", is_active: true }, + { id: "dev-2", name: "Phone", is_active: false }, + ]); + }); + + it("returns [] when getToken() is null (unauthorized) without calling http", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(vi.fn().mockResolvedValue(null), { http }); + await expect(api.getDevices()).resolves.toEqual([]); + expect(http.get).not.toHaveBeenCalled(); + }); + + it("returns [] on a 401/network rejection (graceful)", async () => { + const err: any = new Error("unauthorized"); + err.response = { status: 401 }; + const http = makeHttp({ get: vi.fn().mockRejectedValue(err) }); + const api = new SpotifyConnectApi(token(), { http }); + await expect(api.getDevices()).resolves.toEqual([]); + }); +}); + +describe("SpotifyConnectApi.findDeviceByName", () => { + it("returns the matching device id", async () => { + const http = makeHttp({ + get: vi.fn().mockResolvedValue({ + status: 200, + data: { devices: [{ id: "dev-1", name: "TS Bot", is_active: false }] }, + }), + }); + const api = new SpotifyConnectApi(token(), { http }); + await expect(api.findDeviceByName("TS Bot")).resolves.toBe("dev-1"); + }); + + it("returns null when no device name matches", async () => { + const http = makeHttp({ + get: vi.fn().mockResolvedValue({ + status: 200, + data: { devices: [{ id: "dev-1", name: "Other", is_active: false }] }, + }), + }); + const api = new SpotifyConnectApi(token(), { http }); + await expect(api.findDeviceByName("TS Bot")).resolves.toBeNull(); + }); +}); + +describe("SpotifyConnectApi mutating calls", () => { + it("transfer() PUTs /v1/me/player with device_ids + play=false default", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.transfer("dev-1"); + expect(http.put).toHaveBeenCalledWith( + "/v1/me/player", + { device_ids: ["dev-1"], play: false }, + AUTH, + ); + }); + + it("transfer(id, true) forwards play=true", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.transfer("dev-1", true); + expect(http.put).toHaveBeenCalledWith( + "/v1/me/player", + { device_ids: ["dev-1"], play: true }, + AUTH, + ); + }); + + it("play() PUTs /v1/me/player/play?device_id= with the uris body", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.play("dev-1", "spotify:track:abc"); + expect(http.put).toHaveBeenCalledWith( + "/v1/me/player/play", + { uris: ["spotify:track:abc"] }, + { headers: { Authorization: "Bearer tok123" }, params: { device_id: "dev-1" } }, + ); + }); + + it("pause() PUTs /v1/me/player/pause (no params) with no body", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.pause(); + expect(http.put).toHaveBeenCalledWith("/v1/me/player/pause", undefined, { + headers: { Authorization: "Bearer tok123" }, + params: undefined, + }); + }); + + it("pause(id) forwards device_id param", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.pause("dev-1"); + expect(http.put).toHaveBeenCalledWith("/v1/me/player/pause", undefined, { + headers: { Authorization: "Bearer tok123" }, + params: { device_id: "dev-1" }, + }); + }); + + it("resume() PUTs /v1/me/player/play with no uris body (resume)", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.resume(); + expect(http.put).toHaveBeenCalledWith("/v1/me/player/play", undefined, { + headers: { Authorization: "Bearer tok123" }, + params: undefined, + }); + }); + + it("seek() PUTs /v1/me/player/seek?position_ms=", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.seek(42000); + expect(http.put).toHaveBeenCalledWith("/v1/me/player/seek", undefined, { + headers: { Authorization: "Bearer tok123" }, + params: { position_ms: 42000 }, + }); + }); + + it("seek(ms, id) adds device_id param", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(token(), { http }); + await api.seek(1000, "dev-1"); + expect(http.put).toHaveBeenCalledWith("/v1/me/player/seek", undefined, { + headers: { Authorization: "Bearer tok123" }, + params: { position_ms: 1000, device_id: "dev-1" }, + }); + }); + + it("mutating calls no-op (no http.put) when unauthorized", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(vi.fn().mockResolvedValue(null), { http }); + await api.transfer("dev-1"); + await api.play("dev-1", "spotify:track:x"); + await api.pause(); + expect(http.put).not.toHaveBeenCalled(); + }); +}); + +/** + * REQUIRED CORRECTION C3.6: mutating calls must NOT reject up the queue-advance + * path. A transient 403 (non-Premium) / 404 (no active device) / 429 + * (rate-limited) from Spotify must be swallowed (resolve to void), never thrown, + * so a failed play() degrades to "couldn't play" instead of an unhandled + * rejection that crashes the backend. + */ +describe("SpotifyConnectApi C3.6 — mutating calls are resilient (no throw)", () => { + function rejectingHttp(status: number) { + const err: any = new Error(`http ${status}`); + err.response = { status }; + return makeHttp({ put: vi.fn().mockRejectedValue(err) }); + } + + it("play() does NOT throw on a 404 (no active device)", async () => { + const api = new SpotifyConnectApi(token(), { http: rejectingHttp(404) }); + await expect(api.play("dev-1", "spotify:track:abc")).resolves.toBeUndefined(); + }); + + it("play() does NOT throw on a 429 (rate-limited)", async () => { + const api = new SpotifyConnectApi(token(), { http: rejectingHttp(429) }); + await expect(api.play("dev-1", "spotify:track:abc")).resolves.toBeUndefined(); + }); + + it("transfer() does NOT throw on a 403 (non-Premium)", async () => { + const api = new SpotifyConnectApi(token(), { http: rejectingHttp(403) }); + await expect(api.transfer("dev-1", true)).resolves.toBeUndefined(); + }); + + it("pause/resume/seek do NOT throw on a rejection", async () => { + const api = new SpotifyConnectApi(token(), { http: rejectingHttp(404) }); + await expect(api.pause("dev-1")).resolves.toBeUndefined(); + await expect(api.resume("dev-1")).resolves.toBeUndefined(); + await expect(api.seek(1000, "dev-1")).resolves.toBeUndefined(); + }); + + it("play() does NOT throw on a raw network error (no response)", async () => { + const http = makeHttp({ put: vi.fn().mockRejectedValue(new Error("ECONNRESET")) }); + const api = new SpotifyConnectApi(token(), { http }); + await expect(api.play("dev-1", "spotify:track:abc")).resolves.toBeUndefined(); + }); +}); + +describe("SpotifyConnectApi.getPlaybackState", () => { + it("GETs /v1/me/player and maps is_playing/progress/item", async () => { + const http = makeHttp({ + get: vi.fn().mockResolvedValue({ + status: 200, + data: { + is_playing: true, + progress_ms: 12345, + item: { uri: "spotify:track:abc", duration_ms: 200000 }, + }, + }), + }); + const api = new SpotifyConnectApi(token(), { http }); + const state = await api.getPlaybackState(); + expect(http.get).toHaveBeenCalledWith("/v1/me/player", AUTH); + expect(state).toEqual({ + isPlaying: true, + progressMs: 12345, + trackUri: "spotify:track:abc", + durationMs: 200000, + }); + }); + + it("returns null on 204 (no active device)", async () => { + const http = makeHttp({ get: vi.fn().mockResolvedValue({ status: 204, data: "" }) }); + const api = new SpotifyConnectApi(token(), { http }); + await expect(api.getPlaybackState()).resolves.toBeNull(); + }); + + it("returns null when item is missing / trackUri null", async () => { + const http = makeHttp({ + get: vi.fn().mockResolvedValue({ status: 200, data: { is_playing: false, progress_ms: 0, item: null } }), + }); + const api = new SpotifyConnectApi(token(), { http }); + await expect(api.getPlaybackState()).resolves.toEqual({ + isPlaying: false, + progressMs: 0, + trackUri: null, + durationMs: 0, + }); + }); + + it("returns null on rejection (e.g. 401) instead of throwing", async () => { + const http = makeHttp({ get: vi.fn().mockRejectedValue(new Error("boom")) }); + const api = new SpotifyConnectApi(token(), { http }); + await expect(api.getPlaybackState()).resolves.toBeNull(); + }); + + it("returns null when getToken() is null (unauthorized) without calling http", async () => { + const http = makeHttp(); + const api = new SpotifyConnectApi(vi.fn().mockResolvedValue(null), { http }); + await expect(api.getPlaybackState()).resolves.toBeNull(); + expect(http.get).not.toHaveBeenCalled(); + }); +}); diff --git a/src/music/spotify/connect-api.ts b/src/music/spotify/connect-api.ts new file mode 100644 index 0000000..6a05a22 --- /dev/null +++ b/src/music/spotify/connect-api.ts @@ -0,0 +1,150 @@ +import axios, { type AxiosInstance } from "axios"; + +const API_BASE = "https://api.spotify.com"; + +export interface SpotifyDevice { + id: string; + name: string; + is_active: boolean; +} + +export interface PlaybackState { + isPlaying: boolean; + progressMs: number; + trackUri: string | null; + durationMs: number; +} + +/** + * Spotify Web API "Connect" remote-control client. Wraps an axios instance and + * attaches a live user Bearer token from getToken() to every request. + * + * Error policy: + * - Read-only calls (getDevices/getPlaybackState) degrade to []/null on error. + * - Mutating calls (transfer/play/pause/resume/seek) no-op when unauthorized. + * - REQUIRED CORRECTION C3.6: mutating calls ALSO swallow transport errors + * (403 non-Premium / 404 no active device / 429 rate-limited / network) and + * resolve to void instead of rejecting. The contract keeps the Promise + * signatures, so the backend treats a failed play as "couldn't play" and + * falls back — a transient Spotify error can never surface as an unhandled + * rejection that crashes the queue-advance path. + */ +export class SpotifyConnectApi { + private getToken: () => Promise; + private http: AxiosInstance; + + constructor(getToken: () => Promise, deps?: { http?: AxiosInstance }) { + this.getToken = getToken; + this.http = deps?.http ?? axios.create({ baseURL: API_BASE, timeout: 15_000 }); + } + + /** Bearer auth headers, or null when no valid user token is available. */ + private async authHeaders(): Promise<{ Authorization: string } | null> { + const token = await this.getToken(); + if (!token) return null; + return { Authorization: `Bearer ${token}` }; + } + + async getDevices(): Promise { + const headers = await this.authHeaders(); + if (!headers) return []; + try { + const { data } = await this.http.get("/v1/me/player/devices", { headers }); + const list = Array.isArray(data?.devices) ? data.devices : []; + return list.map((d: any) => ({ + id: d?.id ?? "", + name: d?.name ?? "", + is_active: Boolean(d?.is_active), + })); + } catch { + return []; + } + } + + async findDeviceByName(name: string): Promise { + const devices = await this.getDevices(); + const match = devices.find((d) => d.name === name); + return match ? match.id : null; + } + + async transfer(deviceId: string, play = false): Promise { + const headers = await this.authHeaders(); + if (!headers) return; + try { + await this.http.put("/v1/me/player", { device_ids: [deviceId], play }, { headers }); + } catch { + // C3.6: swallow (e.g. 403/404/429) — never reject up the queue path. + } + } + + async play(deviceId: string, trackUri: string): Promise { + const headers = await this.authHeaders(); + if (!headers) return; + try { + await this.http.put( + "/v1/me/player/play", + { uris: [trackUri] }, + { headers, params: { device_id: deviceId } }, + ); + } catch { + // C3.6: swallow — the backend treats a failed play as "couldn't play". + } + } + + async pause(deviceId?: string): Promise { + const headers = await this.authHeaders(); + if (!headers) return; + try { + await this.http.put("/v1/me/player/pause", undefined, { + headers, + params: deviceId ? { device_id: deviceId } : undefined, + }); + } catch { + // C3.6: swallow. + } + } + + async resume(deviceId?: string): Promise { + const headers = await this.authHeaders(); + if (!headers) return; + try { + await this.http.put("/v1/me/player/play", undefined, { + headers, + params: deviceId ? { device_id: deviceId } : undefined, + }); + } catch { + // C3.6: swallow. + } + } + + async seek(ms: number, deviceId?: string): Promise { + const headers = await this.authHeaders(); + if (!headers) return; + const params: Record = { position_ms: ms }; + if (deviceId) params.device_id = deviceId; + try { + await this.http.put("/v1/me/player/seek", undefined, { headers, params }); + } catch { + // C3.6: swallow. + } + } + + async getPlaybackState(): Promise { + const headers = await this.authHeaders(); + if (!headers) return null; + try { + const res = await this.http.get("/v1/me/player", { headers }); + // 204 = no active device / playback; body is empty. + if (res.status === 204 || !res.data) return null; + const d = res.data; + return { + isPlaying: Boolean(d.is_playing), + progressMs: Number(d.progress_ms ?? 0), + trackUri: d.item?.uri ?? null, + durationMs: Number(d.item?.duration_ms ?? 0), + }; + } catch { + return null; + } + } +}