feat(spotify): implement milestone M3 api commands

This commit is contained in:
2026-04-12 01:52:18 -05:00
parent c8c0876b7c
commit d8570edcf0
8 changed files with 842 additions and 9 deletions
+174
View 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);
}
+14 -7
View File
@@ -4,6 +4,13 @@ import minimist from "minimist";
import { fileURLToPath } from "node:url";
import { getAuthStatus, runAuthorizationFlow } from "./auth.js";
import {
runAddToPlaylistCommand,
runCreatePlaylistCommand,
runRemoveFromPlaylistCommand,
runSearchAndAddCommand
} from "./playlists.js";
import { runListPlaylistsCommand, runSearchCommand } from "./search.js";
export interface CliDeps {
stdout: Pick<NodeJS.WriteStream, "write">;
@@ -14,7 +21,7 @@ export interface ParsedCli {
command: string;
positional: string[];
json: boolean;
public: boolean;
public?: boolean;
limit?: string;
offset?: string;
description?: string;
@@ -57,12 +64,12 @@ export function createDefaultHandlers(): CommandHandlers {
}
return status.configFound && status.tokenFound ? 0 : 1;
},
search: notImplemented("search"),
"list-playlists": notImplemented("list-playlists"),
"create-playlist": notImplemented("create-playlist"),
"add-to-playlist": notImplemented("add-to-playlist"),
"remove-from-playlist": notImplemented("remove-from-playlist"),
"search-and-add": notImplemented("search-and-add"),
search: runSearchCommand,
"list-playlists": runListPlaylistsCommand,
"create-playlist": runCreatePlaylistCommand,
"add-to-playlist": runAddToPlaylistCommand,
"remove-from-playlist": runRemoveFromPlaylistCommand,
"search-and-add": runSearchAndAddCommand,
import: notImplemented("import")
};
}
+118
View File
@@ -0,0 +1,118 @@
import { createSpotifyApiClient, type SpotifyApiClient } from "./api-client.js";
import type { CliDeps, ParsedCli } from "./cli.js";
import { mapPlaylist, mapTrack } from "./search.js";
import type { SpotifyTrack } from "./types.js";
type PlaylistClient = Pick<SpotifyApiClient, "createPlaylist" | "addItemsToPlaylist" | "removeItemsFromPlaylist" | "searchTracks">;
export function validateTrackUris(uris: string[]): string[] {
if (uris.length === 0) {
throw new Error("At least one spotify:track URI is required.");
}
for (const uri of uris) {
if (!uri.startsWith("spotify:track:")) {
throw new Error(`Invalid Spotify track URI: ${uri}`);
}
}
return uris;
}
function snapshotIds(results: Array<{ snapshot_id?: string }>): string[] {
return results.map((result) => result.snapshot_id).filter((id): id is string => Boolean(id));
}
export async function runCreatePlaylistCommand(
args: ParsedCli,
deps: CliDeps,
client: PlaylistClient = createSpotifyApiClient()
): Promise<number> {
const name = args.positional.join(" ").trim();
if (!name) {
throw new Error("Missing playlist name.");
}
const playlist = mapPlaylist(await client.createPlaylist(name, { description: args.description, public: Boolean(args.public) }));
if (args.json) {
deps.stdout.write(`${JSON.stringify({ playlist }, null, 2)}\n`);
} else {
deps.stdout.write(`Created playlist ${playlist.id}: ${playlist.name}\n`);
}
return 0;
}
export async function runAddToPlaylistCommand(
args: ParsedCli,
deps: CliDeps,
client: PlaylistClient = createSpotifyApiClient()
): Promise<number> {
const [playlistId, ...uris] = args.positional;
if (!playlistId) {
throw new Error("Missing playlist id.");
}
const validUris = validateTrackUris(uris);
const results = await client.addItemsToPlaylist(playlistId, validUris);
const output = { playlistId, count: validUris.length, snapshotIds: snapshotIds(results) };
deps.stdout.write(args.json ? `${JSON.stringify(output, null, 2)}\n` : `Added ${output.count} track(s) to ${playlistId}.\n`);
return 0;
}
export async function runRemoveFromPlaylistCommand(
args: ParsedCli,
deps: CliDeps,
client: PlaylistClient = createSpotifyApiClient()
): Promise<number> {
const [playlistId, ...uris] = args.positional;
if (!playlistId) {
throw new Error("Missing playlist id.");
}
const validUris = validateTrackUris(uris);
const results = await client.removeItemsFromPlaylist(playlistId, validUris);
const output = { playlistId, count: validUris.length, snapshotIds: snapshotIds(results) };
deps.stdout.write(args.json ? `${JSON.stringify(output, null, 2)}\n` : `Removed ${output.count} track(s) from ${playlistId}.\n`);
return 0;
}
export async function searchAndAdd(
playlistId: string,
queries: string[],
client: Pick<PlaylistClient, "searchTracks" | "addItemsToPlaylist">
): Promise<{ added: Array<{ query: string; uri: string; name: string; artists: string[] }>; missed: string[]; snapshotIds: string[] }> {
if (!playlistId) {
throw new Error("Missing playlist id.");
}
if (queries.length === 0) {
throw new Error("At least one search query is required.");
}
const added: Array<{ query: string; uri: string; name: string; artists: string[] }> = [];
const missed: string[] = [];
for (const query of queries) {
const tracks: SpotifyTrack[] = await client.searchTracks(query, 1);
const first = tracks[0];
if (!first) {
missed.push(query);
continue;
}
const output = mapTrack(first);
added.push({ query, uri: output.uri, name: output.name, artists: output.artists });
}
const mutationResults = added.length > 0
? await client.addItemsToPlaylist(playlistId, added.map((entry) => entry.uri))
: [];
return { added, missed, snapshotIds: snapshotIds(mutationResults) };
}
export async function runSearchAndAddCommand(
args: ParsedCli,
deps: CliDeps,
client: PlaylistClient = createSpotifyApiClient()
): Promise<number> {
const [playlistId, ...queries] = args.positional;
const output = await searchAndAdd(playlistId, queries, client);
if (args.json) {
deps.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
} else {
deps.stdout.write(`Added ${output.added.length} track(s) to ${playlistId}; missed ${output.missed.length}.\n`);
}
return 0;
}
+98
View File
@@ -0,0 +1,98 @@
import { createSpotifyApiClient, type SpotifyApiClient } from "./api-client.js";
import type { CliDeps, ParsedCli } from "./cli.js";
import type { SpotifyPlaylist, SpotifyTrack } from "./types.js";
export interface TrackOutput {
id: string;
uri: string;
name: string;
artists: string[];
album?: string;
externalUrl?: string;
}
export interface PlaylistOutput {
id: string;
name: string;
public: boolean | null;
owner: string;
externalUrl?: string;
}
function numberOption(value: string | undefined, fallback: number): number {
if (!value) {
return fallback;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
export function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, Math.trunc(value)));
}
export function mapTrack(track: SpotifyTrack): TrackOutput {
return {
id: track.id,
uri: track.uri,
name: track.name,
artists: track.artists.map((artist) => artist.name),
album: track.album?.name,
externalUrl: track.external_urls?.spotify
};
}
export function mapPlaylist(playlist: SpotifyPlaylist): PlaylistOutput {
return {
id: playlist.id,
name: playlist.name,
public: playlist.public,
owner: playlist.owner.display_name ?? playlist.owner.id,
externalUrl: playlist.external_urls?.spotify
};
}
export function formatTrack(track: TrackOutput): string {
const album = track.album ? ` (${track.album})` : "";
return `${track.uri} | ${track.name} | ${track.artists.join(", ")}${album}`;
}
export function formatPlaylist(playlist: PlaylistOutput): string {
const visibility = playlist.public === true ? "public" : playlist.public === false ? "private" : "unknown";
return `${playlist.id} | ${visibility} | ${playlist.owner} | ${playlist.name}`;
}
export async function runSearchCommand(
args: ParsedCli,
deps: CliDeps,
client: Pick<SpotifyApiClient, "searchTracks"> = createSpotifyApiClient()
): Promise<number> {
const query = args.positional.join(" ").trim();
if (!query) {
throw new Error("Missing search query.");
}
const limit = clamp(numberOption(args.limit, 5), 1, 10);
const tracks = (await client.searchTracks(query, limit)).map(mapTrack);
if (args.json) {
deps.stdout.write(`${JSON.stringify({ tracks }, null, 2)}\n`);
} else {
deps.stdout.write(`${tracks.map(formatTrack).join("\n")}${tracks.length ? "\n" : ""}`);
}
return 0;
}
export async function runListPlaylistsCommand(
args: ParsedCli,
deps: CliDeps,
client: Pick<SpotifyApiClient, "listPlaylists"> = createSpotifyApiClient()
): Promise<number> {
const limit = clamp(numberOption(args.limit, 50), 1, 50);
const offset = Math.max(0, numberOption(args.offset, 0));
const playlists = (await client.listPlaylists(limit, offset)).map(mapPlaylist);
if (args.json) {
deps.stdout.write(`${JSON.stringify({ playlists }, null, 2)}\n`);
} else {
deps.stdout.write(`${playlists.map(formatPlaylist).join("\n")}${playlists.length ? "\n" : ""}`);
}
return 0;
}