Files
stef-openclaw-skills/skills/spotify/src/api-client.ts

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);
}