@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.
Files changed (125) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +156 -68
  3. package/contracts/v1/definitions.schema.json +1 -169
  4. package/package.json +7 -23
  5. package/scripts/contract-lib.mjs +0 -7
  6. package/scripts/contract-semantics.mjs +0 -1904
  7. package/scripts/moondog.mjs +285 -74
  8. package/src/core/apple-library-source-status.mjs +0 -16
  9. package/src/core/apple-projection-domain-services.mjs +253 -53
  10. package/src/core/application-update.mjs +320 -0
  11. package/src/core/capability-catalog.mjs +290 -55
  12. package/src/core/listener-avoids.mjs +101 -0
  13. package/src/core/listening-now.mjs +108 -0
  14. package/src/core/listening-profile-domain-services.mjs +189 -41
  15. package/src/core/listening-upkeep.mjs +31 -0
  16. package/src/core/moondog-application.mjs +2115 -107
  17. package/src/core/playback-preferences.mjs +97 -0
  18. package/src/core/spotify-listening-intent.mjs +124 -0
  19. package/src/core/synthetic-domain-services.mjs +24 -3
  20. package/src/core/track-title.mjs +107 -0
  21. package/src/demo/moondog-demo.mjs +4 -22
  22. package/src/i18n/index.mjs +285 -0
  23. package/src/i18n/messages/de.mjs +135 -0
  24. package/src/i18n/messages/en.mjs +136 -0
  25. package/src/i18n/messages/es.mjs +135 -0
  26. package/src/i18n/messages/fr.mjs +135 -0
  27. package/src/i18n/messages/it.mjs +135 -0
  28. package/src/i18n/messages/ja.mjs +135 -0
  29. package/src/i18n/messages/ko.mjs +135 -0
  30. package/src/i18n/messages/pt.mjs +135 -0
  31. package/src/i18n/messages/zh-Hant.mjs +135 -0
  32. package/src/i18n/messages/zh.mjs +135 -0
  33. package/src/i18n/preferences.mjs +34 -0
  34. package/src/i18n/screen/es.mjs +967 -0
  35. package/src/i18n/screen/ja.mjs +967 -0
  36. package/src/i18n/screen/pt.mjs +967 -0
  37. package/src/i18n/screen/zh.mjs +967 -0
  38. package/src/importers/apple-music-library/index.mjs +0 -16
  39. package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
  40. package/src/integrations/apple-music/catalog.mjs +166 -14
  41. package/src/integrations/apple-music/music-app.mjs +232 -0
  42. package/src/integrations/apple-music/storefront.mjs +28 -0
  43. package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
  44. package/src/integrations/lastfm/history.mjs +221 -0
  45. package/src/integrations/lastfm/settings.mjs +75 -0
  46. package/src/integrations/netease/ncm-cli-player.mjs +328 -0
  47. package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
  48. package/src/integrations/public-playlists.mjs +2 -1
  49. package/src/integrations/spotify/account-data-profile.mjs +14 -6
  50. package/src/integrations/spotify/authentication.mjs +149 -15
  51. package/src/integrations/spotify/catalog-resolver.mjs +9 -6
  52. package/src/integrations/spotify/connection.mjs +2 -1
  53. package/src/integrations/spotify/credential-store.mjs +4 -0
  54. package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
  55. package/src/integrations/spotify/history-archive.mjs +1 -1
  56. package/src/integrations/spotify/library-snapshot.mjs +227 -0
  57. package/src/integrations/spotify/recent-activity.mjs +2 -0
  58. package/src/integrations/spotify/service.mjs +873 -69
  59. package/src/integrations/spotify/web-api-client.mjs +410 -127
  60. package/src/integrations/web/brave-web.mjs +45 -0
  61. package/src/integrations/web/codex-web.mjs +15 -82
  62. package/src/integrations/web/deepseek-web.mjs +81 -0
  63. package/src/integrations/web/web-research.mjs +119 -0
  64. package/src/integrations/web/web-result.mjs +215 -0
  65. package/src/integrations/youtube-music/takeout.mjs +3 -1
  66. package/src/memory/local-memory-store.mjs +542 -57
  67. package/src/profile/history-import.mjs +42 -6
  68. package/src/profile/listener-corrections.mjs +22 -0
  69. package/src/profile/listener-profile-build.mjs +461 -0
  70. package/src/profile/listener-profile-store.mjs +130 -0
  71. package/src/profile/listener-profile-verification.mjs +68 -0
  72. package/src/profile/listener-profile-worker.mjs +179 -0
  73. package/src/profile/listening-history-store.mjs +640 -17
  74. package/src/profile/listening-profile-projection.mjs +454 -86
  75. package/src/profile/local-music-data.mjs +1 -1
  76. package/src/profile/moondog-suggestions.mjs +184 -0
  77. package/src/profile/music-providers.mjs +11 -7
  78. package/src/profile/profile-exploration.mjs +92 -0
  79. package/src/profile/recorded-measures.mjs +24 -0
  80. package/src/profile/spotify-archive-taste.mjs +0 -4
  81. package/src/runtime/pi/agent-runtime.mjs +2536 -692
  82. package/src/runtime/pi/authentication.mjs +34 -1
  83. package/src/runtime/pi/configured-runtime.mjs +22 -6
  84. package/src/runtime/pi/conversation-name.mjs +53 -0
  85. package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
  86. package/src/runtime/pi/model-catalog.mjs +28 -5
  87. package/src/runtime/pi/model-settings.mjs +202 -0
  88. package/src/runtime/pi/model-transport.mjs +10 -0
  89. package/src/runtime/pi/persistent-credential-store.mjs +67 -31
  90. package/src/runtime/pi/profile-build-runtime.mjs +161 -0
  91. package/src/runtime/pi/public-model-catalog.mjs +288 -0
  92. package/src/runtime/pi/runtime-settings.mjs +10 -2
  93. package/src/runtime/pi/skills/listener-profile/SKILL.md +48 -0
  94. package/src/runtime/pi/tool-trace.mjs +37 -0
  95. package/src/surfaces/cli/auth-command.mjs +12 -4
  96. package/src/surfaces/cli/brand-components.mjs +310 -132
  97. package/src/surfaces/cli/brand-theme.mjs +51 -28
  98. package/src/surfaces/cli/command-completions.mjs +58 -42
  99. package/src/surfaces/cli/format-output.mjs +199 -388
  100. package/src/surfaces/cli/history-import-view.mjs +292 -139
  101. package/src/surfaces/cli/import-guides.mjs +2 -0
  102. package/src/surfaces/cli/listener-profile-command.mjs +92 -0
  103. package/src/surfaces/cli/listening-menu.mjs +7 -4
  104. package/src/surfaces/cli/profile-command.mjs +30 -21
  105. package/src/surfaces/cli/seasons-chart.mjs +87 -0
  106. package/src/surfaces/cli/spotify-command.mjs +54 -6
  107. package/src/surfaces/cli/taste-profile-model.mjs +78 -79
  108. package/src/surfaces/cli/taste-profile-view.mjs +33 -26
  109. package/src/surfaces/cli/terminal-art.mjs +140 -35
  110. package/src/surfaces/cli/tui.mjs +1066 -357
  111. package/src/surfaces/cli/update-command.mjs +32 -0
  112. package/src/surfaces/cli/web-command.mjs +53 -14
  113. package/src/surfaces/html/tasteprint.mjs +43 -70
  114. package/contracts/v1/capability-policy.schema.json +0 -572
  115. package/contracts/v1/profile-evidence.schema.json +0 -237
  116. package/contracts/v1/tool-invocation.schema.json +0 -318
  117. package/contracts/v1/tool-receipt.schema.json +0 -1066
  118. package/scripts/demo-studio.mjs +0 -58
  119. package/src/core/apple-data-migration.mjs +0 -50
  120. package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
  121. package/src/demo/moondog-tasteprint-demo.mjs +0 -631
  122. package/src/integrations/spotify/index.mjs +0 -80
  123. package/src/surfaces/web/showcase.mjs +0 -272
  124. package/src/surfaces/web/studio-page.mjs +0 -4148
  125. 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 === true,
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 === true,
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 === true,
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, SPOTIFY_WEB_API_LIMITS.searchResultsMax);
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 === true,
457
+ is_public: typeof payload.public === "boolean" ? payload.public : null,
362
458
  };
363
459
  if (includeControlMetadata) {
364
- playlist.collaborative = payload.collaborative === true;
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 token = normalizedToken(await tokenProvider());
496
- const headers = {
497
- accept: "application/json",
498
- authorization: `Bearer ${token}`,
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
- if (response.ok && (response.status === 204 || responseMode === "none")) {
514
- return null;
515
- }
516
- const text = await response.text();
517
- if (text.length > SPOTIFY_WEB_API_LIMITS.responseBytesMax) {
518
- fail("spotify_response_too_large", "Spotify returned too much data.", {
519
- status: response.status,
520
- });
521
- }
522
- let payload = null;
523
- if (text) {
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
- payload = JSON.parse(text);
526
- } catch {
527
- fail("spotify_response_invalid", "Spotify returned an invalid response.", {
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
- if (!response.ok) {
534
- const options = {
535
- status: response.status,
536
- retryAfterSeconds: retryAfterSeconds(response.headers),
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
- if (response.status === 403) {
546
- if (hasInsufficientClientScope(payload)) {
547
- fail(
548
- "spotify_scope_insufficient",
549
- "Spotify authorization is missing a required scope. Run moondog spotify login again.",
550
- options,
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
- fail(
554
- "spotify_action_forbidden",
555
- "Spotify did not allow this action.",
556
- options,
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
- fail(
562
- "spotify_quota_exceeded",
563
- "Spotify development quota is exhausted.",
564
- options,
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
- "spotify_rate_limited",
569
- "Spotify rate limited this request.",
570
- options,
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
- async searchTracks({ query, limit, market } = {}) {
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 getCurrentUserPlaylists({ limit, offset } = {}) {
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 replacePlaylistItems({ playlistId, uris } = {}) {
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 resume(input = {}) {
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
  });