feat(spotify): implement milestone M3 api commands
This commit is contained in:
174
skills/spotify/src/api-client.ts
Normal file
174
skills/spotify/src/api-client.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user