175 lines
6.1 KiB
TypeScript
175 lines
6.1 KiB
TypeScript
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<SpotifyToken | undefined>;
|
|
refreshToken?: () => Promise<SpotifyToken>;
|
|
sleep?: (ms: number) => Promise<void>;
|
|
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<T>(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<void> {
|
|
return undefined;
|
|
}
|
|
|
|
export class SpotifyApiClient {
|
|
private readonly fetchImpl: FetchLike;
|
|
private readonly loadStoredToken: () => Promise<SpotifyToken | undefined>;
|
|
private readonly refreshStoredToken: () => Promise<SpotifyToken>;
|
|
private readonly sleep: (ms: number) => Promise<void>;
|
|
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<SpotifyUser> {
|
|
return this.request<SpotifyUser>("GET", "/me");
|
|
}
|
|
|
|
async searchTracks(query: string, limit: number): Promise<SpotifyTrack[]> {
|
|
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<SpotifyPlaylist[]> {
|
|
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<SpotifyPlaylist> {
|
|
return this.request<SpotifyPlaylist>("POST", "/me/playlists", {
|
|
name,
|
|
description: options.description,
|
|
public: Boolean(options.public)
|
|
});
|
|
}
|
|
|
|
async addItemsToPlaylist(playlistId: string, uris: string[]): Promise<PlaylistMutationResult[]> {
|
|
const results: PlaylistMutationResult[] = [];
|
|
for (const batch of chunk(uris, 100)) {
|
|
results.push(await this.request<PlaylistMutationResult>("POST", `/playlists/${encodeURIComponent(playlistId)}/items`, { uris: batch }));
|
|
}
|
|
return results;
|
|
}
|
|
|
|
async removeItemsFromPlaylist(playlistId: string, uris: string[]): Promise<PlaylistMutationResult[]> {
|
|
const results: PlaylistMutationResult[] = [];
|
|
for (const batch of chunk(uris, 100)) {
|
|
results.push(await this.request<PlaylistMutationResult>("DELETE", `/playlists/${encodeURIComponent(playlistId)}/items`, {
|
|
tracks: batch.map((uri) => ({ uri }))
|
|
}));
|
|
}
|
|
return results;
|
|
}
|
|
|
|
private async getAccessToken(): Promise<SpotifyToken> {
|
|
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<T>(method: string, path: string, body?: unknown, authRetried = false): Promise<T> {
|
|
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<T>(retryResponse);
|
|
}
|
|
return this.parseResponse<T>(response);
|
|
}
|
|
|
|
private async fetchWithTransientRetries(method: string, path: string, accessToken: string, body: unknown): Promise<Response> {
|
|
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<SpotifyToken> {
|
|
try {
|
|
return await this.refreshStoredToken();
|
|
} catch {
|
|
throw new Error("Spotify token refresh failed. Run `scripts/spotify auth` again.");
|
|
}
|
|
}
|
|
|
|
private async parseResponse<T>(response: Response): Promise<T> {
|
|
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<T>;
|
|
}
|
|
}
|
|
|
|
export function createSpotifyApiClient(options: SpotifyApiClientOptions = {}): SpotifyApiClient {
|
|
return new SpotifyApiClient(options);
|
|
}
|