import { buildSpotifyAuthUrl, exchangeSpotifyCode, refreshSpotifyToken } from "@/lib/auth/spotify-auth"; import { type MusicPlatformAdapter } from "@/lib/adapters/types"; import { fetchWithRetry, formatDurationMs } from "@/lib/adapters/http-retry"; import { computeCanonicalAlbumId, computeCanonicalArtistId, computeCanonicalTrackId } from "@/lib/matching/identity"; import type { AccountProfile, CanonicalAlbum, CanonicalArtist, CanonicalPlaylist, CanonicalTrack, TokenSet, TrackSearchQuery, } from "@/lib/types/model"; const API_BASE = "https://api.spotify.com/v1"; const ADD_TO_LIBRARY_BATCH_SIZE = 40; // PUT /me/library cap const ADD_TO_PLAYLIST_BATCH_SIZE = 100; // POST /playlists/{id}/items cap interface SpotifyArtistRef { id: string; name: string; } interface SpotifyTrack { id: string; name: string; duration_ms: number; artists: SpotifyArtistRef[]; album?: { name: string }; external_ids?: { isrc?: string }; } interface SavedTrackItem { track: SpotifyTrack; } interface SavedTracksPage { items: SavedTrackItem[]; next: string | null; } interface SpotifyMe { id: string; display_name: string | null; email?: string; } interface SpotifyPlaylist { id: string; name: string; description: string | null; owner: { id: string }; public: boolean | null; } interface SpotifyPlaylistsPage { items: SpotifyPlaylist[]; next: string | null; } interface SpotifyPlaylistTrackItem { item: SpotifyTrack | null; } interface SpotifyPlaylistTracksPage { items: SpotifyPlaylistTrackItem[]; next: string | null; } interface SpotifyFollowedArtistsResponse { artists: { items: SpotifyArtistRef[]; cursors: { after: string | null }; }; } interface SpotifyAlbum { id: string; name: string; artists: SpotifyArtistRef[]; } interface SpotifySavedAlbumItem { album: SpotifyAlbum; } interface SpotifySavedAlbumsPage { items: SpotifySavedAlbumItem[]; next: string | null; } async function spotifyFetch(path: string, tokens: TokenSet, init?: RequestInit): Promise { const res = await fetchWithRetry(() => fetch(`${API_BASE}${path}`, { ...init, headers: { ...init?.headers, Authorization: `Bearer ${tokens.accessToken}`, }, }) ); if (res.status === 429) { const retryAfter = Number(res.headers.get("Retry-After") ?? "1"); throw new SpotifyRateLimitError(retryAfter * 1000); } if (!res.ok) { const method = init?.method ?? "GET"; throw new Error(`Spotify API error ${res.status} on ${method} ${path}: ${await res.text()}`); } const text = await res.text(); if (!text) return undefined as T; return JSON.parse(text) as T; } export class SpotifyRateLimitError extends Error { constructor(public retryAfterMs: number) { super(`Spotify rate limited, retry after ${formatDurationMs(retryAfterMs)}`); } } function toCanonicalTrack(track: SpotifyTrack): CanonicalTrack { const artists = track.artists.map((a) => a.name); const isrc = track.external_ids?.isrc; return { canonicalId: computeCanonicalTrackId({ isrc, artists, title: track.name, durationMs: track.duration_ms, }), isrc, title: track.name, artists, albumTitle: track.album?.name, durationMs: track.duration_ms, platformIds: { spotify: track.id }, }; } function toCanonicalArtist(artist: SpotifyArtistRef): CanonicalArtist { return { canonicalId: computeCanonicalArtistId(artist.name), name: artist.name, platformIds: { spotify: artist.id }, }; } function toCanonicalAlbum(album: SpotifyAlbum): CanonicalAlbum { const artists = album.artists.map((a) => a.name); return { canonicalId: computeCanonicalAlbumId({ title: album.name, artists }), title: album.name, artists, platformIds: { spotify: album.id }, }; } /** Adds Spotify URIs to the user's library via the unified PUT /me/library endpoint (Feb 2026). */ async function addToLibrary(tokens: TokenSet, uris: string[]): Promise { for (let i = 0; i < uris.length; i += ADD_TO_LIBRARY_BATCH_SIZE) { const batch = uris.slice(i, i + ADD_TO_LIBRARY_BATCH_SIZE); await spotifyFetch(`/me/library?uris=${encodeURIComponent(batch.join(","))}`, tokens, { method: "PUT" }); } } /** Removes Spotify URIs from the user's library via the unified DELETE /me/library endpoint. */ async function removeFromLibrary(tokens: TokenSet, uris: string[]): Promise { for (let i = 0; i < uris.length; i += ADD_TO_LIBRARY_BATCH_SIZE) { const batch = uris.slice(i, i + ADD_TO_LIBRARY_BATCH_SIZE); await spotifyFetch(`/me/library?uris=${encodeURIComponent(batch.join(","))}`, tokens, { method: "DELETE" }); } } export const spotifyAdapter: MusicPlatformAdapter = { platform: "spotify", getAuthUrl(redirectUri) { return buildSpotifyAuthUrl(redirectUri); }, async exchangeCode(code, codeVerifier, redirectUri) { return exchangeSpotifyCode(code, codeVerifier, redirectUri); }, async refreshToken(tokens) { return refreshSpotifyToken(tokens); }, async getAccountProfile(tokens): Promise { const me = await spotifyFetch("/me", tokens); return { id: me.id, displayName: me.display_name ?? me.id, email: me.email }; }, async *fetchLikedSongs(tokens): AsyncGenerator { let path: string | null = "/me/tracks?limit=50"; while (path) { const page: SavedTracksPage = await spotifyFetch(path, tokens); yield page.items .filter((item) => item.track != null) .map((item) => toCanonicalTrack(item.track)); path = page.next ? page.next.replace(API_BASE, "") : null; } }, async *fetchPlaylists(tokens: TokenSet): AsyncGenerator { // GET /me/playlists returns playlists the user OWNS OR FOLLOWS. A // followed (not owned) playlist can't have tracks written to it (403) — // the job runner branches on `owned` to follow rather than recreate it. const me = await spotifyFetch("/me", tokens); let path: string | null = "/me/playlists?limit=50"; while (path) { const page: SpotifyPlaylistsPage = await spotifyFetch(path, tokens); yield page.items.map((playlist) => ({ sourcePlaylistId: playlist.id, name: playlist.name, description: playlist.description ?? undefined, owned: playlist.owner.id === me.id, public: playlist.public ?? false, trackRefs: [], })); path = page.next ? page.next.replace(API_BASE, "") : null; } }, async *fetchPlaylistTracks(tokens: TokenSet, playlistId: string): AsyncGenerator { // Spotify renamed GET /playlists/{id}/tracks to /playlists/{id}/items // (Feb 2026 migration), with the per-row field renamed track -> item. let path: string | null = `/playlists/${playlistId}/items?limit=100`; while (path) { const page: SpotifyPlaylistTracksPage = await spotifyFetch(path, tokens); yield page.items .filter((entry) => entry.item != null) .map((entry) => toCanonicalTrack(entry.item as SpotifyTrack)); path = page.next ? page.next.replace(API_BASE, "") : null; } }, async *fetchFollowedArtists(tokens: TokenSet): AsyncGenerator { let path: string | null = "/me/following?type=artist&limit=50"; while (path) { const page: SpotifyFollowedArtistsResponse = await spotifyFetch(path, tokens); yield page.artists.items.map(toCanonicalArtist); path = page.artists.cursors.after ? `/me/following?type=artist&limit=50&after=${page.artists.cursors.after}` : null; } }, async *fetchSavedAlbums(tokens: TokenSet): AsyncGenerator { let path: string | null = "/me/albums?limit=50"; while (path) { const page: SpotifySavedAlbumsPage = await spotifyFetch(path, tokens); yield page.items.map((item) => toCanonicalAlbum(item.album)); path = page.next ? page.next.replace(API_BASE, "") : null; } }, async searchTrack(tokens, query: TrackSearchQuery): Promise { const q = encodeURIComponent(`track:${query.title} artist:${query.artist}`); const data = await spotifyFetch<{ tracks: { items: SpotifyTrack[] } }>( `/search?type=track&limit=5&q=${q}`, tokens ); return data.tracks.items.map(toCanonicalTrack); }, async lookupTrackByIsrc(tokens, isrc): Promise { const data = await spotifyFetch<{ tracks: { items: SpotifyTrack[] } }>( `/search?type=track&limit=1&q=${encodeURIComponent(`isrc:${isrc}`)}`, tokens ); const track = data.tracks.items[0]; return track ? toCanonicalTrack(track) : null; }, async searchArtist(tokens, name): Promise { const data = await spotifyFetch<{ artists: { items: SpotifyArtistRef[] } }>( `/search?type=artist&limit=5&q=${encodeURIComponent(name)}`, tokens ); return data.artists.items.map(toCanonicalArtist); }, async searchAlbum(tokens, query): Promise { const q = encodeURIComponent(`album:${query.title} artist:${query.artist}`); const data = await spotifyFetch<{ albums: { items: SpotifyAlbum[] } }>( `/search?type=album&limit=5&q=${q}`, tokens ); return data.albums.items.map(toCanonicalAlbum); }, async addLikedSongs(tokens, platformTrackIds): Promise { // Spotify deprecated PUT /me/tracks in favor of a unified /me/library // endpoint (Feb 2026 migration) — old endpoint now 403s unconditionally. await addToLibrary(tokens, platformTrackIds.map((id) => `spotify:track:${id}`)); }, async createPlaylist(tokens, name, description, isPublic = false): Promise<{ id: string }> { // Spotify deprecated POST /users/{user_id}/playlists in favor of // POST /me/playlists (Feb 2026 migration) — old endpoint now 403s. const playlist = await spotifyFetch("/me/playlists", tokens, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, description, public: isPublic }), }); return { id: playlist.id }; }, async addTracksToPlaylist(tokens, playlistId, platformTrackIds): Promise { // Spotify renamed POST /playlists/{id}/tracks to /playlists/{id}/items // (Feb 2026 migration) — body shape (uris) is unchanged. for (let i = 0; i < platformTrackIds.length; i += ADD_TO_PLAYLIST_BATCH_SIZE) { const batch = platformTrackIds.slice(i, i + ADD_TO_PLAYLIST_BATCH_SIZE); await spotifyFetch(`/playlists/${playlistId}/items`, tokens, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ uris: batch.map((id) => `spotify:track:${id}`) }), }); } }, async followArtist(tokens, platformArtistId): Promise { await addToLibrary(tokens, [`spotify:artist:${platformArtistId}`]); }, async saveAlbum(tokens, platformAlbumId): Promise { await addToLibrary(tokens, [`spotify:album:${platformAlbumId}`]); }, async followPlaylist(tokens, platformPlaylistId): Promise { await addToLibrary(tokens, [`spotify:playlist:${platformPlaylistId}`]); }, async unfollowPlaylist(tokens, platformPlaylistId): Promise { await removeFromLibrary(tokens, [`spotify:playlist:${platformPlaylistId}`]); }, };