@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,4 +1,8 @@
1
1
  import { createSpotifyWebApiClient } from "./web-api-client.mjs";
2
+ import { readSpotifyLibrarySnapshot } from "./library-snapshot.mjs";
3
+ import { execFile } from "node:child_process";
4
+ import { hostname } from "node:os";
5
+ import { setTimeout as delay } from "node:timers/promises";
2
6
 
3
7
  export const SPOTIFY_SERVICE_LIMITS = Object.freeze({
4
8
  deviceIdLengthMax: 256,
@@ -13,6 +17,13 @@ export const SPOTIFY_SERVICE_LIMITS = Object.freeze({
13
17
  libraryTracksMax: 20,
14
18
  recentlyPlayedDefault: 20,
15
19
  recentlyPlayedMax: 50,
20
+ deviceNameLengthMax: 128,
21
+ searchQueryLengthMax: 256,
22
+ searchResultsDefault: 5,
23
+ searchResultsMax: 10,
24
+ playbackReadinessSnapshotsMax: 4,
25
+ playbackReadinessTimeoutMs: 5_000,
26
+ playbackReadinessPollMs: 250,
16
27
  });
17
28
 
18
29
  const repeatStates = new Set(["off", "track", "context"]);
@@ -21,16 +32,29 @@ const trackUriPattern = /^spotify:track:[A-Za-z0-9]{1,128}$/u;
21
32
  const contextUriPattern = /^spotify:(?:album|artist|playlist):[A-Za-z0-9]{1,128}$/u;
22
33
 
23
34
  export class SpotifyServiceError extends Error {
24
- constructor(code, message) {
35
+ constructor(code, message, { outcomeUnknown = false, actionNotDispatched = false } = {}) {
25
36
  super(message);
26
37
  this.name = "SpotifyServiceError";
27
38
  this.code = code;
28
39
  this.provider = "spotify";
40
+ this.outcomeUnknown = outcomeUnknown;
41
+ // These errors are raised by local argument validators before client dispatch.
42
+ if (!outcomeUnknown && (actionNotDispatched || /^(?:invalid_|conflicting_)/u.test(code))) this.actionNotDispatched = true;
29
43
  }
30
44
  }
31
45
 
32
- function fail(code, message) {
33
- throw new SpotifyServiceError(code, message);
46
+ function fail(code, message, options) {
47
+ throw new SpotifyServiceError(code, message, options);
48
+ }
49
+
50
+ function failBeforeDispatch(code, message) {
51
+ fail(code, message, { actionNotDispatched: true });
52
+ }
53
+
54
+ function failDeviceSelection(code, message, devices) {
55
+ const error = new SpotifyServiceError(code, message, { actionNotDispatched: true });
56
+ error.availableDevices = devices.slice(0, 20).map(({ name, type, is_active, is_restricted }) => ({ name, type, is_active, is_restricted }));
57
+ throw error;
34
58
  }
35
59
 
36
60
  function isPlainObject(value) {
@@ -186,8 +210,8 @@ function editablePlaylistReason(playlist, ownerId) {
186
210
  return "invalid";
187
211
  }
188
212
  if (playlist.owner_id !== ownerId) return "not_owned";
189
- if (playlist.is_public === true) return "public";
190
- if (playlist.collaborative === true) return "collaborative";
213
+ if (playlist.is_public !== false) return "public";
214
+ if (playlist.collaborative !== false) return "collaborative";
191
215
  if (playlist.tracks_total > SPOTIFY_SERVICE_LIMITS.playlistEditTracksMax) {
192
216
  return "over_track_limit";
193
217
  }
@@ -298,6 +322,37 @@ function recentLimit(value) {
298
322
  return value;
299
323
  }
300
324
 
325
+ function searchQuery(value) {
326
+ if (typeof value !== "string" || !value.trim()) {
327
+ fail("invalid_search_query", "The Spotify search query must not be empty.");
328
+ }
329
+ const query = value.trim();
330
+ if (
331
+ Array.from(query).length > SPOTIFY_SERVICE_LIMITS.searchQueryLengthMax
332
+ ) {
333
+ fail(
334
+ "invalid_search_query",
335
+ `The Spotify search query must be at most ${SPOTIFY_SERVICE_LIMITS.searchQueryLengthMax} characters.`,
336
+ );
337
+ }
338
+ return query;
339
+ }
340
+
341
+ function searchLimit(value) {
342
+ if (value === undefined) return SPOTIFY_SERVICE_LIMITS.searchResultsDefault;
343
+ if (
344
+ !Number.isInteger(value) ||
345
+ value < 1 ||
346
+ value > SPOTIFY_SERVICE_LIMITS.searchResultsMax
347
+ ) {
348
+ fail(
349
+ "invalid_search_limit",
350
+ `Spotify search limit must be an integer from 1 to ${SPOTIFY_SERVICE_LIMITS.searchResultsMax}.`,
351
+ );
352
+ }
353
+ return value;
354
+ }
355
+
301
356
  function actionReceipt(action) {
302
357
  return {
303
358
  provider: "spotify",
@@ -308,11 +363,325 @@ function actionReceipt(action) {
308
363
  };
309
364
  }
310
365
 
311
- async function queueDeviceId(client, requestedDeviceId) {
366
+ const DEVICE_QUERY_FILLER = new Set([
367
+ "a",
368
+ "an",
369
+ "the",
370
+ "my",
371
+ "your",
372
+ "on",
373
+ "onto",
374
+ "to",
375
+ "from",
376
+ "over",
377
+ "spotify",
378
+ "device",
379
+ "devices",
380
+ "playback",
381
+ "play",
382
+ "playing",
383
+ "switch",
384
+ "transfer",
385
+ "move",
386
+ "moving",
387
+ "it",
388
+ "this",
389
+ "that",
390
+ "please",
391
+ "rn",
392
+ "now",
393
+ "there",
394
+ "here",
395
+ // Spanish filler around a device name.
396
+ "en", "el", "la", "mi", "al", "pon", "ponla", "ponlo", "reproduce", "dispositivo", "ahora", "aquí", "allí",
397
+ // Portuguese filler.
398
+ "no", "na", "meu", "minha", "toca", "coloca", "agora",
399
+ ]);
400
+
401
+ const DEVICE_TYPE_ALIASES = new Map([
402
+ ["phone", "smartphone"],
403
+ ["smartphone", "smartphone"],
404
+ ["tablet", "tablet"],
405
+ ["computer", "computer"],
406
+ ["desktop", "computer"],
407
+ ["laptop", "computer"],
408
+ ["电脑", "computer"],
409
+ ["计算机", "computer"],
410
+ ["手机", "smartphone"],
411
+ ["音箱", "speaker"],
412
+ ["音响", "speaker"],
413
+ ["ordenador", "computer"],
414
+ ["computadora", "computer"],
415
+ ["portátil", "computer"],
416
+ ["móvil", "smartphone"],
417
+ ["celular", "smartphone"],
418
+ ["teléfono", "smartphone"],
419
+ ["altavoz", "speaker"],
420
+ ["bocina", "speaker"],
421
+ ["parlante", "speaker"],
422
+ ["tele", "tv"],
423
+ ["televisión", "tv"],
424
+ ["coche", "automobile"],
425
+ ["carro", "automobile"],
426
+ ["computador", "computer"],
427
+ ["notebook", "computer"],
428
+ ["telefone", "smartphone"],
429
+ ["パソコン", "computer"],
430
+ ["スマホ", "smartphone"],
431
+ ["携帯", "smartphone"],
432
+ ["スピーカー", "speaker"],
433
+ ["テレビ", "tv"],
434
+ ["speaker", "speaker"],
435
+ ["tv", "tv"],
436
+ ["television", "tv"],
437
+ ["car", "automobile"],
438
+ ["automobile", "automobile"],
439
+ ]);
440
+
441
+ function clipText(value, maximum) {
442
+ const chars = Array.from(value);
443
+ if (chars.length <= maximum) return value;
444
+ return `${chars.slice(0, maximum).join("")}...`;
445
+ }
446
+
447
+ function normalizeDeviceLabel(value) {
448
+ return value
449
+ .toLocaleLowerCase("en-US")
450
+ .replace(/[\u2018\u2019\u201a\u201b\u2032\u02bc']/gu, "")
451
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
452
+ .trim()
453
+ .replace(/ +/gu, " ");
454
+ }
455
+
456
+ function deviceTokens(value) {
457
+ return value.split(" ").filter(Boolean);
458
+ }
459
+
460
+ function deviceQuery(value) {
461
+ if (typeof value !== "string") {
462
+ fail("invalid_device_query", "The Spotify device name is invalid.");
463
+ }
464
+ const trimmed = value.trim();
465
+ if (
466
+ !trimmed ||
467
+ Array.from(trimmed).length > SPOTIFY_SERVICE_LIMITS.deviceNameLengthMax
468
+ ) {
469
+ fail("invalid_device_query", "The Spotify device name is invalid.");
470
+ }
471
+ const normalized = normalizeDeviceLabel(trimmed);
472
+ if (!normalized) {
473
+ fail("invalid_device_query", "The Spotify device name is invalid.");
474
+ }
475
+ return { display: trimmed, normalized };
476
+ }
477
+
478
+ function meaningfulTokens(normalized) {
479
+ const tokens = deviceTokens(normalized).filter(
480
+ (token) => !DEVICE_QUERY_FILLER.has(token),
481
+ );
482
+ return tokens.length > 0 ? tokens : deviceTokens(normalized);
483
+ }
484
+
485
+ function tokenIsSpecific(token) {
486
+ return token.length >= 3 || /[^\u0000-\u007f]/u.test(token);
487
+ }
488
+
489
+ function tokenMatches(nameToken, queryToken) {
490
+ if (nameToken === queryToken) return true;
491
+ if (queryToken.length < 3 || DEVICE_TYPE_ALIASES.has(queryToken)) return false;
492
+ return nameToken.startsWith(queryToken);
493
+ }
494
+
495
+ function nameMatchScore(device, queryNormalized, tokens) {
496
+ const name = normalizeDeviceLabel(device.name ?? "");
497
+ if (!name) return 0;
498
+ if (name === queryNormalized) return 3;
499
+ const nameTokens = deviceTokens(name);
500
+ const matched =
501
+ tokens.length > 0 &&
502
+ tokens.every(
503
+ (token) =>
504
+ tokenIsSpecific(token) &&
505
+ nameTokens.some((nameToken) => tokenMatches(nameToken, token)),
506
+ );
507
+ if (matched) return 2;
508
+ // Windows is a common listener label for a device Spotify names "PC".
509
+ // This is a name alias only, never an inference that every Computer is Windows.
510
+ return tokens.includes("windows") && tokens.every(token => ["windows", "pc"].includes(token)) && nameTokens.includes("pc") ? 1 : 0;
511
+ }
512
+
513
+ function sharedTypeAlias(tokens) {
514
+ let type = null;
515
+ if (tokens.length === 0) return null;
516
+ for (const token of tokens) {
517
+ const alias = DEVICE_TYPE_ALIASES.get(token);
518
+ if (!alias || (type && alias !== type)) return null;
519
+ type = alias;
520
+ }
521
+ return type;
522
+ }
523
+
524
+ function deviceListLabel(device) {
525
+ const details = [];
526
+ if (device.type && device.type !== "unknown") details.push(device.type);
527
+ if (device.is_active === true) details.push("active");
528
+ if (device.is_restricted === true) details.push("restricted");
529
+ const name = clipText(device.name, 48);
530
+ return details.length > 0 ? `${name} (${details.join(", ")})` : name;
531
+ }
532
+
533
+ function fitDeviceMessage(lead, devices, tail) {
534
+ const labels = [];
535
+ for (const device of devices) {
536
+ const candidate = `${lead}${[...labels, deviceListLabel(device)].join("; ")}${tail}`;
537
+ if (Array.from(candidate).length > 220 && labels.length > 0) break;
538
+ labels.push(deviceListLabel(device));
539
+ }
540
+ const hidden = devices.length - labels.length;
541
+ const hiddenNote = hidden > 0 ? ` +${hidden} more` : "";
542
+ const message = `${lead}${labels.join("; ")}${hiddenNote}${tail}`;
543
+ return Array.from(message).length > 240
544
+ ? Array.from(message).slice(0, 240).join("")
545
+ : message;
546
+ }
547
+
548
+ // Spotify names a desktop device after its computer, so "Audiofool's MacBook Pro" and the
549
+ // host name "Audiofools-MacBook-Pro.local" are one machine once case and punctuation go.
550
+ const deviceNameKey = name => typeof name === "string"
551
+ ? name.normalize("NFKC").toLocaleLowerCase("en-US").replace(/\.local$/u, "").replace(/[^\p{L}\p{N}]+/gu, "") : "";
552
+
553
+ let computerName;
554
+ // The name Spotify gives this computer: the Mac's Computer Name, or else the host name.
555
+ // It stays on this machine; it only picks between devices Spotify already listed.
556
+ export function thisComputerName() {
557
+ computerName ??= new Promise(resolve => {
558
+ if (process.platform !== "darwin") return resolve(hostname());
559
+ execFile("/usr/sbin/scutil", ["--get", "ComputerName"], { timeout: 1_000 },
560
+ (error, stdout) => resolve(error ? hostname() : String(stdout).trim()));
561
+ });
562
+ return computerName;
563
+ }
564
+
565
+ // With nothing active, the obvious device is the only one, or the machine running Moondog:
566
+ // the only computer, or the one named like this computer. Anything else stays an explicit choice.
567
+ export function preferredIdleDevice(devices, { thisComputer } = {}) {
568
+ const usable = devices.filter(device => device.is_restricted !== true);
569
+ if (usable.length === 1) return usable[0];
570
+ const computers = usable.filter(device => device.type?.toLocaleLowerCase("en-US") === "computer");
571
+ if (computers.length === 1) return computers[0];
572
+ const here = deviceNameKey(thisComputer);
573
+ const matches = here ? computers.filter(device => deviceNameKey(device.name) === here) : [];
574
+ return matches.length === 1 ? matches[0] : null;
575
+ }
576
+
577
+ // Spotify answered and refused the request itself. Sign-in, permission and rate limits
578
+ // are left out: sending something else would not get past them.
579
+ function definiteRefusal(error) {
580
+ return Number.isInteger(error?.status) && error.status >= 400 && error.status < 500 &&
581
+ ![401, 403, 429].includes(error.status) && error.outcomeUnknown === false && error.reason !== "NO_ACTIVE_DEVICE";
582
+ }
583
+
584
+ function refusedMove(error, target, player) {
585
+ if (error?.outcomeUnknown || error?.name === "AbortError") return error;
586
+ const stayed = player?.device?.name ? clipText(player.device.name, 80) : null;
587
+ const refused = new SpotifyServiceError("spotify_device_refused",
588
+ `Spotify would not move playback to ${clipText(target.name, 80)}, either as a transfer or as a song started there. ` +
589
+ `${stayed ? `Playback stays on ${stayed}${player.is_playing === false ? ", paused" : ""}. ` : ""}` +
590
+ "The Spotify app on that device usually needs to be open and played once before it accepts remote control.");
591
+ refused.status = error?.status;
592
+ refused.playbackStayedOn = stayed;
593
+ return refused;
594
+ }
595
+
596
+ function listedDevices(result) {
597
+ return (Array.isArray(result?.devices) ? result.devices : []).filter(
598
+ (device) =>
599
+ typeof device?.id === "string" &&
600
+ device.id.length > 0 &&
601
+ typeof device?.name === "string" &&
602
+ device.name.trim().length > 0,
603
+ );
604
+ }
605
+
606
+ function requireTransferDevice(chosen, display) {
607
+ if (chosen.length === 1 && chosen[0].is_restricted === true) {
608
+ failBeforeDispatch(
609
+ "spotify_device_restricted",
610
+ `Spotify does not allow Web API control on ${clipText(chosen[0].name, 80)}. Pick another device.`,
611
+ );
612
+ }
613
+ const controllable = chosen.filter((device) => device.is_restricted !== true);
614
+ if (controllable.length === 1 && chosen.length === 1) return controllable[0];
615
+ if (controllable.length === 0) {
616
+ failBeforeDispatch(
617
+ "spotify_device_restricted",
618
+ fitDeviceMessage(
619
+ "Spotify will not take playback on the matching devices: ",
620
+ chosen,
621
+ ".",
622
+ ),
623
+ );
624
+ }
625
+ failDeviceSelection(
626
+ "spotify_device_ambiguous",
627
+ fitDeviceMessage(
628
+ `Several Spotify devices match "${display}": `,
629
+ chosen,
630
+ ". Say which one.",
631
+ ),
632
+ chosen,
633
+ );
634
+ }
635
+
636
+ async function resolveNamedDevice(client, deviceName, { signal } = {}) {
637
+ const query = deviceQuery(deviceName);
638
+ const devices = listedDevices(await client.getDevices({ signal }));
639
+ signal?.throwIfAborted();
640
+ if (devices.length === 0) {
641
+ failBeforeDispatch(
642
+ "spotify_device_not_found",
643
+ "No Spotify devices are visible. Open Spotify on that device and try again.",
644
+ );
645
+ }
646
+ const tokens = meaningfulTokens(query.normalized);
647
+ let bestScore = 0;
648
+ let named = [];
649
+ for (const device of devices) {
650
+ const score = nameMatchScore(device, query.normalized, tokens);
651
+ if (score === 0 || score < bestScore) continue;
652
+ if (score > bestScore) {
653
+ bestScore = score;
654
+ named = [device];
655
+ } else {
656
+ named.push(device);
657
+ }
658
+ }
659
+ const type = named.length === 0 ? sharedTypeAlias(tokens) : null;
660
+ const chosen = named.length > 0
661
+ ? named
662
+ : devices.filter(
663
+ (device) => (device.type ?? "").toLocaleLowerCase("en-US") === type,
664
+ );
665
+ if (chosen.length === 0) {
666
+ failDeviceSelection(
667
+ "spotify_device_no_match",
668
+ fitDeviceMessage(
669
+ `No Spotify device matches "${clipText(query.display, 40)}". Visible now: `,
670
+ devices,
671
+ ".",
672
+ ),
673
+ devices,
674
+ );
675
+ }
676
+ const display = clipText(query.display, 40);
677
+ return requireTransferDevice(chosen, display);
678
+ }
679
+
680
+ async function queueDeviceId(client, requestedDeviceId, { signal } = {}) {
312
681
  const explicit = optionalDeviceId(requestedDeviceId);
313
682
  if (explicit) return explicit;
314
683
 
315
- const result = await client.getDevices();
684
+ const result = await client.getDevices({ signal });
316
685
  const devices = Array.isArray(result?.devices) ? result.devices : [];
317
686
  const controllable = devices.filter(
318
687
  (device) =>
@@ -324,12 +693,12 @@ async function queueDeviceId(client, requestedDeviceId) {
324
693
  if (active) return active.id;
325
694
  if (controllable.length === 1) return controllable[0].id;
326
695
  if (devices.length > 0 && controllable.length === 0) {
327
- fail(
696
+ failBeforeDispatch(
328
697
  "spotify_device_restricted",
329
698
  "Spotify's available devices do not accept Web API controls. Open Spotify on another device and try again.",
330
699
  );
331
700
  }
332
- fail(
701
+ failBeforeDispatch(
333
702
  "spotify_active_device_required",
334
703
  "No active Spotify device is available. Open Spotify on one device and start playback, then try again.",
335
704
  );
@@ -340,25 +709,229 @@ export function createSpotifyService(options = {}) {
340
709
  if (!client || typeof client !== "object") {
341
710
  throw new TypeError("A Spotify Web API client is required.");
342
711
  }
712
+ const readinessTimeout = options.playbackReadinessTimeoutMs ?? SPOTIFY_SERVICE_LIMITS.playbackReadinessTimeoutMs;
713
+ if (!Number.isInteger(readinessTimeout) || readinessTimeout < 1 || readinessTimeout > 5_000) throw new TypeError("Invalid Spotify playback preparation timeout.");
714
+ const readinessSleep = options.playbackReadinessSleep ?? ((ms, { signal }) => delay(ms, undefined, { signal }));
715
+ // Tests name the computer, or pass null for none; otherwise the system names it, once.
716
+ const thisComputer = async () => (options.thisComputerName !== undefined ? options.thisComputerName : thisComputerName());
717
+
718
+ async function preparedResume(input, { signal, preferredDeviceId, allowDeviceSwitch = input.deviceId !== undefined, allowTransfer = true, excludedDeviceNames = [], onDeviceSelected, beforeDispatch, trackFallback = false } = {}) {
719
+ const effects = [];
720
+ const deadline = Date.now() + readinessTimeout;
721
+ let snapshots = 0, readRecoveries = 0, playAttempts = 0, writeStarted = false, phase = "read";
722
+ let recovered = false, fellBack = false, selected;
723
+ const stop = (code, message) => failBeforeDispatch(code, message);
724
+ // Reads and delays are bounded even if an injected transport ignores abort.
725
+ // Dispatched writes retain the client's separate settlement deadline.
726
+ const boundedRead = async operation => {
727
+ signal?.throwIfAborted();
728
+ const remaining = deadline - Date.now();
729
+ if (remaining <= 0) stop("spotify_device_not_ready", "Spotify device readiness timed out. Open Spotify on the selected device, then request playback again.");
730
+ const controller = new AbortController();
731
+ const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
732
+ let timer, abort;
733
+ try {
734
+ return await Promise.race([Promise.resolve().then(() => operation(combined)), new Promise((_, reject) => {
735
+ abort = () => reject(combined.reason);
736
+ combined.addEventListener("abort", abort, { once: true });
737
+ timer = setTimeout(() => controller.abort(new SpotifyServiceError("spotify_device_not_ready", "Spotify device readiness timed out. Open Spotify on the selected device, then request playback again.")), remaining);
738
+ })]);
739
+ } finally { clearTimeout(timer); combined.removeEventListener("abort", abort); controller.abort(); }
740
+ };
741
+ const snapshot = async (wait = false) => {
742
+ phase = "read";
743
+ if (snapshots >= SPOTIFY_SERVICE_LIMITS.playbackReadinessSnapshotsMax) stop("spotify_device_not_ready", "Spotify has not made the selected device ready. Open Spotify on it, then request playback again.");
744
+ if (wait) await boundedRead(readSignal => readinessSleep(SPOTIFY_SERVICE_LIMITS.playbackReadinessPollMs, { signal: readSignal }));
745
+ snapshots++;
746
+ let listing, player;
747
+ try {
748
+ [listing, player] = await boundedRead(readSignal => Promise.all([
749
+ client.getDevices({ signal: readSignal }), client.getCurrentPlayback({ signal: readSignal }),
750
+ ]));
751
+ } catch (error) {
752
+ // A transient read failure is safe to recover. Keep the same snapshot
753
+ // count and deadline; this never grants another playback/transfer write.
754
+ if (error?.code === "spotify_network_error" && readRecoveries++ === 0 && !signal?.aborted) return snapshot(true);
755
+ throw error;
756
+ }
757
+ signal?.throwIfAborted();
758
+ if (!Array.isArray(listing?.devices) || !["available", "inactive"].includes(player?.state)) stop("spotify_device_state_unconfirmed", "Spotify did not return a usable device and playback state. Check Spotify before requesting playback again.");
759
+ const devices = listedDevices(listing);
760
+ if (new Set(devices.map(device => device.id)).size !== devices.length) stop("spotify_device_state_unconfirmed", "Spotify returned conflicting device entries. Check Spotify's device selector before trying again.");
761
+ const active = new Set(devices.filter(device => device.is_active === true).map(device => device.id));
762
+ if (player.device?.is_active === true) active.add(player.device.id);
763
+ if (active.size > 1) stop("spotify_device_state_unconfirmed", "Spotify device and playback snapshots disagree. Check the active device before trying again.");
764
+ return { devices, player, active: [...active][0], truncated: listing.truncated === true };
765
+ };
766
+ const find = (state, id) => {
767
+ const device = state.devices.find(entry => entry.id === id);
768
+ if (!device) stop("spotify_device_not_found", "The selected Spotify device is not visible. Open Spotify on that device and request playback again; no other device was substituted.");
769
+ if (device.is_restricted !== false) stop("spotify_device_restricted", "The selected device does not confirm Web API control. Choose another available Spotify device.");
770
+ if (excludedDeviceNames.some(name => {
771
+ const query = deviceQuery(name), tokens = meaningfulTokens(query.normalized);
772
+ return nameMatchScore(device, query.normalized, tokens) > 0 || sharedTypeAlias(tokens) === device.type?.toLocaleLowerCase("en-US");
773
+ })) stop("spotify_device_selection_not_authorized", "The listener excluded the selected device. Specify another available Spotify device before playing.");
774
+ return device;
775
+ };
776
+ const check = state => {
777
+ selected = find(state, selected.id);
778
+ if (state.player.device?.id === selected.id && state.player.device.is_restricted === true) stop("spotify_device_restricted", "Spotify playback state restricts control of this device. Choose another available device.");
779
+ if (state.active && state.active !== selected.id && !allowDeviceSwitch) stop("spotify_device_changed", "Another Spotify device became active. Choose the intended device explicitly; playback was not moved automatically.");
780
+ // These flags describe controls for the current playback context. Starting
781
+ // a requested URI/context is not resuming the old item, and another device
782
+ // must not inherit the old device's contextual resume restriction.
783
+ if (!input.uris && !input.contextUri && state.player.device?.id === selected.id &&
784
+ state.player.is_playing !== true && state.player.disallowed_actions?.includes("resuming")) stop("spotify_playback_restricted", "Spotify currently disallows resuming this playback context. Choose a song to start or check the selected device.");
785
+ };
786
+ const prepare = async (state, afterRejection = false) => {
787
+ check(state);
788
+ if (selected.is_active === true && state.active === selected.id) return state;
789
+ // play:false preserves the previous state. It is safe preparation only
790
+ // with affirmative evidence of paused playback, never from a 204/unknown
791
+ // response or a currently playing session (which could play the old song).
792
+ const paused = state.player.state === "available" && state.player.is_playing === false;
793
+ if (!allowTransfer || !paused || state.truncated || state.player.disallowed_actions?.includes("transferring_playback")) {
794
+ if (afterRejection) stop("spotify_device_not_ready", "Spotify rejected playback because the selected device is not ready. Open Spotify on that device, then request the same song again.");
795
+ return state; // One exact-target play can work without a separate transfer.
796
+ }
797
+ if (!effects.length) {
798
+ signal?.throwIfAborted(); phase = "transfer";
799
+ await client.transfer({ deviceId: selected.id, play: false }, { signal, beforeDispatch: () => { signal?.throwIfAborted(); beforeDispatch?.(); writeStarted = true; } });
800
+ writeStarted = true;
801
+ effects.push({ ...actionReceipt("playback.transfer"), device: { name: selected.name, type: selected.type ?? "unknown" } });
802
+ }
803
+ while (true) {
804
+ state = await snapshot(true); check(state);
805
+ if (selected.is_active === true && state.active === selected.id) return state;
806
+ }
807
+ };
808
+ try {
809
+ let state = await snapshot();
810
+ const id = input.deviceId ?? state.active ?? preferredDeviceId ??
811
+ (state.truncated ? null : preferredIdleDevice(state.devices, { thisComputer: await thisComputer() })?.id);
812
+ if (id) selected = find(state, id);
813
+ else {
814
+ if (state.truncated || state.devices.length !== 1) stop(state.devices.length ? "spotify_device_ambiguous" : "spotify_device_not_found",
815
+ state.devices.length ? "Several Spotify devices may be available. Choose the intended device by name before playing." : "No Spotify Connect device is visible. Open Spotify on the intended device and request playback again.");
816
+ selected = find(state, state.devices[0].id);
817
+ }
818
+ onDeviceSelected?.(selected.id);
819
+ input = { ...input, deviceId: selected.id }; // Frozen before any write.
820
+ // A plain resume on the device that is already playing has nothing to do.
821
+ if (!input.uris && !input.contextUri && input.positionMs === undefined && state.active === selected.id && state.player.is_playing === true) {
822
+ return { ...actionReceipt("playback.resume"), already: true, device: { name: selected.name, type: selected.type ?? "unknown" } };
823
+ }
824
+ state = await prepare(state);
825
+ for (;;) {
826
+ signal?.throwIfAborted(); phase = "resume";
827
+ try { await client.resume(input, { signal, beforeDispatch: () => { signal?.throwIfAborted(); beforeDispatch?.(); writeStarted = true; playAttempts++; } }); break; }
828
+ catch (error) {
829
+ // A song started inside its album can meet an album Spotify will not start
830
+ // there. A definite refusal played nothing, so send the song alone once.
831
+ if (trackFallback && !fellBack && input.contextUri && input.offsetUri && definiteRefusal(error) && !signal?.aborted) {
832
+ fellBack = true;
833
+ const { contextUri: _contextUri, offsetUri, ...rest } = input;
834
+ input = { ...rest, uris: [offsetUri] };
835
+ continue;
836
+ }
837
+ // A definite NO_ACTIVE_DEVICE rejection is the other recoverable
838
+ // play response. No queue/skip/transfer or uncertain write is replayed.
839
+ if (playAttempts !== 1 || error?.status !== 404 || error.reason !== "NO_ACTIVE_DEVICE" || error.outcomeUnknown !== false || signal?.aborted) throw error;
840
+ recovered = true;
841
+ state = await snapshot(true);
842
+ await prepare(state, true);
843
+ }
844
+ }
845
+ return { ...actionReceipt("playback.resume"), device: { name: selected.name, type: selected.type ?? "unknown" },
846
+ ...(effects.length ? { preparation_effects: effects } : {}), ...(recovered ? { recovered_no_active_device: true } : {}) };
847
+ } catch (error) {
848
+ // Retain accepted preparation even if a later read, cancellation, or play
849
+ // fails. A preparation failure must not claim that the song was dispatched.
850
+ error.playbackPreparation = effects;
851
+ error.playbackAction = phase === "transfer" ? "playback.transfer" : "playback.resume";
852
+ error.playbackNotDispatched = playAttempts === 0;
853
+ error.playbackPreparationStopped = true;
854
+ error.playbackReadFailure = phase === "read";
855
+ error.playbackRecoveryAttempted = recovered;
856
+ if (phase === "read") error.outcomeUnknown = false;
857
+ if (!writeStarted) error.actionNotDispatched = true;
858
+ else delete error.actionNotDispatched;
859
+ throw error;
860
+ }
861
+ }
343
862
 
344
863
  return Object.freeze({
345
- account() {
346
- return client.getAccount();
864
+ account(options) {
865
+ return client.getAccount(options);
866
+ },
867
+
868
+ currentPlayer(options) {
869
+ return client.getCurrentPlayback(options);
870
+ },
871
+
872
+ devices(options) {
873
+ return client.getDevices(options);
347
874
  },
348
875
 
349
- currentPlayer() {
350
- return client.getCurrentPlayback();
876
+ async resolveDevice(value, { signal } = {}) {
877
+ signal?.throwIfAborted();
878
+ const input = inputObject(value);
879
+ if (input.deviceId !== undefined && input.deviceName !== undefined) fail("conflicting_device_target", "Choose one device target.");
880
+ let device;
881
+ if (input.deviceName !== undefined) device = await resolveNamedDevice(client, input.deviceName, { signal });
882
+ else {
883
+ const id = optionalDeviceId(input.deviceId);
884
+ const devices = listedDevices(await client.getDevices({ signal }));
885
+ signal?.throwIfAborted();
886
+ const matching = devices.filter((item) => id ? item.id === id : item.is_active === true);
887
+ if (matching.length === 0) failBeforeDispatch("spotify_device_not_found", "That Spotify device is no longer available. List devices again or open Spotify on it.");
888
+ device = requireTransferDevice(matching, "selected device");
889
+ }
890
+ if (input.forVolume && device.supports_volume === false) failBeforeDispatch("spotify_volume_unsupported", "Spotify cannot change this device's volume through the Web API. Use its hardware or Spotify app volume control.");
891
+ return device;
351
892
  },
352
893
 
353
- devices() {
354
- return client.getDevices();
894
+ queue(options) {
895
+ return client.getQueue(options);
355
896
  },
356
897
 
357
- queue() {
358
- return client.getQueue();
898
+ // The playlist the active device plays from: its name, size and first songs. Spotify's own
899
+ // editorial and generated playlists answer most apps with not found, and some playlists keep
900
+ // their songs from apps; the songs are then null.
901
+ async playingPlaylist({ uri } = {}, { signal } = {}) {
902
+ const playlistId = requiredPlaylistId(String(uri ?? "").replace(/^spotify:playlist:/u, ""));
903
+ const playlist = await client.getPlaylist({ playlistId }, { signal });
904
+ if (!playlist) fail("spotify_playlist_unavailable", "Spotify did not describe this playlist.");
905
+ const page = await client.getPlaylistItems({ playlistId, limit: SPOTIFY_SERVICE_LIMITS.playlistItemsPageMax, offset: 0 }, { signal })
906
+ .catch((error) => (signal?.aborted ? Promise.reject(error) : null));
907
+ return { name: playlist.name, uri: playlist.uri, tracks_total: playlist.tracks_total ?? null,
908
+ tracks: page ? page.items.map((entry) => entry.item).filter(Boolean) : null };
359
909
  },
360
910
 
361
- async recentActivity(value) {
911
+ // One device decision for a whole queue request. An idle device cannot take a
912
+ // queue entry, so the caller starts the first song there and queues the rest.
913
+ async queueTarget(value, { signal } = {}) {
914
+ signal?.throwIfAborted();
915
+ const input = inputObject(value ?? {});
916
+ // A named device needs no lookup: an idle one answers NO_ACTIVE_DEVICE and the caller starts playback there.
917
+ const explicit = optionalDeviceId(input.deviceId);
918
+ if (explicit) return { deviceId: explicit, active: null };
919
+ const [listing, player] = await Promise.all([client.getDevices({ signal }), client.getCurrentPlayback({ signal })]);
920
+ signal?.throwIfAborted();
921
+ const devices = listedDevices(listing);
922
+ const describe = device => ({ deviceId: device.id, device: { name: device.name, type: device.type ?? "unknown" } });
923
+ const active = devices.find(device => device.is_active === true && device.is_restricted !== true) ??
924
+ (player?.device?.is_active === true ? devices.find(device => device.id === player.device.id && device.is_restricted !== true) : undefined);
925
+ if (active) return { ...describe(active), active: true };
926
+ const idle = preferredIdleDevice(devices, { thisComputer: await thisComputer() });
927
+ if (idle) return { ...describe(idle), active: false };
928
+ failBeforeDispatch(devices.length ? "spotify_device_ambiguous" : "spotify_device_not_found", devices.length
929
+ ? `Nothing is playing, and several Spotify devices are available (${devices.map(device => device.name).slice(0, 5).join(", ")}). Say which one to use.`
930
+ : "No Spotify device is open. Open Spotify on this computer or your phone, then ask again.");
931
+ },
932
+
933
+ async recentActivity(value, { signal } = {}) {
934
+ signal?.throwIfAborted();
362
935
  const input = inputObject(value);
363
936
  const after = recentCursor(input.after, "after");
364
937
  const before = recentCursor(input.before, "before");
@@ -372,16 +945,97 @@ export function createSpotifyService(options = {}) {
372
945
  limit: recentLimit(input.limit),
373
946
  ...(after !== undefined ? { after } : {}),
374
947
  ...(before !== undefined ? { before } : {}),
375
- });
948
+ }, { signal });
949
+ },
950
+
951
+ // A bounded read of the whole library for a profile import, never an action.
952
+ async librarySnapshot({ signal, onProgress } = {}) {
953
+ signal?.throwIfAborted();
954
+ return readSpotifyLibrarySnapshot(client, { signal, onProgress });
955
+ },
956
+
957
+ async topItems(value, { signal } = {}) {
958
+ signal?.throwIfAborted();
959
+ const input = inputObject(value);
960
+ const type = input.type ?? "tracks";
961
+ const timeRange = input.timeRange ?? "medium_term";
962
+ if (!["artists", "tracks"].includes(type) || !["short_term", "medium_term", "long_term"].includes(timeRange)) {
963
+ fail("invalid_top_items", "Spotify top items require artists or tracks and short_term, medium_term, or long_term.");
964
+ }
965
+ const limit = searchLimit(input.limit);
966
+ const result = await client.getTopItems({ type, timeRange, limit }, { signal });
967
+ signal?.throwIfAborted();
968
+ const items = Array.isArray(result?.items) ? result.items : [];
969
+ return { provider: "spotify", type, time_range: timeRange,
970
+ evidence_basis: "spotify_calculated_affinity", items: items.slice(0, limit),
971
+ truncated: result?.truncated === true || items.length > limit };
972
+ },
973
+
974
+ async libraryBrowse(value, { signal } = {}) {
975
+ signal?.throwIfAborted();
976
+ const input = inputObject(value);
977
+ const type = input.type ?? "tracks";
978
+ const limit = input.limit ?? 10;
979
+ if (!["tracks", "albums", "shows", "playlists", "artists"].includes(type) || !Number.isInteger(limit) || limit < 1 || limit > 20) {
980
+ fail("invalid_library_page", "Browse saved tracks, albums, shows, playlists, or followed artists with a limit from 1 to 20.");
981
+ }
982
+ if (type === "artists") {
983
+ if (input.offset !== undefined || input.after !== undefined && (typeof input.after !== "string" || !/^[A-Za-z0-9]{1,128}$/u.test(input.after))) fail("invalid_artist_cursor", "Use the returned after cursor for followed artists, not offset.");
984
+ return client.getFollowedArtists({ limit, after: input.after }, { signal });
985
+ }
986
+ if (input.after !== undefined) fail("invalid_library_cursor", "Use offset for this library type.");
987
+ return client.getSavedItems({ type, limit, offset: playlistPageOffset(input.offset) }, { signal });
988
+ },
989
+
990
+ async searchTracks(value, { signal } = {}) {
991
+ const input = inputObject(value);
992
+ const type = input.type ?? "track";
993
+ if (!["track", "album", "artist", "playlist", "show", "episode"].includes(type)) fail("invalid_search_type", "Choose track, album, artist, playlist, show, or episode.");
994
+ const offset = input.offset ?? 0;
995
+ if (!Number.isInteger(offset) || offset < 0 || offset > 1000) fail("invalid_search_offset", "Search offset must be from 0 to 1000.");
996
+ if (type !== "track" || offset !== 0) return client.searchItems({ query: searchQuery(input.query), type, limit: searchLimit(input.limit), offset }, { signal });
997
+ return client.searchTracks({
998
+ query: searchQuery(input.query),
999
+ limit: searchLimit(input.limit),
1000
+ }, { signal });
376
1001
  },
377
1002
 
378
- async editablePlaylists(value) {
1003
+ async catalogChildren(value, { signal } = {}) {
1004
+ signal?.throwIfAborted();
1005
+ const input = inputObject(value);
1006
+ if (!["album", "show"].includes(input.type) || typeof input.id !== "string" || !/^[A-Za-z0-9]{1,128}$/u.test(input.id)) fail("invalid_catalog_parent", "Choose a returned album or show reference.");
1007
+ const limit = input.limit ?? 10;
1008
+ if (!Number.isInteger(limit) || limit < 1 || limit > 20) fail("invalid_catalog_limit", "Read from 1 to 20 album tracks or episodes per page.");
1009
+ return client.getCatalogChildren({ type: input.type, id: input.id, limit, offset: playlistPageOffset(input.offset) }, { signal });
1010
+ },
1011
+
1012
+ async saveItems(value, { signal } = {}) {
1013
+ signal?.throwIfAborted();
1014
+ const uris = inputObject(value).uris;
1015
+ if (!Array.isArray(uris) || uris.length < 1 || uris.length > 12 || uris.some((uri) => typeof uri !== "string" || !/^spotify:(?:track|album|episode|show|playlist):[A-Za-z0-9]{1,128}$/u.test(uri))) fail("invalid_library_items", "Choose from 1 to 12 returned Spotify items.");
1016
+ const unique = [...new Set(uris)];
1017
+ await client.saveTracks({ uris: unique }, { signal });
1018
+ return { ...actionReceipt("library.save"), item_count: unique.length };
1019
+ },
1020
+
1021
+ async removeSavedItem(value, { signal } = {}) {
1022
+ signal?.throwIfAborted();
1023
+ const uri = inputObject(value).uri;
1024
+ if (typeof uri !== "string" || !/^spotify:(?:track|album|episode|show):[A-Za-z0-9]{1,128}$/u.test(uri)) fail("invalid_library_item", "Choose one returned saved item.");
1025
+ await client.removeLibraryItems({ uris: [uri] }, { signal });
1026
+ return { ...actionReceipt("library.remove"), item_count: 1 };
1027
+ },
1028
+
1029
+ async editablePlaylists(value, { signal } = {}) {
1030
+ signal?.throwIfAborted();
379
1031
  const input = inputObject(value);
380
1032
  const limit = playlistPageLimit(input.limit);
381
1033
  const offset = playlistPageOffset(input.offset);
382
- const account = await client.getAccount();
1034
+ const account = await client.getAccount({ signal });
1035
+ signal?.throwIfAborted();
383
1036
  const ownerId = accountId(account);
384
- const page = await client.getCurrentUserPlaylists({ limit, offset });
1037
+ const page = await client.getCurrentUserPlaylists({ limit, offset }, { signal });
1038
+ signal?.throwIfAborted();
385
1039
  const excluded = {
386
1040
  public: 0,
387
1041
  not_owned: 0,
@@ -390,6 +1044,10 @@ export function createSpotifyService(options = {}) {
390
1044
  invalid: 0,
391
1045
  };
392
1046
  const playlists = [];
1047
+ const names = new Map();
1048
+ for (const item of page?.items ?? []) {
1049
+ if (typeof item?.name === "string") names.set(item.name, (names.get(item.name) ?? 0) + 1);
1050
+ }
393
1051
  for (const playlist of Array.isArray(page?.items) ? page.items : []) {
394
1052
  const reason = editablePlaylistReason(playlist, ownerId);
395
1053
  if (reason) {
@@ -408,24 +1066,54 @@ export function createSpotifyService(options = {}) {
408
1066
  return {
409
1067
  provider: "spotify",
410
1068
  playlists,
1069
+ name_selection_complete: page?.complete_for_name_selection === true,
1070
+ ambiguous_names: [...names].filter(([, count]) => count > 1).map(([name]) => name),
411
1071
  excluded,
412
1072
  has_more: hasMore,
413
1073
  next_offset: hasMore ? pageOffset + pageLimit : null,
414
1074
  };
415
1075
  },
416
1076
 
417
- async playlistSnapshot(value) {
1077
+ async playlistRemovalTarget(value, { signal } = {}) {
1078
+ signal?.throwIfAborted();
418
1079
  const input = inputObject(value);
419
1080
  const playlistId = requiredPlaylistId(input.playlistId);
420
- const account = await client.getAccount();
1081
+ const account = await client.getAccount({ signal });
1082
+ signal?.throwIfAborted();
1083
+ const playlist = await client.getPlaylist({ playlistId }, { signal });
1084
+ signal?.throwIfAborted();
1085
+ if (playlist?.id !== playlistId || playlist.owner_id !== accountId(account) || playlist.is_public !== false || playlist.collaborative !== false ||
1086
+ typeof playlist.name !== "string" || !playlist.name || playlist.uri !== `spotify:playlist:${playlistId}`) {
1087
+ fail("playlist_not_removable", "Remove only a verified owned private, non-collaborative playlist from your library.");
1088
+ }
1089
+ return { playlistId, name: playlist.name, snapshotId: requiredSnapshotId(playlist.snapshot_id) };
1090
+ },
1091
+
1092
+ async removePlaylistFromLibrary(value, { signal } = {}) {
1093
+ signal?.throwIfAborted();
1094
+ const input = inputObject(value);
1095
+ const current = await this.playlistRemovalTarget(input, { signal });
1096
+ if (current.name !== input.expectedName || current.snapshotId !== input.expectedSnapshotId) fail("playlist_snapshot_changed", "The playlist changed since confirmation was prepared. Review it again.");
1097
+ signal?.throwIfAborted();
1098
+ await client.removeLibraryItems({ uris: [`spotify:playlist:${current.playlistId}`] }, { signal });
1099
+ return { ...actionReceipt("playlist.unfollow"), playlist: { name: current.name, is_public: false } };
1100
+ },
1101
+
1102
+ async playlistSnapshot(value, { signal } = {}) {
1103
+ signal?.throwIfAborted();
1104
+ const input = inputObject(value);
1105
+ const playlistId = requiredPlaylistId(input.playlistId);
1106
+ const account = await client.getAccount({ signal });
1107
+ signal?.throwIfAborted();
421
1108
  const ownerId = accountId(account);
422
1109
  const before = requireEditablePlaylist(
423
- await client.getPlaylist({ playlistId }),
1110
+ await client.getPlaylist({ playlistId }, { signal }),
424
1111
  ownerId,
425
1112
  );
426
1113
  const tracks = [];
427
1114
  let offset = 0;
428
1115
  while (offset < before.tracks_total) {
1116
+ signal?.throwIfAborted();
429
1117
  const page = await client.getPlaylistItems({
430
1118
  playlistId,
431
1119
  limit: Math.min(
@@ -433,7 +1121,7 @@ export function createSpotifyService(options = {}) {
433
1121
  before.tracks_total - offset,
434
1122
  ),
435
1123
  offset,
436
- });
1124
+ }, { signal });
437
1125
  if (
438
1126
  !isPlainObject(page) ||
439
1127
  page.total !== before.tracks_total ||
@@ -457,11 +1145,15 @@ export function createSpotifyService(options = {}) {
457
1145
  );
458
1146
  }
459
1147
  }
1148
+ signal?.throwIfAborted();
460
1149
  const after = requireEditablePlaylist(
461
- await client.getPlaylist({ playlistId }),
1150
+ await client.getPlaylist({ playlistId }, { signal }),
462
1151
  ownerId,
463
1152
  );
1153
+ signal?.throwIfAborted();
464
1154
  if (
1155
+ before.id !== playlistId || after.id !== playlistId ||
1156
+ after.name !== before.name ||
465
1157
  after.snapshot_id !== before.snapshot_id ||
466
1158
  after.tracks_total !== before.tracks_total ||
467
1159
  tracks.length !== before.tracks_total
@@ -483,15 +1175,51 @@ export function createSpotifyService(options = {}) {
483
1175
  };
484
1176
  },
485
1177
 
486
- async replacePlaylistItems(value) {
1178
+ async quickEditPlaylist(value, { signal, beforeWrite } = {}) {
1179
+ signal?.throwIfAborted();
1180
+ const input = inputObject(value);
1181
+ const playlistId = requiredPlaylistId(input.playlistId);
1182
+ const expectedSnapshotId = requiredSnapshotId(input.expectedSnapshotId);
1183
+ if (!["rename", "remove_track"].includes(input.action)) fail("invalid_playlist_edit", "Unsupported quick playlist edit.");
1184
+ const current = await this.playlistSnapshot({ playlistId }, { signal });
1185
+ if (current.playlist.snapshot_id !== expectedSnapshotId || current.playlist.name !== input.expectedName ||
1186
+ current.items.length !== input.expectedTrackCount) {
1187
+ fail("playlist_snapshot_changed", "The playlist changed after inspection. Inspect it again before editing.");
1188
+ }
1189
+ let name = current.playlist.name;
1190
+ let count = current.items.length;
1191
+ if (input.action === "rename") {
1192
+ name = cleanPlaylistText(input.name, 100, "invalid_playlist_name", "name");
1193
+ if (name !== input.name || name === current.playlist.name) fail("invalid_playlist_name", "The exact new playlist name must be valid and different.");
1194
+ beforeWrite?.();
1195
+ signal?.throwIfAborted();
1196
+ await client.renamePlaylist({ playlistId, name }, { signal, beforeDispatch: beforeWrite });
1197
+ } else {
1198
+ const uri = requiredItemUri(input.uri);
1199
+ if (!trackUriPattern.test(uri) || current.items.filter((item) => item.uri === uri).length !== 1) {
1200
+ fail("playlist_removal_ambiguous", "Single-track removal requires exactly one occurrence. Preview the exact final order for duplicates or bulk edits.");
1201
+ }
1202
+ beforeWrite?.();
1203
+ signal?.throwIfAborted();
1204
+ await client.removePlaylistItem({ playlistId, uri, snapshotId: expectedSnapshotId }, { signal, beforeDispatch: beforeWrite });
1205
+ count -= 1;
1206
+ }
1207
+ return { ...actionReceipt(input.action === "rename" ? "playlist.rename" : "playlist.remove_track"),
1208
+ playlist: { name, track_count: count, is_public: false }, previous_track_count: current.items.length,
1209
+ ...(input.action === "remove_track" ? { track_count: 1 } : {}) };
1210
+ },
1211
+
1212
+ async replacePlaylistItems(value, { signal } = {}) {
1213
+ signal?.throwIfAborted();
487
1214
  const input = inputObject(value);
488
1215
  const playlistId = requiredPlaylistId(input.playlistId);
489
1216
  const expectedSnapshotId = requiredSnapshotId(input.expectedSnapshotId);
490
1217
  const uris = requiredPlaylistEditTrackUris(input.uris);
491
- const account = await client.getAccount();
1218
+ const account = await client.getAccount({ signal });
1219
+ signal?.throwIfAborted();
492
1220
  const ownerId = accountId(account);
493
1221
  const playlist = requireEditablePlaylist(
494
- await client.getPlaylist({ playlistId }),
1222
+ await client.getPlaylist({ playlistId }, { signal }),
495
1223
  ownerId,
496
1224
  );
497
1225
  if (playlist.snapshot_id !== expectedSnapshotId) {
@@ -500,7 +1228,8 @@ export function createSpotifyService(options = {}) {
500
1228
  "The Spotify playlist changed after preview. Inspect it again before editing.",
501
1229
  );
502
1230
  }
503
- await client.replacePlaylistItems({ playlistId, uris });
1231
+ signal?.throwIfAborted();
1232
+ await client.replacePlaylistItems({ playlistId, uris }, { signal });
504
1233
  return {
505
1234
  ...actionReceipt("playlist.edit"),
506
1235
  playlist: {
@@ -512,7 +1241,8 @@ export function createSpotifyService(options = {}) {
512
1241
  };
513
1242
  },
514
1243
 
515
- async resume(value) {
1244
+ async resume(value, { signal, ...preparation } = {}) {
1245
+ signal?.throwIfAborted();
516
1246
  const input = inputObject(value);
517
1247
  const contextUri = optionalContextUri(input.contextUri);
518
1248
  const uris = optionalUris(input.uris);
@@ -528,36 +1258,51 @@ export function createSpotifyService(options = {}) {
528
1258
  if (offsetPosition !== undefined && offsetUri !== undefined) {
529
1259
  fail("conflicting_offset", "Choose one Spotify playback offset.");
530
1260
  }
531
- await client.resume({
1261
+ // Spotify starts partway only into an album or a playlist, not an artist.
1262
+ if ((offsetPosition !== undefined || offsetUri !== undefined) && contextUri && !/^spotify:(?:album|playlist):/u.test(contextUri)) {
1263
+ fail("invalid_spotify_offset", "Spotify can start partway only into an album or a playlist.");
1264
+ }
1265
+ return preparedResume({
532
1266
  deviceId: optionalDeviceId(input.deviceId),
533
1267
  contextUri,
534
1268
  uris,
535
1269
  offsetPosition,
536
1270
  offsetUri,
537
1271
  positionMs: optionalPosition(input.positionMs, "invalid_position"),
538
- });
539
- return actionReceipt("playback.resume");
1272
+ }, { signal, ...preparation });
540
1273
  },
541
1274
 
542
- async pause(value) {
1275
+ async pause(value, { signal } = {}) {
1276
+ signal?.throwIfAborted();
543
1277
  const input = inputObject(value);
544
- await client.pause({ deviceId: optionalDeviceId(input.deviceId) });
1278
+ try {
1279
+ await client.pause({ deviceId: optionalDeviceId(input.deviceId) }, { signal });
1280
+ } catch (error) {
1281
+ // Spotify refuses to pause a player that is already paused; that is the requested state.
1282
+ if (error?.status !== 403 || error.outcomeUnknown) throw error;
1283
+ const player = await Promise.resolve().then(() => client.getCurrentPlayback({ signal })).catch(() => null);
1284
+ if (player?.state === "available" && player.is_playing === false) return { ...actionReceipt("playback.pause"), already: true };
1285
+ throw error;
1286
+ }
545
1287
  return actionReceipt("playback.pause");
546
1288
  },
547
1289
 
548
- async next(value) {
1290
+ async next(value, { signal } = {}) {
1291
+ signal?.throwIfAborted();
549
1292
  const input = inputObject(value);
550
- await client.next({ deviceId: optionalDeviceId(input.deviceId) });
1293
+ await client.next({ deviceId: optionalDeviceId(input.deviceId) }, { signal });
551
1294
  return actionReceipt("playback.next");
552
1295
  },
553
1296
 
554
- async previous(value) {
1297
+ async previous(value, { signal } = {}) {
1298
+ signal?.throwIfAborted();
555
1299
  const input = inputObject(value);
556
- await client.previous({ deviceId: optionalDeviceId(input.deviceId) });
1300
+ await client.previous({ deviceId: optionalDeviceId(input.deviceId) }, { signal });
557
1301
  return actionReceipt("playback.previous");
558
1302
  },
559
1303
 
560
- async setVolume(value) {
1304
+ async setVolume(value, { signal } = {}) {
1305
+ signal?.throwIfAborted();
561
1306
  const input = inputObject(value);
562
1307
  if (!Number.isInteger(input.percent) || input.percent < 0 || input.percent > 100) {
563
1308
  fail("invalid_volume", "Spotify volume must be an integer from 0 to 100.");
@@ -565,11 +1310,12 @@ export function createSpotifyService(options = {}) {
565
1310
  await client.setVolume({
566
1311
  percent: input.percent,
567
1312
  deviceId: optionalDeviceId(input.deviceId),
568
- });
1313
+ }, { signal });
569
1314
  return actionReceipt("playback.volume.set");
570
1315
  },
571
1316
 
572
- async seek(value) {
1317
+ async seek(value, { signal } = {}) {
1318
+ signal?.throwIfAborted();
573
1319
  const input = inputObject(value);
574
1320
  if (input.positionMs === undefined) {
575
1321
  fail("invalid_position", "The Spotify playback position is invalid.");
@@ -577,11 +1323,12 @@ export function createSpotifyService(options = {}) {
577
1323
  await client.seek({
578
1324
  positionMs: optionalPosition(input.positionMs, "invalid_position"),
579
1325
  deviceId: optionalDeviceId(input.deviceId),
580
- });
1326
+ }, { signal });
581
1327
  return actionReceipt("playback.seek");
582
1328
  },
583
1329
 
584
- async setShuffle(value) {
1330
+ async setShuffle(value, { signal } = {}) {
1331
+ signal?.throwIfAborted();
585
1332
  const input = inputObject(value);
586
1333
  if (typeof input.state !== "boolean") {
587
1334
  fail("invalid_shuffle_state", "Spotify shuffle state must be a boolean.");
@@ -589,11 +1336,12 @@ export function createSpotifyService(options = {}) {
589
1336
  await client.setShuffle({
590
1337
  state: input.state,
591
1338
  deviceId: optionalDeviceId(input.deviceId),
592
- });
1339
+ }, { signal });
593
1340
  return actionReceipt("playback.shuffle.set");
594
1341
  },
595
1342
 
596
- async setRepeat(value) {
1343
+ async setRepeat(value, { signal } = {}) {
1344
+ signal?.throwIfAborted();
597
1345
  const input = inputObject(value);
598
1346
  if (!repeatStates.has(input.state)) {
599
1347
  fail("invalid_repeat_state", "Spotify repeat state is invalid.");
@@ -601,33 +1349,75 @@ export function createSpotifyService(options = {}) {
601
1349
  await client.setRepeat({
602
1350
  state: input.state,
603
1351
  deviceId: optionalDeviceId(input.deviceId),
604
- });
1352
+ }, { signal });
605
1353
  return actionReceipt("playback.repeat.set");
606
1354
  },
607
1355
 
608
- async transfer(value) {
1356
+ async transfer(value, { signal, onDeviceSelected } = {}) {
1357
+ signal?.throwIfAborted();
609
1358
  const input = inputObject(value);
610
- const deviceId = optionalDeviceId(input.deviceId);
611
- if (!deviceId) {
612
- fail("invalid_device_id", "A Spotify device identifier is required.");
613
- }
614
1359
  if (input.play !== undefined && typeof input.play !== "boolean") {
615
1360
  fail("invalid_play_state", "Spotify transfer play state must be a boolean.");
616
1361
  }
617
- await client.transfer({ deviceId, play: input.play ?? false });
618
- return actionReceipt("playback.transfer");
1362
+ const deviceId =
1363
+ input.deviceId === undefined ? undefined : optionalDeviceId(input.deviceId);
1364
+ const hasName = input.deviceName !== undefined;
1365
+ if (deviceId && hasName) {
1366
+ fail(
1367
+ "conflicting_device_target",
1368
+ "Pass either a Spotify device ID or a device name.",
1369
+ );
1370
+ }
1371
+ if (!deviceId && !hasName) {
1372
+ fail("invalid_device_id", "A Spotify device identifier is required.");
1373
+ }
1374
+ // Reads before the move are best effort: a failed read never blocks the move itself.
1375
+ const tryRead = (read) => Promise.resolve().then(read).catch(() => null);
1376
+ const device = deviceId
1377
+ ? listedDevices(await tryRead(() => client.getDevices({ signal }))).find((entry) => entry.id === deviceId) ?? { id: deviceId, name: "the selected device" }
1378
+ : await resolveNamedDevice(client, input.deviceName, { signal });
1379
+ onDeviceSelected?.(device.id);
1380
+ signal?.throwIfAborted();
1381
+ const display = { name: device.name, type: device.type || "unknown" };
1382
+ const player = await tryRead(() => client.getCurrentPlayback({ signal }));
1383
+ signal?.throwIfAborted();
1384
+ const playing = player?.state === "available" && player.is_playing === true;
1385
+ const play = input.play ?? playing;
1386
+ if (player?.device?.id === device.id && player.device.is_active !== false && (!play || playing)) {
1387
+ return { ...actionReceipt("playback.transfer"), already: true, device: display };
1388
+ }
1389
+ try {
1390
+ await client.transfer({ deviceId: device.id, play }, { signal });
1391
+ return { ...actionReceipt("playback.transfer"), device: display };
1392
+ } catch (error) {
1393
+ // Spotify sometimes refuses a transfer that it accepts as a play request aimed at
1394
+ // the device. Start the same song there, at the same moment, once.
1395
+ const item = player?.item?.uri;
1396
+ if (![403, 404, 502].includes(error?.status) || error.outcomeUnknown || !item || signal?.aborted) throw refusedMove(error, display, player);
1397
+ try {
1398
+ await client.resume({ deviceId: device.id, uris: [item], ...(Number.isInteger(player.progress_ms) ? { positionMs: player.progress_ms } : {}) }, { signal });
1399
+ } catch (fallback) {
1400
+ throw refusedMove(fallback, display, player);
1401
+ }
1402
+ if (!play) await client.pause({ deviceId: device.id }, { signal }).catch(() => {});
1403
+ return { ...actionReceipt("playback.transfer"), device: display, method: "play_on_device" };
1404
+ }
619
1405
  },
620
1406
 
621
- async addToQueue(value) {
1407
+ async addToQueue(value, { signal } = {}) {
1408
+ signal?.throwIfAborted();
622
1409
  const input = inputObject(value);
623
- await client.addToQueue({
624
- uri: requiredItemUri(input.uri),
625
- deviceId: await queueDeviceId(client, input.deviceId),
626
- });
1410
+ const uri = requiredItemUri(input.uri);
1411
+ const deviceId = await queueDeviceId(client, input.deviceId, { signal });
1412
+ signal?.throwIfAborted();
1413
+ // Let a dispatched write settle so cancellation can report accepted effects.
1414
+ // Never dispatch the next write after the prompt has been cancelled.
1415
+ await client.addToQueue({ uri, deviceId }, { signal });
627
1416
  return actionReceipt("playback.queue.add");
628
1417
  },
629
1418
 
630
- async createPlaylistWithTracks(value) {
1419
+ async createPlaylistWithTracks(value, { signal } = {}) {
1420
+ signal?.throwIfAborted();
631
1421
  const input = inputObject(value);
632
1422
  const name = cleanPlaylistText(
633
1423
  input.name,
@@ -652,16 +1442,28 @@ export function createSpotifyService(options = {}) {
652
1442
  const playlist = await client.createPlaylist({
653
1443
  name,
654
1444
  ...(description ? { description } : {}),
655
- });
1445
+ }, { signal });
656
1446
  if (!playlist?.id) {
657
1447
  fail(
658
1448
  "playlist_creation_failed",
659
1449
  "Spotify did not return the created playlist.",
1450
+ { outcomeUnknown: true },
660
1451
  );
661
1452
  }
662
1453
  try {
663
- await client.addPlaylistTracks({ playlistId: playlist.id, uris });
664
- } catch {
1454
+ signal?.throwIfAborted();
1455
+ await client.addPlaylistTracks({ playlistId: playlist.id, uris }, { signal });
1456
+ } catch (error) {
1457
+ const rejected = error?.outcomeUnknown === false ||
1458
+ (Number.isInteger(error?.status) && error.status >= 400 && error.status < 500) ||
1459
+ (error?.name === "AbortError" && signal?.aborted);
1460
+ if (error?.outcomeUnknown === true || !rejected) {
1461
+ fail(
1462
+ "playlist_created_tracks_unknown",
1463
+ "Spotify created the private playlist, but did not confirm whether its tracks were added. Check the playlist before trying again.",
1464
+ { outcomeUnknown: true },
1465
+ );
1466
+ }
665
1467
  fail(
666
1468
  "playlist_created_without_tracks",
667
1469
  "Spotify created the private playlist but did not accept its tracks. Check the empty playlist before trying again.",
@@ -679,28 +1481,30 @@ export function createSpotifyService(options = {}) {
679
1481
  };
680
1482
  },
681
1483
 
682
- async saveTracks(value) {
1484
+ async saveTracks(value, { signal } = {}) {
1485
+ signal?.throwIfAborted();
683
1486
  const input = inputObject(value);
684
1487
  const uris = requiredTrackUris(
685
1488
  input.uris,
686
1489
  "invalid_library_tracks",
687
1490
  SPOTIFY_SERVICE_LIMITS.libraryTracksMax,
688
1491
  );
689
- await client.saveTracks({ uris });
1492
+ await client.saveTracks({ uris }, { signal });
690
1493
  return {
691
1494
  ...actionReceipt("library.save"),
692
1495
  track_count: uris.length,
693
1496
  };
694
1497
  },
695
1498
 
696
- async checkSavedTracks(value) {
1499
+ async checkSavedTracks(value, { signal } = {}) {
1500
+ signal?.throwIfAborted();
697
1501
  const input = inputObject(value);
698
1502
  const uris = requiredTrackUris(
699
1503
  input.uris,
700
1504
  "invalid_library_tracks",
701
1505
  SPOTIFY_SERVICE_LIMITS.libraryTracksMax,
702
1506
  );
703
- const saved = await client.checkSavedTracks({ uris });
1507
+ const saved = await client.checkSavedTracks({ uris }, { signal });
704
1508
  return {
705
1509
  provider: "spotify",
706
1510
  checked: uris.map((uri, index) => ({