@audiofool/moondog 0.1.0 → 0.2.0-beta.2
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 +153 -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 +168 -36
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +278 -55
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +153 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +1908 -104
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +121 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +128 -0
- package/src/i18n/messages/en.mjs +129 -0
- package/src/i18n/messages/es.mjs +128 -0
- package/src/i18n/messages/fr.mjs +128 -0
- package/src/i18n/messages/it.mjs +128 -0
- package/src/i18n/messages/ja.mjs +128 -0
- package/src/i18n/messages/ko.mjs +128 -0
- package/src/i18n/messages/pt.mjs +128 -0
- package/src/i18n/messages/zh-Hant.mjs +128 -0
- package/src/i18n/messages/zh.mjs +128 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +966 -0
- package/src/i18n/screen/ja.mjs +966 -0
- package/src/i18n/screen/pt.mjs +966 -0
- package/src/i18n/screen/zh.mjs +966 -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 +123 -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 +321 -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 +8 -4
- 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 +407 -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 +469 -41
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-profile-build.mjs +378 -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 +172 -0
- package/src/profile/listening-history-store.mjs +424 -8
- package/src/profile/listening-profile-projection.mjs +242 -76
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +84 -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 +2204 -605
- 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 +160 -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 +46 -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,6 +324,7 @@ 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);
|
|
@@ -272,21 +333,51 @@ function normalizeTrackSearchItem(raw) {
|
|
|
272
333
|
return item;
|
|
273
334
|
}
|
|
274
335
|
|
|
275
|
-
function normalizeTrackSearch(payload) {
|
|
336
|
+
function normalizeTrackSearch(payload, { limit = SPOTIFY_WEB_API_LIMITS.searchResultsMax } = {}) {
|
|
276
337
|
const rawItems = Array.isArray(payload?.tracks?.items)
|
|
277
338
|
? payload.tracks.items
|
|
278
339
|
: [];
|
|
279
340
|
const items = rawItems
|
|
280
341
|
.map(normalizeTrackSearchItem)
|
|
281
342
|
.filter(Boolean)
|
|
282
|
-
.slice(0,
|
|
343
|
+
.slice(0, limit);
|
|
344
|
+
const hasMore = Boolean(payload?.tracks?.next) || Number.isInteger(payload?.tracks?.total) && rawItems.length < payload.tracks.total;
|
|
283
345
|
return {
|
|
284
346
|
provider: "spotify",
|
|
285
347
|
items,
|
|
348
|
+
has_more: hasMore,
|
|
349
|
+
next_offset: hasMore && rawItems.length ? Math.min(rawItems.length, limit) : null,
|
|
286
350
|
truncated: rawItems.length > items.length,
|
|
287
351
|
};
|
|
288
352
|
}
|
|
289
353
|
|
|
354
|
+
function normalizeCatalogItem(raw, type) {
|
|
355
|
+
if (type === "track") return normalizeTrackSearchItem(raw);
|
|
356
|
+
if (!isPlainObject(raw) || !safeText(raw.name) ||
|
|
357
|
+
typeof raw.uri !== "string" || !new RegExp(`^spotify:${type}:[A-Za-z0-9]{1,128}$`, "u").test(raw.uri)) return null;
|
|
358
|
+
const item = { type, uri: raw.uri, name: safeText(raw.name), artists: normalizeArtistNames(raw.artists) };
|
|
359
|
+
for (const field of ["publisher", "release_date"]) if (safeText(raw[field])) item[field] = safeText(raw[field]);
|
|
360
|
+
const duration = safeInteger(raw.duration_ms, 0, 86_400_000);
|
|
361
|
+
if (duration !== undefined) item.duration_ms = duration;
|
|
362
|
+
if (typeof raw.explicit === "boolean") item.explicit = raw.explicit;
|
|
363
|
+
return item;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function normalizeLibraryPage(payload, type, { limit, offset }) {
|
|
367
|
+
const raw = Array.isArray(payload?.items) ? payload.items : [];
|
|
368
|
+
const singular = type.slice(0, -1);
|
|
369
|
+
const items = raw.slice(0, limit).map((entry) => {
|
|
370
|
+
const item = normalizeCatalogItem(["playlists", "artists"].includes(type) ? entry : entry?.[singular], singular);
|
|
371
|
+
const addedAt = normalizedUtcTimestamp(entry?.added_at);
|
|
372
|
+
return item ? { ...item, ...(addedAt ? { added_at: addedAt } : {}) } : null;
|
|
373
|
+
}).filter(Boolean);
|
|
374
|
+
const total = safeInteger(payload?.total, 0, 1_000_000);
|
|
375
|
+
const hasMore = Boolean(payload?.next) || (total !== undefined && offset + raw.length < total);
|
|
376
|
+
return { provider: "spotify", type, items, offset, limit, ...(total !== undefined ? { total } : {}),
|
|
377
|
+
has_more: hasMore, next_offset: hasMore && raw.length > 0 ? offset + Math.min(raw.length, limit) : null,
|
|
378
|
+
truncated: raw.length > items.length };
|
|
379
|
+
}
|
|
380
|
+
|
|
290
381
|
function normalizedUtcTimestamp(value) {
|
|
291
382
|
if (typeof value !== "string") return undefined;
|
|
292
383
|
const milliseconds = Date.parse(value);
|
|
@@ -311,6 +402,7 @@ function normalizeRecentlyPlayedItem(raw) {
|
|
|
311
402
|
const artists = normalizeArtistNames(raw.track.artists);
|
|
312
403
|
if (!playedAt || !id || !name || !album || artists.length === 0) return null;
|
|
313
404
|
const track = { id, name, artists, album };
|
|
405
|
+
if (albumUri(raw.track)) track.album_uri = albumUri(raw.track);
|
|
314
406
|
const durationMs = safeInteger(raw.track.duration_ms, 0, 86_400_000);
|
|
315
407
|
if (durationMs !== undefined) track.duration_ms = durationMs;
|
|
316
408
|
const isrc = safeText(raw.track.external_ids?.isrc, 32);
|
|
@@ -339,6 +431,7 @@ function normalizeRecentlyPlayed(payload) {
|
|
|
339
431
|
provider: "spotify",
|
|
340
432
|
items,
|
|
341
433
|
truncated: rawItems.length > items.length,
|
|
434
|
+
has_more: Boolean(payload?.next),
|
|
342
435
|
...(cursorAfterMs !== undefined
|
|
343
436
|
? { cursor_after_ms: cursorAfterMs }
|
|
344
437
|
: {}),
|
|
@@ -358,10 +451,10 @@ function normalizePlaylist(payload, { includeControlMetadata = false } = {}) {
|
|
|
358
451
|
id,
|
|
359
452
|
uri,
|
|
360
453
|
name,
|
|
361
|
-
is_public: payload.public ===
|
|
454
|
+
is_public: typeof payload.public === "boolean" ? payload.public : null,
|
|
362
455
|
};
|
|
363
456
|
if (includeControlMetadata) {
|
|
364
|
-
playlist.collaborative = payload.collaborative ===
|
|
457
|
+
playlist.collaborative = typeof payload.collaborative === "boolean" ? payload.collaborative : null;
|
|
365
458
|
const ownerId = safeText(payload.owner?.id, 128);
|
|
366
459
|
const snapshotId = safeText(payload.snapshot_id, 128);
|
|
367
460
|
if (ownerId) playlist.owner_id = ownerId;
|
|
@@ -398,7 +491,9 @@ function normalizePlaylistPage(payload) {
|
|
|
398
491
|
total,
|
|
399
492
|
limit,
|
|
400
493
|
offset,
|
|
401
|
-
has_more: offset + rawItems.length < total,
|
|
494
|
+
has_more: offset + rawItems.length < total || Boolean(payload?.next),
|
|
495
|
+
complete_for_name_selection: payload?.offset === 0 && safeInteger(payload?.total, 0, 1_000_000) !== undefined &&
|
|
496
|
+
rawItems.length === items.length && items.length === payload.total && payload?.next === null,
|
|
402
497
|
};
|
|
403
498
|
}
|
|
404
499
|
|
|
@@ -415,34 +510,45 @@ function normalizePlaylistItem(raw) {
|
|
|
415
510
|
if (item && typeof source?.is_playable === "boolean") {
|
|
416
511
|
item.is_playable = source.is_playable;
|
|
417
512
|
}
|
|
513
|
+
const addedAt = normalizedUtcTimestamp(raw.added_at);
|
|
418
514
|
return {
|
|
419
515
|
is_local: raw.is_local === true || source?.is_local === true,
|
|
420
516
|
item,
|
|
517
|
+
...(addedAt ? { added_at: addedAt } : {}),
|
|
421
518
|
};
|
|
422
519
|
}
|
|
423
520
|
|
|
424
521
|
function normalizePlaylistItemsPage(payload) {
|
|
425
522
|
const rawItems = Array.isArray(payload?.items) ? payload.items : [];
|
|
523
|
+
const offset =
|
|
524
|
+
safeInteger(payload?.offset, 0, 1_000_000) ?? 0;
|
|
426
525
|
const items = rawItems
|
|
427
526
|
.slice(0, SPOTIFY_WEB_API_LIMITS.playlistItemsPageMax)
|
|
428
527
|
.map(normalizePlaylistItem);
|
|
429
528
|
const total =
|
|
430
529
|
safeInteger(payload?.total, 0, 1_000_000) ?? items.length;
|
|
530
|
+
if (offset === 0 && total > 0 && rawItems.length === 0) {
|
|
531
|
+
// The items migration's silent failure mode: a retired playlist-items
|
|
532
|
+
// path answers 200 with an empty list. Never present that as an empty
|
|
533
|
+
// playlist — fail so the caller inspects instead of acting on a lie.
|
|
534
|
+
fail(
|
|
535
|
+
"spotify_playlist_items_mismatch",
|
|
536
|
+
`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.`,
|
|
537
|
+
);
|
|
538
|
+
}
|
|
431
539
|
const limit =
|
|
432
540
|
safeInteger(
|
|
433
541
|
payload?.limit,
|
|
434
542
|
1,
|
|
435
543
|
SPOTIFY_WEB_API_LIMITS.playlistItemsPageMax,
|
|
436
544
|
) ?? Math.max(1, items.length);
|
|
437
|
-
const offset =
|
|
438
|
-
safeInteger(payload?.offset, 0, 1_000_000) ?? 0;
|
|
439
545
|
return {
|
|
440
546
|
provider: "spotify",
|
|
441
547
|
items,
|
|
442
548
|
total,
|
|
443
549
|
limit,
|
|
444
550
|
offset,
|
|
445
|
-
has_more: offset + rawItems.length < total,
|
|
551
|
+
has_more: offset + rawItems.length < total || Boolean(payload?.next),
|
|
446
552
|
};
|
|
447
553
|
}
|
|
448
554
|
|
|
@@ -479,6 +585,9 @@ export function createSpotifyWebApiClient({
|
|
|
479
585
|
fetchImpl = globalThis.fetch,
|
|
480
586
|
tokenProvider,
|
|
481
587
|
baseUrl = SPOTIFY_WEB_API_BASE_URL,
|
|
588
|
+
sleepImpl = defaultSleep,
|
|
589
|
+
refreshAccessToken,
|
|
590
|
+
writeTimeoutMs = 15_000,
|
|
482
591
|
} = {}) {
|
|
483
592
|
if (typeof fetchImpl !== "function") {
|
|
484
593
|
throw new TypeError("A fetch implementation is required.");
|
|
@@ -487,157 +596,306 @@ export function createSpotifyWebApiClient({
|
|
|
487
596
|
throw new TypeError("A Spotify token provider is required.");
|
|
488
597
|
}
|
|
489
598
|
const normalizedBaseUrl = String(baseUrl).replace(/\/+$/u, "");
|
|
599
|
+
if (!Number.isInteger(writeTimeoutMs) || writeTimeoutMs < 1 || writeTimeoutMs > 60_000) throw new TypeError("Invalid Spotify write settlement timeout.");
|
|
600
|
+
|
|
601
|
+
// A 429 re-arms the limiter for the whole client: the server's cooldown
|
|
602
|
+
// paces later requests too, not just the retried one.
|
|
603
|
+
let rateLimitResumeAtMs = 0;
|
|
604
|
+
|
|
605
|
+
const rearmRateLimit = (waitMs) => {
|
|
606
|
+
const bounded = Math.min(
|
|
607
|
+
Math.max(0, waitMs),
|
|
608
|
+
SPOTIFY_WEB_API_LIMITS.rateLimitMaxWaitMs,
|
|
609
|
+
);
|
|
610
|
+
const resumeAt = Date.now() + bounded;
|
|
611
|
+
if (resumeAt > rateLimitResumeAtMs) rateLimitResumeAtMs = resumeAt;
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
// A used-up development quota answers 429 QUOTA_EXCEEDED with a wait of hours, for some endpoints
|
|
615
|
+
// only. Asking again before then gets the same answer, so until the time Spotify gave, the client
|
|
616
|
+
// answers for that endpoint itself and sends nothing.
|
|
617
|
+
const quotaResumeAtMs = new Map();
|
|
618
|
+
const quotaExhausted = (retryAfter, options) => fail("spotify_quota_exceeded",
|
|
619
|
+
`Spotify development quota is exhausted.${retryAfter ? ` Spotify allows this request again in about ${waitText(retryAfter)}.` : ""}`,
|
|
620
|
+
{ ...options, status: 429, reason: "QUOTA_EXCEEDED", retryAfterSeconds: retryAfter });
|
|
490
621
|
|
|
491
622
|
const request = async (
|
|
492
623
|
path,
|
|
493
|
-
{ method = "GET", body, responseMode = "json" } = {},
|
|
624
|
+
{ method = "GET", body, responseMode = "json", signal, beforeDispatch } = {},
|
|
494
625
|
) => {
|
|
495
|
-
const
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
626
|
+
const endpoint = String(path).split("?")[0];
|
|
627
|
+
const quotaWaitMs = (quotaResumeAtMs.get(endpoint) ?? 0) - Date.now();
|
|
628
|
+
if (quotaWaitMs > 0) quotaExhausted(Math.ceil(quotaWaitMs / 1_000), { outcomeUnknown: false });
|
|
629
|
+
const initForToken = (token) => {
|
|
630
|
+
const headers = {
|
|
631
|
+
accept: "application/json",
|
|
632
|
+
authorization: `Bearer ${token}`,
|
|
633
|
+
};
|
|
634
|
+
const init = { method, headers };
|
|
635
|
+
if (body !== undefined) {
|
|
636
|
+
headers["content-type"] = "application/json";
|
|
637
|
+
init.body = JSON.stringify(body);
|
|
638
|
+
}
|
|
639
|
+
return init;
|
|
499
640
|
};
|
|
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
641
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
})
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
642
|
+
// Read recovery shares one request budget. Every write gets one dispatch;
|
|
643
|
+
// a rejected token may be refreshed for a later explicitly requested action.
|
|
644
|
+
const read = method === "GET";
|
|
645
|
+
const maxAttempts = read ? SPOTIFY_WEB_API_LIMITS.rateLimitMaxAttempts : 1;
|
|
646
|
+
const accessToken = async (operation) => {
|
|
647
|
+
try {
|
|
648
|
+
return normalizedToken(await operation());
|
|
649
|
+
} catch (error) {
|
|
650
|
+
// Token lookup occurs before dispatch, or after a definite 401. Its
|
|
651
|
+
// failure cannot turn the protected write into an unknown effect.
|
|
652
|
+
if (!read && error && typeof error === "object") error.outcomeUnknown = false;
|
|
653
|
+
throw error;
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
signal?.throwIfAborted();
|
|
657
|
+
let token = await accessToken(() => tokenProvider({ signal }));
|
|
658
|
+
let refreshed = false;
|
|
659
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
660
|
+
// Recheck before every dispatch, including after refresh or retry backoff.
|
|
661
|
+
// Another in-flight 429 can extend the deadline while this request sleeps.
|
|
662
|
+
// Backoff already elapsed; wait only for any remaining shared cooldown.
|
|
663
|
+
while (true) {
|
|
664
|
+
signal?.throwIfAborted();
|
|
665
|
+
const remainingMs = rateLimitResumeAtMs - Date.now();
|
|
666
|
+
if (remainingMs <= 0) break;
|
|
667
|
+
await sleepImpl(remainingMs, { signal });
|
|
668
|
+
}
|
|
669
|
+
const init = initForToken(token);
|
|
670
|
+
// Let a dispatched write settle so cancellation cannot hide its receipt.
|
|
671
|
+
if (read && signal) init.signal = signal;
|
|
672
|
+
const deadline = Date.now() + writeTimeoutMs;
|
|
673
|
+
const transport = read ? null : new AbortController();
|
|
674
|
+
if (transport) init.signal = transport.signal;
|
|
675
|
+
const settle = async (operation) => {
|
|
676
|
+
if (read) return operation();
|
|
677
|
+
let timer;
|
|
678
|
+
try {
|
|
679
|
+
return await Promise.race([Promise.resolve(operation()), new Promise((_, reject) => {
|
|
680
|
+
timer = setTimeout(() => {
|
|
681
|
+
transport.abort();
|
|
682
|
+
reject(new Error("Spotify write settlement deadline exceeded."));
|
|
683
|
+
}, Math.max(0, deadline - Date.now()));
|
|
684
|
+
})]);
|
|
685
|
+
} finally { clearTimeout(timer); }
|
|
686
|
+
};
|
|
687
|
+
let response;
|
|
688
|
+
// A host authority guard runs after async token/refresh/lock waiting.
|
|
689
|
+
// Local refusal precedes fetch and must not become an uncertain write.
|
|
690
|
+
beforeDispatch?.();
|
|
691
|
+
try {
|
|
692
|
+
response = await settle(() => fetchImpl(`${normalizedBaseUrl}${path}`, init));
|
|
693
|
+
} catch (error) {
|
|
694
|
+
if (read) {
|
|
695
|
+
signal?.throwIfAborted();
|
|
696
|
+
if (error?.name === "AbortError") throw error;
|
|
697
|
+
}
|
|
698
|
+
// Writes have no transport cancellation signal: a transport AbortError
|
|
699
|
+
// cannot prove that the dispatched action had no effect.
|
|
700
|
+
fail("spotify_network_error", "Spotify could not be reached.", { outcomeUnknown: !read });
|
|
701
|
+
}
|
|
702
|
+
if (read) signal?.throwIfAborted();
|
|
703
|
+
if (response.ok && (response.status === 204 || responseMode === "none")) return null;
|
|
704
|
+
let text;
|
|
524
705
|
try {
|
|
525
|
-
|
|
526
|
-
} catch {
|
|
527
|
-
|
|
706
|
+
text = await settle(() => response.text());
|
|
707
|
+
} catch (error) {
|
|
708
|
+
if (read) {
|
|
709
|
+
signal?.throwIfAborted();
|
|
710
|
+
if (error?.name === "AbortError") throw error;
|
|
711
|
+
}
|
|
712
|
+
fail("spotify_network_error", "Spotify's response could not be read.", {
|
|
528
713
|
status: response.status,
|
|
714
|
+
outcomeUnknown: !read && !(response.status >= 400 && response.status < 500),
|
|
529
715
|
});
|
|
530
716
|
}
|
|
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
|
-
);
|
|
717
|
+
if (read) signal?.throwIfAborted();
|
|
718
|
+
if (Buffer.byteLength(text, "utf8") > SPOTIFY_WEB_API_LIMITS.responseBytesMax) {
|
|
719
|
+
fail("spotify_response_too_large", "Spotify returned too much data.", {
|
|
720
|
+
status: response.status,
|
|
721
|
+
outcomeUnknown: !read && !(response.status >= 400 && response.status < 500),
|
|
722
|
+
});
|
|
544
723
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
);
|
|
724
|
+
let payload = null;
|
|
725
|
+
if (text) {
|
|
726
|
+
try { payload = JSON.parse(text); } catch {
|
|
727
|
+
// Error responses can be plain text. Their HTTP status still decides
|
|
728
|
+
// whether authentication or a bounded read retry is appropriate.
|
|
729
|
+
if (response.ok) fail("spotify_response_invalid", "Spotify returned an invalid response.", { status: response.status, outcomeUnknown: !read });
|
|
552
730
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
731
|
+
}
|
|
732
|
+
if (response.ok) return payload;
|
|
733
|
+
const retryAfter = retryAfterSeconds(response.headers);
|
|
734
|
+
const options = { status: response.status, reason: quotaReason(payload), retryAfterSeconds: retryAfter,
|
|
735
|
+
outcomeUnknown: !read && response.status >= 500 };
|
|
736
|
+
if (response.status === 401) {
|
|
737
|
+
if (!refreshed && typeof refreshAccessToken === "function" && (!read || attempt < maxAttempts)) {
|
|
738
|
+
signal?.throwIfAborted();
|
|
739
|
+
token = await accessToken(() => refreshAccessToken({ signal, rejectedAccessToken: token }));
|
|
740
|
+
refreshed = true;
|
|
741
|
+
if (!read) {
|
|
742
|
+
signal?.throwIfAborted();
|
|
743
|
+
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);
|
|
744
|
+
}
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
fail("spotify_authentication_required", "Spotify authentication must be refreshed.", options);
|
|
558
748
|
}
|
|
559
749
|
if (response.status === 429) {
|
|
560
750
|
if (quotaReason(payload) === "QUOTA_EXCEEDED") {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
751
|
+
if (retryAfter) quotaResumeAtMs.set(endpoint, Date.now() + retryAfter * 1_000);
|
|
752
|
+
quotaExhausted(retryAfter, options);
|
|
753
|
+
}
|
|
754
|
+
const waitMs = retryAfter === null ? SPOTIFY_WEB_API_LIMITS.rateLimitDefaultWaitMs : retryAfter * 1_000;
|
|
755
|
+
rearmRateLimit(waitMs);
|
|
756
|
+
if (read && attempt < maxAttempts && waitMs <= SPOTIFY_WEB_API_LIMITS.rateLimitMaxWaitMs) {
|
|
757
|
+
signal?.throwIfAborted();
|
|
758
|
+
const jittered = Math.min(SPOTIFY_WEB_API_LIMITS.rateLimitMaxWaitMs,
|
|
759
|
+
waitMs + Math.floor(Math.random() * SPOTIFY_WEB_API_LIMITS.rateLimitJitterMs));
|
|
760
|
+
await sleepImpl(jittered, { signal });
|
|
761
|
+
continue;
|
|
566
762
|
}
|
|
567
|
-
fail(
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
763
|
+
fail("spotify_rate_limited", "Spotify rate limited this request.", options);
|
|
764
|
+
}
|
|
765
|
+
if (response.status === 403) {
|
|
766
|
+
if (hasInsufficientClientScope(payload)) {
|
|
767
|
+
fail("spotify_scope_insufficient", "Spotify authorization is missing a required scope. Run moondog spotify login again.", options);
|
|
768
|
+
}
|
|
769
|
+
if (refusalMessage(payload) === undefined) {
|
|
770
|
+
// The items migration's signature: retired or app-restricted
|
|
771
|
+
// endpoints answer 403 with no helpful body. Report that
|
|
772
|
+
// distinctly instead of a generic forbidden action.
|
|
773
|
+
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);
|
|
774
|
+
}
|
|
775
|
+
fail("spotify_action_forbidden", "Spotify did not allow this action.", options);
|
|
572
776
|
}
|
|
573
777
|
fail("spotify_api_error", "Spotify rejected this request.", options);
|
|
574
778
|
}
|
|
575
|
-
return payload;
|
|
576
779
|
};
|
|
577
780
|
|
|
578
781
|
return Object.freeze({
|
|
579
|
-
async getAccount() {
|
|
580
|
-
return normalizeAccount(await request("/me"));
|
|
782
|
+
async getAccount({ signal } = {}) {
|
|
783
|
+
return normalizeAccount(await request("/me", { signal }));
|
|
581
784
|
},
|
|
582
785
|
|
|
583
|
-
async getCurrentPlayback() {
|
|
584
|
-
return normalizePlayback(await request("/me/player"));
|
|
786
|
+
async getCurrentPlayback({ signal } = {}) {
|
|
787
|
+
return normalizePlayback(await request("/me/player?additional_types=episode", { signal }));
|
|
585
788
|
},
|
|
586
789
|
|
|
587
|
-
async getDevices() {
|
|
588
|
-
return normalizeDevices(await request("/me/player/devices"));
|
|
790
|
+
async getDevices({ signal } = {}) {
|
|
791
|
+
return normalizeDevices(await request("/me/player/devices", { signal }));
|
|
589
792
|
},
|
|
590
793
|
|
|
591
|
-
async getQueue() {
|
|
592
|
-
return normalizeQueue(await request("/me/player/queue"));
|
|
794
|
+
async getQueue({ signal } = {}) {
|
|
795
|
+
return normalizeQueue(await request("/me/player/queue", { signal }));
|
|
593
796
|
},
|
|
594
797
|
|
|
595
|
-
async getRecentlyPlayed({ limit, after, before } = {}) {
|
|
798
|
+
async getRecentlyPlayed({ limit, after, before } = {}, { signal } = {}) {
|
|
596
799
|
return normalizeRecentlyPlayed(
|
|
597
800
|
await request(
|
|
598
|
-
`/me/player/recently-played${queryString({ limit, after, before })}`,
|
|
801
|
+
`/me/player/recently-played${queryString({ limit, after, before })}`, { signal },
|
|
599
802
|
),
|
|
600
803
|
);
|
|
601
804
|
},
|
|
602
805
|
|
|
603
|
-
|
|
806
|
+
// Agent browsing is capped by the service; a library import reads Spotify's full page of 50.
|
|
807
|
+
async getSavedItems({ type = "tracks", limit = 20, offset = 0 } = {}, { signal } = {}) {
|
|
808
|
+
if (!["tracks", "albums", "shows", "playlists"].includes(type) || !Number.isInteger(limit) || limit < 1 || limit > 50 ||
|
|
809
|
+
!Number.isInteger(offset) || offset < 0 || offset > 1_000_000) fail("invalid_library_page", "Choose a supported library type and bounded page.");
|
|
810
|
+
return normalizeLibraryPage(await request(`/me/${type}${queryString({ limit, offset })}`, { signal }), type, { limit, offset });
|
|
811
|
+
},
|
|
812
|
+
|
|
813
|
+
async getFollowedArtists({ limit = 10, after } = {}, { signal } = {}) {
|
|
814
|
+
const payload = await request(`/me/following${queryString({ type: "artist", limit, after })}`, { signal });
|
|
815
|
+
const result = normalizeLibraryPage(payload?.artists, "artists", { limit, offset: 0 });
|
|
816
|
+
delete result.next_offset;
|
|
817
|
+
result.has_more = Boolean(payload?.artists?.next);
|
|
818
|
+
const cursor = payload?.artists?.cursors?.after;
|
|
819
|
+
if (result.has_more && typeof cursor === "string" && cursor !== after && /^[A-Za-z0-9]{1,128}$/u.test(cursor)) result.next_after = cursor;
|
|
820
|
+
return result;
|
|
821
|
+
},
|
|
822
|
+
|
|
823
|
+
async searchItems({ query, type, limit, offset = 0 } = {}, { signal } = {}) {
|
|
824
|
+
const payload = await request(`/search${queryString({ q: query, type, limit, offset })}`, { signal });
|
|
825
|
+
const page = payload?.[`${type}s`];
|
|
826
|
+
const raw = Array.isArray(page?.items) ? page.items : [];
|
|
827
|
+
const items = raw.slice(0, limit).map((item) => normalizeCatalogItem(item, type)).filter(Boolean);
|
|
828
|
+
const hasMore = Boolean(page?.next) || Number.isInteger(page?.total) && offset + raw.length < page.total;
|
|
829
|
+
return { provider: "spotify", type, items, has_more: hasMore,
|
|
830
|
+
next_offset: hasMore && raw.length ? offset + Math.min(raw.length, limit) : null,
|
|
831
|
+
truncated: raw.length > items.length };
|
|
832
|
+
},
|
|
833
|
+
|
|
834
|
+
async getCatalogChildren({ type, id, limit, offset } = {}, { signal } = {}) {
|
|
835
|
+
const childType = type === "album" ? "track" : "episode";
|
|
836
|
+
const payload = await request(`/${type}s/${encodeURIComponent(id)}/${childType}s${queryString({ limit, offset })}`, { signal });
|
|
837
|
+
const raw = Array.isArray(payload?.items) ? payload.items : [];
|
|
838
|
+
const items = raw.slice(0, limit).map((item) => normalizeCatalogItem(item, childType)).filter(Boolean);
|
|
839
|
+
const hasMore = Boolean(payload?.next) || Number.isInteger(payload?.total) && offset + raw.length < payload.total;
|
|
840
|
+
return { provider: "spotify", source: "catalog", items, limit, offset, has_more: hasMore,
|
|
841
|
+
next_offset: hasMore && raw.length ? offset + Math.min(raw.length, limit) : null, truncated: raw.length > items.length };
|
|
842
|
+
},
|
|
843
|
+
|
|
844
|
+
async searchTracks({ query, limit, market } = {}, { signal } = {}) {
|
|
604
845
|
return normalizeTrackSearch(
|
|
605
846
|
await request(
|
|
606
|
-
`/search${queryString({ q: query, type: "track", limit, market })}`,
|
|
607
|
-
),
|
|
847
|
+
`/search${queryString({ q: query, type: "track", limit, market })}`, { signal },
|
|
848
|
+
), { limit },
|
|
608
849
|
);
|
|
609
850
|
},
|
|
610
851
|
|
|
611
|
-
async
|
|
852
|
+
async getTopItems({ type = "tracks", timeRange = "medium_term", limit = 5 } = {}, { signal } = {}) {
|
|
853
|
+
if (!["artists", "tracks"].includes(type) || !["short_term", "medium_term", "long_term"].includes(timeRange) ||
|
|
854
|
+
!Number.isInteger(limit) || limit < 1 || limit > 50) {
|
|
855
|
+
fail("invalid_top_items", "Spotify top items require a supported type, time range, and limit from 1 to 50.");
|
|
856
|
+
}
|
|
857
|
+
const payload = await request(`/me/top/${type}${queryString({ time_range: timeRange, limit, offset: 0 })}`, { signal });
|
|
858
|
+
const raw = Array.isArray(payload?.items) ? payload.items : [];
|
|
859
|
+
const items = raw.slice(0, limit).map((item, index) => {
|
|
860
|
+
const artistUri = typeof item?.uri === "string" && /^spotify:artist:[A-Za-z0-9]{22}$/u.test(item.uri) ? item.uri : null;
|
|
861
|
+
const normalized = type === "tracks" ? normalizeTrackSearchItem(item) :
|
|
862
|
+
(safeText(item?.name) ? { name: safeText(item.name), type: "artist", ...(artistUri ? { uri: artistUri } : {}) } : null);
|
|
863
|
+
return normalized ? { ...normalized, affinity_rank: index + 1 } : null;
|
|
864
|
+
}).filter(Boolean);
|
|
865
|
+
return { provider: "spotify", items, truncated: raw.length > items.length || Boolean(payload?.next) || payload?.total > raw.length };
|
|
866
|
+
},
|
|
867
|
+
|
|
868
|
+
async getCurrentUserPlaylists({ limit, offset } = {}, { signal } = {}) {
|
|
612
869
|
return normalizePlaylistPage(
|
|
613
870
|
await request(
|
|
614
|
-
`/me/playlists${queryString({ limit, offset })}`,
|
|
871
|
+
`/me/playlists${queryString({ limit, offset })}`, { signal },
|
|
615
872
|
),
|
|
616
873
|
);
|
|
617
874
|
},
|
|
618
875
|
|
|
619
|
-
async getPlaylist({ playlistId } = {}) {
|
|
876
|
+
async getPlaylist({ playlistId } = {}, { signal } = {}) {
|
|
620
877
|
return normalizePlaylist(
|
|
621
|
-
await request(`/playlists/${encodeURIComponent(playlistId)}
|
|
878
|
+
await request(`/playlists/${encodeURIComponent(playlistId)}`, { signal }),
|
|
622
879
|
{ includeControlMetadata: true },
|
|
623
880
|
);
|
|
624
881
|
},
|
|
625
882
|
|
|
626
|
-
async getPlaylistItems({ playlistId, limit, offset } = {}) {
|
|
883
|
+
async getPlaylistItems({ playlistId, limit, offset } = {}, { signal } = {}) {
|
|
627
884
|
return normalizePlaylistItemsPage(
|
|
628
885
|
await request(
|
|
629
886
|
`/playlists/${encodeURIComponent(playlistId)}/items${queryString({
|
|
630
887
|
limit,
|
|
631
888
|
offset,
|
|
632
|
-
})}`,
|
|
889
|
+
})}`, { signal },
|
|
633
890
|
),
|
|
634
891
|
);
|
|
635
892
|
},
|
|
636
893
|
|
|
637
|
-
async createPlaylist({ name, description } = {}) {
|
|
894
|
+
async createPlaylist({ name, description } = {}, { signal } = {}) {
|
|
638
895
|
return normalizePlaylist(
|
|
639
896
|
await request("/me/playlists", {
|
|
640
897
|
method: "POST",
|
|
898
|
+
signal,
|
|
641
899
|
body: {
|
|
642
900
|
name,
|
|
643
901
|
...(description ? { description } : {}),
|
|
@@ -647,114 +905,136 @@ export function createSpotifyWebApiClient({
|
|
|
647
905
|
);
|
|
648
906
|
},
|
|
649
907
|
|
|
650
|
-
async addPlaylistTracks({ playlistId, uris } = {}) {
|
|
908
|
+
async addPlaylistTracks({ playlistId, uris } = {}, { signal } = {}) {
|
|
651
909
|
const payload = await request(
|
|
652
910
|
`/playlists/${encodeURIComponent(playlistId)}/items`,
|
|
653
|
-
{ method: "POST", body: { uris } },
|
|
911
|
+
{ method: "POST", body: { uris }, signal },
|
|
654
912
|
);
|
|
655
913
|
const snapshotId = safeText(payload?.snapshot_id, 128);
|
|
656
914
|
return snapshotId ? { snapshot_id: snapshotId } : {};
|
|
657
915
|
},
|
|
658
916
|
|
|
659
|
-
async
|
|
917
|
+
async renamePlaylist({ playlistId, name } = {}, { signal, beforeDispatch } = {}) {
|
|
918
|
+
await request(`/playlists/${encodeURIComponent(playlistId)}`, {
|
|
919
|
+
method: "PUT", body: { name }, responseMode: "none", signal, beforeDispatch,
|
|
920
|
+
});
|
|
921
|
+
},
|
|
922
|
+
|
|
923
|
+
async removePlaylistItem({ playlistId, uri, snapshotId } = {}, { signal, beforeDispatch } = {}) {
|
|
924
|
+
const payload = await request(`/playlists/${encodeURIComponent(playlistId)}/items`, {
|
|
925
|
+
method: "DELETE", body: { items: [{ uri }], snapshot_id: snapshotId }, signal, beforeDispatch,
|
|
926
|
+
});
|
|
927
|
+
const snapshot = safeText(payload?.snapshot_id, 128);
|
|
928
|
+
if (!snapshot) fail("spotify_write_receipt_invalid", "Spotify did not return a valid removal receipt. Inspect the playlist before retrying.", { outcomeUnknown: true });
|
|
929
|
+
return { snapshot_id: snapshot };
|
|
930
|
+
},
|
|
931
|
+
|
|
932
|
+
async replacePlaylistItems({ playlistId, uris } = {}, { signal } = {}) {
|
|
660
933
|
const payload = await request(
|
|
661
934
|
`/playlists/${encodeURIComponent(playlistId)}/items`,
|
|
662
|
-
{ method: "PUT", body: { uris } },
|
|
935
|
+
{ method: "PUT", body: { uris }, signal },
|
|
663
936
|
);
|
|
664
937
|
const snapshotId = safeText(payload?.snapshot_id, 128);
|
|
665
938
|
return snapshotId ? { snapshot_id: snapshotId } : {};
|
|
666
939
|
},
|
|
667
940
|
|
|
668
|
-
async checkSavedTracks({ uris } = {}) {
|
|
941
|
+
async checkSavedTracks({ uris } = {}, { signal } = {}) {
|
|
669
942
|
return normalizeSavedContains(
|
|
670
943
|
await request(
|
|
671
|
-
`/me/library/contains${queryString({ uris: uris.join(",") })}`,
|
|
944
|
+
`/me/library/contains${queryString({ uris: uris.join(",") })}`, { signal },
|
|
672
945
|
),
|
|
673
946
|
);
|
|
674
947
|
},
|
|
675
948
|
|
|
676
|
-
async saveTracks({ uris } = {}) {
|
|
949
|
+
async saveTracks({ uris } = {}, { signal } = {}) {
|
|
677
950
|
await request(`/me/library${queryString({ uris: uris.join(",") })}`, {
|
|
678
951
|
method: "PUT",
|
|
679
952
|
responseMode: "none",
|
|
953
|
+
signal,
|
|
680
954
|
});
|
|
681
955
|
return null;
|
|
682
956
|
},
|
|
683
957
|
|
|
684
|
-
async
|
|
958
|
+
async removeLibraryItems({ uris } = {}, { signal } = {}) {
|
|
959
|
+
await request(`/me/library${queryString({ uris: uris.join(",") })}`, { method: "DELETE", responseMode: "none", signal });
|
|
960
|
+
},
|
|
961
|
+
|
|
962
|
+
async resume(input = {}, { signal, beforeDispatch } = {}) {
|
|
685
963
|
await request(
|
|
686
964
|
`/me/player/play${queryString({ device_id: input.deviceId })}`,
|
|
687
|
-
{ method: "PUT", body: playbackBody(input), responseMode: "none" },
|
|
965
|
+
{ method: "PUT", body: playbackBody(input), responseMode: "none", signal, beforeDispatch },
|
|
688
966
|
);
|
|
689
967
|
},
|
|
690
968
|
|
|
691
|
-
async pause({ deviceId } = {}) {
|
|
969
|
+
async pause({ deviceId } = {}, { signal } = {}) {
|
|
692
970
|
await request(
|
|
693
971
|
`/me/player/pause${queryString({ device_id: deviceId })}`,
|
|
694
|
-
{ method: "PUT", responseMode: "none" },
|
|
972
|
+
{ method: "PUT", responseMode: "none", signal },
|
|
695
973
|
);
|
|
696
974
|
},
|
|
697
975
|
|
|
698
|
-
async next({ deviceId } = {}) {
|
|
976
|
+
async next({ deviceId } = {}, { signal } = {}) {
|
|
699
977
|
await request(
|
|
700
978
|
`/me/player/next${queryString({ device_id: deviceId })}`,
|
|
701
|
-
{ method: "POST", responseMode: "none" },
|
|
979
|
+
{ method: "POST", responseMode: "none", signal },
|
|
702
980
|
);
|
|
703
981
|
},
|
|
704
982
|
|
|
705
|
-
async previous({ deviceId } = {}) {
|
|
983
|
+
async previous({ deviceId } = {}, { signal } = {}) {
|
|
706
984
|
await request(
|
|
707
985
|
`/me/player/previous${queryString({ device_id: deviceId })}`,
|
|
708
|
-
{ method: "POST", responseMode: "none" },
|
|
986
|
+
{ method: "POST", responseMode: "none", signal },
|
|
709
987
|
);
|
|
710
988
|
},
|
|
711
989
|
|
|
712
|
-
async setVolume({ percent, deviceId }) {
|
|
990
|
+
async setVolume({ percent, deviceId }, { signal } = {}) {
|
|
713
991
|
await request(
|
|
714
992
|
`/me/player/volume${queryString({
|
|
715
993
|
volume_percent: percent,
|
|
716
994
|
device_id: deviceId,
|
|
717
995
|
})}`,
|
|
718
|
-
{ method: "PUT", responseMode: "none" },
|
|
996
|
+
{ method: "PUT", responseMode: "none", signal },
|
|
719
997
|
);
|
|
720
998
|
},
|
|
721
999
|
|
|
722
|
-
async seek({ positionMs, deviceId }) {
|
|
1000
|
+
async seek({ positionMs, deviceId }, { signal } = {}) {
|
|
723
1001
|
await request(
|
|
724
1002
|
`/me/player/seek${queryString({
|
|
725
1003
|
position_ms: positionMs,
|
|
726
1004
|
device_id: deviceId,
|
|
727
1005
|
})}`,
|
|
728
|
-
{ method: "PUT", responseMode: "none" },
|
|
1006
|
+
{ method: "PUT", responseMode: "none", signal },
|
|
729
1007
|
);
|
|
730
1008
|
},
|
|
731
1009
|
|
|
732
|
-
async setShuffle({ state, deviceId }) {
|
|
1010
|
+
async setShuffle({ state, deviceId }, { signal } = {}) {
|
|
733
1011
|
await request(
|
|
734
1012
|
`/me/player/shuffle${queryString({ state, device_id: deviceId })}`,
|
|
735
|
-
{ method: "PUT", responseMode: "none" },
|
|
1013
|
+
{ method: "PUT", responseMode: "none", signal },
|
|
736
1014
|
);
|
|
737
1015
|
},
|
|
738
1016
|
|
|
739
|
-
async setRepeat({ state, deviceId }) {
|
|
1017
|
+
async setRepeat({ state, deviceId }, { signal } = {}) {
|
|
740
1018
|
await request(
|
|
741
1019
|
`/me/player/repeat${queryString({ state, device_id: deviceId })}`,
|
|
742
|
-
{ method: "PUT", responseMode: "none" },
|
|
1020
|
+
{ method: "PUT", responseMode: "none", signal },
|
|
743
1021
|
);
|
|
744
1022
|
},
|
|
745
1023
|
|
|
746
|
-
async transfer({ deviceId, play }) {
|
|
1024
|
+
async transfer({ deviceId, play }, { signal, beforeDispatch } = {}) {
|
|
747
1025
|
await request("/me/player", {
|
|
748
1026
|
method: "PUT",
|
|
749
1027
|
body: { device_ids: [deviceId], play },
|
|
750
1028
|
responseMode: "none",
|
|
1029
|
+
signal,
|
|
1030
|
+
beforeDispatch,
|
|
751
1031
|
});
|
|
752
1032
|
},
|
|
753
1033
|
|
|
754
|
-
async addToQueue({ uri, deviceId }) {
|
|
1034
|
+
async addToQueue({ uri, deviceId }, { signal } = {}) {
|
|
755
1035
|
await request(
|
|
756
1036
|
`/me/player/queue${queryString({ uri, device_id: deviceId })}`,
|
|
757
|
-
{ method: "POST", responseMode: "none" },
|
|
1037
|
+
{ method: "POST", responseMode: "none", signal },
|
|
758
1038
|
);
|
|
759
1039
|
},
|
|
760
1040
|
});
|