@audiofool/moondog 0.1.0

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 (109) hide show
  1. package/README.md +125 -0
  2. package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
  3. package/contracts/v1/capability-policy.schema.json +572 -0
  4. package/contracts/v1/definitions.schema.json +365 -0
  5. package/contracts/v1/library-track-observation.schema.json +138 -0
  6. package/contracts/v1/listening-event.schema.json +111 -0
  7. package/contracts/v1/profile-evidence.schema.json +237 -0
  8. package/contracts/v1/taste-event.schema.json +258 -0
  9. package/contracts/v1/tool-invocation.schema.json +318 -0
  10. package/contracts/v1/tool-receipt.schema.json +1066 -0
  11. package/contracts/v1/track-ref.schema.json +275 -0
  12. package/contracts/v2/profile-evidence.schema.json +284 -0
  13. package/package.json +105 -0
  14. package/scripts/contract-lib.mjs +361 -0
  15. package/scripts/contract-semantics.mjs +2630 -0
  16. package/scripts/demo-studio.mjs +58 -0
  17. package/scripts/moondog +52 -0
  18. package/scripts/moondog.mjs +954 -0
  19. package/src/core/apple-data-migration.mjs +50 -0
  20. package/src/core/apple-library-source-status.mjs +220 -0
  21. package/src/core/apple-projection-domain-services.mjs +2356 -0
  22. package/src/core/capability-catalog.mjs +577 -0
  23. package/src/core/listening-profile-domain-services.mjs +1010 -0
  24. package/src/core/lyric-library.mjs +121 -0
  25. package/src/core/lyric-profile.mjs +50 -0
  26. package/src/core/lyric-service.mjs +41 -0
  27. package/src/core/moondog-application.mjs +1827 -0
  28. package/src/core/state-directory.mjs +19 -0
  29. package/src/core/synthetic-domain-services.mjs +1485 -0
  30. package/src/core/uuid-v5.mjs +42 -0
  31. package/src/demo/fictional-spotify-history.mjs +400 -0
  32. package/src/demo/moondog-demo.mjs +355 -0
  33. package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
  34. package/src/demo/moondog-tasteprint-demo.mjs +631 -0
  35. package/src/importers/apple-music-library/import-batch.mjs +290 -0
  36. package/src/importers/apple-music-library/index.mjs +76 -0
  37. package/src/importers/apple-music-library/normalize.mjs +1126 -0
  38. package/src/importers/apple-music-library/parse-plist.mjs +357 -0
  39. package/src/importers/apple-music-library/projection-records.mjs +557 -0
  40. package/src/importers/apple-music-library/read-batch.mjs +276 -0
  41. package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
  42. package/src/importers/apple-music-library/stable-ids.mjs +19 -0
  43. package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
  44. package/src/integrations/apple-music/catalog.mjs +770 -0
  45. package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
  46. package/src/integrations/listenbrainz/history-file.mjs +560 -0
  47. package/src/integrations/lyrics/lrclib.mjs +55 -0
  48. package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
  49. package/src/integrations/public-playlists.mjs +104 -0
  50. package/src/integrations/spotify/account-data-history.mjs +545 -0
  51. package/src/integrations/spotify/account-data-profile.mjs +918 -0
  52. package/src/integrations/spotify/authentication.mjs +791 -0
  53. package/src/integrations/spotify/catalog-resolver.mjs +657 -0
  54. package/src/integrations/spotify/connection.mjs +125 -0
  55. package/src/integrations/spotify/credential-store.mjs +26 -0
  56. package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
  57. package/src/integrations/spotify/history-archive.mjs +59 -0
  58. package/src/integrations/spotify/index.mjs +80 -0
  59. package/src/integrations/spotify/recent-activity.mjs +198 -0
  60. package/src/integrations/spotify/resolution-cache.mjs +260 -0
  61. package/src/integrations/spotify/service.mjs +713 -0
  62. package/src/integrations/spotify/settings.mjs +89 -0
  63. package/src/integrations/spotify/web-api-client.mjs +761 -0
  64. package/src/integrations/web/codex-web.mjs +263 -0
  65. package/src/integrations/youtube-music/takeout.mjs +143 -0
  66. package/src/memory/local-memory-store.mjs +1705 -0
  67. package/src/memory/reflection-worker.mjs +87 -0
  68. package/src/profile/apple-library-import.mjs +28 -0
  69. package/src/profile/history-import.mjs +163 -0
  70. package/src/profile/listener-corrections.mjs +217 -0
  71. package/src/profile/listening-history-store.mjs +2055 -0
  72. package/src/profile/listening-profile-projection.mjs +2520 -0
  73. package/src/profile/local-music-data.mjs +812 -0
  74. package/src/profile/music-import-bundle.mjs +91 -0
  75. package/src/profile/music-providers.mjs +25 -0
  76. package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
  77. package/src/profile/spotify-archive-taste.mjs +482 -0
  78. package/src/runtime/pi/agent-runtime.mjs +6444 -0
  79. package/src/runtime/pi/authentication.mjs +104 -0
  80. package/src/runtime/pi/configured-runtime.mjs +98 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
  82. package/src/runtime/pi/model-catalog.mjs +62 -0
  83. package/src/runtime/pi/model-transport.mjs +117 -0
  84. package/src/runtime/pi/persistent-credential-store.mjs +670 -0
  85. package/src/runtime/pi/provider-registry.mjs +74 -0
  86. package/src/runtime/pi/runtime-settings.mjs +81 -0
  87. package/src/surfaces/cli/auth-command.mjs +355 -0
  88. package/src/surfaces/cli/brand-components.mjs +390 -0
  89. package/src/surfaces/cli/brand-theme.mjs +231 -0
  90. package/src/surfaces/cli/catalog-command.mjs +509 -0
  91. package/src/surfaces/cli/command-completions.mjs +113 -0
  92. package/src/surfaces/cli/data-command.mjs +214 -0
  93. package/src/surfaces/cli/format-output.mjs +750 -0
  94. package/src/surfaces/cli/history-import-view.mjs +445 -0
  95. package/src/surfaces/cli/import-guides.mjs +9 -0
  96. package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
  97. package/src/surfaces/cli/listening-menu.mjs +171 -0
  98. package/src/surfaces/cli/profile-command.mjs +290 -0
  99. package/src/surfaces/cli/spotify-command.mjs +849 -0
  100. package/src/surfaces/cli/taste-profile-model.mjs +306 -0
  101. package/src/surfaces/cli/taste-profile-view.mjs +272 -0
  102. package/src/surfaces/cli/terminal-art.mjs +295 -0
  103. package/src/surfaces/cli/tui.mjs +1714 -0
  104. package/src/surfaces/cli/web-command.mjs +52 -0
  105. package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
  106. package/src/surfaces/html/tasteprint.mjs +3575 -0
  107. package/src/surfaces/web/showcase.mjs +272 -0
  108. package/src/surfaces/web/studio-page.mjs +4148 -0
  109. package/src/surfaces/web/studio.mjs +2288 -0
@@ -0,0 +1,713 @@
1
+ import { createSpotifyWebApiClient } from "./web-api-client.mjs";
2
+
3
+ export const SPOTIFY_SERVICE_LIMITS = Object.freeze({
4
+ deviceIdLengthMax: 256,
5
+ playbackUrisMax: 100,
6
+ positionMsMax: 86_400_000,
7
+ playlistNameLengthMax: 100,
8
+ playlistDescriptionLengthMax: 200,
9
+ playlistTracksMax: 20,
10
+ editablePlaylistsPageMax: 50,
11
+ playlistEditTracksMax: 100,
12
+ playlistItemsPageMax: 50,
13
+ libraryTracksMax: 20,
14
+ recentlyPlayedDefault: 20,
15
+ recentlyPlayedMax: 50,
16
+ });
17
+
18
+ const repeatStates = new Set(["off", "track", "context"]);
19
+ const itemUriPattern = /^spotify:(?:episode|track):[A-Za-z0-9]{1,128}$/u;
20
+ const trackUriPattern = /^spotify:track:[A-Za-z0-9]{1,128}$/u;
21
+ const contextUriPattern = /^spotify:(?:album|artist|playlist):[A-Za-z0-9]{1,128}$/u;
22
+
23
+ export class SpotifyServiceError extends Error {
24
+ constructor(code, message) {
25
+ super(message);
26
+ this.name = "SpotifyServiceError";
27
+ this.code = code;
28
+ this.provider = "spotify";
29
+ }
30
+ }
31
+
32
+ function fail(code, message) {
33
+ throw new SpotifyServiceError(code, message);
34
+ }
35
+
36
+ function isPlainObject(value) {
37
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
38
+ const prototype = Object.getPrototypeOf(value);
39
+ return prototype === Object.prototype || prototype === null;
40
+ }
41
+
42
+ function inputObject(value) {
43
+ if (value === undefined) return {};
44
+ if (!isPlainObject(value)) fail("invalid_arguments", "Spotify action arguments are invalid.");
45
+ return value;
46
+ }
47
+
48
+ function optionalDeviceId(value) {
49
+ if (value === undefined) return undefined;
50
+ if (
51
+ typeof value !== "string" ||
52
+ !value.trim() ||
53
+ Array.from(value).length > SPOTIFY_SERVICE_LIMITS.deviceIdLengthMax
54
+ ) {
55
+ fail("invalid_device_id", "The Spotify device identifier is invalid.");
56
+ }
57
+ return value.trim();
58
+ }
59
+
60
+ function requiredItemUri(value) {
61
+ if (typeof value !== "string" || !itemUriPattern.test(value)) {
62
+ fail("invalid_spotify_uri", "A Spotify track or episode URI is required.");
63
+ }
64
+ return value;
65
+ }
66
+
67
+ function optionalContextUri(value) {
68
+ if (value === undefined) return undefined;
69
+ if (typeof value !== "string" || !contextUriPattern.test(value)) {
70
+ fail("invalid_spotify_context", "The Spotify playback context is invalid.");
71
+ }
72
+ return value;
73
+ }
74
+
75
+ function optionalUris(value) {
76
+ if (value === undefined) return undefined;
77
+ if (
78
+ !Array.isArray(value) ||
79
+ value.length < 1 ||
80
+ value.length > SPOTIFY_SERVICE_LIMITS.playbackUrisMax
81
+ ) {
82
+ fail("invalid_spotify_uris", "The Spotify playback URI list is invalid.");
83
+ }
84
+ return value.map(requiredItemUri);
85
+ }
86
+
87
+ function requiredTrackUris(value, code, maximum) {
88
+ if (
89
+ !Array.isArray(value) ||
90
+ value.length < 1 ||
91
+ value.length > maximum ||
92
+ new Set(value).size !== value.length
93
+ ) {
94
+ fail(code, "The Spotify track URI list is invalid.");
95
+ }
96
+ for (const uri of value) {
97
+ if (typeof uri !== "string" || !trackUriPattern.test(uri)) {
98
+ fail(code, "The Spotify track URI list is invalid.");
99
+ }
100
+ }
101
+ return value;
102
+ }
103
+
104
+ function requiredPlaylistEditTrackUris(value) {
105
+ if (
106
+ !Array.isArray(value) ||
107
+ value.length < 1 ||
108
+ value.length > SPOTIFY_SERVICE_LIMITS.playlistEditTracksMax
109
+ ) {
110
+ fail("invalid_playlist_tracks", "The Spotify track URI list is invalid.");
111
+ }
112
+ for (const uri of value) {
113
+ if (typeof uri !== "string" || !trackUriPattern.test(uri)) {
114
+ fail("invalid_playlist_tracks", "The Spotify track URI list is invalid.");
115
+ }
116
+ }
117
+ return value;
118
+ }
119
+
120
+ function requiredPlaylistId(value) {
121
+ if (
122
+ typeof value !== "string" ||
123
+ !/^[A-Za-z0-9_-]{1,128}$/u.test(value)
124
+ ) {
125
+ fail("invalid_playlist_id", "The Spotify playlist identifier is invalid.");
126
+ }
127
+ return value;
128
+ }
129
+
130
+ function requiredSnapshotId(value) {
131
+ if (
132
+ typeof value !== "string" ||
133
+ value.length < 1 ||
134
+ value.length > 128 ||
135
+ /[\u0000-\u001f\u007f]/u.test(value)
136
+ ) {
137
+ fail("invalid_playlist_snapshot", "The Spotify playlist snapshot is invalid.");
138
+ }
139
+ return value;
140
+ }
141
+
142
+ function playlistPageLimit(value) {
143
+ if (value === undefined) return 20;
144
+ if (
145
+ !Number.isInteger(value) ||
146
+ value < 1 ||
147
+ value > SPOTIFY_SERVICE_LIMITS.editablePlaylistsPageMax
148
+ ) {
149
+ fail(
150
+ "invalid_playlist_page",
151
+ `Spotify playlist page size must be an integer from 1 to ${SPOTIFY_SERVICE_LIMITS.editablePlaylistsPageMax}.`,
152
+ );
153
+ }
154
+ return value;
155
+ }
156
+
157
+ function playlistPageOffset(value) {
158
+ if (value === undefined) return 0;
159
+ if (!Number.isInteger(value) || value < 0 || value > 1_000_000) {
160
+ fail("invalid_playlist_page", "The Spotify playlist page offset is invalid.");
161
+ }
162
+ return value;
163
+ }
164
+
165
+ function accountId(account) {
166
+ if (typeof account?.account_id !== "string" || !account.account_id) {
167
+ fail(
168
+ "spotify_account_identity_unavailable",
169
+ "Spotify did not return the connected account identity.",
170
+ );
171
+ }
172
+ return account.account_id;
173
+ }
174
+
175
+ function editablePlaylistReason(playlist, ownerId) {
176
+ if (
177
+ !isPlainObject(playlist) ||
178
+ typeof playlist.id !== "string" ||
179
+ !playlist.id ||
180
+ typeof playlist.name !== "string" ||
181
+ !playlist.name ||
182
+ typeof playlist.owner_id !== "string" ||
183
+ !Number.isInteger(playlist.tracks_total) ||
184
+ playlist.tracks_total < 0
185
+ ) {
186
+ return "invalid";
187
+ }
188
+ if (playlist.owner_id !== ownerId) return "not_owned";
189
+ if (playlist.is_public === true) return "public";
190
+ if (playlist.collaborative === true) return "collaborative";
191
+ if (playlist.tracks_total > SPOTIFY_SERVICE_LIMITS.playlistEditTracksMax) {
192
+ return "over_track_limit";
193
+ }
194
+ return null;
195
+ }
196
+
197
+ function requireEditablePlaylist(playlist, ownerId) {
198
+ const reason = editablePlaylistReason(playlist, ownerId);
199
+ if (reason) {
200
+ fail(
201
+ "playlist_not_editable",
202
+ "Moondog edits only connected-account-owned private, non-collaborative Spotify playlists with at most 100 tracks.",
203
+ );
204
+ }
205
+ requiredPlaylistId(playlist.id);
206
+ requiredSnapshotId(playlist.snapshot_id);
207
+ return playlist;
208
+ }
209
+
210
+ function normalizedSnapshotTrack(entry) {
211
+ const item = entry?.item;
212
+ if (
213
+ entry?.is_local === true ||
214
+ !isPlainObject(item) ||
215
+ item.type !== "track" ||
216
+ item.is_playable === false ||
217
+ typeof item.uri !== "string" ||
218
+ !trackUriPattern.test(item.uri) ||
219
+ typeof item.name !== "string" ||
220
+ !item.name ||
221
+ !Array.isArray(item.artists) ||
222
+ item.artists.length < 1 ||
223
+ item.artists.some((artist) => typeof artist !== "string" || !artist)
224
+ ) {
225
+ fail(
226
+ "playlist_items_unsupported",
227
+ "This Spotify playlist contains local, unavailable, or non-track items that Moondog cannot safely rewrite.",
228
+ );
229
+ }
230
+ return {
231
+ uri: item.uri,
232
+ title: item.name,
233
+ artists: [...item.artists],
234
+ ...(typeof item.album === "string" && item.album
235
+ ? { album: item.album }
236
+ : {}),
237
+ ...(Number.isInteger(item.duration_ms) && item.duration_ms >= 0
238
+ ? { duration_ms: item.duration_ms }
239
+ : {}),
240
+ };
241
+ }
242
+
243
+ function cleanPlaylistText(value, maximum, code, label) {
244
+ if (typeof value !== "string") {
245
+ fail(code, `The Spotify playlist ${label} is invalid.`);
246
+ }
247
+ const cleaned = value
248
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "")
249
+ .replace(/[\u202a-\u202e\u2066-\u2069]/gu, "")
250
+ .replace(/\s+/gu, " ")
251
+ .trim();
252
+ if (!cleaned || Array.from(cleaned).length > maximum) {
253
+ fail(code, `The Spotify playlist ${label} is invalid.`);
254
+ }
255
+ return cleaned;
256
+ }
257
+
258
+ function optionalPosition(value, code) {
259
+ if (value === undefined) return undefined;
260
+ if (
261
+ !Number.isInteger(value) ||
262
+ value < 0 ||
263
+ value > SPOTIFY_SERVICE_LIMITS.positionMsMax
264
+ ) {
265
+ fail(code, "The Spotify playback position is invalid.");
266
+ }
267
+ return value;
268
+ }
269
+
270
+ function optionalOffsetPosition(value) {
271
+ if (value === undefined) return undefined;
272
+ if (!Number.isInteger(value) || value < 0 || value > 100_000) {
273
+ fail("invalid_offset", "The Spotify playback offset is invalid.");
274
+ }
275
+ return value;
276
+ }
277
+
278
+ function recentCursor(value, label) {
279
+ if (value === undefined) return undefined;
280
+ if (!Number.isSafeInteger(value) || value < 0) {
281
+ fail("invalid_recent_cursor", `The Spotify ${label} cursor is invalid.`);
282
+ }
283
+ return value;
284
+ }
285
+
286
+ function recentLimit(value) {
287
+ if (value === undefined) return SPOTIFY_SERVICE_LIMITS.recentlyPlayedDefault;
288
+ if (
289
+ !Number.isInteger(value) ||
290
+ value < 1 ||
291
+ value > SPOTIFY_SERVICE_LIMITS.recentlyPlayedMax
292
+ ) {
293
+ fail(
294
+ "invalid_recent_limit",
295
+ `Spotify recent activity limit must be an integer from 1 to ${SPOTIFY_SERVICE_LIMITS.recentlyPlayedMax}.`,
296
+ );
297
+ }
298
+ return value;
299
+ }
300
+
301
+ function actionReceipt(action) {
302
+ return {
303
+ provider: "spotify",
304
+ ok: true,
305
+ effect: "write_external",
306
+ action,
307
+ state: "accepted",
308
+ };
309
+ }
310
+
311
+ async function queueDeviceId(client, requestedDeviceId) {
312
+ const explicit = optionalDeviceId(requestedDeviceId);
313
+ if (explicit) return explicit;
314
+
315
+ const result = await client.getDevices();
316
+ const devices = Array.isArray(result?.devices) ? result.devices : [];
317
+ const controllable = devices.filter(
318
+ (device) =>
319
+ typeof device?.id === "string" &&
320
+ device.id.length > 0 &&
321
+ device.is_restricted !== true,
322
+ );
323
+ const active = controllable.find((device) => device.is_active === true);
324
+ if (active) return active.id;
325
+ if (controllable.length === 1) return controllable[0].id;
326
+ if (devices.length > 0 && controllable.length === 0) {
327
+ fail(
328
+ "spotify_device_restricted",
329
+ "Spotify's available devices do not accept Web API controls. Open Spotify on another device and try again.",
330
+ );
331
+ }
332
+ fail(
333
+ "spotify_active_device_required",
334
+ "No active Spotify device is available. Open Spotify on one device and start playback, then try again.",
335
+ );
336
+ }
337
+
338
+ export function createSpotifyService(options = {}) {
339
+ const client = options.client ?? createSpotifyWebApiClient(options);
340
+ if (!client || typeof client !== "object") {
341
+ throw new TypeError("A Spotify Web API client is required.");
342
+ }
343
+
344
+ return Object.freeze({
345
+ account() {
346
+ return client.getAccount();
347
+ },
348
+
349
+ currentPlayer() {
350
+ return client.getCurrentPlayback();
351
+ },
352
+
353
+ devices() {
354
+ return client.getDevices();
355
+ },
356
+
357
+ queue() {
358
+ return client.getQueue();
359
+ },
360
+
361
+ async recentActivity(value) {
362
+ const input = inputObject(value);
363
+ const after = recentCursor(input.after, "after");
364
+ const before = recentCursor(input.before, "before");
365
+ if (after !== undefined && before !== undefined) {
366
+ fail(
367
+ "conflicting_recent_cursors",
368
+ "Choose either a Spotify after cursor or a before cursor.",
369
+ );
370
+ }
371
+ return client.getRecentlyPlayed({
372
+ limit: recentLimit(input.limit),
373
+ ...(after !== undefined ? { after } : {}),
374
+ ...(before !== undefined ? { before } : {}),
375
+ });
376
+ },
377
+
378
+ async editablePlaylists(value) {
379
+ const input = inputObject(value);
380
+ const limit = playlistPageLimit(input.limit);
381
+ const offset = playlistPageOffset(input.offset);
382
+ const account = await client.getAccount();
383
+ const ownerId = accountId(account);
384
+ const page = await client.getCurrentUserPlaylists({ limit, offset });
385
+ const excluded = {
386
+ public: 0,
387
+ not_owned: 0,
388
+ collaborative: 0,
389
+ over_track_limit: 0,
390
+ invalid: 0,
391
+ };
392
+ const playlists = [];
393
+ for (const playlist of Array.isArray(page?.items) ? page.items : []) {
394
+ const reason = editablePlaylistReason(playlist, ownerId);
395
+ if (reason) {
396
+ excluded[reason] += 1;
397
+ continue;
398
+ }
399
+ playlists.push({
400
+ playlist_id: playlist.id,
401
+ name: playlist.name,
402
+ track_count: playlist.tracks_total,
403
+ });
404
+ }
405
+ const pageOffset = Number.isInteger(page?.offset) ? page.offset : offset;
406
+ const pageLimit = Number.isInteger(page?.limit) ? page.limit : limit;
407
+ const hasMore = page?.has_more === true;
408
+ return {
409
+ provider: "spotify",
410
+ playlists,
411
+ excluded,
412
+ has_more: hasMore,
413
+ next_offset: hasMore ? pageOffset + pageLimit : null,
414
+ };
415
+ },
416
+
417
+ async playlistSnapshot(value) {
418
+ const input = inputObject(value);
419
+ const playlistId = requiredPlaylistId(input.playlistId);
420
+ const account = await client.getAccount();
421
+ const ownerId = accountId(account);
422
+ const before = requireEditablePlaylist(
423
+ await client.getPlaylist({ playlistId }),
424
+ ownerId,
425
+ );
426
+ const tracks = [];
427
+ let offset = 0;
428
+ while (offset < before.tracks_total) {
429
+ const page = await client.getPlaylistItems({
430
+ playlistId,
431
+ limit: Math.min(
432
+ SPOTIFY_SERVICE_LIMITS.playlistItemsPageMax,
433
+ before.tracks_total - offset,
434
+ ),
435
+ offset,
436
+ });
437
+ if (
438
+ !isPlainObject(page) ||
439
+ page.total !== before.tracks_total ||
440
+ page.offset !== offset ||
441
+ !Array.isArray(page.items) ||
442
+ page.items.length < 1
443
+ ) {
444
+ fail(
445
+ "playlist_snapshot_changed",
446
+ "The Spotify playlist changed while Moondog was reading it. Inspect it again before editing.",
447
+ );
448
+ }
449
+ for (const entry of page.items) {
450
+ tracks.push(normalizedSnapshotTrack(entry));
451
+ }
452
+ offset += page.items.length;
453
+ if (tracks.length > before.tracks_total) {
454
+ fail(
455
+ "playlist_snapshot_changed",
456
+ "The Spotify playlist changed while Moondog was reading it. Inspect it again before editing.",
457
+ );
458
+ }
459
+ }
460
+ const after = requireEditablePlaylist(
461
+ await client.getPlaylist({ playlistId }),
462
+ ownerId,
463
+ );
464
+ if (
465
+ after.snapshot_id !== before.snapshot_id ||
466
+ after.tracks_total !== before.tracks_total ||
467
+ tracks.length !== before.tracks_total
468
+ ) {
469
+ fail(
470
+ "playlist_snapshot_changed",
471
+ "The Spotify playlist changed while Moondog was reading it. Inspect it again before editing.",
472
+ );
473
+ }
474
+ return {
475
+ provider: "spotify",
476
+ playlist: {
477
+ playlist_id: before.id,
478
+ name: before.name,
479
+ track_count: before.tracks_total,
480
+ snapshot_id: before.snapshot_id,
481
+ },
482
+ items: tracks,
483
+ };
484
+ },
485
+
486
+ async replacePlaylistItems(value) {
487
+ const input = inputObject(value);
488
+ const playlistId = requiredPlaylistId(input.playlistId);
489
+ const expectedSnapshotId = requiredSnapshotId(input.expectedSnapshotId);
490
+ const uris = requiredPlaylistEditTrackUris(input.uris);
491
+ const account = await client.getAccount();
492
+ const ownerId = accountId(account);
493
+ const playlist = requireEditablePlaylist(
494
+ await client.getPlaylist({ playlistId }),
495
+ ownerId,
496
+ );
497
+ if (playlist.snapshot_id !== expectedSnapshotId) {
498
+ fail(
499
+ "playlist_snapshot_changed",
500
+ "The Spotify playlist changed after preview. Inspect it again before editing.",
501
+ );
502
+ }
503
+ await client.replacePlaylistItems({ playlistId, uris });
504
+ return {
505
+ ...actionReceipt("playlist.edit"),
506
+ playlist: {
507
+ name: playlist.name,
508
+ track_count: uris.length,
509
+ is_public: false,
510
+ },
511
+ previous_track_count: playlist.tracks_total,
512
+ };
513
+ },
514
+
515
+ async resume(value) {
516
+ const input = inputObject(value);
517
+ const contextUri = optionalContextUri(input.contextUri);
518
+ const uris = optionalUris(input.uris);
519
+ const offsetPosition = optionalOffsetPosition(input.offsetPosition);
520
+ const offsetUri =
521
+ input.offsetUri === undefined ? undefined : requiredItemUri(input.offsetUri);
522
+ if (contextUri && uris) {
523
+ fail(
524
+ "conflicting_playback_source",
525
+ "Choose either a Spotify context or explicit item URIs.",
526
+ );
527
+ }
528
+ if (offsetPosition !== undefined && offsetUri !== undefined) {
529
+ fail("conflicting_offset", "Choose one Spotify playback offset.");
530
+ }
531
+ await client.resume({
532
+ deviceId: optionalDeviceId(input.deviceId),
533
+ contextUri,
534
+ uris,
535
+ offsetPosition,
536
+ offsetUri,
537
+ positionMs: optionalPosition(input.positionMs, "invalid_position"),
538
+ });
539
+ return actionReceipt("playback.resume");
540
+ },
541
+
542
+ async pause(value) {
543
+ const input = inputObject(value);
544
+ await client.pause({ deviceId: optionalDeviceId(input.deviceId) });
545
+ return actionReceipt("playback.pause");
546
+ },
547
+
548
+ async next(value) {
549
+ const input = inputObject(value);
550
+ await client.next({ deviceId: optionalDeviceId(input.deviceId) });
551
+ return actionReceipt("playback.next");
552
+ },
553
+
554
+ async previous(value) {
555
+ const input = inputObject(value);
556
+ await client.previous({ deviceId: optionalDeviceId(input.deviceId) });
557
+ return actionReceipt("playback.previous");
558
+ },
559
+
560
+ async setVolume(value) {
561
+ const input = inputObject(value);
562
+ if (!Number.isInteger(input.percent) || input.percent < 0 || input.percent > 100) {
563
+ fail("invalid_volume", "Spotify volume must be an integer from 0 to 100.");
564
+ }
565
+ await client.setVolume({
566
+ percent: input.percent,
567
+ deviceId: optionalDeviceId(input.deviceId),
568
+ });
569
+ return actionReceipt("playback.volume.set");
570
+ },
571
+
572
+ async seek(value) {
573
+ const input = inputObject(value);
574
+ if (input.positionMs === undefined) {
575
+ fail("invalid_position", "The Spotify playback position is invalid.");
576
+ }
577
+ await client.seek({
578
+ positionMs: optionalPosition(input.positionMs, "invalid_position"),
579
+ deviceId: optionalDeviceId(input.deviceId),
580
+ });
581
+ return actionReceipt("playback.seek");
582
+ },
583
+
584
+ async setShuffle(value) {
585
+ const input = inputObject(value);
586
+ if (typeof input.state !== "boolean") {
587
+ fail("invalid_shuffle_state", "Spotify shuffle state must be a boolean.");
588
+ }
589
+ await client.setShuffle({
590
+ state: input.state,
591
+ deviceId: optionalDeviceId(input.deviceId),
592
+ });
593
+ return actionReceipt("playback.shuffle.set");
594
+ },
595
+
596
+ async setRepeat(value) {
597
+ const input = inputObject(value);
598
+ if (!repeatStates.has(input.state)) {
599
+ fail("invalid_repeat_state", "Spotify repeat state is invalid.");
600
+ }
601
+ await client.setRepeat({
602
+ state: input.state,
603
+ deviceId: optionalDeviceId(input.deviceId),
604
+ });
605
+ return actionReceipt("playback.repeat.set");
606
+ },
607
+
608
+ async transfer(value) {
609
+ 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
+ if (input.play !== undefined && typeof input.play !== "boolean") {
615
+ fail("invalid_play_state", "Spotify transfer play state must be a boolean.");
616
+ }
617
+ await client.transfer({ deviceId, play: input.play ?? false });
618
+ return actionReceipt("playback.transfer");
619
+ },
620
+
621
+ async addToQueue(value) {
622
+ const input = inputObject(value);
623
+ await client.addToQueue({
624
+ uri: requiredItemUri(input.uri),
625
+ deviceId: await queueDeviceId(client, input.deviceId),
626
+ });
627
+ return actionReceipt("playback.queue.add");
628
+ },
629
+
630
+ async createPlaylistWithTracks(value) {
631
+ const input = inputObject(value);
632
+ const name = cleanPlaylistText(
633
+ input.name,
634
+ SPOTIFY_SERVICE_LIMITS.playlistNameLengthMax,
635
+ "invalid_playlist_name",
636
+ "name",
637
+ );
638
+ const description =
639
+ input.description === undefined
640
+ ? undefined
641
+ : cleanPlaylistText(
642
+ input.description,
643
+ SPOTIFY_SERVICE_LIMITS.playlistDescriptionLengthMax,
644
+ "invalid_playlist_description",
645
+ "description",
646
+ );
647
+ const uris = requiredTrackUris(
648
+ input.uris,
649
+ "invalid_playlist_tracks",
650
+ SPOTIFY_SERVICE_LIMITS.playlistTracksMax,
651
+ );
652
+ const playlist = await client.createPlaylist({
653
+ name,
654
+ ...(description ? { description } : {}),
655
+ });
656
+ if (!playlist?.id) {
657
+ fail(
658
+ "playlist_creation_failed",
659
+ "Spotify did not return the created playlist.",
660
+ );
661
+ }
662
+ try {
663
+ await client.addPlaylistTracks({ playlistId: playlist.id, uris });
664
+ } catch {
665
+ fail(
666
+ "playlist_created_without_tracks",
667
+ "Spotify created the private playlist but did not accept its tracks. Check the empty playlist before trying again.",
668
+ );
669
+ }
670
+ return {
671
+ ...actionReceipt("playlist.write"),
672
+ playlist: {
673
+ name: playlist.name ?? name,
674
+ track_count: uris.length,
675
+ is_public: false,
676
+ },
677
+ ...(playlist.uri ? { playlist_uri: playlist.uri } : {}),
678
+ ...(playlist.id ? { playlist_id: playlist.id } : {}),
679
+ };
680
+ },
681
+
682
+ async saveTracks(value) {
683
+ const input = inputObject(value);
684
+ const uris = requiredTrackUris(
685
+ input.uris,
686
+ "invalid_library_tracks",
687
+ SPOTIFY_SERVICE_LIMITS.libraryTracksMax,
688
+ );
689
+ await client.saveTracks({ uris });
690
+ return {
691
+ ...actionReceipt("library.save"),
692
+ track_count: uris.length,
693
+ };
694
+ },
695
+
696
+ async checkSavedTracks(value) {
697
+ const input = inputObject(value);
698
+ const uris = requiredTrackUris(
699
+ input.uris,
700
+ "invalid_library_tracks",
701
+ SPOTIFY_SERVICE_LIMITS.libraryTracksMax,
702
+ );
703
+ const saved = await client.checkSavedTracks({ uris });
704
+ return {
705
+ provider: "spotify",
706
+ checked: uris.map((uri, index) => ({
707
+ uri,
708
+ saved: saved[index] === true,
709
+ })),
710
+ };
711
+ },
712
+ });
713
+ }