@audiofool/moondog 0.1.0 → 0.2.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +153 -68
- package/contracts/v1/definitions.schema.json +1 -169
- package/package.json +7 -23
- package/scripts/contract-lib.mjs +0 -7
- package/scripts/contract-semantics.mjs +0 -1904
- package/scripts/moondog.mjs +285 -74
- package/src/core/apple-library-source-status.mjs +0 -16
- package/src/core/apple-projection-domain-services.mjs +168 -36
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +278 -55
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +153 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +1908 -104
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +121 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +128 -0
- package/src/i18n/messages/en.mjs +129 -0
- package/src/i18n/messages/es.mjs +128 -0
- package/src/i18n/messages/fr.mjs +128 -0
- package/src/i18n/messages/it.mjs +128 -0
- package/src/i18n/messages/ja.mjs +128 -0
- package/src/i18n/messages/ko.mjs +128 -0
- package/src/i18n/messages/pt.mjs +128 -0
- package/src/i18n/messages/zh-Hant.mjs +128 -0
- package/src/i18n/messages/zh.mjs +128 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +966 -0
- package/src/i18n/screen/ja.mjs +966 -0
- package/src/i18n/screen/pt.mjs +966 -0
- package/src/i18n/screen/zh.mjs +966 -0
- package/src/importers/apple-music-library/index.mjs +0 -16
- package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
- package/src/integrations/apple-music/catalog.mjs +123 -14
- package/src/integrations/apple-music/music-app.mjs +232 -0
- package/src/integrations/apple-music/storefront.mjs +28 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
- package/src/integrations/lastfm/history.mjs +221 -0
- package/src/integrations/lastfm/settings.mjs +75 -0
- package/src/integrations/netease/ncm-cli-player.mjs +321 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
- package/src/integrations/public-playlists.mjs +2 -1
- package/src/integrations/spotify/account-data-profile.mjs +14 -6
- package/src/integrations/spotify/authentication.mjs +149 -15
- package/src/integrations/spotify/catalog-resolver.mjs +8 -4
- package/src/integrations/spotify/connection.mjs +2 -1
- package/src/integrations/spotify/credential-store.mjs +4 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
- package/src/integrations/spotify/history-archive.mjs +1 -1
- package/src/integrations/spotify/library-snapshot.mjs +227 -0
- package/src/integrations/spotify/recent-activity.mjs +2 -0
- package/src/integrations/spotify/service.mjs +873 -69
- package/src/integrations/spotify/web-api-client.mjs +407 -127
- package/src/integrations/web/brave-web.mjs +45 -0
- package/src/integrations/web/codex-web.mjs +15 -82
- package/src/integrations/web/deepseek-web.mjs +81 -0
- package/src/integrations/web/web-research.mjs +119 -0
- package/src/integrations/web/web-result.mjs +215 -0
- package/src/integrations/youtube-music/takeout.mjs +3 -1
- package/src/memory/local-memory-store.mjs +469 -41
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-profile-build.mjs +378 -0
- package/src/profile/listener-profile-store.mjs +130 -0
- package/src/profile/listener-profile-verification.mjs +68 -0
- package/src/profile/listener-profile-worker.mjs +172 -0
- package/src/profile/listening-history-store.mjs +424 -8
- package/src/profile/listening-profile-projection.mjs +242 -76
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +84 -0
- package/src/profile/recorded-measures.mjs +24 -0
- package/src/profile/spotify-archive-taste.mjs +0 -4
- package/src/runtime/pi/agent-runtime.mjs +2204 -605
- package/src/runtime/pi/authentication.mjs +34 -1
- package/src/runtime/pi/configured-runtime.mjs +22 -6
- package/src/runtime/pi/conversation-name.mjs +53 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
- package/src/runtime/pi/model-catalog.mjs +28 -5
- package/src/runtime/pi/model-settings.mjs +202 -0
- package/src/runtime/pi/model-transport.mjs +10 -0
- package/src/runtime/pi/persistent-credential-store.mjs +67 -31
- package/src/runtime/pi/profile-build-runtime.mjs +160 -0
- package/src/runtime/pi/public-model-catalog.mjs +288 -0
- package/src/runtime/pi/runtime-settings.mjs +10 -2
- package/src/runtime/pi/skills/listener-profile/SKILL.md +46 -0
- package/src/runtime/pi/tool-trace.mjs +37 -0
- package/src/surfaces/cli/auth-command.mjs +12 -4
- package/src/surfaces/cli/brand-components.mjs +310 -132
- package/src/surfaces/cli/brand-theme.mjs +51 -28
- package/src/surfaces/cli/command-completions.mjs +58 -42
- package/src/surfaces/cli/format-output.mjs +199 -388
- package/src/surfaces/cli/history-import-view.mjs +292 -139
- package/src/surfaces/cli/import-guides.mjs +2 -0
- package/src/surfaces/cli/listener-profile-command.mjs +92 -0
- package/src/surfaces/cli/listening-menu.mjs +7 -4
- package/src/surfaces/cli/profile-command.mjs +30 -21
- package/src/surfaces/cli/seasons-chart.mjs +87 -0
- package/src/surfaces/cli/spotify-command.mjs +54 -6
- package/src/surfaces/cli/taste-profile-model.mjs +78 -79
- package/src/surfaces/cli/taste-profile-view.mjs +33 -26
- package/src/surfaces/cli/terminal-art.mjs +140 -35
- package/src/surfaces/cli/tui.mjs +1066 -357
- package/src/surfaces/cli/update-command.mjs +32 -0
- package/src/surfaces/cli/web-command.mjs +53 -14
- package/src/surfaces/html/tasteprint.mjs +43 -70
- package/contracts/v1/capability-policy.schema.json +0 -572
- package/contracts/v1/profile-evidence.schema.json +0 -237
- package/contracts/v1/tool-invocation.schema.json +0 -318
- package/contracts/v1/tool-receipt.schema.json +0 -1066
- package/scripts/demo-studio.mjs +0 -58
- package/src/core/apple-data-migration.mjs +0 -50
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
- package/src/demo/moondog-tasteprint-demo.mjs +0 -631
- package/src/integrations/spotify/index.mjs +0 -80
- package/src/surfaces/web/showcase.mjs +0 -272
- package/src/surfaces/web/studio-page.mjs +0 -4148
- package/src/surfaces/web/studio.mjs +0 -2288
|
@@ -1,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
|
|
190
|
-
if (playlist.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
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
|
-
|
|
354
|
-
return client.
|
|
894
|
+
queue(options) {
|
|
895
|
+
return client.getQueue(options);
|
|
355
896
|
},
|
|
356
897
|
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
618
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
664
|
-
|
|
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) => ({
|