mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
feat(spotify): add SpotifyConnectApi Web API Connect control client
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
333f7606e4
commit
540bf8c032
2 files changed
+421
No files matched your search
@@ -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<Record<"get" | "put", any>>) {
|
||||
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<string | null>>().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();
|
||||
});
|
||||
});
|
||||
@@ -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<void>
|
||||
* 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<string | null>;
|
||||
private http: AxiosInstance;
|
||||
|
||||
constructor(getToken: () => Promise<string | null>, 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<SpotifyDevice[]> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const headers = await this.authHeaders();
|
||||
if (!headers) return;
|
||||
const params: Record<string, unknown> = { 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<PlaybackState | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user