diff --git a/src/music/spotify/provider.test.ts b/src/music/spotify/provider.test.ts new file mode 100644 index 0000000..aabe6b9 --- /dev/null +++ b/src/music/spotify/provider.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi } from "vitest"; +import { SpotifyProvider } from "./provider.js"; +import { SpotifyWebApi } from "./webapi.js"; + +function fakeApi(over: Partial = {}): SpotifyWebApi { + return { + hasCreds: () => true, + setCreds: vi.fn(), + search: vi.fn().mockResolvedValue({ songs: [], playlists: [], albums: [] }), + getTrack: vi.fn().mockResolvedValue(null), + getAlbumTracks: vi.fn().mockResolvedValue([]), + getPlaylistTracks: vi.fn().mockResolvedValue([]), + ...over, + } as unknown as SpotifyWebApi; +} + +describe("SpotifyProvider", () => { + it("has platform 'spotify'", () => { + expect(new SpotifyProvider(fakeApi()).platform).toBe("spotify"); + }); + + it("getSongUrl returns the spotify: sentinel, not a real URL", async () => { + const p = new SpotifyProvider(fakeApi()); + const r = await p.getSongUrl("4iV5W9uYEdYUVa79Axb7Rh"); + expect(r).toEqual({ url: "spotify:track:4iV5W9uYEdYUVa79Axb7Rh" }); + }); + + it("search delegates to the web API", async () => { + const api = fakeApi({ + search: vi.fn().mockResolvedValue({ + songs: [{ id: "t1", platform: "spotify" }], + playlists: [], + albums: [], + }), + }); + const out = await new SpotifyProvider(api).search("queen", 5); + expect(api.search).toHaveBeenCalledWith("queen", 5); + expect(out.songs[0].id).toBe("t1"); + }); + + it("getAuthStatus reflects credential presence", async () => { + expect((await new SpotifyProvider(fakeApi({ hasCreds: () => true })).getAuthStatus()).loggedIn).toBe(true); + expect((await new SpotifyProvider(fakeApi({ hasCreds: () => false })).getAuthStatus()).loggedIn).toBe(false); + }); + + it("getPlaylistSongs / getAlbumSongs delegate to the web API", async () => { + const api = fakeApi({ + getPlaylistTracks: vi.fn().mockResolvedValue([{ id: "p", platform: "spotify" }]), + getAlbumTracks: vi.fn().mockResolvedValue([{ id: "a", platform: "spotify" }]), + }); + const p = new SpotifyProvider(api); + expect((await p.getPlaylistSongs("37i9"))[0].id).toBe("p"); + expect((await p.getAlbumSongs("1abc"))[0].id).toBe("a"); + }); + + it("no-op auth surfaces (QR expired, empty lyrics/recommend)", async () => { + const p = new SpotifyProvider(fakeApi()); + expect(await p.getLyrics("x")).toEqual([]); + expect(await p.getRecommendPlaylists()).toEqual([]); + expect((await p.getQrCode()).key).toBe(""); + expect(await p.checkQrCodeStatus("k")).toBe("expired"); + }); +}); diff --git a/src/music/spotify/provider.ts b/src/music/spotify/provider.ts new file mode 100644 index 0000000..8d4d644 --- /dev/null +++ b/src/music/spotify/provider.ts @@ -0,0 +1,86 @@ +import type { + MusicProvider, + Song, + SongUrlResult, + Playlist, + Album, + SearchResult, + LyricLine, + QrCodeResult, + AuthStatus, +} from "../provider.js"; +import { SpotifyWebApi, type SpotifyCreds } from "./webapi.js"; + +export class SpotifyProvider implements MusicProvider { + readonly platform = "spotify" as const; + private api: SpotifyWebApi; + private creds: SpotifyCreds = { clientId: "", clientSecret: "" }; + private quality = "320"; + + constructor(api?: SpotifyWebApi) { + this.api = api ?? new SpotifyWebApi(() => this.creds); + } + + setCreds(clientId: string, clientSecret: string): void { + this.creds = { clientId: clientId ?? "", clientSecret: clientSecret ?? "" }; + this.api.setCreds(this.creds); + } + + async search(query: string, limit = 20): Promise { + return this.api.search(query, limit); + } + + // Stage 1: return a sentinel URI. The play path recognizes `spotify:` and + // skips with a "not playable yet" message; real audio arrives in Stage 2/3. + async getSongUrl(songId: string): Promise { + return { url: `spotify:track:${songId}` }; + } + + setQuality(quality: string): void { + this.quality = quality; + } + getQuality(): string { + return this.quality; + } + + async getSongDetail(songId: string): Promise { + return this.api.getTrack(songId); + } + + async getPlaylistSongs(playlistId: string): Promise { + return this.api.getPlaylistTracks(playlistId); + } + + async getAlbumSongs(albumId: string): Promise { + return this.api.getAlbumTracks(albumId); + } + + async getRecommendPlaylists(): Promise { + return []; + } + + async getLyrics(_songId: string): Promise { + return []; + } + + async getQrCode(): Promise { + return { qrUrl: "", key: "" }; + } + + async checkQrCodeStatus( + _key: string + ): Promise<"waiting" | "scanned" | "confirmed" | "expired"> { + return "expired"; + } + + setCookie(_cookie: string): void {} + getCookie(): string { + return ""; + } + + async getAuthStatus(): Promise { + return this.api.hasCreds() + ? { loggedIn: true, nickname: "Spotify" } + : { loggedIn: false, nickname: "Spotify (未配置 Client ID/Secret)" }; + } +}