spotify-client.ts•4.61 kB
import type { AuthManager } from "./auth.js";
import type { Logger } from "./logger.js";
const SPOTIFY_API_BASE = "https://api.spotify.com/v1";
export class SpotifyClient {
private auth: AuthManager;
private logger: Logger;
constructor(auth: AuthManager, logger: Logger) {
this.auth = auth;
this.logger = logger;
}
/**
* Make a GET request to the Spotify API
*/
async get<T>(endpoint: string, params?: Record<string, any>): Promise<T> {
const url = this.buildUrl(endpoint, params);
return this.request<T>(url, { method: "GET" });
}
/**
* Make a POST request to the Spotify API
*/
async post<T>(
endpoint: string,
body?: any,
params?: Record<string, any>
): Promise<T> {
const url = this.buildUrl(endpoint, params);
return this.request<T>(url, {
method: "POST",
body: body ? JSON.stringify(body) : undefined,
});
}
/**
* Make a PUT request to the Spotify API
*/
async put<T>(
endpoint: string,
body?: any,
params?: Record<string, any>
): Promise<T> {
const url = this.buildUrl(endpoint, params);
return this.request<T>(url, {
method: "PUT",
body: body ? JSON.stringify(body) : undefined,
});
}
/**
* Make a DELETE request to the Spotify API
*/
async delete<T>(endpoint: string, params?: Record<string, any>): Promise<T> {
const url = this.buildUrl(endpoint, params);
return this.request<T>(url, { method: "DELETE" });
}
/**
* Build full URL with query parameters
*/
private buildUrl(
endpoint: string,
params?: Record<string, any>
): string {
// Remove leading slash if present
const cleanEndpoint = endpoint.startsWith("/") ? endpoint.slice(1) : endpoint;
const url = new URL(`${SPOTIFY_API_BASE}/${cleanEndpoint}`);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.append(key, String(value));
}
});
}
return url.toString();
}
/**
* Make an authenticated request to Spotify API with retry logic
*/
private async request<T>(
url: string,
options: RequestInit
): Promise<T> {
const accessToken = await this.auth.getAccessToken();
const headers: HeadersInit = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
...options.headers,
};
await this.logger.debug(`${options.method} ${url}`);
try {
const response = await fetch(url, {
...options,
headers,
});
// Handle successful responses
if (response.ok) {
// Some endpoints return 204 No Content
if (response.status === 204) {
return {} as T;
}
return (await response.json()) as T;
}
// Handle specific error cases
if (response.status === 401) {
// Token might be invalid, try refreshing once
await this.logger.info("Received 401, attempting to refresh token and retry");
const newToken = await this.auth.getAccessToken();
const retryResponse = await fetch(url, {
...options,
headers: {
...headers,
Authorization: `Bearer ${newToken}`,
},
});
if (retryResponse.ok) {
if (retryResponse.status === 204) {
return {} as T;
}
return (await retryResponse.json()) as T;
}
const retryError = await retryResponse.text();
throw new Error(
`Spotify API error (retry): ${retryResponse.status} ${retryError}`
);
}
// Handle rate limiting
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After");
throw new Error(
`Rate limited. Retry after ${retryAfter || "unknown"} seconds`
);
}
// Handle other errors
const errorText = await response.text();
let errorMessage = `Spotify API error: ${response.status}`;
try {
const errorJson = JSON.parse(errorText);
if (errorJson.error?.message) {
errorMessage += ` - ${errorJson.error.message}`;
}
} catch {
if (errorText) {
errorMessage += ` - ${errorText}`;
}
}
throw new Error(errorMessage);
} catch (error) {
if (error instanceof Error) {
await this.logger.error(`Request failed: ${error.message}`);
throw error;
}
throw new Error(`Unknown error during request: ${error}`);
}
}
}