From 6c16e2d966801f7ca4e8122c1285f4a187c0043b Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 1 Jul 2026 23:52:22 +0800 Subject: [PATCH] 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) --- src/audio/queue.ts | 2 +- src/bot/instance.ts | 2 +- src/data/database.ts | 2 +- src/music/provider.ts | 8 +- src/music/spotify/webapi.test.ts | 125 +++++++++++++++++++++ src/music/spotify/webapi.ts | 184 +++++++++++++++++++++++++++++++ 6 files changed, 316 insertions(+), 7 deletions(-) create mode 100644 src/music/spotify/webapi.test.ts create mode 100644 src/music/spotify/webapi.ts diff --git a/src/audio/queue.ts b/src/audio/queue.ts index 221b74b..31f49b4 100644 --- a/src/audio/queue.ts +++ b/src/audio/queue.ts @@ -10,7 +10,7 @@ export interface QueuedSong { name: string; artist: string; album: string; - platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou"; + platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"; url?: string; // resolved lazily at play time coverUrl: string; duration: number; // seconds diff --git a/src/bot/instance.ts b/src/bot/instance.ts index f0bb312..68197d7 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -527,7 +527,7 @@ export class BotInstance extends EventEmitter { } } - getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou"): MusicProvider { + getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"): MusicProvider { if (platform === "bilibili") return this.bilibiliProvider; if (platform === "youtube") return this.youtubeProvider; if (platform === "local") return this.localProvider; diff --git a/src/data/database.ts b/src/data/database.ts index b08ca19..5bf375c 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -8,7 +8,7 @@ export interface PlayHistoryEntry { songName: string; artist: string; album: string; - platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou"; + platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"; coverUrl: string; } diff --git a/src/music/provider.ts b/src/music/provider.ts index 6c66bd9..b484a57 100644 --- a/src/music/provider.ts +++ b/src/music/provider.ts @@ -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; getSongUrl(songId: string, quality?: string): Promise; diff --git a/src/music/spotify/webapi.test.ts b/src/music/spotify/webapi.test.ts new file mode 100644 index 0000000..2003a62 --- /dev/null +++ b/src/music/spotify/webapi.test.ts @@ -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: [] }); + }); +}); diff --git a/src/music/spotify/webapi.ts b/src/music/spotify/webapi.ts new file mode 100644 index 0000000..5d94345 --- /dev/null +++ b/src/music/spotify/webapi.ts @@ -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:); 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 { + 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, + retryOn429 = true + ): Promise { + 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 { + 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 { + const data = await this.get(`/v1/tracks/${id}`); + return data ? mapSpotifyTrack(data) : null; + } + + async getAlbumTracks(albumId: string): Promise { + // 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 { + 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); + } +}