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);
|
||||||
|
}
|
||||||
@@ -4,6 +4,13 @@ import minimist from "minimist";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { getAuthStatus, runAuthorizationFlow } from "./auth.js";
|
import { getAuthStatus, runAuthorizationFlow } from "./auth.js";
|
||||||
|
import {
|
||||||
|
runAddToPlaylistCommand,
|
||||||
|
runCreatePlaylistCommand,
|
||||||
|
runRemoveFromPlaylistCommand,
|
||||||
|
runSearchAndAddCommand
|
||||||
|
} from "./playlists.js";
|
||||||
|
import { runListPlaylistsCommand, runSearchCommand } from "./search.js";
|
||||||
|
|
||||||
export interface CliDeps {
|
export interface CliDeps {
|
||||||
stdout: Pick<NodeJS.WriteStream, "write">;
|
stdout: Pick<NodeJS.WriteStream, "write">;
|
||||||
@@ -14,7 +21,7 @@ export interface ParsedCli {
|
|||||||
command: string;
|
command: string;
|
||||||
positional: string[];
|
positional: string[];
|
||||||
json: boolean;
|
json: boolean;
|
||||||
public: boolean;
|
public?: boolean;
|
||||||
limit?: string;
|
limit?: string;
|
||||||
offset?: string;
|
offset?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -57,12 +64,12 @@ export function createDefaultHandlers(): CommandHandlers {
|
|||||||
}
|
}
|
||||||
return status.configFound && status.tokenFound ? 0 : 1;
|
return status.configFound && status.tokenFound ? 0 : 1;
|
||||||
},
|
},
|
||||||
search: notImplemented("search"),
|
search: runSearchCommand,
|
||||||
"list-playlists": notImplemented("list-playlists"),
|
"list-playlists": runListPlaylistsCommand,
|
||||||
"create-playlist": notImplemented("create-playlist"),
|
"create-playlist": runCreatePlaylistCommand,
|
||||||
"add-to-playlist": notImplemented("add-to-playlist"),
|
"add-to-playlist": runAddToPlaylistCommand,
|
||||||
"remove-from-playlist": notImplemented("remove-from-playlist"),
|
"remove-from-playlist": runRemoveFromPlaylistCommand,
|
||||||
"search-and-add": notImplemented("search-and-add"),
|
"search-and-add": runSearchAndAddCommand,
|
||||||
import: notImplemented("import")
|
import: notImplemented("import")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
118
skills/spotify/src/playlists.ts
Normal file
118
skills/spotify/src/playlists.ts
Normal 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
skills/spotify/src/search.ts
Normal file
98
skills/spotify/src/search.ts
Normal 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;
|
||||||
|
}
|
||||||
181
skills/spotify/tests/api-client.test.ts
Normal file
181
skills/spotify/tests/api-client.test.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
import { createSpotifyApiClient } from "../src/api-client.js";
|
||||||
|
import type { SpotifyToken } from "../src/types.js";
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200, headers?: HeadersInit): Response {
|
||||||
|
return new Response(JSON.stringify(body), { status, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
function token(expiresAt = Date.now() + 3_600_000): SpotifyToken {
|
||||||
|
return { accessToken: "access-token", refreshToken: "refresh-token", expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("searchTracks uses current search endpoint", async () => {
|
||||||
|
const urls: string[] = [];
|
||||||
|
const client = createSpotifyApiClient({
|
||||||
|
loadToken: async () => token(),
|
||||||
|
fetchImpl: (async (url) => {
|
||||||
|
urls.push(String(url));
|
||||||
|
return jsonResponse({ tracks: { items: [{ id: "1", uri: "spotify:track:1", name: "Song", artists: [] }] } });
|
||||||
|
}) as typeof fetch
|
||||||
|
});
|
||||||
|
|
||||||
|
const tracks = await client.searchTracks("Karma Police", 3);
|
||||||
|
|
||||||
|
assert.equal(tracks[0].uri, "spotify:track:1");
|
||||||
|
assert.equal(urls[0], "https://api.spotify.com/v1/search?type=track&q=Karma+Police&limit=3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("proactively refreshes expired token before request", async () => {
|
||||||
|
const accessTokens: string[] = [];
|
||||||
|
const client = createSpotifyApiClient({
|
||||||
|
now: () => 1_000,
|
||||||
|
loadToken: async () => token(1_000),
|
||||||
|
refreshToken: async () => ({ accessToken: "fresh-token", refreshToken: "refresh-token", expiresAt: 10_000 }),
|
||||||
|
fetchImpl: (async (_url, init) => {
|
||||||
|
accessTokens.push(String(init?.headers && (init.headers as Record<string, string>).authorization));
|
||||||
|
return jsonResponse({ id: "me" });
|
||||||
|
}) as typeof fetch
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.getCurrentUser();
|
||||||
|
|
||||||
|
assert.deepEqual(accessTokens, ["Bearer fresh-token"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reactively refreshes once on 401 and retries original request", async () => {
|
||||||
|
const accessTokens: string[] = [];
|
||||||
|
const client = createSpotifyApiClient({
|
||||||
|
loadToken: async () => token(),
|
||||||
|
refreshToken: async () => ({ accessToken: "fresh-token", refreshToken: "refresh-token", expiresAt: 20_000 }),
|
||||||
|
fetchImpl: (async (_url, init) => {
|
||||||
|
accessTokens.push(String(init?.headers && (init.headers as Record<string, string>).authorization));
|
||||||
|
if (accessTokens.length === 1) {
|
||||||
|
return jsonResponse({ error: "expired" }, 401);
|
||||||
|
}
|
||||||
|
return jsonResponse({ id: "me" });
|
||||||
|
}) as typeof fetch
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.getCurrentUser();
|
||||||
|
|
||||||
|
assert.deepEqual(accessTokens, ["Bearer access-token", "Bearer fresh-token"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("proactive refresh failure is sanitized", async () => {
|
||||||
|
const client = createSpotifyApiClient({
|
||||||
|
now: () => 1_000,
|
||||||
|
loadToken: async () => ({ accessToken: "access-secret", refreshToken: "refresh-secret", expiresAt: 1_000 }),
|
||||||
|
refreshToken: async () => {
|
||||||
|
throw new Error("refresh-secret access-secret credential-path");
|
||||||
|
},
|
||||||
|
fetchImpl: (async () => jsonResponse({ id: "me" })) as typeof fetch
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => client.getCurrentUser(),
|
||||||
|
(error) => error instanceof Error &&
|
||||||
|
error.message === "Spotify token refresh failed. Run `scripts/spotify auth` again." &&
|
||||||
|
!error.message.includes("secret") &&
|
||||||
|
!error.message.includes("credential-path")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reactive refresh failure is sanitized", async () => {
|
||||||
|
const client = createSpotifyApiClient({
|
||||||
|
loadToken: async () => token(),
|
||||||
|
refreshToken: async () => {
|
||||||
|
throw new Error("refresh-secret access-secret credential-path");
|
||||||
|
},
|
||||||
|
fetchImpl: (async () => jsonResponse({ error: "expired" }, 401)) as typeof fetch
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => client.getCurrentUser(),
|
||||||
|
(error) => error instanceof Error &&
|
||||||
|
error.message === "Spotify token refresh failed. Run `scripts/spotify auth` again." &&
|
||||||
|
!error.message.includes("secret") &&
|
||||||
|
!error.message.includes("credential-path")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retries 429 using retry-after", async () => {
|
||||||
|
const sleeps: number[] = [];
|
||||||
|
let attempts = 0;
|
||||||
|
const client = createSpotifyApiClient({
|
||||||
|
loadToken: async () => token(),
|
||||||
|
sleep: async (ms) => { sleeps.push(ms); },
|
||||||
|
fetchImpl: (async () => {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts === 1) {
|
||||||
|
return jsonResponse({ error: "rate" }, 429, { "retry-after": "2" });
|
||||||
|
}
|
||||||
|
return jsonResponse({ id: "me" });
|
||||||
|
}) as typeof fetch
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.getCurrentUser();
|
||||||
|
|
||||||
|
assert.equal(attempts, 2);
|
||||||
|
assert.deepEqual(sleeps, [2000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("429 retry has a separate budget from 5xx retry", async () => {
|
||||||
|
const statuses = [500, 429, 200];
|
||||||
|
const client = createSpotifyApiClient({
|
||||||
|
loadToken: async () => token(),
|
||||||
|
sleep: async () => undefined,
|
||||||
|
fetchImpl: (async () => {
|
||||||
|
const status = statuses.shift() ?? 200;
|
||||||
|
return status === 200 ? jsonResponse({ id: "me" }) : jsonResponse({ error: status }, status);
|
||||||
|
}) as typeof fetch
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.getCurrentUser();
|
||||||
|
|
||||||
|
assert.deepEqual(statuses, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retries 5xx up to bounded attempts", async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const client = createSpotifyApiClient({
|
||||||
|
loadToken: async () => token(),
|
||||||
|
sleep: async () => undefined,
|
||||||
|
fetchImpl: (async () => {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts < 3) {
|
||||||
|
return jsonResponse({ error: "server" }, 500);
|
||||||
|
}
|
||||||
|
return jsonResponse({ id: "me" });
|
||||||
|
}) as typeof fetch
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.getCurrentUser();
|
||||||
|
|
||||||
|
assert.equal(attempts, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("playlist mutations use current items endpoints and chunk batches", async () => {
|
||||||
|
const calls: Array<{ url: string; method?: string; body?: string }> = [];
|
||||||
|
const client = createSpotifyApiClient({
|
||||||
|
loadToken: async () => token(),
|
||||||
|
fetchImpl: (async (url, init) => {
|
||||||
|
calls.push({ url: String(url), method: init?.method, body: String(init?.body) });
|
||||||
|
return jsonResponse({ snapshot_id: `snap-${calls.length}` });
|
||||||
|
}) as typeof fetch
|
||||||
|
});
|
||||||
|
const uris = Array.from({ length: 101 }, (_, index) => `spotify:track:${index}`);
|
||||||
|
|
||||||
|
const addResults = await client.addItemsToPlaylist("playlist id", uris);
|
||||||
|
const removeResults = await client.removeItemsFromPlaylist("playlist id", uris);
|
||||||
|
|
||||||
|
assert.deepEqual(addResults.map((result) => result.snapshot_id), ["snap-1", "snap-2"]);
|
||||||
|
assert.deepEqual(removeResults.map((result) => result.snapshot_id), ["snap-3", "snap-4"]);
|
||||||
|
assert.equal(calls[0].url, "https://api.spotify.com/v1/playlists/playlist%20id/items");
|
||||||
|
assert.equal(calls[0].method, "POST");
|
||||||
|
assert.equal(JSON.parse(calls[0].body ?? "{}").uris.length, 100);
|
||||||
|
assert.equal(calls[2].method, "DELETE");
|
||||||
|
assert.equal(JSON.parse(calls[2].body ?? "{}").tracks.length, 100);
|
||||||
|
});
|
||||||
@@ -74,12 +74,12 @@ test("dispatches known command with json flag", async () => {
|
|||||||
test("known unimplemented commands return structured placeholder errors in json mode", async () => {
|
test("known unimplemented commands return structured placeholder errors in json mode", async () => {
|
||||||
const stdout = createBuffer();
|
const stdout = createBuffer();
|
||||||
const stderr = createBuffer();
|
const stderr = createBuffer();
|
||||||
const code = await runCli(["search", "--json"], { stdout: stdout.stream, stderr: stderr.stream });
|
const code = await runCli(["import", "--json"], { stdout: stdout.stream, stderr: stderr.stream });
|
||||||
|
|
||||||
assert.equal(code, 2);
|
assert.equal(code, 2);
|
||||||
assert.deepEqual(JSON.parse(stdout.output()), {
|
assert.deepEqual(JSON.parse(stdout.output()), {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: "Command not implemented yet: search"
|
error: "Command not implemented yet: import"
|
||||||
});
|
});
|
||||||
assert.equal(stderr.output(), "");
|
assert.equal(stderr.output(), "");
|
||||||
});
|
});
|
||||||
|
|||||||
158
skills/spotify/tests/playlists.test.ts
Normal file
158
skills/spotify/tests/playlists.test.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
runAddToPlaylistCommand,
|
||||||
|
runCreatePlaylistCommand,
|
||||||
|
runRemoveFromPlaylistCommand,
|
||||||
|
runSearchAndAddCommand,
|
||||||
|
searchAndAdd,
|
||||||
|
validateTrackUris
|
||||||
|
} from "../src/playlists.js";
|
||||||
|
import type { CliDeps } from "../src/cli.js";
|
||||||
|
import type { SpotifyPlaylist } from "../src/types.js";
|
||||||
|
|
||||||
|
function createDeps(): { deps: CliDeps; stdout: () => string; stderr: () => string } {
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
return {
|
||||||
|
deps: {
|
||||||
|
stdout: { write: (chunk: string) => { stdout += chunk; return true; } },
|
||||||
|
stderr: { write: (chunk: string) => { stderr += chunk; return true; } }
|
||||||
|
},
|
||||||
|
stdout: () => stdout,
|
||||||
|
stderr: () => stderr
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function playlist(name: string): SpotifyPlaylist {
|
||||||
|
return {
|
||||||
|
id: "playlist-id",
|
||||||
|
uri: "spotify:playlist:playlist-id",
|
||||||
|
name,
|
||||||
|
public: false,
|
||||||
|
owner: { id: "owner-id" },
|
||||||
|
external_urls: { spotify: "https://open.spotify.com/playlist/playlist-id" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("create playlist defaults private unless public flag is present", async () => {
|
||||||
|
const io = createDeps();
|
||||||
|
let observed: { name: string; public?: boolean; description?: string } | undefined;
|
||||||
|
|
||||||
|
await runCreatePlaylistCommand(
|
||||||
|
{ command: "create-playlist", positional: ["My", "Mix"], json: true, public: false, description: "desc" },
|
||||||
|
io.deps,
|
||||||
|
{
|
||||||
|
createPlaylist: async (name, options) => {
|
||||||
|
observed = { name, ...options };
|
||||||
|
return playlist(name);
|
||||||
|
},
|
||||||
|
addItemsToPlaylist: async () => [],
|
||||||
|
removeItemsFromPlaylist: async () => [],
|
||||||
|
searchTracks: async () => []
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(observed, { name: "My Mix", public: false, description: "desc" });
|
||||||
|
assert.equal(JSON.parse(io.stdout()).playlist.id, "playlist-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("create playlist treats omitted public flag as private", async () => {
|
||||||
|
const io = createDeps();
|
||||||
|
let observedPublic: boolean | undefined;
|
||||||
|
|
||||||
|
await runCreatePlaylistCommand(
|
||||||
|
{ command: "create-playlist", positional: ["My Mix"], json: true },
|
||||||
|
io.deps,
|
||||||
|
{
|
||||||
|
createPlaylist: async (name, options) => {
|
||||||
|
observedPublic = options?.public;
|
||||||
|
return playlist(name);
|
||||||
|
},
|
||||||
|
addItemsToPlaylist: async () => [],
|
||||||
|
removeItemsFromPlaylist: async () => [],
|
||||||
|
searchTracks: async () => []
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(observedPublic, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validates spotify track uris", () => {
|
||||||
|
assert.deepEqual(validateTrackUris(["spotify:track:1"]), ["spotify:track:1"]);
|
||||||
|
assert.throws(() => validateTrackUris(["spotify:album:1"]), /Invalid Spotify track URI/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add to playlist outputs mutation summary", async () => {
|
||||||
|
const io = createDeps();
|
||||||
|
let observedUris: string[] = [];
|
||||||
|
await runAddToPlaylistCommand(
|
||||||
|
{ command: "add-to-playlist", positional: ["playlist-id", "spotify:track:1"], json: true, public: false },
|
||||||
|
io.deps,
|
||||||
|
{
|
||||||
|
createPlaylist: async () => playlist("unused"),
|
||||||
|
addItemsToPlaylist: async (_playlistId, uris) => {
|
||||||
|
observedUris = uris;
|
||||||
|
return [{ snapshot_id: "snap" }];
|
||||||
|
},
|
||||||
|
removeItemsFromPlaylist: async () => [],
|
||||||
|
searchTracks: async () => []
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(observedUris, ["spotify:track:1"]);
|
||||||
|
assert.deepEqual(JSON.parse(io.stdout()), { playlistId: "playlist-id", count: 1, snapshotIds: ["snap"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("remove from playlist outputs mutation summary", async () => {
|
||||||
|
const io = createDeps();
|
||||||
|
await runRemoveFromPlaylistCommand(
|
||||||
|
{ command: "remove-from-playlist", positional: ["playlist-id", "spotify:track:1"], json: false, public: false },
|
||||||
|
io.deps,
|
||||||
|
{
|
||||||
|
createPlaylist: async () => playlist("unused"),
|
||||||
|
addItemsToPlaylist: async () => [],
|
||||||
|
removeItemsFromPlaylist: async () => [{ snapshot_id: "snap" }],
|
||||||
|
searchTracks: async () => []
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(io.stdout(), "Removed 1 track(s) from playlist-id.\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("searchAndAdd adds first matches and records misses", async () => {
|
||||||
|
let observedUris: string[] = [];
|
||||||
|
const result = await searchAndAdd("playlist-id", ["found", "missing"], {
|
||||||
|
searchTracks: async (query) => query === "found"
|
||||||
|
? [{ id: "track-id", uri: "spotify:track:track-id", name: "Song", artists: [{ name: "Artist" }] }]
|
||||||
|
: [],
|
||||||
|
addItemsToPlaylist: async (_playlistId, uris) => {
|
||||||
|
observedUris = uris;
|
||||||
|
return [{ snapshot_id: "snap" }];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(observedUris, ["spotify:track:track-id"]);
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
added: [{ query: "found", uri: "spotify:track:track-id", name: "Song", artists: ["Artist"] }],
|
||||||
|
missed: ["missing"],
|
||||||
|
snapshotIds: ["snap"]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search-and-add command writes JSON summary", async () => {
|
||||||
|
const io = createDeps();
|
||||||
|
await runSearchAndAddCommand(
|
||||||
|
{ command: "search-and-add", positional: ["playlist-id", "found"], json: true, public: false },
|
||||||
|
io.deps,
|
||||||
|
{
|
||||||
|
createPlaylist: async () => playlist("unused"),
|
||||||
|
addItemsToPlaylist: async () => [{ snapshot_id: "snap" }],
|
||||||
|
removeItemsFromPlaylist: async () => [],
|
||||||
|
searchTracks: async () => [{ id: "track-id", uri: "spotify:track:track-id", name: "Song", artists: [{ name: "Artist" }] }]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(JSON.parse(io.stdout()).added[0].uri, "spotify:track:track-id");
|
||||||
|
});
|
||||||
97
skills/spotify/tests/search.test.ts
Normal file
97
skills/spotify/tests/search.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
import { mapPlaylist, mapTrack, runListPlaylistsCommand, runSearchCommand } from "../src/search.js";
|
||||||
|
import type { CliDeps } from "../src/cli.js";
|
||||||
|
import type { SpotifyPlaylist, SpotifyTrack } from "../src/types.js";
|
||||||
|
|
||||||
|
function createDeps(): { deps: CliDeps; stdout: () => string; stderr: () => string } {
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
return {
|
||||||
|
deps: {
|
||||||
|
stdout: { write: (chunk: string) => { stdout += chunk; return true; } },
|
||||||
|
stderr: { write: (chunk: string) => { stderr += chunk; return true; } }
|
||||||
|
},
|
||||||
|
stdout: () => stdout,
|
||||||
|
stderr: () => stderr
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const track: SpotifyTrack = {
|
||||||
|
id: "track-id",
|
||||||
|
uri: "spotify:track:track-id",
|
||||||
|
name: "Karma Police",
|
||||||
|
artists: [{ name: "Radiohead" }],
|
||||||
|
album: { name: "OK Computer" },
|
||||||
|
external_urls: { spotify: "https://open.spotify.com/track/track-id" }
|
||||||
|
};
|
||||||
|
|
||||||
|
const playlist: SpotifyPlaylist = {
|
||||||
|
id: "playlist-id",
|
||||||
|
uri: "spotify:playlist:playlist-id",
|
||||||
|
name: "Private Mix",
|
||||||
|
public: false,
|
||||||
|
owner: { id: "owner-id", display_name: "Owner" },
|
||||||
|
external_urls: { spotify: "https://open.spotify.com/playlist/playlist-id" }
|
||||||
|
};
|
||||||
|
|
||||||
|
test("maps raw track to flattened output DTO", () => {
|
||||||
|
assert.deepEqual(mapTrack(track), {
|
||||||
|
id: "track-id",
|
||||||
|
uri: "spotify:track:track-id",
|
||||||
|
name: "Karma Police",
|
||||||
|
artists: ["Radiohead"],
|
||||||
|
album: "OK Computer",
|
||||||
|
externalUrl: "https://open.spotify.com/track/track-id"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search command clamps limit and writes JSON", async () => {
|
||||||
|
const io = createDeps();
|
||||||
|
let observedLimit = 0;
|
||||||
|
const code = await runSearchCommand(
|
||||||
|
{ command: "search", positional: ["Karma", "Police"], json: true, public: false, limit: "99" },
|
||||||
|
io.deps,
|
||||||
|
{
|
||||||
|
searchTracks: async (_query, limit) => {
|
||||||
|
observedLimit = limit;
|
||||||
|
return [track];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.equal(observedLimit, 10);
|
||||||
|
assert.equal(JSON.parse(io.stdout()).tracks[0].externalUrl, "https://open.spotify.com/track/track-id");
|
||||||
|
assert.equal(io.stderr(), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps playlist to flattened output DTO", () => {
|
||||||
|
assert.deepEqual(mapPlaylist(playlist), {
|
||||||
|
id: "playlist-id",
|
||||||
|
name: "Private Mix",
|
||||||
|
public: false,
|
||||||
|
owner: "Owner",
|
||||||
|
externalUrl: "https://open.spotify.com/playlist/playlist-id"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("list playlists command clamps limit and writes human output", async () => {
|
||||||
|
const io = createDeps();
|
||||||
|
let observed = { limit: 0, offset: -1 };
|
||||||
|
const code = await runListPlaylistsCommand(
|
||||||
|
{ command: "list-playlists", positional: [], json: false, public: false, limit: "200", offset: "-5" },
|
||||||
|
io.deps,
|
||||||
|
{
|
||||||
|
listPlaylists: async (limit, offset) => {
|
||||||
|
observed = { limit, offset };
|
||||||
|
return [playlist];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.deepEqual(observed, { limit: 50, offset: 0 });
|
||||||
|
assert.match(io.stdout(), /playlist-id \\| private \\| Owner \\| Private Mix/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user