@audiofool/moondog 0.1.0 → 0.2.0-beta.3
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/LICENSE +202 -0
- package/README.md +156 -68
- package/contracts/v1/definitions.schema.json +1 -169
- package/package.json +7 -23
- package/scripts/contract-lib.mjs +0 -7
- package/scripts/contract-semantics.mjs +0 -1904
- package/scripts/moondog.mjs +285 -74
- package/src/core/apple-library-source-status.mjs +0 -16
- package/src/core/apple-projection-domain-services.mjs +253 -53
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +290 -55
- package/src/core/listener-avoids.mjs +101 -0
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +189 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +2115 -107
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +124 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/core/track-title.mjs +107 -0
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +135 -0
- package/src/i18n/messages/en.mjs +136 -0
- package/src/i18n/messages/es.mjs +135 -0
- package/src/i18n/messages/fr.mjs +135 -0
- package/src/i18n/messages/it.mjs +135 -0
- package/src/i18n/messages/ja.mjs +135 -0
- package/src/i18n/messages/ko.mjs +135 -0
- package/src/i18n/messages/pt.mjs +135 -0
- package/src/i18n/messages/zh-Hant.mjs +135 -0
- package/src/i18n/messages/zh.mjs +135 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +967 -0
- package/src/i18n/screen/ja.mjs +967 -0
- package/src/i18n/screen/pt.mjs +967 -0
- package/src/i18n/screen/zh.mjs +967 -0
- package/src/importers/apple-music-library/index.mjs +0 -16
- package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
- package/src/integrations/apple-music/catalog.mjs +166 -14
- package/src/integrations/apple-music/music-app.mjs +232 -0
- package/src/integrations/apple-music/storefront.mjs +28 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
- package/src/integrations/lastfm/history.mjs +221 -0
- package/src/integrations/lastfm/settings.mjs +75 -0
- package/src/integrations/netease/ncm-cli-player.mjs +328 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
- package/src/integrations/public-playlists.mjs +2 -1
- package/src/integrations/spotify/account-data-profile.mjs +14 -6
- package/src/integrations/spotify/authentication.mjs +149 -15
- package/src/integrations/spotify/catalog-resolver.mjs +9 -6
- package/src/integrations/spotify/connection.mjs +2 -1
- package/src/integrations/spotify/credential-store.mjs +4 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
- package/src/integrations/spotify/history-archive.mjs +1 -1
- package/src/integrations/spotify/library-snapshot.mjs +227 -0
- package/src/integrations/spotify/recent-activity.mjs +2 -0
- package/src/integrations/spotify/service.mjs +873 -69
- package/src/integrations/spotify/web-api-client.mjs +410 -127
- package/src/integrations/web/brave-web.mjs +45 -0
- package/src/integrations/web/codex-web.mjs +15 -82
- package/src/integrations/web/deepseek-web.mjs +81 -0
- package/src/integrations/web/web-research.mjs +119 -0
- package/src/integrations/web/web-result.mjs +215 -0
- package/src/integrations/youtube-music/takeout.mjs +3 -1
- package/src/memory/local-memory-store.mjs +542 -57
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-corrections.mjs +22 -0
- package/src/profile/listener-profile-build.mjs +461 -0
- package/src/profile/listener-profile-store.mjs +130 -0
- package/src/profile/listener-profile-verification.mjs +68 -0
- package/src/profile/listener-profile-worker.mjs +179 -0
- package/src/profile/listening-history-store.mjs +640 -17
- package/src/profile/listening-profile-projection.mjs +454 -86
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/moondog-suggestions.mjs +184 -0
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +92 -0
- package/src/profile/recorded-measures.mjs +24 -0
- package/src/profile/spotify-archive-taste.mjs +0 -4
- package/src/runtime/pi/agent-runtime.mjs +2536 -692
- package/src/runtime/pi/authentication.mjs +34 -1
- package/src/runtime/pi/configured-runtime.mjs +22 -6
- package/src/runtime/pi/conversation-name.mjs +53 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
- package/src/runtime/pi/model-catalog.mjs +28 -5
- package/src/runtime/pi/model-settings.mjs +202 -0
- package/src/runtime/pi/model-transport.mjs +10 -0
- package/src/runtime/pi/persistent-credential-store.mjs +67 -31
- package/src/runtime/pi/profile-build-runtime.mjs +161 -0
- package/src/runtime/pi/public-model-catalog.mjs +288 -0
- package/src/runtime/pi/runtime-settings.mjs +10 -2
- package/src/runtime/pi/skills/listener-profile/SKILL.md +48 -0
- package/src/runtime/pi/tool-trace.mjs +37 -0
- package/src/surfaces/cli/auth-command.mjs +12 -4
- package/src/surfaces/cli/brand-components.mjs +310 -132
- package/src/surfaces/cli/brand-theme.mjs +51 -28
- package/src/surfaces/cli/command-completions.mjs +58 -42
- package/src/surfaces/cli/format-output.mjs +199 -388
- package/src/surfaces/cli/history-import-view.mjs +292 -139
- package/src/surfaces/cli/import-guides.mjs +2 -0
- package/src/surfaces/cli/listener-profile-command.mjs +92 -0
- package/src/surfaces/cli/listening-menu.mjs +7 -4
- package/src/surfaces/cli/profile-command.mjs +30 -21
- package/src/surfaces/cli/seasons-chart.mjs +87 -0
- package/src/surfaces/cli/spotify-command.mjs +54 -6
- package/src/surfaces/cli/taste-profile-model.mjs +78 -79
- package/src/surfaces/cli/taste-profile-view.mjs +33 -26
- package/src/surfaces/cli/terminal-art.mjs +140 -35
- package/src/surfaces/cli/tui.mjs +1066 -357
- package/src/surfaces/cli/update-command.mjs +32 -0
- package/src/surfaces/cli/web-command.mjs +53 -14
- package/src/surfaces/html/tasteprint.mjs +43 -70
- package/contracts/v1/capability-policy.schema.json +0 -572
- package/contracts/v1/profile-evidence.schema.json +0 -237
- package/contracts/v1/tool-invocation.schema.json +0 -318
- package/contracts/v1/tool-receipt.schema.json +0 -1066
- package/scripts/demo-studio.mjs +0 -58
- package/src/core/apple-data-migration.mjs +0 -50
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
- package/src/demo/moondog-tasteprint-demo.mjs +0 -631
- package/src/integrations/spotify/index.mjs +0 -80
- package/src/surfaces/web/showcase.mjs +0 -272
- package/src/surfaces/web/studio-page.mjs +0 -4148
- package/src/surfaces/web/studio.mjs +0 -2288
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
|
|
1
3
|
export const SPOTIFY_WEB_API_BASE_URL = "https://api.spotify.com/v1";
|
|
2
4
|
|
|
3
5
|
export const SPOTIFY_WEB_API_LIMITS = Object.freeze({
|
|
@@ -13,6 +15,14 @@ export const SPOTIFY_WEB_API_LIMITS = Object.freeze({
|
|
|
13
15
|
playlistEditTracksMax: 100,
|
|
14
16
|
libraryItemsMax: 20,
|
|
15
17
|
recentlyPlayedMax: 50,
|
|
18
|
+
// 429 retry budget for idempotent requests: total attempts (initial plus
|
|
19
|
+
// up to two retries). Retries are spaced by the server's Retry-After
|
|
20
|
+
// header when present, with a bounded default and jitter, and never wait
|
|
21
|
+
// longer than rateLimitMaxWaitMs for a single attempt.
|
|
22
|
+
rateLimitMaxAttempts: 3,
|
|
23
|
+
rateLimitDefaultWaitMs: 1_000,
|
|
24
|
+
rateLimitMaxWaitMs: 10_000,
|
|
25
|
+
rateLimitJitterMs: 250,
|
|
16
26
|
});
|
|
17
27
|
|
|
18
28
|
const playbackActions = new Set([
|
|
@@ -28,14 +38,29 @@ const playbackActions = new Set([
|
|
|
28
38
|
"transferring_playback",
|
|
29
39
|
]);
|
|
30
40
|
|
|
41
|
+
// Provider error bodies are untrusted and can contain identifiers or credentials.
|
|
42
|
+
// Keep only known machine reasons; never return an arbitrary response message.
|
|
43
|
+
const publicErrorReasons = new Set([
|
|
44
|
+
"NO_ACTIVE_DEVICE", "PREMIUM_REQUIRED", "RESTRICTION_VIOLATED",
|
|
45
|
+
"DEVICE_NOT_CONTROLLABLE", "REMOTE_CONTROL_DISALLOW", "CONTEXT_DISALLOW",
|
|
46
|
+
"NOT_ALLOWED", "TRACK_NOT_PLAYABLE", "CONTENT_UNAVAILABLE", "NON_PLAYABLE",
|
|
47
|
+
"QUOTA_EXCEEDED", "UNKNOWN", "NO_SPECIFIC_REASON",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
export function spotifyErrorReason(value) {
|
|
51
|
+
return publicErrorReasons.has(value) ? value : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
31
54
|
export class SpotifyWebApiError extends Error {
|
|
32
|
-
constructor(code, message, { status = null, retryAfterSeconds = null } = {}) {
|
|
55
|
+
constructor(code, message, { status = null, reason = null, retryAfterSeconds = null, outcomeUnknown = false } = {}) {
|
|
33
56
|
super(message);
|
|
34
57
|
this.name = "SpotifyWebApiError";
|
|
35
58
|
this.code = code;
|
|
36
59
|
this.provider = "spotify";
|
|
37
60
|
this.status = status;
|
|
61
|
+
this.reason = spotifyErrorReason(reason);
|
|
38
62
|
this.retryAfterSeconds = retryAfterSeconds;
|
|
63
|
+
this.outcomeUnknown = outcomeUnknown;
|
|
39
64
|
}
|
|
40
65
|
}
|
|
41
66
|
|
|
@@ -95,6 +120,16 @@ function retryAfterSeconds(headers) {
|
|
|
95
120
|
return Math.min(Math.max(0, Math.ceil((date - Date.now()) / 1_000)), 86_400);
|
|
96
121
|
}
|
|
97
122
|
|
|
123
|
+
// "4 h 11 min", "3 min": how long a wait is, for messages people and the agent read.
|
|
124
|
+
function waitText(seconds) {
|
|
125
|
+
const minutes = Math.max(1, Math.ceil(seconds / 60));
|
|
126
|
+
return minutes < 60 ? `${minutes} min` : `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function defaultSleep(milliseconds, { signal } = {}) {
|
|
130
|
+
return delay(milliseconds, undefined, { signal });
|
|
131
|
+
}
|
|
132
|
+
|
|
98
133
|
function quotaReason(payload) {
|
|
99
134
|
if (!isPlainObject(payload)) return undefined;
|
|
100
135
|
return safeText(payload.reason ?? payload.error?.reason, 64);
|
|
@@ -107,6 +142,12 @@ function hasInsufficientClientScope(payload) {
|
|
|
107
142
|
);
|
|
108
143
|
}
|
|
109
144
|
|
|
145
|
+
function refusalMessage(payload) {
|
|
146
|
+
if (!isPlainObject(payload)) return undefined;
|
|
147
|
+
const error = isPlainObject(payload.error) ? payload.error : null;
|
|
148
|
+
return safeText(error?.message ?? payload.message, 128);
|
|
149
|
+
}
|
|
150
|
+
|
|
110
151
|
function normalizeArtistNames(rawArtists) {
|
|
111
152
|
if (!Array.isArray(rawArtists)) return [];
|
|
112
153
|
const result = [];
|
|
@@ -118,6 +159,22 @@ function normalizeArtistNames(rawArtists) {
|
|
|
118
159
|
return result;
|
|
119
160
|
}
|
|
120
161
|
|
|
162
|
+
// Cover art a window can draw: one Spotify CDN image, the largest that is at most 640 pixels.
|
|
163
|
+
function coverImage(images) {
|
|
164
|
+
const usable = (Array.isArray(images) ? images : []).filter((image) =>
|
|
165
|
+
typeof image?.url === "string" && /^https:\/\/i\.scdn\.co\/image\/[A-Za-z0-9_-]{1,128}$/u.test(image.url));
|
|
166
|
+
const sized = usable.filter((image) => !Number.isInteger(image.width) || image.width <= 640)
|
|
167
|
+
.sort((left, right) => (right.width ?? 0) - (left.width ?? 0));
|
|
168
|
+
return (sized[0] ?? usable.at(-1))?.url;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// A track's album, kept so a song played alone can go on into the rest of its album.
|
|
172
|
+
// Hosts keep it next to the track URI and never show it to the model.
|
|
173
|
+
function albumUri(raw) {
|
|
174
|
+
const uri = raw?.album?.uri;
|
|
175
|
+
return typeof uri === "string" && /^spotify:album:[A-Za-z0-9]{1,128}$/u.test(uri) ? uri : undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
121
178
|
function normalizePlayableItem(raw) {
|
|
122
179
|
if (!isPlainObject(raw)) return null;
|
|
123
180
|
const type = ["track", "episode", "ad", "unknown"].includes(raw.type)
|
|
@@ -135,9 +192,12 @@ function normalizePlayableItem(raw) {
|
|
|
135
192
|
if (name) item.name = name;
|
|
136
193
|
if (artists.length > 0) item.artists = artists;
|
|
137
194
|
if (album) item.album = album;
|
|
195
|
+
if (type === "track" && albumUri(raw)) item.album_uri = albumUri(raw);
|
|
138
196
|
if (publisher) item.publisher = publisher;
|
|
139
197
|
if (durationMs !== undefined) item.duration_ms = durationMs;
|
|
140
198
|
if (typeof raw.explicit === "boolean") item.explicit = raw.explicit;
|
|
199
|
+
const image = coverImage(raw.album?.images ?? raw.images ?? raw.show?.images);
|
|
200
|
+
if (image) item.image = image;
|
|
141
201
|
return item;
|
|
142
202
|
}
|
|
143
203
|
|
|
@@ -151,9 +211,9 @@ function normalizeDevice(raw) {
|
|
|
151
211
|
id,
|
|
152
212
|
name,
|
|
153
213
|
type: type ?? "unknown",
|
|
154
|
-
is_active: raw.is_active ===
|
|
214
|
+
is_active: typeof raw.is_active === "boolean" ? raw.is_active : null,
|
|
155
215
|
is_private_session: raw.is_private_session === true,
|
|
156
|
-
is_restricted: raw.is_restricted ===
|
|
216
|
+
is_restricted: typeof raw.is_restricted === "boolean" ? raw.is_restricted : null,
|
|
157
217
|
supports_volume: raw.supports_volume === true,
|
|
158
218
|
};
|
|
159
219
|
const volume = safeInteger(raw.volume_percent, 0, 100);
|
|
@@ -196,14 +256,14 @@ function normalizePlayback(payload) {
|
|
|
196
256
|
const result = {
|
|
197
257
|
provider: "spotify",
|
|
198
258
|
state: "available",
|
|
199
|
-
is_playing: payload.is_playing ===
|
|
259
|
+
is_playing: typeof payload.is_playing === "boolean" ? payload.is_playing : null,
|
|
200
260
|
shuffle_state: payload.shuffle_state === true,
|
|
201
261
|
repeat_state: ["off", "track", "context"].includes(payload.repeat_state)
|
|
202
262
|
? payload.repeat_state
|
|
203
263
|
: "off",
|
|
204
264
|
currently_playing_type:
|
|
205
265
|
safeText(payload.currently_playing_type, 32) ?? "unknown",
|
|
206
|
-
disallowed_actions: normalizeDisallowedActions(payload.actions?.disallows),
|
|
266
|
+
disallowed_actions: normalizeDisallowedActions(payload.actions?.disallows ?? payload.actions),
|
|
207
267
|
};
|
|
208
268
|
const progressMs = safeInteger(payload.progress_ms, 0, 86_400_000);
|
|
209
269
|
const timestamp = safeInteger(payload.timestamp, 0, Number.MAX_SAFE_INTEGER);
|
|
@@ -251,7 +311,7 @@ function normalizeQueue(payload) {
|
|
|
251
311
|
}
|
|
252
312
|
|
|
253
313
|
function normalizeTrackSearchItem(raw) {
|
|
254
|
-
if (!isPlainObject(raw)) return null;
|
|
314
|
+
if (!isPlainObject(raw) || raw.is_local === true || raw.is_playable === false) return null;
|
|
255
315
|
const uri = safeText(raw.uri);
|
|
256
316
|
const id = safeText(raw.id, 128);
|
|
257
317
|
const name = safeText(raw.name);
|
|
@@ -264,29 +324,63 @@ function normalizeTrackSearchItem(raw) {
|
|
|
264
324
|
};
|
|
265
325
|
const album = safeText(raw.album?.name);
|
|
266
326
|
if (album) item.album = album;
|
|
327
|
+
if (albumUri(raw)) item.album_uri = albumUri(raw);
|
|
267
328
|
const durationMs = safeInteger(raw.duration_ms, 0, 86_400_000);
|
|
268
329
|
if (durationMs !== undefined) item.duration_ms = durationMs;
|
|
269
330
|
const popularity = safeInteger(raw.popularity, 0, 100);
|
|
270
331
|
if (popularity !== undefined) item.popularity = popularity;
|
|
271
332
|
if (typeof raw.explicit === "boolean") item.explicit = raw.explicit;
|
|
333
|
+
// Host-only, like the URI: it tells two copies of one recording apart from two songs.
|
|
334
|
+
const isrc = safeText(raw.external_ids?.isrc, 32);
|
|
335
|
+
if (isrc) item.isrc = isrc.toUpperCase();
|
|
272
336
|
return item;
|
|
273
337
|
}
|
|
274
338
|
|
|
275
|
-
function normalizeTrackSearch(payload) {
|
|
339
|
+
function normalizeTrackSearch(payload, { limit = SPOTIFY_WEB_API_LIMITS.searchResultsMax } = {}) {
|
|
276
340
|
const rawItems = Array.isArray(payload?.tracks?.items)
|
|
277
341
|
? payload.tracks.items
|
|
278
342
|
: [];
|
|
279
343
|
const items = rawItems
|
|
280
344
|
.map(normalizeTrackSearchItem)
|
|
281
345
|
.filter(Boolean)
|
|
282
|
-
.slice(0,
|
|
346
|
+
.slice(0, limit);
|
|
347
|
+
const hasMore = Boolean(payload?.tracks?.next) || Number.isInteger(payload?.tracks?.total) && rawItems.length < payload.tracks.total;
|
|
283
348
|
return {
|
|
284
349
|
provider: "spotify",
|
|
285
350
|
items,
|
|
351
|
+
has_more: hasMore,
|
|
352
|
+
next_offset: hasMore && rawItems.length ? Math.min(rawItems.length, limit) : null,
|
|
286
353
|
truncated: rawItems.length > items.length,
|
|
287
354
|
};
|
|
288
355
|
}
|
|
289
356
|
|
|
357
|
+
function normalizeCatalogItem(raw, type) {
|
|
358
|
+
if (type === "track") return normalizeTrackSearchItem(raw);
|
|
359
|
+
if (!isPlainObject(raw) || !safeText(raw.name) ||
|
|
360
|
+
typeof raw.uri !== "string" || !new RegExp(`^spotify:${type}:[A-Za-z0-9]{1,128}$`, "u").test(raw.uri)) return null;
|
|
361
|
+
const item = { type, uri: raw.uri, name: safeText(raw.name), artists: normalizeArtistNames(raw.artists) };
|
|
362
|
+
for (const field of ["publisher", "release_date"]) if (safeText(raw[field])) item[field] = safeText(raw[field]);
|
|
363
|
+
const duration = safeInteger(raw.duration_ms, 0, 86_400_000);
|
|
364
|
+
if (duration !== undefined) item.duration_ms = duration;
|
|
365
|
+
if (typeof raw.explicit === "boolean") item.explicit = raw.explicit;
|
|
366
|
+
return item;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function normalizeLibraryPage(payload, type, { limit, offset }) {
|
|
370
|
+
const raw = Array.isArray(payload?.items) ? payload.items : [];
|
|
371
|
+
const singular = type.slice(0, -1);
|
|
372
|
+
const items = raw.slice(0, limit).map((entry) => {
|
|
373
|
+
const item = normalizeCatalogItem(["playlists", "artists"].includes(type) ? entry : entry?.[singular], singular);
|
|
374
|
+
const addedAt = normalizedUtcTimestamp(entry?.added_at);
|
|
375
|
+
return item ? { ...item, ...(addedAt ? { added_at: addedAt } : {}) } : null;
|
|
376
|
+
}).filter(Boolean);
|
|
377
|
+
const total = safeInteger(payload?.total, 0, 1_000_000);
|
|
378
|
+
const hasMore = Boolean(payload?.next) || (total !== undefined && offset + raw.length < total);
|
|
379
|
+
return { provider: "spotify", type, items, offset, limit, ...(total !== undefined ? { total } : {}),
|
|
380
|
+
has_more: hasMore, next_offset: hasMore && raw.length > 0 ? offset + Math.min(raw.length, limit) : null,
|
|
381
|
+
truncated: raw.length > items.length };
|
|
382
|
+
}
|
|
383
|
+
|
|
290
384
|
function normalizedUtcTimestamp(value) {
|
|
291
385
|
if (typeof value !== "string") return undefined;
|
|
292
386
|
const milliseconds = Date.parse(value);
|
|
@@ -311,6 +405,7 @@ function normalizeRecentlyPlayedItem(raw) {
|
|
|
311
405
|
const artists = normalizeArtistNames(raw.track.artists);
|
|
312
406
|
if (!playedAt || !id || !name || !album || artists.length === 0) return null;
|
|
313
407
|
const track = { id, name, artists, album };
|
|
408
|
+
if (albumUri(raw.track)) track.album_uri = albumUri(raw.track);
|
|
314
409
|
const durationMs = safeInteger(raw.track.duration_ms, 0, 86_400_000);
|
|
315
410
|
if (durationMs !== undefined) track.duration_ms = durationMs;
|
|
316
411
|
const isrc = safeText(raw.track.external_ids?.isrc, 32);
|
|
@@ -339,6 +434,7 @@ function normalizeRecentlyPlayed(payload) {
|
|
|
339
434
|
provider: "spotify",
|
|
340
435
|
items,
|
|
341
436
|
truncated: rawItems.length > items.length,
|
|
437
|
+
has_more: Boolean(payload?.next),
|
|
342
438
|
...(cursorAfterMs !== undefined
|
|
343
439
|
? { cursor_after_ms: cursorAfterMs }
|
|
344
440
|
: {}),
|
|
@@ -358,10 +454,10 @@ function normalizePlaylist(payload, { includeControlMetadata = false } = {}) {
|
|
|
358
454
|
id,
|
|
359
455
|
uri,
|
|
360
456
|
name,
|
|
361
|
-
is_public: payload.public ===
|
|
457
|
+
is_public: typeof payload.public === "boolean" ? payload.public : null,
|
|
362
458
|
};
|
|
363
459
|
if (includeControlMetadata) {
|
|
364
|
-
playlist.collaborative = payload.collaborative ===
|
|
460
|
+
playlist.collaborative = typeof payload.collaborative === "boolean" ? payload.collaborative : null;
|
|
365
461
|
const ownerId = safeText(payload.owner?.id, 128);
|
|
366
462
|
const snapshotId = safeText(payload.snapshot_id, 128);
|
|
367
463
|
if (ownerId) playlist.owner_id = ownerId;
|
|
@@ -398,7 +494,9 @@ function normalizePlaylistPage(payload) {
|
|
|
398
494
|
total,
|
|
399
495
|
limit,
|
|
400
496
|
offset,
|
|
401
|
-
has_more: offset + rawItems.length < total,
|
|
497
|
+
has_more: offset + rawItems.length < total || Boolean(payload?.next),
|
|
498
|
+
complete_for_name_selection: payload?.offset === 0 && safeInteger(payload?.total, 0, 1_000_000) !== undefined &&
|
|
499
|
+
rawItems.length === items.length && items.length === payload.total && payload?.next === null,
|
|
402
500
|
};
|
|
403
501
|
}
|
|
404
502
|
|
|
@@ -415,34 +513,45 @@ function normalizePlaylistItem(raw) {
|
|
|
415
513
|
if (item && typeof source?.is_playable === "boolean") {
|
|
416
514
|
item.is_playable = source.is_playable;
|
|
417
515
|
}
|
|
516
|
+
const addedAt = normalizedUtcTimestamp(raw.added_at);
|
|
418
517
|
return {
|
|
419
518
|
is_local: raw.is_local === true || source?.is_local === true,
|
|
420
519
|
item,
|
|
520
|
+
...(addedAt ? { added_at: addedAt } : {}),
|
|
421
521
|
};
|
|
422
522
|
}
|
|
423
523
|
|
|
424
524
|
function normalizePlaylistItemsPage(payload) {
|
|
425
525
|
const rawItems = Array.isArray(payload?.items) ? payload.items : [];
|
|
526
|
+
const offset =
|
|
527
|
+
safeInteger(payload?.offset, 0, 1_000_000) ?? 0;
|
|
426
528
|
const items = rawItems
|
|
427
529
|
.slice(0, SPOTIFY_WEB_API_LIMITS.playlistItemsPageMax)
|
|
428
530
|
.map(normalizePlaylistItem);
|
|
429
531
|
const total =
|
|
430
532
|
safeInteger(payload?.total, 0, 1_000_000) ?? items.length;
|
|
533
|
+
if (offset === 0 && total > 0 && rawItems.length === 0) {
|
|
534
|
+
// The items migration's silent failure mode: a retired playlist-items
|
|
535
|
+
// path answers 200 with an empty list. Never present that as an empty
|
|
536
|
+
// playlist — fail so the caller inspects instead of acting on a lie.
|
|
537
|
+
fail(
|
|
538
|
+
"spotify_playlist_items_mismatch",
|
|
539
|
+
`Spotify reported ${total} playlist items but returned none. The playlist may use a retired response shape, or its items are unavailable. Inspect it in the Spotify app before retrying.`,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
431
542
|
const limit =
|
|
432
543
|
safeInteger(
|
|
433
544
|
payload?.limit,
|
|
434
545
|
1,
|
|
435
546
|
SPOTIFY_WEB_API_LIMITS.playlistItemsPageMax,
|
|
436
547
|
) ?? Math.max(1, items.length);
|
|
437
|
-
const offset =
|
|
438
|
-
safeInteger(payload?.offset, 0, 1_000_000) ?? 0;
|
|
439
548
|
return {
|
|
440
549
|
provider: "spotify",
|
|
441
550
|
items,
|
|
442
551
|
total,
|
|
443
552
|
limit,
|
|
444
553
|
offset,
|
|
445
|
-
has_more: offset + rawItems.length < total,
|
|
554
|
+
has_more: offset + rawItems.length < total || Boolean(payload?.next),
|
|
446
555
|
};
|
|
447
556
|
}
|
|
448
557
|
|
|
@@ -479,6 +588,9 @@ export function createSpotifyWebApiClient({
|
|
|
479
588
|
fetchImpl = globalThis.fetch,
|
|
480
589
|
tokenProvider,
|
|
481
590
|
baseUrl = SPOTIFY_WEB_API_BASE_URL,
|
|
591
|
+
sleepImpl = defaultSleep,
|
|
592
|
+
refreshAccessToken,
|
|
593
|
+
writeTimeoutMs = 15_000,
|
|
482
594
|
} = {}) {
|
|
483
595
|
if (typeof fetchImpl !== "function") {
|
|
484
596
|
throw new TypeError("A fetch implementation is required.");
|
|
@@ -487,157 +599,306 @@ export function createSpotifyWebApiClient({
|
|
|
487
599
|
throw new TypeError("A Spotify token provider is required.");
|
|
488
600
|
}
|
|
489
601
|
const normalizedBaseUrl = String(baseUrl).replace(/\/+$/u, "");
|
|
602
|
+
if (!Number.isInteger(writeTimeoutMs) || writeTimeoutMs < 1 || writeTimeoutMs > 60_000) throw new TypeError("Invalid Spotify write settlement timeout.");
|
|
603
|
+
|
|
604
|
+
// A 429 re-arms the limiter for the whole client: the server's cooldown
|
|
605
|
+
// paces later requests too, not just the retried one.
|
|
606
|
+
let rateLimitResumeAtMs = 0;
|
|
607
|
+
|
|
608
|
+
const rearmRateLimit = (waitMs) => {
|
|
609
|
+
const bounded = Math.min(
|
|
610
|
+
Math.max(0, waitMs),
|
|
611
|
+
SPOTIFY_WEB_API_LIMITS.rateLimitMaxWaitMs,
|
|
612
|
+
);
|
|
613
|
+
const resumeAt = Date.now() + bounded;
|
|
614
|
+
if (resumeAt > rateLimitResumeAtMs) rateLimitResumeAtMs = resumeAt;
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
// A used-up development quota answers 429 QUOTA_EXCEEDED with a wait of hours, for some endpoints
|
|
618
|
+
// only. Asking again before then gets the same answer, so until the time Spotify gave, the client
|
|
619
|
+
// answers for that endpoint itself and sends nothing.
|
|
620
|
+
const quotaResumeAtMs = new Map();
|
|
621
|
+
const quotaExhausted = (retryAfter, options) => fail("spotify_quota_exceeded",
|
|
622
|
+
`Spotify development quota is exhausted.${retryAfter ? ` Spotify allows this request again in about ${waitText(retryAfter)}.` : ""}`,
|
|
623
|
+
{ ...options, status: 429, reason: "QUOTA_EXCEEDED", retryAfterSeconds: retryAfter });
|
|
490
624
|
|
|
491
625
|
const request = async (
|
|
492
626
|
path,
|
|
493
|
-
{ method = "GET", body, responseMode = "json" } = {},
|
|
627
|
+
{ method = "GET", body, responseMode = "json", signal, beforeDispatch } = {},
|
|
494
628
|
) => {
|
|
495
|
-
const
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
629
|
+
const endpoint = String(path).split("?")[0];
|
|
630
|
+
const quotaWaitMs = (quotaResumeAtMs.get(endpoint) ?? 0) - Date.now();
|
|
631
|
+
if (quotaWaitMs > 0) quotaExhausted(Math.ceil(quotaWaitMs / 1_000), { outcomeUnknown: false });
|
|
632
|
+
const initForToken = (token) => {
|
|
633
|
+
const headers = {
|
|
634
|
+
accept: "application/json",
|
|
635
|
+
authorization: `Bearer ${token}`,
|
|
636
|
+
};
|
|
637
|
+
const init = { method, headers };
|
|
638
|
+
if (body !== undefined) {
|
|
639
|
+
headers["content-type"] = "application/json";
|
|
640
|
+
init.body = JSON.stringify(body);
|
|
641
|
+
}
|
|
642
|
+
return init;
|
|
499
643
|
};
|
|
500
|
-
const init = { method, headers };
|
|
501
|
-
if (body !== undefined) {
|
|
502
|
-
headers["content-type"] = "application/json";
|
|
503
|
-
init.body = JSON.stringify(body);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
let response;
|
|
507
|
-
try {
|
|
508
|
-
response = await fetchImpl(`${normalizedBaseUrl}${path}`, init);
|
|
509
|
-
} catch {
|
|
510
|
-
fail("spotify_network_error", "Spotify could not be reached.");
|
|
511
|
-
}
|
|
512
644
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
})
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
645
|
+
// Read recovery shares one request budget. Every write gets one dispatch;
|
|
646
|
+
// a rejected token may be refreshed for a later explicitly requested action.
|
|
647
|
+
const read = method === "GET";
|
|
648
|
+
const maxAttempts = read ? SPOTIFY_WEB_API_LIMITS.rateLimitMaxAttempts : 1;
|
|
649
|
+
const accessToken = async (operation) => {
|
|
650
|
+
try {
|
|
651
|
+
return normalizedToken(await operation());
|
|
652
|
+
} catch (error) {
|
|
653
|
+
// Token lookup occurs before dispatch, or after a definite 401. Its
|
|
654
|
+
// failure cannot turn the protected write into an unknown effect.
|
|
655
|
+
if (!read && error && typeof error === "object") error.outcomeUnknown = false;
|
|
656
|
+
throw error;
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
signal?.throwIfAborted();
|
|
660
|
+
let token = await accessToken(() => tokenProvider({ signal }));
|
|
661
|
+
let refreshed = false;
|
|
662
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
663
|
+
// Recheck before every dispatch, including after refresh or retry backoff.
|
|
664
|
+
// Another in-flight 429 can extend the deadline while this request sleeps.
|
|
665
|
+
// Backoff already elapsed; wait only for any remaining shared cooldown.
|
|
666
|
+
while (true) {
|
|
667
|
+
signal?.throwIfAborted();
|
|
668
|
+
const remainingMs = rateLimitResumeAtMs - Date.now();
|
|
669
|
+
if (remainingMs <= 0) break;
|
|
670
|
+
await sleepImpl(remainingMs, { signal });
|
|
671
|
+
}
|
|
672
|
+
const init = initForToken(token);
|
|
673
|
+
// Let a dispatched write settle so cancellation cannot hide its receipt.
|
|
674
|
+
if (read && signal) init.signal = signal;
|
|
675
|
+
const deadline = Date.now() + writeTimeoutMs;
|
|
676
|
+
const transport = read ? null : new AbortController();
|
|
677
|
+
if (transport) init.signal = transport.signal;
|
|
678
|
+
const settle = async (operation) => {
|
|
679
|
+
if (read) return operation();
|
|
680
|
+
let timer;
|
|
681
|
+
try {
|
|
682
|
+
return await Promise.race([Promise.resolve(operation()), new Promise((_, reject) => {
|
|
683
|
+
timer = setTimeout(() => {
|
|
684
|
+
transport.abort();
|
|
685
|
+
reject(new Error("Spotify write settlement deadline exceeded."));
|
|
686
|
+
}, Math.max(0, deadline - Date.now()));
|
|
687
|
+
})]);
|
|
688
|
+
} finally { clearTimeout(timer); }
|
|
689
|
+
};
|
|
690
|
+
let response;
|
|
691
|
+
// A host authority guard runs after async token/refresh/lock waiting.
|
|
692
|
+
// Local refusal precedes fetch and must not become an uncertain write.
|
|
693
|
+
beforeDispatch?.();
|
|
694
|
+
try {
|
|
695
|
+
response = await settle(() => fetchImpl(`${normalizedBaseUrl}${path}`, init));
|
|
696
|
+
} catch (error) {
|
|
697
|
+
if (read) {
|
|
698
|
+
signal?.throwIfAborted();
|
|
699
|
+
if (error?.name === "AbortError") throw error;
|
|
700
|
+
}
|
|
701
|
+
// Writes have no transport cancellation signal: a transport AbortError
|
|
702
|
+
// cannot prove that the dispatched action had no effect.
|
|
703
|
+
fail("spotify_network_error", "Spotify could not be reached.", { outcomeUnknown: !read });
|
|
704
|
+
}
|
|
705
|
+
if (read) signal?.throwIfAborted();
|
|
706
|
+
if (response.ok && (response.status === 204 || responseMode === "none")) return null;
|
|
707
|
+
let text;
|
|
524
708
|
try {
|
|
525
|
-
|
|
526
|
-
} catch {
|
|
527
|
-
|
|
709
|
+
text = await settle(() => response.text());
|
|
710
|
+
} catch (error) {
|
|
711
|
+
if (read) {
|
|
712
|
+
signal?.throwIfAborted();
|
|
713
|
+
if (error?.name === "AbortError") throw error;
|
|
714
|
+
}
|
|
715
|
+
fail("spotify_network_error", "Spotify's response could not be read.", {
|
|
528
716
|
status: response.status,
|
|
717
|
+
outcomeUnknown: !read && !(response.status >= 400 && response.status < 500),
|
|
529
718
|
});
|
|
530
719
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
};
|
|
538
|
-
if (response.status === 401) {
|
|
539
|
-
fail(
|
|
540
|
-
"spotify_authentication_required",
|
|
541
|
-
"Spotify authentication must be refreshed.",
|
|
542
|
-
options,
|
|
543
|
-
);
|
|
720
|
+
if (read) signal?.throwIfAborted();
|
|
721
|
+
if (Buffer.byteLength(text, "utf8") > SPOTIFY_WEB_API_LIMITS.responseBytesMax) {
|
|
722
|
+
fail("spotify_response_too_large", "Spotify returned too much data.", {
|
|
723
|
+
status: response.status,
|
|
724
|
+
outcomeUnknown: !read && !(response.status >= 400 && response.status < 500),
|
|
725
|
+
});
|
|
544
726
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
);
|
|
727
|
+
let payload = null;
|
|
728
|
+
if (text) {
|
|
729
|
+
try { payload = JSON.parse(text); } catch {
|
|
730
|
+
// Error responses can be plain text. Their HTTP status still decides
|
|
731
|
+
// whether authentication or a bounded read retry is appropriate.
|
|
732
|
+
if (response.ok) fail("spotify_response_invalid", "Spotify returned an invalid response.", { status: response.status, outcomeUnknown: !read });
|
|
552
733
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
734
|
+
}
|
|
735
|
+
if (response.ok) return payload;
|
|
736
|
+
const retryAfter = retryAfterSeconds(response.headers);
|
|
737
|
+
const options = { status: response.status, reason: quotaReason(payload), retryAfterSeconds: retryAfter,
|
|
738
|
+
outcomeUnknown: !read && response.status >= 500 };
|
|
739
|
+
if (response.status === 401) {
|
|
740
|
+
if (!refreshed && typeof refreshAccessToken === "function" && (!read || attempt < maxAttempts)) {
|
|
741
|
+
signal?.throwIfAborted();
|
|
742
|
+
token = await accessToken(() => refreshAccessToken({ signal, rejectedAccessToken: token }));
|
|
743
|
+
refreshed = true;
|
|
744
|
+
if (!read) {
|
|
745
|
+
signal?.throwIfAborted();
|
|
746
|
+
fail("spotify_action_not_replayed", "Spotify rejected this action. Authentication was refreshed, but the action was not replayed. Submit a new request if you still want it.", options);
|
|
747
|
+
}
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
fail("spotify_authentication_required", "Spotify authentication must be refreshed.", options);
|
|
558
751
|
}
|
|
559
752
|
if (response.status === 429) {
|
|
560
753
|
if (quotaReason(payload) === "QUOTA_EXCEEDED") {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
754
|
+
if (retryAfter) quotaResumeAtMs.set(endpoint, Date.now() + retryAfter * 1_000);
|
|
755
|
+
quotaExhausted(retryAfter, options);
|
|
756
|
+
}
|
|
757
|
+
const waitMs = retryAfter === null ? SPOTIFY_WEB_API_LIMITS.rateLimitDefaultWaitMs : retryAfter * 1_000;
|
|
758
|
+
rearmRateLimit(waitMs);
|
|
759
|
+
if (read && attempt < maxAttempts && waitMs <= SPOTIFY_WEB_API_LIMITS.rateLimitMaxWaitMs) {
|
|
760
|
+
signal?.throwIfAborted();
|
|
761
|
+
const jittered = Math.min(SPOTIFY_WEB_API_LIMITS.rateLimitMaxWaitMs,
|
|
762
|
+
waitMs + Math.floor(Math.random() * SPOTIFY_WEB_API_LIMITS.rateLimitJitterMs));
|
|
763
|
+
await sleepImpl(jittered, { signal });
|
|
764
|
+
continue;
|
|
566
765
|
}
|
|
567
|
-
fail(
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
766
|
+
fail("spotify_rate_limited", "Spotify rate limited this request.", options);
|
|
767
|
+
}
|
|
768
|
+
if (response.status === 403) {
|
|
769
|
+
if (hasInsufficientClientScope(payload)) {
|
|
770
|
+
fail("spotify_scope_insufficient", "Spotify authorization is missing a required scope. Run moondog spotify login again.", options);
|
|
771
|
+
}
|
|
772
|
+
if (refusalMessage(payload) === undefined) {
|
|
773
|
+
// The items migration's signature: retired or app-restricted
|
|
774
|
+
// endpoints answer 403 with no helpful body. Report that
|
|
775
|
+
// distinctly instead of a generic forbidden action.
|
|
776
|
+
fail("spotify_endpoint_retired", "Spotify refused this request without detail. The endpoint or content may have been retired or restricted for this app after Spotify's items migration.", options);
|
|
777
|
+
}
|
|
778
|
+
fail("spotify_action_forbidden", "Spotify did not allow this action.", options);
|
|
572
779
|
}
|
|
573
780
|
fail("spotify_api_error", "Spotify rejected this request.", options);
|
|
574
781
|
}
|
|
575
|
-
return payload;
|
|
576
782
|
};
|
|
577
783
|
|
|
578
784
|
return Object.freeze({
|
|
579
|
-
async getAccount() {
|
|
580
|
-
return normalizeAccount(await request("/me"));
|
|
785
|
+
async getAccount({ signal } = {}) {
|
|
786
|
+
return normalizeAccount(await request("/me", { signal }));
|
|
581
787
|
},
|
|
582
788
|
|
|
583
|
-
async getCurrentPlayback() {
|
|
584
|
-
return normalizePlayback(await request("/me/player"));
|
|
789
|
+
async getCurrentPlayback({ signal } = {}) {
|
|
790
|
+
return normalizePlayback(await request("/me/player?additional_types=episode", { signal }));
|
|
585
791
|
},
|
|
586
792
|
|
|
587
|
-
async getDevices() {
|
|
588
|
-
return normalizeDevices(await request("/me/player/devices"));
|
|
793
|
+
async getDevices({ signal } = {}) {
|
|
794
|
+
return normalizeDevices(await request("/me/player/devices", { signal }));
|
|
589
795
|
},
|
|
590
796
|
|
|
591
|
-
async getQueue() {
|
|
592
|
-
return normalizeQueue(await request("/me/player/queue"));
|
|
797
|
+
async getQueue({ signal } = {}) {
|
|
798
|
+
return normalizeQueue(await request("/me/player/queue", { signal }));
|
|
593
799
|
},
|
|
594
800
|
|
|
595
|
-
async getRecentlyPlayed({ limit, after, before } = {}) {
|
|
801
|
+
async getRecentlyPlayed({ limit, after, before } = {}, { signal } = {}) {
|
|
596
802
|
return normalizeRecentlyPlayed(
|
|
597
803
|
await request(
|
|
598
|
-
`/me/player/recently-played${queryString({ limit, after, before })}`,
|
|
804
|
+
`/me/player/recently-played${queryString({ limit, after, before })}`, { signal },
|
|
599
805
|
),
|
|
600
806
|
);
|
|
601
807
|
},
|
|
602
808
|
|
|
603
|
-
|
|
809
|
+
// Agent browsing is capped by the service; a library import reads Spotify's full page of 50.
|
|
810
|
+
async getSavedItems({ type = "tracks", limit = 20, offset = 0 } = {}, { signal } = {}) {
|
|
811
|
+
if (!["tracks", "albums", "shows", "playlists"].includes(type) || !Number.isInteger(limit) || limit < 1 || limit > 50 ||
|
|
812
|
+
!Number.isInteger(offset) || offset < 0 || offset > 1_000_000) fail("invalid_library_page", "Choose a supported library type and bounded page.");
|
|
813
|
+
return normalizeLibraryPage(await request(`/me/${type}${queryString({ limit, offset })}`, { signal }), type, { limit, offset });
|
|
814
|
+
},
|
|
815
|
+
|
|
816
|
+
async getFollowedArtists({ limit = 10, after } = {}, { signal } = {}) {
|
|
817
|
+
const payload = await request(`/me/following${queryString({ type: "artist", limit, after })}`, { signal });
|
|
818
|
+
const result = normalizeLibraryPage(payload?.artists, "artists", { limit, offset: 0 });
|
|
819
|
+
delete result.next_offset;
|
|
820
|
+
result.has_more = Boolean(payload?.artists?.next);
|
|
821
|
+
const cursor = payload?.artists?.cursors?.after;
|
|
822
|
+
if (result.has_more && typeof cursor === "string" && cursor !== after && /^[A-Za-z0-9]{1,128}$/u.test(cursor)) result.next_after = cursor;
|
|
823
|
+
return result;
|
|
824
|
+
},
|
|
825
|
+
|
|
826
|
+
async searchItems({ query, type, limit, offset = 0 } = {}, { signal } = {}) {
|
|
827
|
+
const payload = await request(`/search${queryString({ q: query, type, limit, offset })}`, { signal });
|
|
828
|
+
const page = payload?.[`${type}s`];
|
|
829
|
+
const raw = Array.isArray(page?.items) ? page.items : [];
|
|
830
|
+
const items = raw.slice(0, limit).map((item) => normalizeCatalogItem(item, type)).filter(Boolean);
|
|
831
|
+
const hasMore = Boolean(page?.next) || Number.isInteger(page?.total) && offset + raw.length < page.total;
|
|
832
|
+
return { provider: "spotify", type, items, has_more: hasMore,
|
|
833
|
+
next_offset: hasMore && raw.length ? offset + Math.min(raw.length, limit) : null,
|
|
834
|
+
truncated: raw.length > items.length };
|
|
835
|
+
},
|
|
836
|
+
|
|
837
|
+
async getCatalogChildren({ type, id, limit, offset } = {}, { signal } = {}) {
|
|
838
|
+
const childType = type === "album" ? "track" : "episode";
|
|
839
|
+
const payload = await request(`/${type}s/${encodeURIComponent(id)}/${childType}s${queryString({ limit, offset })}`, { signal });
|
|
840
|
+
const raw = Array.isArray(payload?.items) ? payload.items : [];
|
|
841
|
+
const items = raw.slice(0, limit).map((item) => normalizeCatalogItem(item, childType)).filter(Boolean);
|
|
842
|
+
const hasMore = Boolean(payload?.next) || Number.isInteger(payload?.total) && offset + raw.length < payload.total;
|
|
843
|
+
return { provider: "spotify", source: "catalog", items, limit, offset, has_more: hasMore,
|
|
844
|
+
next_offset: hasMore && raw.length ? offset + Math.min(raw.length, limit) : null, truncated: raw.length > items.length };
|
|
845
|
+
},
|
|
846
|
+
|
|
847
|
+
async searchTracks({ query, limit, market } = {}, { signal } = {}) {
|
|
604
848
|
return normalizeTrackSearch(
|
|
605
849
|
await request(
|
|
606
|
-
`/search${queryString({ q: query, type: "track", limit, market })}`,
|
|
607
|
-
),
|
|
850
|
+
`/search${queryString({ q: query, type: "track", limit, market })}`, { signal },
|
|
851
|
+
), { limit },
|
|
608
852
|
);
|
|
609
853
|
},
|
|
610
854
|
|
|
611
|
-
async
|
|
855
|
+
async getTopItems({ type = "tracks", timeRange = "medium_term", limit = 5 } = {}, { signal } = {}) {
|
|
856
|
+
if (!["artists", "tracks"].includes(type) || !["short_term", "medium_term", "long_term"].includes(timeRange) ||
|
|
857
|
+
!Number.isInteger(limit) || limit < 1 || limit > 50) {
|
|
858
|
+
fail("invalid_top_items", "Spotify top items require a supported type, time range, and limit from 1 to 50.");
|
|
859
|
+
}
|
|
860
|
+
const payload = await request(`/me/top/${type}${queryString({ time_range: timeRange, limit, offset: 0 })}`, { signal });
|
|
861
|
+
const raw = Array.isArray(payload?.items) ? payload.items : [];
|
|
862
|
+
const items = raw.slice(0, limit).map((item, index) => {
|
|
863
|
+
const artistUri = typeof item?.uri === "string" && /^spotify:artist:[A-Za-z0-9]{22}$/u.test(item.uri) ? item.uri : null;
|
|
864
|
+
const normalized = type === "tracks" ? normalizeTrackSearchItem(item) :
|
|
865
|
+
(safeText(item?.name) ? { name: safeText(item.name), type: "artist", ...(artistUri ? { uri: artistUri } : {}) } : null);
|
|
866
|
+
return normalized ? { ...normalized, affinity_rank: index + 1 } : null;
|
|
867
|
+
}).filter(Boolean);
|
|
868
|
+
return { provider: "spotify", items, truncated: raw.length > items.length || Boolean(payload?.next) || payload?.total > raw.length };
|
|
869
|
+
},
|
|
870
|
+
|
|
871
|
+
async getCurrentUserPlaylists({ limit, offset } = {}, { signal } = {}) {
|
|
612
872
|
return normalizePlaylistPage(
|
|
613
873
|
await request(
|
|
614
|
-
`/me/playlists${queryString({ limit, offset })}`,
|
|
874
|
+
`/me/playlists${queryString({ limit, offset })}`, { signal },
|
|
615
875
|
),
|
|
616
876
|
);
|
|
617
877
|
},
|
|
618
878
|
|
|
619
|
-
async getPlaylist({ playlistId } = {}) {
|
|
879
|
+
async getPlaylist({ playlistId } = {}, { signal } = {}) {
|
|
620
880
|
return normalizePlaylist(
|
|
621
|
-
await request(`/playlists/${encodeURIComponent(playlistId)}
|
|
881
|
+
await request(`/playlists/${encodeURIComponent(playlistId)}`, { signal }),
|
|
622
882
|
{ includeControlMetadata: true },
|
|
623
883
|
);
|
|
624
884
|
},
|
|
625
885
|
|
|
626
|
-
async getPlaylistItems({ playlistId, limit, offset } = {}) {
|
|
886
|
+
async getPlaylistItems({ playlistId, limit, offset } = {}, { signal } = {}) {
|
|
627
887
|
return normalizePlaylistItemsPage(
|
|
628
888
|
await request(
|
|
629
889
|
`/playlists/${encodeURIComponent(playlistId)}/items${queryString({
|
|
630
890
|
limit,
|
|
631
891
|
offset,
|
|
632
|
-
})}`,
|
|
892
|
+
})}`, { signal },
|
|
633
893
|
),
|
|
634
894
|
);
|
|
635
895
|
},
|
|
636
896
|
|
|
637
|
-
async createPlaylist({ name, description } = {}) {
|
|
897
|
+
async createPlaylist({ name, description } = {}, { signal } = {}) {
|
|
638
898
|
return normalizePlaylist(
|
|
639
899
|
await request("/me/playlists", {
|
|
640
900
|
method: "POST",
|
|
901
|
+
signal,
|
|
641
902
|
body: {
|
|
642
903
|
name,
|
|
643
904
|
...(description ? { description } : {}),
|
|
@@ -647,114 +908,136 @@ export function createSpotifyWebApiClient({
|
|
|
647
908
|
);
|
|
648
909
|
},
|
|
649
910
|
|
|
650
|
-
async addPlaylistTracks({ playlistId, uris } = {}) {
|
|
911
|
+
async addPlaylistTracks({ playlistId, uris } = {}, { signal } = {}) {
|
|
651
912
|
const payload = await request(
|
|
652
913
|
`/playlists/${encodeURIComponent(playlistId)}/items`,
|
|
653
|
-
{ method: "POST", body: { uris } },
|
|
914
|
+
{ method: "POST", body: { uris }, signal },
|
|
654
915
|
);
|
|
655
916
|
const snapshotId = safeText(payload?.snapshot_id, 128);
|
|
656
917
|
return snapshotId ? { snapshot_id: snapshotId } : {};
|
|
657
918
|
},
|
|
658
919
|
|
|
659
|
-
async
|
|
920
|
+
async renamePlaylist({ playlistId, name } = {}, { signal, beforeDispatch } = {}) {
|
|
921
|
+
await request(`/playlists/${encodeURIComponent(playlistId)}`, {
|
|
922
|
+
method: "PUT", body: { name }, responseMode: "none", signal, beforeDispatch,
|
|
923
|
+
});
|
|
924
|
+
},
|
|
925
|
+
|
|
926
|
+
async removePlaylistItem({ playlistId, uri, snapshotId } = {}, { signal, beforeDispatch } = {}) {
|
|
927
|
+
const payload = await request(`/playlists/${encodeURIComponent(playlistId)}/items`, {
|
|
928
|
+
method: "DELETE", body: { items: [{ uri }], snapshot_id: snapshotId }, signal, beforeDispatch,
|
|
929
|
+
});
|
|
930
|
+
const snapshot = safeText(payload?.snapshot_id, 128);
|
|
931
|
+
if (!snapshot) fail("spotify_write_receipt_invalid", "Spotify did not return a valid removal receipt. Inspect the playlist before retrying.", { outcomeUnknown: true });
|
|
932
|
+
return { snapshot_id: snapshot };
|
|
933
|
+
},
|
|
934
|
+
|
|
935
|
+
async replacePlaylistItems({ playlistId, uris } = {}, { signal } = {}) {
|
|
660
936
|
const payload = await request(
|
|
661
937
|
`/playlists/${encodeURIComponent(playlistId)}/items`,
|
|
662
|
-
{ method: "PUT", body: { uris } },
|
|
938
|
+
{ method: "PUT", body: { uris }, signal },
|
|
663
939
|
);
|
|
664
940
|
const snapshotId = safeText(payload?.snapshot_id, 128);
|
|
665
941
|
return snapshotId ? { snapshot_id: snapshotId } : {};
|
|
666
942
|
},
|
|
667
943
|
|
|
668
|
-
async checkSavedTracks({ uris } = {}) {
|
|
944
|
+
async checkSavedTracks({ uris } = {}, { signal } = {}) {
|
|
669
945
|
return normalizeSavedContains(
|
|
670
946
|
await request(
|
|
671
|
-
`/me/library/contains${queryString({ uris: uris.join(",") })}`,
|
|
947
|
+
`/me/library/contains${queryString({ uris: uris.join(",") })}`, { signal },
|
|
672
948
|
),
|
|
673
949
|
);
|
|
674
950
|
},
|
|
675
951
|
|
|
676
|
-
async saveTracks({ uris } = {}) {
|
|
952
|
+
async saveTracks({ uris } = {}, { signal } = {}) {
|
|
677
953
|
await request(`/me/library${queryString({ uris: uris.join(",") })}`, {
|
|
678
954
|
method: "PUT",
|
|
679
955
|
responseMode: "none",
|
|
956
|
+
signal,
|
|
680
957
|
});
|
|
681
958
|
return null;
|
|
682
959
|
},
|
|
683
960
|
|
|
684
|
-
async
|
|
961
|
+
async removeLibraryItems({ uris } = {}, { signal } = {}) {
|
|
962
|
+
await request(`/me/library${queryString({ uris: uris.join(",") })}`, { method: "DELETE", responseMode: "none", signal });
|
|
963
|
+
},
|
|
964
|
+
|
|
965
|
+
async resume(input = {}, { signal, beforeDispatch } = {}) {
|
|
685
966
|
await request(
|
|
686
967
|
`/me/player/play${queryString({ device_id: input.deviceId })}`,
|
|
687
|
-
{ method: "PUT", body: playbackBody(input), responseMode: "none" },
|
|
968
|
+
{ method: "PUT", body: playbackBody(input), responseMode: "none", signal, beforeDispatch },
|
|
688
969
|
);
|
|
689
970
|
},
|
|
690
971
|
|
|
691
|
-
async pause({ deviceId } = {}) {
|
|
972
|
+
async pause({ deviceId } = {}, { signal } = {}) {
|
|
692
973
|
await request(
|
|
693
974
|
`/me/player/pause${queryString({ device_id: deviceId })}`,
|
|
694
|
-
{ method: "PUT", responseMode: "none" },
|
|
975
|
+
{ method: "PUT", responseMode: "none", signal },
|
|
695
976
|
);
|
|
696
977
|
},
|
|
697
978
|
|
|
698
|
-
async next({ deviceId } = {}) {
|
|
979
|
+
async next({ deviceId } = {}, { signal } = {}) {
|
|
699
980
|
await request(
|
|
700
981
|
`/me/player/next${queryString({ device_id: deviceId })}`,
|
|
701
|
-
{ method: "POST", responseMode: "none" },
|
|
982
|
+
{ method: "POST", responseMode: "none", signal },
|
|
702
983
|
);
|
|
703
984
|
},
|
|
704
985
|
|
|
705
|
-
async previous({ deviceId } = {}) {
|
|
986
|
+
async previous({ deviceId } = {}, { signal } = {}) {
|
|
706
987
|
await request(
|
|
707
988
|
`/me/player/previous${queryString({ device_id: deviceId })}`,
|
|
708
|
-
{ method: "POST", responseMode: "none" },
|
|
989
|
+
{ method: "POST", responseMode: "none", signal },
|
|
709
990
|
);
|
|
710
991
|
},
|
|
711
992
|
|
|
712
|
-
async setVolume({ percent, deviceId }) {
|
|
993
|
+
async setVolume({ percent, deviceId }, { signal } = {}) {
|
|
713
994
|
await request(
|
|
714
995
|
`/me/player/volume${queryString({
|
|
715
996
|
volume_percent: percent,
|
|
716
997
|
device_id: deviceId,
|
|
717
998
|
})}`,
|
|
718
|
-
{ method: "PUT", responseMode: "none" },
|
|
999
|
+
{ method: "PUT", responseMode: "none", signal },
|
|
719
1000
|
);
|
|
720
1001
|
},
|
|
721
1002
|
|
|
722
|
-
async seek({ positionMs, deviceId }) {
|
|
1003
|
+
async seek({ positionMs, deviceId }, { signal } = {}) {
|
|
723
1004
|
await request(
|
|
724
1005
|
`/me/player/seek${queryString({
|
|
725
1006
|
position_ms: positionMs,
|
|
726
1007
|
device_id: deviceId,
|
|
727
1008
|
})}`,
|
|
728
|
-
{ method: "PUT", responseMode: "none" },
|
|
1009
|
+
{ method: "PUT", responseMode: "none", signal },
|
|
729
1010
|
);
|
|
730
1011
|
},
|
|
731
1012
|
|
|
732
|
-
async setShuffle({ state, deviceId }) {
|
|
1013
|
+
async setShuffle({ state, deviceId }, { signal } = {}) {
|
|
733
1014
|
await request(
|
|
734
1015
|
`/me/player/shuffle${queryString({ state, device_id: deviceId })}`,
|
|
735
|
-
{ method: "PUT", responseMode: "none" },
|
|
1016
|
+
{ method: "PUT", responseMode: "none", signal },
|
|
736
1017
|
);
|
|
737
1018
|
},
|
|
738
1019
|
|
|
739
|
-
async setRepeat({ state, deviceId }) {
|
|
1020
|
+
async setRepeat({ state, deviceId }, { signal } = {}) {
|
|
740
1021
|
await request(
|
|
741
1022
|
`/me/player/repeat${queryString({ state, device_id: deviceId })}`,
|
|
742
|
-
{ method: "PUT", responseMode: "none" },
|
|
1023
|
+
{ method: "PUT", responseMode: "none", signal },
|
|
743
1024
|
);
|
|
744
1025
|
},
|
|
745
1026
|
|
|
746
|
-
async transfer({ deviceId, play }) {
|
|
1027
|
+
async transfer({ deviceId, play }, { signal, beforeDispatch } = {}) {
|
|
747
1028
|
await request("/me/player", {
|
|
748
1029
|
method: "PUT",
|
|
749
1030
|
body: { device_ids: [deviceId], play },
|
|
750
1031
|
responseMode: "none",
|
|
1032
|
+
signal,
|
|
1033
|
+
beforeDispatch,
|
|
751
1034
|
});
|
|
752
1035
|
},
|
|
753
1036
|
|
|
754
|
-
async addToQueue({ uri, deviceId }) {
|
|
1037
|
+
async addToQueue({ uri, deviceId }, { signal } = {}) {
|
|
755
1038
|
await request(
|
|
756
1039
|
`/me/player/queue${queryString({ uri, device_id: deviceId })}`,
|
|
757
|
-
{ method: "POST", responseMode: "none" },
|
|
1040
|
+
{ method: "POST", responseMode: "none", signal },
|
|
758
1041
|
);
|
|
759
1042
|
},
|
|
760
1043
|
});
|