import { refreshStoredToken } from "./auth.js"; import { loadToken, tokenNeedsRefresh } from "./token-store.js"; import type { SpotifyPlaylist, SpotifyToken, SpotifyTrack } from "./types.js"; type FetchLike = typeof fetch; export interface SpotifyUser { id: string; display_name?: string | null; } export interface SpotifyApiClientOptions { fetchImpl?: FetchLike; loadToken?: () => Promise; refreshToken?: () => Promise; sleep?: (ms: number) => Promise; now?: () => number; baseUrl?: string; } export interface CreatePlaylistOptions { description?: string; public?: boolean; } export interface PlaylistMutationResult { snapshot_id?: string; } const DEFAULT_BASE_URL = "https://api.spotify.com/v1"; function chunk(items: T[], size: number): T[][] { const chunks: T[][] = []; for (let index = 0; index < items.length; index += size) { chunks.push(items.slice(index, index + size)); } return chunks; } async function noSleep(): Promise { return undefined; } export class SpotifyApiClient { private readonly fetchImpl: FetchLike; private readonly loadStoredToken: () => Promise; private readonly refreshStoredToken: () => Promise; private readonly sleep: (ms: number) => Promise; private readonly now: () => number; private readonly baseUrl: string; constructor(options: SpotifyApiClientOptions = {}) { this.fetchImpl = options.fetchImpl ?? fetch; this.loadStoredToken = options.loadToken ?? (() => loadToken()); this.refreshStoredToken = options.refreshToken ?? (() => refreshStoredToken()); this.sleep = options.sleep ?? noSleep; this.now = options.now ?? Date.now; this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL; } async getCurrentUser(): Promise { return this.request("GET", "/me"); } async searchTracks(query: string, limit: number): Promise { const params = new URLSearchParams({ type: "track", q: query, limit: String(limit) }); const response = await this.request<{ tracks: { items: SpotifyTrack[] } }>("GET", `/search?${params.toString()}`); return response.tracks.items; } async listPlaylists(limit: number, offset: number): Promise { const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); const response = await this.request<{ items: SpotifyPlaylist[] }>("GET", `/me/playlists?${params.toString()}`); return response.items; } async createPlaylist(name: string, options: CreatePlaylistOptions = {}): Promise { return this.request("POST", "/me/playlists", { name, description: options.description, public: Boolean(options.public) }); } async addItemsToPlaylist(playlistId: string, uris: string[]): Promise { const results: PlaylistMutationResult[] = []; for (const batch of chunk(uris, 100)) { results.push(await this.request("POST", `/playlists/${encodeURIComponent(playlistId)}/items`, { uris: batch })); } return results; } async removeItemsFromPlaylist(playlistId: string, uris: string[]): Promise { const results: PlaylistMutationResult[] = []; for (const batch of chunk(uris, 100)) { results.push(await this.request("DELETE", `/playlists/${encodeURIComponent(playlistId)}/items`, { tracks: batch.map((uri) => ({ uri })) })); } return results; } private async getAccessToken(): Promise { const token = await this.loadStoredToken(); if (!token) { throw new Error("Spotify auth token not found. Run `scripts/spotify auth` first."); } if (tokenNeedsRefresh(token, this.now())) { return this.refreshAccessTokenSafely(); } return token; } private async request(method: string, path: string, body?: unknown, authRetried = false): Promise { const token = await this.getAccessToken(); const response = await this.fetchWithTransientRetries(method, path, token.accessToken, body); if (response.status === 401 && !authRetried) { const refreshed = await this.refreshAccessTokenSafely(); const retryResponse = await this.fetchWithTransientRetries(method, path, refreshed.accessToken, body); return this.parseResponse(retryResponse); } return this.parseResponse(response); } private async fetchWithTransientRetries(method: string, path: string, accessToken: string, body: unknown): Promise { let retried429 = false; let serverRetries = 0; while (true) { const response = await this.fetchImpl(`${this.baseUrl}${path}`, { method, headers: { authorization: `Bearer ${accessToken}`, ...(body === undefined ? {} : { "content-type": "application/json" }) }, body: body === undefined ? undefined : JSON.stringify(body) }); if (response.status === 429 && !retried429) { retried429 = true; const retryAfter = Number(response.headers.get("retry-after") ?? "1"); await this.sleep(Number.isFinite(retryAfter) ? retryAfter * 1000 : 1000); continue; } if (response.status >= 500 && response.status < 600 && serverRetries < 2) { serverRetries += 1; await this.sleep(100 * serverRetries); continue; } return response; } } private async refreshAccessTokenSafely(): Promise { try { return await this.refreshStoredToken(); } catch { throw new Error("Spotify token refresh failed. Run `scripts/spotify auth` again."); } } private async parseResponse(response: Response): Promise { if (!response.ok) { throw new Error(`Spotify API request failed with status ${response.status}.`); } if (response.status === 204) { return undefined as T; } return response.json() as Promise; } } export function createSpotifyApiClient(options: SpotifyApiClientOptions = {}): SpotifyApiClient { return new SpotifyApiClient(options); }