feat(spotify): Web API client + catalog mappers, add spotify platform

Adds src/music/spotify/webapi.ts (client-credentials token, catalog
mappers, 429 retry) + tests, and threads the new "spotify" platform id
through the type unions in provider.ts and database.ts. Also widens the
downstream QueuedSong.platform union (audio/queue.ts) and the
getProviderFor parameter (bot/instance.ts) so tsc --noEmit stays clean;
these two are the necessary call-site fixes for the new union member.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.8 committed 2026-07-01 23:52:22 +08:00
1 parent b352d71528
commit 6c16e2d966
6 files changed
+316 -7

No files matched your search

+4 -4
View File
@@ -5,7 +5,7 @@ export interface Song {
album: string;
duration: number; // seconds
coverUrl: string;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
/** VIP / copyright-restricted: non-VIP users can only play a trial fragment
* (NetEase fee=1 VIP / fee=4 album-only, or QQ pay.payplay/paytrackprice=1). */
vip?: boolean;
@@ -27,7 +27,7 @@ export interface Playlist {
name: string;
coverUrl: string;
songCount: number;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
}
export interface PlaylistDetail {
@@ -44,7 +44,7 @@ export interface Album {
artist: string;
coverUrl: string;
songCount: number;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
}
export interface LyricLine {
@@ -72,7 +72,7 @@ export interface AuthStatus {
}
export interface MusicProvider {
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
search(query: string, limit?: number): Promise<SearchResult>;
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
+125
View File
@@ -0,0 +1,125 @@
import { describe, it, expect, vi } from "vitest";
import {
mapSpotifyTrack,
mapSpotifyTracks,
mapSpotifyAlbum,
mapSpotifyPlaylist,
isSpotifyUri,
SpotifyWebApi,
} from "./webapi.js";
describe("mapSpotifyTrack", () => {
// Shape trimmed from GET /v1/search?type=track.
const raw = {
id: "4iV5W9uYEdYUVa79Axb7Rh",
name: "Bohemian Rhapsody",
artists: [{ name: "Queen" }],
album: { name: "A Night at the Opera", images: [{ url: "https://i.scdn.co/x.jpg" }] },
duration_ms: 354320,
};
it("maps a track to a Song with platform 'spotify' and seconds duration", () => {
const s = mapSpotifyTrack(raw);
expect(s.platform).toBe("spotify");
expect(s.id).toBe("4iV5W9uYEdYUVa79Axb7Rh");
expect(s.name).toBe("Bohemian Rhapsody");
expect(s.artist).toBe("Queen");
expect(s.album).toBe("A Night at the Opera");
expect(s.duration).toBe(354); // 354320ms → 354s
expect(s.coverUrl).toBe("https://i.scdn.co/x.jpg");
});
it("joins multiple artists with ', '", () => {
const s = mapSpotifyTrack({ ...raw, artists: [{ name: "A" }, { name: "B" }] });
expect(s.artist).toBe("A, B");
});
it("tolerates missing fields", () => {
const s = mapSpotifyTrack({});
expect(s.id).toBe("");
expect(s.name).toBe("Unknown");
expect(s.artist).toBe("");
expect(s.duration).toBe(0);
expect(s.coverUrl).toBe("");
expect(s.platform).toBe("spotify");
});
it("mapSpotifyTracks returns [] for non-array input", () => {
expect(mapSpotifyTracks(undefined as any)).toEqual([]);
});
});
describe("mapSpotifyAlbum", () => {
it("maps an album with total_tracks → songCount", () => {
const a = mapSpotifyAlbum({
id: "1abc",
name: "A Night at the Opera",
artists: [{ name: "Queen" }],
images: [{ url: "https://i.scdn.co/a.jpg" }],
total_tracks: 12,
});
expect(a).toEqual({
id: "1abc",
name: "A Night at the Opera",
artist: "Queen",
coverUrl: "https://i.scdn.co/a.jpg",
songCount: 12,
platform: "spotify",
});
});
});
describe("mapSpotifyPlaylist", () => {
it("maps a playlist with tracks.total → songCount", () => {
const p = mapSpotifyPlaylist({
id: "37i9",
name: "Today's Top Hits",
images: [{ url: "https://i.scdn.co/p.jpg" }],
tracks: { total: 50 },
});
expect(p).toEqual({
id: "37i9",
name: "Today's Top Hits",
coverUrl: "https://i.scdn.co/p.jpg",
songCount: 50,
platform: "spotify",
});
});
});
describe("isSpotifyUri", () => {
it("recognizes the sentinel URI", () => {
expect(isSpotifyUri("spotify:track:4iV5W9uYEdYUVa79Axb7Rh")).toBe(true);
expect(isSpotifyUri("https://music.126.net/x.mp3")).toBe(false);
expect(isSpotifyUri("")).toBe(false);
});
});
describe("SpotifyWebApi rate-limit handling", () => {
it("retries once on 429 (honoring Retry-After) then returns data", async () => {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
let call = 0;
const http = {
get: vi.fn().mockImplementation(() => {
call += 1;
if (call === 1) {
return Promise.reject({ response: { status: 429, headers: { "retry-after": "0" } } });
}
return Promise.resolve({
data: { tracks: { items: [{ id: "t1", name: "n", artists: [], duration_ms: 1000 }] } },
});
}),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const out = await api.search("queen");
expect(http.get).toHaveBeenCalledTimes(2); // one 429, one success
expect(out.songs[0].id).toBe("t1");
});
it("returns empty results when unconfigured (no creds → no token)", async () => {
const api = new SpotifyWebApi(() => ({ clientId: "", clientSecret: "" }));
expect(await api.search("queen")).toEqual({ songs: [], playlists: [], albums: [] });
});
});
+184
View File
@@ -0,0 +1,184 @@
import axios, { type AxiosInstance } from "axios";
import type { Song, Album, Playlist, SearchResult } from "../provider.js";
export interface SpotifyCreds {
clientId: string;
clientSecret: string;
}
const ACCOUNTS_BASE = "https://accounts.spotify.com";
const API_BASE = "https://api.spotify.com";
function artistsToString(artists: unknown): string {
return Array.isArray(artists)
? artists.map((a: any) => a?.name).filter(Boolean).join(", ")
: "";
}
/** Map a Spotify track object (search / tracks / playlist item .track) to a Song. */
export function mapSpotifyTrack(raw: any): Song {
return {
id: raw?.id ?? "",
name: raw?.name ?? "Unknown",
artist: artistsToString(raw?.artists),
album: raw?.album?.name ?? "",
duration: Math.round((raw?.duration_ms ?? 0) / 1000),
coverUrl: raw?.album?.images?.[0]?.url ?? "",
platform: "spotify",
};
}
export function mapSpotifyTracks(raw: any): Song[] {
return Array.isArray(raw) ? raw.map(mapSpotifyTrack) : [];
}
export function mapSpotifyAlbum(raw: any): Album {
return {
id: raw?.id ?? "",
name: raw?.name ?? "Unknown",
artist: artistsToString(raw?.artists),
coverUrl: raw?.images?.[0]?.url ?? "",
songCount: raw?.total_tracks ?? 0,
platform: "spotify",
};
}
export function mapSpotifyPlaylist(raw: any): Playlist {
return {
id: raw?.id ?? "",
name: raw?.name ?? "Unknown",
coverUrl: raw?.images?.[0]?.url ?? "",
songCount: raw?.tracks?.total ?? 0,
platform: "spotify",
};
}
/** True for the getSongUrl sentinel (spotify:track:<id>); real audio lands in Stage 2/3. */
export function isSpotifyUri(url: string): boolean {
return typeof url === "string" && url.startsWith("spotify:");
}
export class SpotifyWebApi {
private getCreds: () => SpotifyCreds;
private http: AxiosInstance;
private auth: AxiosInstance;
private token = "";
private tokenExpiresAt = 0;
constructor(
getCreds: () => SpotifyCreds,
deps?: { http?: AxiosInstance; auth?: AxiosInstance }
) {
this.getCreds = getCreds;
this.http = deps?.http ?? axios.create({ baseURL: API_BASE, timeout: 15_000 });
this.auth = deps?.auth ?? axios.create({ baseURL: ACCOUNTS_BASE, timeout: 15_000 });
}
setCreds(_c: SpotifyCreds): void {
// Creds are read live via getCreds(); force a token refresh on next call.
this.token = "";
this.tokenExpiresAt = 0;
}
hasCreds(): boolean {
const c = this.getCreds();
return !!c.clientId && !!c.clientSecret;
}
/** Client-Credentials app token, cached until ~30s before expiry. */
private async getToken(): Promise<string | null> {
if (!this.hasCreds()) return null;
if (this.token && Date.now() < this.tokenExpiresAt) return this.token;
const { clientId, clientSecret } = this.getCreds();
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
try {
const { data } = await this.auth.post(
"/api/token",
"grant_type=client_credentials",
{
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
}
);
this.token = data?.access_token ?? "";
this.tokenExpiresAt = Date.now() + ((data?.expires_in ?? 3600) - 30) * 1000;
return this.token || null;
} catch {
return null;
}
}
private async get(
path: string,
params?: Record<string, unknown>,
retryOn429 = true
): Promise<any | null> {
const token = await this.getToken();
if (!token) return null;
try {
const { data } = await this.http.get(path, {
params,
headers: { Authorization: `Bearer ${token}` },
});
return data;
} catch (err: any) {
// Spotify rate-limits on a rolling 30s window (429 + Retry-After seconds).
// Retry once after the advised delay before giving up.
if (retryOn429 && err?.response?.status === 429) {
const retryAfter = Number(err.response.headers?.["retry-after"] ?? 1);
await new Promise((r) => setTimeout(r, Math.min(retryAfter, 10) * 1000));
return this.get(path, params, false);
}
return null;
}
}
async search(query: string, limit = 20): Promise<SearchResult> {
const data = await this.get("/v1/search", {
q: query,
type: "track,album,playlist",
limit,
});
if (!data) return { songs: [], playlists: [], albums: [] };
return {
songs: mapSpotifyTracks(data?.tracks?.items),
albums: Array.isArray(data?.albums?.items)
? data.albums.items.filter(Boolean).map(mapSpotifyAlbum)
: [],
playlists: Array.isArray(data?.playlists?.items)
? data.playlists.items.filter(Boolean).map(mapSpotifyPlaylist)
: [],
};
}
async getTrack(id: string): Promise<Song | null> {
const data = await this.get(`/v1/tracks/${id}`);
return data ? mapSpotifyTrack(data) : null;
}
async getAlbumTracks(albumId: string): Promise<Song[]> {
// Album-track objects omit the album block; fetch the album cover once and inject it.
const album = await this.get(`/v1/albums/${albumId}`);
const cover = album?.images?.[0]?.url ?? "";
const albumName = album?.name ?? "";
const items = album?.tracks?.items;
if (!Array.isArray(items)) return [];
return items.filter(Boolean).map((t: any) => ({
...mapSpotifyTrack(t),
album: albumName,
coverUrl: cover,
}));
}
async getPlaylistTracks(playlistId: string): Promise<Song[]> {
const data = await this.get(`/v1/playlists/${playlistId}/tracks`, { limit: 100 });
const items = data?.items;
if (!Array.isArray(items)) return [];
return items
.map((it: any) => it?.track)
.filter((t: any) => t && t.id)
.map(mapSpotifyTrack);
}
}