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