mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
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:
1 parent
b352d71528
commit
6c16e2d966
6 files changed
+316
-7
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user