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 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
+ }