musix-box 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/package.json +34 -0
- package/src/index.ts +225 -0
- package/src/providers/media_provider.ts +152 -0
- package/src/providers/spotify/spotify_api_client.ts +220 -0
- package/src/providers/spotify/spotify_oauth.ts +197 -0
- package/src/providers/spotify/spotify_provider.ts +159 -0
- package/src/providers/spotify/spotify_session.ts +95 -0
- package/src/spotify_handler.ts +128 -0
- package/src/tool_errors.ts +180 -0
- package/src/types.ts +32 -0
- package/src/worker.ts +41 -0
- package/tests/providers/media_provider.test.ts +43 -0
- package/tests/providers/spotify/spotify_oauth.test.ts +106 -0
- package/tests/providers/spotify/spotify_provider.test.ts +218 -0
- package/tests/providers/spotify/spotify_session.test.ts +78 -0
- package/tests/secret_hygiene.test.ts +93 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +39 -0
- package/wrangler.example.jsonc +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# musix-box
|
|
2
|
+
|
|
3
|
+
An MCP server giving an agent real Spotify Web API access — full paginated
|
|
4
|
+
library, following, and search — instead of the capped, fuzzy first-party
|
|
5
|
+
connector.
|
|
6
|
+
|
|
7
|
+
## Why this exists
|
|
8
|
+
|
|
9
|
+
The built-in Spotify connector's tools return at most 5 results per search
|
|
10
|
+
call, with no pagination, and have no tool at all for listing your full
|
|
11
|
+
liked-songs library, followed artists, saved albums, or playlists. Asking
|
|
12
|
+
"what bands do I like" cannot be answered completely by a connector that
|
|
13
|
+
can only return 5 results.
|
|
14
|
+
|
|
15
|
+
`musix-box` calls the real Spotify Web API directly, with tools that page
|
|
16
|
+
through complete lists rather than returning a capped snapshot.
|
|
17
|
+
|
|
18
|
+
## Architecture
|
|
19
|
+
|
|
20
|
+
Built on the same pattern as `other-memory`: a Cloudflare Worker running an
|
|
21
|
+
MCP agent (`agents/mcp`), with `@cloudflare/workers-oauth-provider` as the
|
|
22
|
+
outer OAuth layer between the MCP client and this server, and Spotify as
|
|
23
|
+
the identity and data-access provider.
|
|
24
|
+
|
|
25
|
+
The one structural difference from `other-memory`: GitHub there is used
|
|
26
|
+
only to answer "who is this," against a token this server never stores or
|
|
27
|
+
reuses. Spotify here is also the source every tool actually calls, so the
|
|
28
|
+
tokens from the OAuth exchange are persisted (in `SPOTIFY_TOKENS`, a KV
|
|
29
|
+
namespace) and refreshed on every call that needs one — see
|
|
30
|
+
`src/providers/spotify/spotify_session.ts`.
|
|
31
|
+
|
|
32
|
+
`src/providers/media_provider.ts` defines a provider-agnostic interface
|
|
33
|
+
(`MediaProvider`) that `src/providers/spotify/` implements. Adding YouTube
|
|
34
|
+
Music or Apple Music later is a matter of writing a new file under
|
|
35
|
+
`providers/`, not touching the tools or the MCP wiring.
|
|
36
|
+
|
|
37
|
+
## Tools
|
|
38
|
+
|
|
39
|
+
- `search_media` — search tracks, artists, albums, playlists (up to 10 per
|
|
40
|
+
type per call, paginated)
|
|
41
|
+
- `get_liked_tracks` — the user's full liked-songs library, paginated
|
|
42
|
+
- `get_followed_artists` — every artist the user follows, paginated
|
|
43
|
+
- `get_saved_albums` — the user's saved albums, paginated
|
|
44
|
+
- `get_playlists` — playlists the user owns or follows, paginated
|
|
45
|
+
- `get_playlist_tracks` — every track in one playlist, paginated
|
|
46
|
+
|
|
47
|
+
## Setup
|
|
48
|
+
|
|
49
|
+
1. Create a Spotify app at https://developer.spotify.com/dashboard to get a
|
|
50
|
+
`Client ID`. This flow uses PKCE, so no client secret is needed.
|
|
51
|
+
2. Add `https://<your-worker>.workers.dev/callback` as a Redirect URI on
|
|
52
|
+
the app.
|
|
53
|
+
3. `npx wrangler kv namespace create OAUTH_KV` and
|
|
54
|
+
`npx wrangler kv namespace create SPOTIFY_TOKENS`, then copy
|
|
55
|
+
`wrangler.example.jsonc` to `wrangler.jsonc` and fill in the returned
|
|
56
|
+
ids plus your Spotify user id.
|
|
57
|
+
4. Set secrets:
|
|
58
|
+
```sh
|
|
59
|
+
printf %s "$SPOTIFY_CLIENT_ID" | npx wrangler secret put SPOTIFY_CLIENT_ID
|
|
60
|
+
openssl rand -hex 32 | npx wrangler secret put COOKIE_ENCRYPTION_KEY
|
|
61
|
+
```
|
|
62
|
+
5. `npm run deploy`.
|
|
63
|
+
|
|
64
|
+
## Testing
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
npm test # worker + repository projects
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
No `integration` project yet — those would exercise the real Spotify API
|
|
71
|
+
and need a test account and app configured, which this package does not
|
|
72
|
+
have set up.
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "musix-box",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "An MCP server giving an agent real Spotify Web API access — full paginated library, following, and search — instead of the capped, fuzzy first-party connector.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "Idin K",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/idin/musix-box.git"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"dev": "wrangler dev",
|
|
14
|
+
"deploy": "wrangler deploy",
|
|
15
|
+
"test": "vitest run --project worker --project repository",
|
|
16
|
+
"test:integration": "vitest run --project integration",
|
|
17
|
+
"typecheck": "tsc --noEmit"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@cloudflare/workers-oauth-provider": "^0.8.1",
|
|
21
|
+
"@modelcontextprotocol/sdk": "1.29.0",
|
|
22
|
+
"agents": "^0.17.1",
|
|
23
|
+
"hono": "^4.12.27",
|
|
24
|
+
"zod": "^4.0.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@cloudflare/vitest-pool-workers": "^0.20.3",
|
|
28
|
+
"@cloudflare/workers-types": "^5.20260801.1",
|
|
29
|
+
"@types/node": "^26.2.0",
|
|
30
|
+
"typescript": "^5.8.2",
|
|
31
|
+
"vitest": "^4.1.10",
|
|
32
|
+
"wrangler": "^4.120.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { McpAgent } from "agents/mcp";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
import { version as PACKAGE_VERSION } from "../package.json";
|
|
6
|
+
|
|
7
|
+
import { SpotifyProvider } from "./providers/spotify/spotify_provider";
|
|
8
|
+
import { getAccessToken } from "./providers/spotify/spotify_session";
|
|
9
|
+
import type { SearchType } from "./providers/media_provider";
|
|
10
|
+
import {
|
|
11
|
+
buildFailure,
|
|
12
|
+
consoleFailureSink,
|
|
13
|
+
reportingFailures,
|
|
14
|
+
type FailureSink,
|
|
15
|
+
} from "./tool_errors";
|
|
16
|
+
import type { Env, UserProps } from "./types";
|
|
17
|
+
|
|
18
|
+
const SEARCH_TYPES = ["track", "artist", "album", "playlist"] as const satisfies readonly SearchType[];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Every list tool shares the same two paging parameters, so the shape is
|
|
22
|
+
* declared once here rather than repeated per tool with a chance to drift.
|
|
23
|
+
*/
|
|
24
|
+
const PAGING_SCHEMA = {
|
|
25
|
+
limit: z
|
|
26
|
+
.number()
|
|
27
|
+
.int()
|
|
28
|
+
.positive()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe("Items per page. Provider's own maximum applies if higher."),
|
|
31
|
+
cursor: z
|
|
32
|
+
.string()
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("Cursor from a previous call's response, to fetch the next page."),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export class MusixBoxMCP extends McpAgent<Env, unknown, UserProps> {
|
|
38
|
+
server = new McpServer({
|
|
39
|
+
name: "musix-box",
|
|
40
|
+
title: "Musix Box",
|
|
41
|
+
version: PACKAGE_VERSION,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
protected failureSink: FailureSink = consoleFailureSink;
|
|
45
|
+
|
|
46
|
+
private async provider(): Promise<SpotifyProvider> {
|
|
47
|
+
if (!this.props?.spotifyUserId) {
|
|
48
|
+
throw new Error("Not authenticated with Spotify.");
|
|
49
|
+
}
|
|
50
|
+
const accessToken = await getAccessToken(this.env.SPOTIFY_TOKENS, this.props.spotifyUserId, {
|
|
51
|
+
clientId: this.env.SPOTIFY_CLIENT_ID,
|
|
52
|
+
now: () => Date.now(),
|
|
53
|
+
});
|
|
54
|
+
return new SpotifyProvider(accessToken);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Register a tool whose failures are recorded rather than thrown.
|
|
59
|
+
*
|
|
60
|
+
* Same reasoning as `other-memory`'s equivalent wrapper: a tool
|
|
61
|
+
* registered the direct SDK way would still work, and would lose its
|
|
62
|
+
* failures silently — invisible until the day someone needed the log.
|
|
63
|
+
*/
|
|
64
|
+
protected registerTool<InputSchema extends z.ZodRawShape>(
|
|
65
|
+
name: string,
|
|
66
|
+
definition: { description: string; inputSchema: InputSchema },
|
|
67
|
+
handler: (args: { [Key in keyof InputSchema]: z.infer<InputSchema[Key]> }) => Promise<unknown>,
|
|
68
|
+
): void {
|
|
69
|
+
type Arguments = { [Key in keyof InputSchema]: z.infer<InputSchema[Key]> };
|
|
70
|
+
this.server.registerTool(
|
|
71
|
+
name,
|
|
72
|
+
definition as Parameters<McpServer["registerTool"]>[1],
|
|
73
|
+
(async (args: Arguments) =>
|
|
74
|
+
reportingFailures({
|
|
75
|
+
tool: name,
|
|
76
|
+
args,
|
|
77
|
+
spotifyUserId: this.props?.spotifyUserId ?? null,
|
|
78
|
+
sink: this.failureSink,
|
|
79
|
+
run: async () => handler(args),
|
|
80
|
+
})) as unknown as Parameters<McpServer["registerTool"]>[2],
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async init() {
|
|
85
|
+
this.registerSearchTool();
|
|
86
|
+
this.registerLikedTracksTool();
|
|
87
|
+
this.registerFollowedArtistsTool();
|
|
88
|
+
this.registerSavedAlbumsTool();
|
|
89
|
+
this.registerPlaylistsTool();
|
|
90
|
+
this.registerPlaylistTracksTool();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private registerSearchTool() {
|
|
94
|
+
this.registerTool(
|
|
95
|
+
"search_media",
|
|
96
|
+
{
|
|
97
|
+
description:
|
|
98
|
+
"Search Spotify for tracks, artists, albums and playlists. Unlike " +
|
|
99
|
+
"the built-in Spotify connector's search (capped at 5 results " +
|
|
100
|
+
"total, one page, no way to ask for more), this returns up to 10 " +
|
|
101
|
+
"results per type per call and accepts a cursor to page further. " +
|
|
102
|
+
"Supports Spotify's field filters in the query, e.g. " +
|
|
103
|
+
'`artist:Radiohead track:Karma Police` or `year:2020`.',
|
|
104
|
+
inputSchema: {
|
|
105
|
+
query: z.string().describe("Free-text query, optionally using Spotify field filters."),
|
|
106
|
+
types: z
|
|
107
|
+
.array(z.enum(SEARCH_TYPES))
|
|
108
|
+
.optional()
|
|
109
|
+
.describe("Which kinds to search. All four when omitted."),
|
|
110
|
+
...PAGING_SCHEMA,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
async ({ query, types, limit, cursor }) => {
|
|
114
|
+
const provider = await this.provider();
|
|
115
|
+
const result = await provider.search(query, { types, limit, cursor });
|
|
116
|
+
return {
|
|
117
|
+
content: [
|
|
118
|
+
{
|
|
119
|
+
type: "text" as const,
|
|
120
|
+
text: JSON.stringify(result, null, 2),
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private registerLikedTracksTool() {
|
|
129
|
+
this.registerTool(
|
|
130
|
+
"get_liked_tracks",
|
|
131
|
+
{
|
|
132
|
+
description:
|
|
133
|
+
"List the user's liked (saved) tracks on Spotify, one page at a " +
|
|
134
|
+
"time. Unlike the built-in connector, which has no tool for this " +
|
|
135
|
+
"at all — only a 5-result search — this pages through the full " +
|
|
136
|
+
"library via the real Spotify API. Pass the returned cursor back " +
|
|
137
|
+
"to fetch the next page; a null cursor means this was the last page.",
|
|
138
|
+
inputSchema: PAGING_SCHEMA,
|
|
139
|
+
},
|
|
140
|
+
async ({ limit, cursor }) => {
|
|
141
|
+
const provider = await this.provider();
|
|
142
|
+
const page = await provider.getLikedTracks({ limit, cursor });
|
|
143
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(page, null, 2) }] };
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private registerFollowedArtistsTool() {
|
|
149
|
+
this.registerTool(
|
|
150
|
+
"get_followed_artists",
|
|
151
|
+
{
|
|
152
|
+
description:
|
|
153
|
+
"List every artist the user follows on Spotify, one page at a " +
|
|
154
|
+
"time — this is what answers \"what bands do I like\" completely, " +
|
|
155
|
+
"which the built-in connector's 5-result search cannot. Pass the " +
|
|
156
|
+
"returned cursor back to fetch the next page.",
|
|
157
|
+
inputSchema: PAGING_SCHEMA,
|
|
158
|
+
},
|
|
159
|
+
async ({ limit, cursor }) => {
|
|
160
|
+
const provider = await this.provider();
|
|
161
|
+
const page = await provider.getFollowedArtists({ limit, cursor });
|
|
162
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(page, null, 2) }] };
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private registerSavedAlbumsTool() {
|
|
168
|
+
this.registerTool(
|
|
169
|
+
"get_saved_albums",
|
|
170
|
+
{
|
|
171
|
+
description:
|
|
172
|
+
"List the user's saved albums on Spotify, one page at a time.",
|
|
173
|
+
inputSchema: PAGING_SCHEMA,
|
|
174
|
+
},
|
|
175
|
+
async ({ limit, cursor }) => {
|
|
176
|
+
const provider = await this.provider();
|
|
177
|
+
const page = await provider.getSavedAlbums({ limit, cursor });
|
|
178
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(page, null, 2) }] };
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private registerPlaylistsTool() {
|
|
184
|
+
this.registerTool(
|
|
185
|
+
"get_playlists",
|
|
186
|
+
{
|
|
187
|
+
description:
|
|
188
|
+
"List the playlists the user owns or follows on Spotify, one " +
|
|
189
|
+
"page at a time.",
|
|
190
|
+
inputSchema: PAGING_SCHEMA,
|
|
191
|
+
},
|
|
192
|
+
async ({ limit, cursor }) => {
|
|
193
|
+
const provider = await this.provider();
|
|
194
|
+
const page = await provider.getPlaylists({ limit, cursor });
|
|
195
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(page, null, 2) }] };
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private registerPlaylistTracksTool() {
|
|
201
|
+
this.registerTool(
|
|
202
|
+
"get_playlist_tracks",
|
|
203
|
+
{
|
|
204
|
+
description:
|
|
205
|
+
"List every track in one Spotify playlist, one page at a time. " +
|
|
206
|
+
"Unlike the built-in connector's fetch_tracks, this is not " +
|
|
207
|
+
"limited to a playlist already shown in the current widget " +
|
|
208
|
+
"session — any playlist id works, and it pages through the full " +
|
|
209
|
+
"track list rather than returning it all at once uncapped.",
|
|
210
|
+
inputSchema: {
|
|
211
|
+
playlist_id: z.string().describe("The Spotify playlist id."),
|
|
212
|
+
...PAGING_SCHEMA,
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
async ({ playlist_id, limit, cursor }) => {
|
|
216
|
+
const provider = await this.provider();
|
|
217
|
+
const page = await provider.getPlaylistTracks(playlist_id, { limit, cursor });
|
|
218
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(page, null, 2) }] };
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export { buildFailure };
|
|
225
|
+
export type { Env, UserProps } from "./types";
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract every media provider implements.
|
|
3
|
+
*
|
|
4
|
+
* Tools call this interface, never a provider's own client directly — that is
|
|
5
|
+
* what makes adding YouTube Music or Apple Music later a matter of writing a
|
|
6
|
+
* new file under `providers/`, not touching every tool.
|
|
7
|
+
*
|
|
8
|
+
* Every list method is paginated and exhaustive by construction: none of them
|
|
9
|
+
* return a capped top-N. The gap this whole package exists to close is a
|
|
10
|
+
* connector whose search and library tools return at most five results with
|
|
11
|
+
* no way to ask for more — every method here is shaped so that limitation
|
|
12
|
+
* cannot recur.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type Track = {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
artistNames: string[];
|
|
19
|
+
albumName: string;
|
|
20
|
+
durationMs: number;
|
|
21
|
+
uri: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type Artist = {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
genres: string[];
|
|
28
|
+
uri: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type Album = {
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
artistNames: string[];
|
|
35
|
+
releaseDate: string;
|
|
36
|
+
uri: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type Playlist = {
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
ownerName: string;
|
|
43
|
+
trackCount: number;
|
|
44
|
+
uri: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type SearchResults = {
|
|
48
|
+
tracks: Track[];
|
|
49
|
+
artists: Artist[];
|
|
50
|
+
albums: Album[];
|
|
51
|
+
playlists: Playlist[];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** What kinds of thing a search may be restricted to. */
|
|
55
|
+
export type SearchType = "track" | "artist" | "album" | "playlist";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A single page of a paginated list, plus what is needed to fetch the next
|
|
59
|
+
* one.
|
|
60
|
+
*
|
|
61
|
+
* `cursor` is provider-shaped on purpose — Spotify pages saved tracks by
|
|
62
|
+
* numeric offset and followed artists by an artist-id cursor, and forcing
|
|
63
|
+
* both into one shape would either lose information or invent a fake offset
|
|
64
|
+
* for a cursor-based endpoint. Callers pass the cursor back unexamined.
|
|
65
|
+
*/
|
|
66
|
+
export type Page<Item> = {
|
|
67
|
+
items: Item[];
|
|
68
|
+
/** Present when more items exist; absent when this is the last page. */
|
|
69
|
+
nextCursor: string | null;
|
|
70
|
+
/** Total item count, when the provider reports one. */
|
|
71
|
+
total: number | null;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export interface MediaProvider {
|
|
75
|
+
/** The provider's own name, e.g. "spotify". Used to tag results and errors. */
|
|
76
|
+
readonly name: string;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Search for content, across one or more types.
|
|
80
|
+
*
|
|
81
|
+
* @param query - Free-text query.
|
|
82
|
+
* @param options.types - Which kinds to search. All four when omitted.
|
|
83
|
+
* @param options.limit - Items per type, per page.
|
|
84
|
+
* @param options.cursor - Page cursor from a previous call.
|
|
85
|
+
*/
|
|
86
|
+
search(
|
|
87
|
+
query: string,
|
|
88
|
+
options?: { types?: SearchType[]; limit?: number; cursor?: string },
|
|
89
|
+
): Promise<{
|
|
90
|
+
tracks: Page<Track>;
|
|
91
|
+
artists: Page<Artist>;
|
|
92
|
+
albums: Page<Album>;
|
|
93
|
+
playlists: Page<Playlist>;
|
|
94
|
+
}>;
|
|
95
|
+
|
|
96
|
+
/** One page of the user's liked/saved tracks. */
|
|
97
|
+
getLikedTracks(options?: { limit?: number; cursor?: string }): Promise<Page<Track>>;
|
|
98
|
+
|
|
99
|
+
/** One page of the artists the user follows. */
|
|
100
|
+
getFollowedArtists(options?: {
|
|
101
|
+
limit?: number;
|
|
102
|
+
cursor?: string;
|
|
103
|
+
}): Promise<Page<Artist>>;
|
|
104
|
+
|
|
105
|
+
/** One page of the user's saved albums. */
|
|
106
|
+
getSavedAlbums(options?: { limit?: number; cursor?: string }): Promise<Page<Album>>;
|
|
107
|
+
|
|
108
|
+
/** One page of the playlists the user owns or follows. */
|
|
109
|
+
getPlaylists(options?: { limit?: number; cursor?: string }): Promise<Page<Playlist>>;
|
|
110
|
+
|
|
111
|
+
/** Every track in one playlist, in order. */
|
|
112
|
+
getPlaylistTracks(
|
|
113
|
+
playlistId: string,
|
|
114
|
+
options?: { limit?: number; cursor?: string },
|
|
115
|
+
): Promise<Page<Track>>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Fetch every page of a paginated call, following `nextCursor` until it is
|
|
120
|
+
* null.
|
|
121
|
+
*
|
|
122
|
+
* The interface returns one page at a time because a Worker has a bounded
|
|
123
|
+
* subrequest budget per invocation — see `other-memory`'s own
|
|
124
|
+
* `FILES_INDEXED_PER_SEARCH` for the shape of that constraint recurring.
|
|
125
|
+
* This helper is for callers who know the total is small enough (a personal
|
|
126
|
+
* library, not an open-ended catalogue) to fetch in full within one call.
|
|
127
|
+
*
|
|
128
|
+
* @param fetchPage - Fetches one page, given the previous cursor or none.
|
|
129
|
+
* @param options.maxPages - Hard ceiling, so a provider bug returning a
|
|
130
|
+
* cursor that never terminates cannot loop forever.
|
|
131
|
+
* @returns Every item across every page.
|
|
132
|
+
*/
|
|
133
|
+
export async function fetchAllPages<Item>(
|
|
134
|
+
fetchPage: (cursor: string | undefined) => Promise<Page<Item>>,
|
|
135
|
+
options: { maxPages: number },
|
|
136
|
+
): Promise<Item[]> {
|
|
137
|
+
const items: Item[] = [];
|
|
138
|
+
let cursor: string | undefined;
|
|
139
|
+
for (let page = 0; page < options.maxPages; page += 1) {
|
|
140
|
+
const result = await fetchPage(cursor);
|
|
141
|
+
items.push(...result.items);
|
|
142
|
+
if (result.nextCursor === null) {
|
|
143
|
+
return items;
|
|
144
|
+
}
|
|
145
|
+
cursor = result.nextCursor;
|
|
146
|
+
}
|
|
147
|
+
throw new Error(
|
|
148
|
+
`fetchAllPages did not terminate within ${options.maxPages} pages. `
|
|
149
|
+
+ "Either raise maxPages for a genuinely large list, or the provider's "
|
|
150
|
+
+ "cursor is not advancing.",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw HTTP calls against the Spotify Web API.
|
|
3
|
+
*
|
|
4
|
+
* Kept separate from `SpotifyProvider` (which maps these responses onto the
|
|
5
|
+
* shared `MediaProvider` shape) so the two concerns — "what Spotify's API
|
|
6
|
+
* actually returns" and "what this package promises callers" — can each be
|
|
7
|
+
* read and tested on their own.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const SPOTIFY_API_BASE = "https://api.spotify.com/v1";
|
|
11
|
+
|
|
12
|
+
/** The maximum `limit` Spotify's search endpoint accepts, per item type. */
|
|
13
|
+
export const SEARCH_MAX_LIMIT = 10;
|
|
14
|
+
|
|
15
|
+
/** The maximum `limit` Spotify's library-listing endpoints accept. */
|
|
16
|
+
export const LIBRARY_MAX_LIMIT = 50;
|
|
17
|
+
|
|
18
|
+
/** One page of a Spotify "paging object" response, generic over item shape. */
|
|
19
|
+
export type SpotifyPagingObject<Item> = {
|
|
20
|
+
href: string;
|
|
21
|
+
limit: number;
|
|
22
|
+
next: string | null;
|
|
23
|
+
offset: number;
|
|
24
|
+
previous: string | null;
|
|
25
|
+
total: number;
|
|
26
|
+
items: Item[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** One page of a Spotify cursor-paginated response (used for followed artists). */
|
|
30
|
+
export type SpotifyCursorPage<Item> = {
|
|
31
|
+
href: string;
|
|
32
|
+
limit: number;
|
|
33
|
+
next: string | null;
|
|
34
|
+
cursors: { after: string | null; before?: string };
|
|
35
|
+
total: number;
|
|
36
|
+
items: Item[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type SpotifyImage = { url: string; height: number | null; width: number | null };
|
|
40
|
+
|
|
41
|
+
export type SpotifyArtist = {
|
|
42
|
+
id: string;
|
|
43
|
+
name: string;
|
|
44
|
+
genres: string[];
|
|
45
|
+
uri: string;
|
|
46
|
+
images: SpotifyImage[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type SpotifyAlbum = {
|
|
50
|
+
id: string;
|
|
51
|
+
name: string;
|
|
52
|
+
artists: { id: string; name: string }[];
|
|
53
|
+
release_date: string;
|
|
54
|
+
uri: string;
|
|
55
|
+
images: SpotifyImage[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type SpotifyTrack = {
|
|
59
|
+
id: string;
|
|
60
|
+
name: string;
|
|
61
|
+
artists: { id: string; name: string }[];
|
|
62
|
+
album: { id: string; name: string };
|
|
63
|
+
duration_ms: number;
|
|
64
|
+
uri: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type SpotifyPlaylist = {
|
|
68
|
+
id: string;
|
|
69
|
+
name: string;
|
|
70
|
+
owner: { id: string; display_name: string | null };
|
|
71
|
+
tracks: { total: number };
|
|
72
|
+
uri: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Thrown when Spotify's API returns a non-2xx response. */
|
|
76
|
+
export class SpotifyApiError extends Error {
|
|
77
|
+
readonly status: number;
|
|
78
|
+
readonly retryAfterSeconds: number | null;
|
|
79
|
+
|
|
80
|
+
constructor(options: { status: number; body: string; retryAfterSeconds: number | null }) {
|
|
81
|
+
super(`Spotify API returned ${options.status}: ${options.body}`);
|
|
82
|
+
this.name = "SpotifyApiError";
|
|
83
|
+
this.status = options.status;
|
|
84
|
+
this.retryAfterSeconds = options.retryAfterSeconds;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Whether an error means Spotify is rate-limiting rather than rejecting the request. */
|
|
89
|
+
export function isSpotifyRateLimited(error: unknown): boolean {
|
|
90
|
+
return error instanceof SpotifyApiError && error.status === 429;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A thin client for the Spotify Web API, authenticated with one access
|
|
95
|
+
* token.
|
|
96
|
+
*
|
|
97
|
+
* Holds no OAuth state of its own — refreshing the token and constructing a
|
|
98
|
+
* new client with the fresh one is the caller's job, kept in
|
|
99
|
+
* `spotify_oauth.ts` so this file only ever has to reason about the Web API
|
|
100
|
+
* itself.
|
|
101
|
+
*/
|
|
102
|
+
export class SpotifyApiClient {
|
|
103
|
+
private readonly accessToken: string;
|
|
104
|
+
|
|
105
|
+
constructor(accessToken: string) {
|
|
106
|
+
this.accessToken = accessToken;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private async get<Result>(path: string, params: Record<string, string | number | undefined>): Promise<Result> {
|
|
110
|
+
const url = new URL(`${SPOTIFY_API_BASE}${path}`);
|
|
111
|
+
for (const [key, value] of Object.entries(params)) {
|
|
112
|
+
if (value !== undefined) {
|
|
113
|
+
url.searchParams.set(key, String(value));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const response = await fetch(url, {
|
|
118
|
+
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
const body = await response.text();
|
|
123
|
+
const retryAfterHeader = response.headers.get("Retry-After");
|
|
124
|
+
throw new SpotifyApiError({
|
|
125
|
+
status: response.status,
|
|
126
|
+
body,
|
|
127
|
+
retryAfterSeconds: retryAfterHeader ? Number(retryAfterHeader) : null,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return (await response.json()) as Result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* GET /v1/search.
|
|
136
|
+
*
|
|
137
|
+
* @param query - Free-text query, or one using Spotify's field filters
|
|
138
|
+
* (`artist:`, `track:`, `album:`, `year:`, `genre:`).
|
|
139
|
+
* @param types - Which item types to search. Spotify allows more types
|
|
140
|
+
* than this package models; only the four `MediaProvider` covers are
|
|
141
|
+
* accepted here.
|
|
142
|
+
* @param options.limit - Per-type result count. Spotify's own ceiling is
|
|
143
|
+
* {@link SEARCH_MAX_LIMIT} — much lower than the library endpoints.
|
|
144
|
+
* @param options.offset - Starting index, for paging past the limit.
|
|
145
|
+
*/
|
|
146
|
+
async search(
|
|
147
|
+
query: string,
|
|
148
|
+
types: Array<"track" | "artist" | "album" | "playlist">,
|
|
149
|
+
options: { limit?: number; offset?: number } = {},
|
|
150
|
+
): Promise<{
|
|
151
|
+
tracks?: SpotifyPagingObject<SpotifyTrack>;
|
|
152
|
+
artists?: SpotifyPagingObject<SpotifyArtist>;
|
|
153
|
+
albums?: SpotifyPagingObject<SpotifyAlbum>;
|
|
154
|
+
playlists?: SpotifyPagingObject<SpotifyPlaylist>;
|
|
155
|
+
}> {
|
|
156
|
+
return this.get("/search", {
|
|
157
|
+
q: query,
|
|
158
|
+
type: types.join(","),
|
|
159
|
+
limit: options.limit,
|
|
160
|
+
offset: options.offset,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** GET /v1/me/tracks — the user's liked songs. Scope: user-library-read. */
|
|
165
|
+
async getSavedTracks(
|
|
166
|
+
options: { limit?: number; offset?: number } = {},
|
|
167
|
+
): Promise<SpotifyPagingObject<{ added_at: string; track: SpotifyTrack }>> {
|
|
168
|
+
return this.get("/me/tracks", { limit: options.limit, offset: options.offset });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** GET /v1/me/albums — the user's saved albums. Scope: user-library-read. */
|
|
172
|
+
async getSavedAlbums(
|
|
173
|
+
options: { limit?: number; offset?: number } = {},
|
|
174
|
+
): Promise<SpotifyPagingObject<{ added_at: string; album: SpotifyAlbum }>> {
|
|
175
|
+
return this.get("/me/albums", { limit: options.limit, offset: options.offset });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* GET /v1/me/following?type=artist — the artists the user follows.
|
|
180
|
+
* Scope: user-follow-read. Cursor-paginated, not offset-paginated — see
|
|
181
|
+
* {@link SpotifyCursorPage}.
|
|
182
|
+
*/
|
|
183
|
+
async getFollowedArtists(
|
|
184
|
+
options: { limit?: number; after?: string } = {},
|
|
185
|
+
): Promise<{ artists: SpotifyCursorPage<SpotifyArtist> }> {
|
|
186
|
+
return this.get("/me/following", {
|
|
187
|
+
type: "artist",
|
|
188
|
+
limit: options.limit,
|
|
189
|
+
after: options.after,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** GET /v1/me/playlists — playlists the user owns or follows. Scope: playlist-read-private. */
|
|
194
|
+
async getPlaylists(
|
|
195
|
+
options: { limit?: number; offset?: number } = {},
|
|
196
|
+
): Promise<SpotifyPagingObject<SpotifyPlaylist>> {
|
|
197
|
+
return this.get("/me/playlists", { limit: options.limit, offset: options.offset });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* GET /v1/playlists/{id}/items — every track in one playlist.
|
|
202
|
+
* Scope: playlist-read-private.
|
|
203
|
+
*
|
|
204
|
+
* `/tracks` is the deprecated form of this endpoint as of Spotify's
|
|
205
|
+
* February 2026 migration: the path became `/items`, and the item's field
|
|
206
|
+
* holding the track object was renamed from `track` to `item`. Verified
|
|
207
|
+
* directly against Spotify's own reference page
|
|
208
|
+
* (get-playlists-items) before writing this, not assumed from the older
|
|
209
|
+
* form still used elsewhere.
|
|
210
|
+
*/
|
|
211
|
+
async getPlaylistTracks(
|
|
212
|
+
playlistId: string,
|
|
213
|
+
options: { limit?: number; offset?: number } = {},
|
|
214
|
+
): Promise<SpotifyPagingObject<{ item: SpotifyTrack }>> {
|
|
215
|
+
return this.get(`/playlists/${playlistId}/items`, {
|
|
216
|
+
limit: options.limit,
|
|
217
|
+
offset: options.offset,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|