@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.
- package/README.md +125 -0
- package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
- package/contracts/v1/capability-policy.schema.json +572 -0
- package/contracts/v1/definitions.schema.json +365 -0
- package/contracts/v1/library-track-observation.schema.json +138 -0
- package/contracts/v1/listening-event.schema.json +111 -0
- package/contracts/v1/profile-evidence.schema.json +237 -0
- package/contracts/v1/taste-event.schema.json +258 -0
- package/contracts/v1/tool-invocation.schema.json +318 -0
- package/contracts/v1/tool-receipt.schema.json +1066 -0
- package/contracts/v1/track-ref.schema.json +275 -0
- package/contracts/v2/profile-evidence.schema.json +284 -0
- package/package.json +105 -0
- package/scripts/contract-lib.mjs +361 -0
- package/scripts/contract-semantics.mjs +2630 -0
- package/scripts/demo-studio.mjs +58 -0
- package/scripts/moondog +52 -0
- package/scripts/moondog.mjs +954 -0
- package/src/core/apple-data-migration.mjs +50 -0
- package/src/core/apple-library-source-status.mjs +220 -0
- package/src/core/apple-projection-domain-services.mjs +2356 -0
- package/src/core/capability-catalog.mjs +577 -0
- package/src/core/listening-profile-domain-services.mjs +1010 -0
- package/src/core/lyric-library.mjs +121 -0
- package/src/core/lyric-profile.mjs +50 -0
- package/src/core/lyric-service.mjs +41 -0
- package/src/core/moondog-application.mjs +1827 -0
- package/src/core/state-directory.mjs +19 -0
- package/src/core/synthetic-domain-services.mjs +1485 -0
- package/src/core/uuid-v5.mjs +42 -0
- package/src/demo/fictional-spotify-history.mjs +400 -0
- package/src/demo/moondog-demo.mjs +355 -0
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
- package/src/demo/moondog-tasteprint-demo.mjs +631 -0
- package/src/importers/apple-music-library/import-batch.mjs +290 -0
- package/src/importers/apple-music-library/index.mjs +76 -0
- package/src/importers/apple-music-library/normalize.mjs +1126 -0
- package/src/importers/apple-music-library/parse-plist.mjs +357 -0
- package/src/importers/apple-music-library/projection-records.mjs +557 -0
- package/src/importers/apple-music-library/read-batch.mjs +276 -0
- package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
- package/src/importers/apple-music-library/stable-ids.mjs +19 -0
- package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
- package/src/integrations/apple-music/catalog.mjs +770 -0
- package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
- package/src/integrations/listenbrainz/history-file.mjs +560 -0
- package/src/integrations/lyrics/lrclib.mjs +55 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
- package/src/integrations/public-playlists.mjs +104 -0
- package/src/integrations/spotify/account-data-history.mjs +545 -0
- package/src/integrations/spotify/account-data-profile.mjs +918 -0
- package/src/integrations/spotify/authentication.mjs +791 -0
- package/src/integrations/spotify/catalog-resolver.mjs +657 -0
- package/src/integrations/spotify/connection.mjs +125 -0
- package/src/integrations/spotify/credential-store.mjs +26 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
- package/src/integrations/spotify/history-archive.mjs +59 -0
- package/src/integrations/spotify/index.mjs +80 -0
- package/src/integrations/spotify/recent-activity.mjs +198 -0
- package/src/integrations/spotify/resolution-cache.mjs +260 -0
- package/src/integrations/spotify/service.mjs +713 -0
- package/src/integrations/spotify/settings.mjs +89 -0
- package/src/integrations/spotify/web-api-client.mjs +761 -0
- package/src/integrations/web/codex-web.mjs +263 -0
- package/src/integrations/youtube-music/takeout.mjs +143 -0
- package/src/memory/local-memory-store.mjs +1705 -0
- package/src/memory/reflection-worker.mjs +87 -0
- package/src/profile/apple-library-import.mjs +28 -0
- package/src/profile/history-import.mjs +163 -0
- package/src/profile/listener-corrections.mjs +217 -0
- package/src/profile/listening-history-store.mjs +2055 -0
- package/src/profile/listening-profile-projection.mjs +2520 -0
- package/src/profile/local-music-data.mjs +812 -0
- package/src/profile/music-import-bundle.mjs +91 -0
- package/src/profile/music-providers.mjs +25 -0
- package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
- package/src/profile/spotify-archive-taste.mjs +482 -0
- package/src/runtime/pi/agent-runtime.mjs +6444 -0
- package/src/runtime/pi/authentication.mjs +104 -0
- package/src/runtime/pi/configured-runtime.mjs +98 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
- package/src/runtime/pi/model-catalog.mjs +62 -0
- package/src/runtime/pi/model-transport.mjs +117 -0
- package/src/runtime/pi/persistent-credential-store.mjs +670 -0
- package/src/runtime/pi/provider-registry.mjs +74 -0
- package/src/runtime/pi/runtime-settings.mjs +81 -0
- package/src/surfaces/cli/auth-command.mjs +355 -0
- package/src/surfaces/cli/brand-components.mjs +390 -0
- package/src/surfaces/cli/brand-theme.mjs +231 -0
- package/src/surfaces/cli/catalog-command.mjs +509 -0
- package/src/surfaces/cli/command-completions.mjs +113 -0
- package/src/surfaces/cli/data-command.mjs +214 -0
- package/src/surfaces/cli/format-output.mjs +750 -0
- package/src/surfaces/cli/history-import-view.mjs +445 -0
- package/src/surfaces/cli/import-guides.mjs +9 -0
- package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
- package/src/surfaces/cli/listening-menu.mjs +171 -0
- package/src/surfaces/cli/profile-command.mjs +290 -0
- package/src/surfaces/cli/spotify-command.mjs +849 -0
- package/src/surfaces/cli/taste-profile-model.mjs +306 -0
- package/src/surfaces/cli/taste-profile-view.mjs +272 -0
- package/src/surfaces/cli/terminal-art.mjs +295 -0
- package/src/surfaces/cli/tui.mjs +1714 -0
- package/src/surfaces/cli/web-command.mjs +52 -0
- package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
- package/src/surfaces/html/tasteprint.mjs +3575 -0
- package/src/surfaces/web/showcase.mjs +272 -0
- package/src/surfaces/web/studio-page.mjs +4148 -0
- package/src/surfaces/web/studio.mjs +2288 -0
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
export const SPOTIFY_WEB_API_BASE_URL = "https://api.spotify.com/v1";
|
|
2
|
+
|
|
3
|
+
export const SPOTIFY_WEB_API_LIMITS = Object.freeze({
|
|
4
|
+
responseBytesMax: 2_000_000,
|
|
5
|
+
textLengthMax: 256,
|
|
6
|
+
devicesMax: 20,
|
|
7
|
+
queueItemsMax: 50,
|
|
8
|
+
artistsPerItemMax: 5,
|
|
9
|
+
searchResultsMax: 10,
|
|
10
|
+
playlistTracksMax: 20,
|
|
11
|
+
playlistsPageMax: 50,
|
|
12
|
+
playlistItemsPageMax: 50,
|
|
13
|
+
playlistEditTracksMax: 100,
|
|
14
|
+
libraryItemsMax: 20,
|
|
15
|
+
recentlyPlayedMax: 50,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const playbackActions = new Set([
|
|
19
|
+
"interrupting_playback",
|
|
20
|
+
"pausing",
|
|
21
|
+
"resuming",
|
|
22
|
+
"seeking",
|
|
23
|
+
"skipping_next",
|
|
24
|
+
"skipping_prev",
|
|
25
|
+
"toggling_repeat_context",
|
|
26
|
+
"toggling_repeat_track",
|
|
27
|
+
"toggling_shuffle",
|
|
28
|
+
"transferring_playback",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export class SpotifyWebApiError extends Error {
|
|
32
|
+
constructor(code, message, { status = null, retryAfterSeconds = null } = {}) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "SpotifyWebApiError";
|
|
35
|
+
this.code = code;
|
|
36
|
+
this.provider = "spotify";
|
|
37
|
+
this.status = status;
|
|
38
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fail(code, message, options) {
|
|
43
|
+
throw new SpotifyWebApiError(code, message, options);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isPlainObject(value) {
|
|
47
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
48
|
+
const prototype = Object.getPrototypeOf(value);
|
|
49
|
+
return prototype === Object.prototype || prototype === null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function safeText(value, maximum = SPOTIFY_WEB_API_LIMITS.textLengthMax) {
|
|
53
|
+
if (typeof value !== "string") return undefined;
|
|
54
|
+
const cleaned = value
|
|
55
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "")
|
|
56
|
+
.replace(/[\u202a-\u202e\u2066-\u2069]/gu, "")
|
|
57
|
+
.replace(/\s+/gu, " ")
|
|
58
|
+
.trim();
|
|
59
|
+
if (!cleaned) return undefined;
|
|
60
|
+
return Array.from(cleaned).slice(0, maximum).join("");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function safeInteger(value, minimum, maximum) {
|
|
64
|
+
if (!Number.isInteger(value) || value < minimum || value > maximum) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizedToken(value) {
|
|
71
|
+
const token =
|
|
72
|
+
typeof value === "string"
|
|
73
|
+
? value
|
|
74
|
+
: isPlainObject(value)
|
|
75
|
+
? (value.accessToken ?? value.access_token ?? value.access)
|
|
76
|
+
: undefined;
|
|
77
|
+
if (typeof token !== "string" || !token.trim()) {
|
|
78
|
+
fail(
|
|
79
|
+
"spotify_access_token_unavailable",
|
|
80
|
+
"Spotify authentication is not available.",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return token.trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function retryAfterSeconds(headers) {
|
|
87
|
+
const raw = headers.get("retry-after");
|
|
88
|
+
if (!raw) return null;
|
|
89
|
+
const numeric = Number(raw);
|
|
90
|
+
if (Number.isFinite(numeric) && numeric >= 0) {
|
|
91
|
+
return Math.min(Math.ceil(numeric), 86_400);
|
|
92
|
+
}
|
|
93
|
+
const date = Date.parse(raw);
|
|
94
|
+
if (!Number.isFinite(date)) return null;
|
|
95
|
+
return Math.min(Math.max(0, Math.ceil((date - Date.now()) / 1_000)), 86_400);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function quotaReason(payload) {
|
|
99
|
+
if (!isPlainObject(payload)) return undefined;
|
|
100
|
+
return safeText(payload.reason ?? payload.error?.reason, 64);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function hasInsufficientClientScope(payload) {
|
|
104
|
+
return (
|
|
105
|
+
isPlainObject(payload) &&
|
|
106
|
+
safeText(payload.error?.message, 64) === "Insufficient client scope"
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeArtistNames(rawArtists) {
|
|
111
|
+
if (!Array.isArray(rawArtists)) return [];
|
|
112
|
+
const result = [];
|
|
113
|
+
for (const artist of rawArtists) {
|
|
114
|
+
const name = safeText(artist?.name);
|
|
115
|
+
if (name && !result.includes(name)) result.push(name);
|
|
116
|
+
if (result.length === SPOTIFY_WEB_API_LIMITS.artistsPerItemMax) break;
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizePlayableItem(raw) {
|
|
122
|
+
if (!isPlainObject(raw)) return null;
|
|
123
|
+
const type = ["track", "episode", "ad", "unknown"].includes(raw.type)
|
|
124
|
+
? raw.type
|
|
125
|
+
: "unknown";
|
|
126
|
+
const item = { type };
|
|
127
|
+
const uri = safeText(raw.uri);
|
|
128
|
+
const name = safeText(raw.name);
|
|
129
|
+
const artists = normalizeArtistNames(raw.artists);
|
|
130
|
+
const album = safeText(raw.album?.name);
|
|
131
|
+
const publisher = safeText(raw.show?.publisher);
|
|
132
|
+
const durationMs = safeInteger(raw.duration_ms, 0, 86_400_000);
|
|
133
|
+
|
|
134
|
+
if (uri) item.uri = uri;
|
|
135
|
+
if (name) item.name = name;
|
|
136
|
+
if (artists.length > 0) item.artists = artists;
|
|
137
|
+
if (album) item.album = album;
|
|
138
|
+
if (publisher) item.publisher = publisher;
|
|
139
|
+
if (durationMs !== undefined) item.duration_ms = durationMs;
|
|
140
|
+
if (typeof raw.explicit === "boolean") item.explicit = raw.explicit;
|
|
141
|
+
return item;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function normalizeDevice(raw) {
|
|
145
|
+
if (!isPlainObject(raw)) return null;
|
|
146
|
+
const id = safeText(raw.id);
|
|
147
|
+
const name = safeText(raw.name);
|
|
148
|
+
const type = safeText(raw.type, 64);
|
|
149
|
+
if (!id || !name) return null;
|
|
150
|
+
const device = {
|
|
151
|
+
id,
|
|
152
|
+
name,
|
|
153
|
+
type: type ?? "unknown",
|
|
154
|
+
is_active: raw.is_active === true,
|
|
155
|
+
is_private_session: raw.is_private_session === true,
|
|
156
|
+
is_restricted: raw.is_restricted === true,
|
|
157
|
+
supports_volume: raw.supports_volume === true,
|
|
158
|
+
};
|
|
159
|
+
const volume = safeInteger(raw.volume_percent, 0, 100);
|
|
160
|
+
if (volume !== undefined) device.volume_percent = volume;
|
|
161
|
+
return device;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function normalizeDisallowedActions(raw) {
|
|
165
|
+
if (!isPlainObject(raw)) return [];
|
|
166
|
+
return Object.entries(raw)
|
|
167
|
+
.filter(([key, value]) => playbackActions.has(key) && value === true)
|
|
168
|
+
.map(([key]) => key)
|
|
169
|
+
.sort();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function normalizeAccount(payload) {
|
|
173
|
+
const account = { provider: "spotify" };
|
|
174
|
+
if (!isPlainObject(payload)) return account;
|
|
175
|
+
const accountId = safeText(payload.id, 128);
|
|
176
|
+
const displayName = safeText(payload.display_name);
|
|
177
|
+
const country = safeText(payload.country, 8);
|
|
178
|
+
const product = safeText(payload.product, 32);
|
|
179
|
+
if (accountId) account.account_id = accountId;
|
|
180
|
+
if (displayName) account.display_name = displayName;
|
|
181
|
+
if (country) account.country = country;
|
|
182
|
+
if (product) account.product = product;
|
|
183
|
+
if (isPlainObject(payload.explicit_content)) {
|
|
184
|
+
account.explicit_content = {
|
|
185
|
+
filter_enabled: payload.explicit_content.filter_enabled === true,
|
|
186
|
+
filter_locked: payload.explicit_content.filter_locked === true,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
return account;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalizePlayback(payload) {
|
|
193
|
+
if (!isPlainObject(payload)) {
|
|
194
|
+
return { provider: "spotify", state: "inactive" };
|
|
195
|
+
}
|
|
196
|
+
const result = {
|
|
197
|
+
provider: "spotify",
|
|
198
|
+
state: "available",
|
|
199
|
+
is_playing: payload.is_playing === true,
|
|
200
|
+
shuffle_state: payload.shuffle_state === true,
|
|
201
|
+
repeat_state: ["off", "track", "context"].includes(payload.repeat_state)
|
|
202
|
+
? payload.repeat_state
|
|
203
|
+
: "off",
|
|
204
|
+
currently_playing_type:
|
|
205
|
+
safeText(payload.currently_playing_type, 32) ?? "unknown",
|
|
206
|
+
disallowed_actions: normalizeDisallowedActions(payload.actions?.disallows),
|
|
207
|
+
};
|
|
208
|
+
const progressMs = safeInteger(payload.progress_ms, 0, 86_400_000);
|
|
209
|
+
const timestamp = safeInteger(payload.timestamp, 0, Number.MAX_SAFE_INTEGER);
|
|
210
|
+
const device = normalizeDevice(payload.device);
|
|
211
|
+
const item = normalizePlayableItem(payload.item);
|
|
212
|
+
const contextType = safeText(payload.context?.type, 32);
|
|
213
|
+
const contextUri = safeText(payload.context?.uri);
|
|
214
|
+
if (progressMs !== undefined) result.progress_ms = progressMs;
|
|
215
|
+
if (timestamp !== undefined) result.observed_at_ms = timestamp;
|
|
216
|
+
if (device) result.device = device;
|
|
217
|
+
if (item) result.item = item;
|
|
218
|
+
if (contextType || contextUri) {
|
|
219
|
+
result.context = {};
|
|
220
|
+
if (contextType) result.context.type = contextType;
|
|
221
|
+
if (contextUri) result.context.uri = contextUri;
|
|
222
|
+
}
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function normalizeDevices(payload) {
|
|
227
|
+
const rawDevices = Array.isArray(payload?.devices) ? payload.devices : [];
|
|
228
|
+
const devices = rawDevices
|
|
229
|
+
.map(normalizeDevice)
|
|
230
|
+
.filter(Boolean)
|
|
231
|
+
.slice(0, SPOTIFY_WEB_API_LIMITS.devicesMax);
|
|
232
|
+
return {
|
|
233
|
+
provider: "spotify",
|
|
234
|
+
devices,
|
|
235
|
+
truncated: rawDevices.length > devices.length,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function normalizeQueue(payload) {
|
|
240
|
+
const rawQueue = Array.isArray(payload?.queue) ? payload.queue : [];
|
|
241
|
+
const queue = rawQueue
|
|
242
|
+
.map(normalizePlayableItem)
|
|
243
|
+
.filter(Boolean)
|
|
244
|
+
.slice(0, SPOTIFY_WEB_API_LIMITS.queueItemsMax);
|
|
245
|
+
return {
|
|
246
|
+
provider: "spotify",
|
|
247
|
+
currently_playing: normalizePlayableItem(payload?.currently_playing),
|
|
248
|
+
queue,
|
|
249
|
+
truncated: rawQueue.length > queue.length,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function normalizeTrackSearchItem(raw) {
|
|
254
|
+
if (!isPlainObject(raw)) return null;
|
|
255
|
+
const uri = safeText(raw.uri);
|
|
256
|
+
const id = safeText(raw.id, 128);
|
|
257
|
+
const name = safeText(raw.name);
|
|
258
|
+
if (!uri || !id || !name) return null;
|
|
259
|
+
const item = {
|
|
260
|
+
uri,
|
|
261
|
+
id,
|
|
262
|
+
name,
|
|
263
|
+
artists: normalizeArtistNames(raw.artists),
|
|
264
|
+
};
|
|
265
|
+
const album = safeText(raw.album?.name);
|
|
266
|
+
if (album) item.album = album;
|
|
267
|
+
const durationMs = safeInteger(raw.duration_ms, 0, 86_400_000);
|
|
268
|
+
if (durationMs !== undefined) item.duration_ms = durationMs;
|
|
269
|
+
const popularity = safeInteger(raw.popularity, 0, 100);
|
|
270
|
+
if (popularity !== undefined) item.popularity = popularity;
|
|
271
|
+
if (typeof raw.explicit === "boolean") item.explicit = raw.explicit;
|
|
272
|
+
return item;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function normalizeTrackSearch(payload) {
|
|
276
|
+
const rawItems = Array.isArray(payload?.tracks?.items)
|
|
277
|
+
? payload.tracks.items
|
|
278
|
+
: [];
|
|
279
|
+
const items = rawItems
|
|
280
|
+
.map(normalizeTrackSearchItem)
|
|
281
|
+
.filter(Boolean)
|
|
282
|
+
.slice(0, SPOTIFY_WEB_API_LIMITS.searchResultsMax);
|
|
283
|
+
return {
|
|
284
|
+
provider: "spotify",
|
|
285
|
+
items,
|
|
286
|
+
truncated: rawItems.length > items.length,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function normalizedUtcTimestamp(value) {
|
|
291
|
+
if (typeof value !== "string") return undefined;
|
|
292
|
+
const milliseconds = Date.parse(value);
|
|
293
|
+
if (!Number.isFinite(milliseconds)) return undefined;
|
|
294
|
+
return new Date(milliseconds).toISOString();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function normalizedCursor(value) {
|
|
298
|
+
const numeric =
|
|
299
|
+
typeof value === "string" && /^(?:0|[1-9][0-9]*)$/u.test(value)
|
|
300
|
+
? Number(value)
|
|
301
|
+
: value;
|
|
302
|
+
return safeInteger(numeric, 0, Number.MAX_SAFE_INTEGER);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function normalizeRecentlyPlayedItem(raw) {
|
|
306
|
+
if (!isPlainObject(raw) || !isPlainObject(raw.track)) return null;
|
|
307
|
+
const playedAt = normalizedUtcTimestamp(raw.played_at);
|
|
308
|
+
const id = safeText(raw.track.id, 128);
|
|
309
|
+
const name = safeText(raw.track.name);
|
|
310
|
+
const album = safeText(raw.track.album?.name);
|
|
311
|
+
const artists = normalizeArtistNames(raw.track.artists);
|
|
312
|
+
if (!playedAt || !id || !name || !album || artists.length === 0) return null;
|
|
313
|
+
const track = { id, name, artists, album };
|
|
314
|
+
const durationMs = safeInteger(raw.track.duration_ms, 0, 86_400_000);
|
|
315
|
+
if (durationMs !== undefined) track.duration_ms = durationMs;
|
|
316
|
+
const isrc = safeText(raw.track.external_ids?.isrc, 32);
|
|
317
|
+
if (isrc) track.isrc = isrc.toUpperCase();
|
|
318
|
+
const releaseDate = safeText(raw.track.album?.release_date, 32);
|
|
319
|
+
if (releaseDate) track.release_date = releaseDate;
|
|
320
|
+
const externalUrl = safeText(raw.track.external_urls?.spotify, 512);
|
|
321
|
+
if (externalUrl) track.external_url = externalUrl;
|
|
322
|
+
const contextType = safeText(raw.context?.type, 32);
|
|
323
|
+
return {
|
|
324
|
+
played_at: playedAt,
|
|
325
|
+
track,
|
|
326
|
+
...(contextType ? { context_type: contextType } : {}),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function normalizeRecentlyPlayed(payload) {
|
|
331
|
+
const rawItems = Array.isArray(payload?.items) ? payload.items : [];
|
|
332
|
+
const items = rawItems
|
|
333
|
+
.map(normalizeRecentlyPlayedItem)
|
|
334
|
+
.filter(Boolean)
|
|
335
|
+
.slice(0, SPOTIFY_WEB_API_LIMITS.recentlyPlayedMax);
|
|
336
|
+
const cursorAfterMs = normalizedCursor(payload?.cursors?.after);
|
|
337
|
+
const cursorBeforeMs = normalizedCursor(payload?.cursors?.before);
|
|
338
|
+
return {
|
|
339
|
+
provider: "spotify",
|
|
340
|
+
items,
|
|
341
|
+
truncated: rawItems.length > items.length,
|
|
342
|
+
...(cursorAfterMs !== undefined
|
|
343
|
+
? { cursor_after_ms: cursorAfterMs }
|
|
344
|
+
: {}),
|
|
345
|
+
...(cursorBeforeMs !== undefined
|
|
346
|
+
? { cursor_before_ms: cursorBeforeMs }
|
|
347
|
+
: {}),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function normalizePlaylist(payload, { includeControlMetadata = false } = {}) {
|
|
352
|
+
if (!isPlainObject(payload)) return null;
|
|
353
|
+
const id = safeText(payload.id, 128);
|
|
354
|
+
const uri = safeText(payload.uri);
|
|
355
|
+
const name = safeText(payload.name, 200);
|
|
356
|
+
if (!id || !uri || !name) return null;
|
|
357
|
+
const playlist = {
|
|
358
|
+
id,
|
|
359
|
+
uri,
|
|
360
|
+
name,
|
|
361
|
+
is_public: payload.public === true,
|
|
362
|
+
};
|
|
363
|
+
if (includeControlMetadata) {
|
|
364
|
+
playlist.collaborative = payload.collaborative === true;
|
|
365
|
+
const ownerId = safeText(payload.owner?.id, 128);
|
|
366
|
+
const snapshotId = safeText(payload.snapshot_id, 128);
|
|
367
|
+
if (ownerId) playlist.owner_id = ownerId;
|
|
368
|
+
if (snapshotId) playlist.snapshot_id = snapshotId;
|
|
369
|
+
}
|
|
370
|
+
const tracksTotal = safeInteger(
|
|
371
|
+
payload.items?.total ?? payload.tracks?.total,
|
|
372
|
+
0,
|
|
373
|
+
1_000_000,
|
|
374
|
+
);
|
|
375
|
+
if (tracksTotal !== undefined) playlist.tracks_total = tracksTotal;
|
|
376
|
+
return playlist;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function normalizePlaylistPage(payload) {
|
|
380
|
+
const rawItems = Array.isArray(payload?.items) ? payload.items : [];
|
|
381
|
+
const items = rawItems
|
|
382
|
+
.slice(0, SPOTIFY_WEB_API_LIMITS.playlistsPageMax)
|
|
383
|
+
.map((item) => normalizePlaylist(item, { includeControlMetadata: true }))
|
|
384
|
+
.filter(Boolean);
|
|
385
|
+
const total =
|
|
386
|
+
safeInteger(payload?.total, 0, 1_000_000) ?? items.length;
|
|
387
|
+
const limit =
|
|
388
|
+
safeInteger(
|
|
389
|
+
payload?.limit,
|
|
390
|
+
1,
|
|
391
|
+
SPOTIFY_WEB_API_LIMITS.playlistsPageMax,
|
|
392
|
+
) ?? Math.max(1, items.length);
|
|
393
|
+
const offset =
|
|
394
|
+
safeInteger(payload?.offset, 0, 1_000_000) ?? 0;
|
|
395
|
+
return {
|
|
396
|
+
provider: "spotify",
|
|
397
|
+
items,
|
|
398
|
+
total,
|
|
399
|
+
limit,
|
|
400
|
+
offset,
|
|
401
|
+
has_more: offset + rawItems.length < total,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function normalizePlaylistItem(raw) {
|
|
406
|
+
if (!isPlainObject(raw)) {
|
|
407
|
+
return { is_local: false, item: null };
|
|
408
|
+
}
|
|
409
|
+
const source = isPlainObject(raw.item)
|
|
410
|
+
? raw.item
|
|
411
|
+
: isPlainObject(raw.track)
|
|
412
|
+
? raw.track
|
|
413
|
+
: null;
|
|
414
|
+
const item = normalizePlayableItem(source);
|
|
415
|
+
if (item && typeof source?.is_playable === "boolean") {
|
|
416
|
+
item.is_playable = source.is_playable;
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
is_local: raw.is_local === true || source?.is_local === true,
|
|
420
|
+
item,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function normalizePlaylistItemsPage(payload) {
|
|
425
|
+
const rawItems = Array.isArray(payload?.items) ? payload.items : [];
|
|
426
|
+
const items = rawItems
|
|
427
|
+
.slice(0, SPOTIFY_WEB_API_LIMITS.playlistItemsPageMax)
|
|
428
|
+
.map(normalizePlaylistItem);
|
|
429
|
+
const total =
|
|
430
|
+
safeInteger(payload?.total, 0, 1_000_000) ?? items.length;
|
|
431
|
+
const limit =
|
|
432
|
+
safeInteger(
|
|
433
|
+
payload?.limit,
|
|
434
|
+
1,
|
|
435
|
+
SPOTIFY_WEB_API_LIMITS.playlistItemsPageMax,
|
|
436
|
+
) ?? Math.max(1, items.length);
|
|
437
|
+
const offset =
|
|
438
|
+
safeInteger(payload?.offset, 0, 1_000_000) ?? 0;
|
|
439
|
+
return {
|
|
440
|
+
provider: "spotify",
|
|
441
|
+
items,
|
|
442
|
+
total,
|
|
443
|
+
limit,
|
|
444
|
+
offset,
|
|
445
|
+
has_more: offset + rawItems.length < total,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function normalizeSavedContains(payload) {
|
|
450
|
+
if (!Array.isArray(payload)) return [];
|
|
451
|
+
return payload
|
|
452
|
+
.slice(0, SPOTIFY_WEB_API_LIMITS.libraryItemsMax)
|
|
453
|
+
.map((value) => value === true);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function queryString(values) {
|
|
457
|
+
const query = new URLSearchParams();
|
|
458
|
+
for (const [key, value] of Object.entries(values)) {
|
|
459
|
+
if (value !== undefined && value !== null) query.set(key, String(value));
|
|
460
|
+
}
|
|
461
|
+
const serialized = query.toString();
|
|
462
|
+
return serialized ? `?${serialized}` : "";
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function playbackBody(input) {
|
|
466
|
+
const body = {};
|
|
467
|
+
if (input.contextUri !== undefined) body.context_uri = input.contextUri;
|
|
468
|
+
if (input.uris !== undefined) body.uris = input.uris;
|
|
469
|
+
if (input.offsetPosition !== undefined) {
|
|
470
|
+
body.offset = { position: input.offsetPosition };
|
|
471
|
+
} else if (input.offsetUri !== undefined) {
|
|
472
|
+
body.offset = { uri: input.offsetUri };
|
|
473
|
+
}
|
|
474
|
+
if (input.positionMs !== undefined) body.position_ms = input.positionMs;
|
|
475
|
+
return Object.keys(body).length > 0 ? body : undefined;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export function createSpotifyWebApiClient({
|
|
479
|
+
fetchImpl = globalThis.fetch,
|
|
480
|
+
tokenProvider,
|
|
481
|
+
baseUrl = SPOTIFY_WEB_API_BASE_URL,
|
|
482
|
+
} = {}) {
|
|
483
|
+
if (typeof fetchImpl !== "function") {
|
|
484
|
+
throw new TypeError("A fetch implementation is required.");
|
|
485
|
+
}
|
|
486
|
+
if (typeof tokenProvider !== "function") {
|
|
487
|
+
throw new TypeError("A Spotify token provider is required.");
|
|
488
|
+
}
|
|
489
|
+
const normalizedBaseUrl = String(baseUrl).replace(/\/+$/u, "");
|
|
490
|
+
|
|
491
|
+
const request = async (
|
|
492
|
+
path,
|
|
493
|
+
{ method = "GET", body, responseMode = "json" } = {},
|
|
494
|
+
) => {
|
|
495
|
+
const token = normalizedToken(await tokenProvider());
|
|
496
|
+
const headers = {
|
|
497
|
+
accept: "application/json",
|
|
498
|
+
authorization: `Bearer ${token}`,
|
|
499
|
+
};
|
|
500
|
+
const init = { method, headers };
|
|
501
|
+
if (body !== undefined) {
|
|
502
|
+
headers["content-type"] = "application/json";
|
|
503
|
+
init.body = JSON.stringify(body);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
let response;
|
|
507
|
+
try {
|
|
508
|
+
response = await fetchImpl(`${normalizedBaseUrl}${path}`, init);
|
|
509
|
+
} catch {
|
|
510
|
+
fail("spotify_network_error", "Spotify could not be reached.");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (response.ok && (response.status === 204 || responseMode === "none")) {
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
const text = await response.text();
|
|
517
|
+
if (text.length > SPOTIFY_WEB_API_LIMITS.responseBytesMax) {
|
|
518
|
+
fail("spotify_response_too_large", "Spotify returned too much data.", {
|
|
519
|
+
status: response.status,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
let payload = null;
|
|
523
|
+
if (text) {
|
|
524
|
+
try {
|
|
525
|
+
payload = JSON.parse(text);
|
|
526
|
+
} catch {
|
|
527
|
+
fail("spotify_response_invalid", "Spotify returned an invalid response.", {
|
|
528
|
+
status: response.status,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (!response.ok) {
|
|
534
|
+
const options = {
|
|
535
|
+
status: response.status,
|
|
536
|
+
retryAfterSeconds: retryAfterSeconds(response.headers),
|
|
537
|
+
};
|
|
538
|
+
if (response.status === 401) {
|
|
539
|
+
fail(
|
|
540
|
+
"spotify_authentication_required",
|
|
541
|
+
"Spotify authentication must be refreshed.",
|
|
542
|
+
options,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
if (response.status === 403) {
|
|
546
|
+
if (hasInsufficientClientScope(payload)) {
|
|
547
|
+
fail(
|
|
548
|
+
"spotify_scope_insufficient",
|
|
549
|
+
"Spotify authorization is missing a required scope. Run moondog spotify login again.",
|
|
550
|
+
options,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
fail(
|
|
554
|
+
"spotify_action_forbidden",
|
|
555
|
+
"Spotify did not allow this action.",
|
|
556
|
+
options,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
if (response.status === 429) {
|
|
560
|
+
if (quotaReason(payload) === "QUOTA_EXCEEDED") {
|
|
561
|
+
fail(
|
|
562
|
+
"spotify_quota_exceeded",
|
|
563
|
+
"Spotify development quota is exhausted.",
|
|
564
|
+
options,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
fail(
|
|
568
|
+
"spotify_rate_limited",
|
|
569
|
+
"Spotify rate limited this request.",
|
|
570
|
+
options,
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
fail("spotify_api_error", "Spotify rejected this request.", options);
|
|
574
|
+
}
|
|
575
|
+
return payload;
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
return Object.freeze({
|
|
579
|
+
async getAccount() {
|
|
580
|
+
return normalizeAccount(await request("/me"));
|
|
581
|
+
},
|
|
582
|
+
|
|
583
|
+
async getCurrentPlayback() {
|
|
584
|
+
return normalizePlayback(await request("/me/player"));
|
|
585
|
+
},
|
|
586
|
+
|
|
587
|
+
async getDevices() {
|
|
588
|
+
return normalizeDevices(await request("/me/player/devices"));
|
|
589
|
+
},
|
|
590
|
+
|
|
591
|
+
async getQueue() {
|
|
592
|
+
return normalizeQueue(await request("/me/player/queue"));
|
|
593
|
+
},
|
|
594
|
+
|
|
595
|
+
async getRecentlyPlayed({ limit, after, before } = {}) {
|
|
596
|
+
return normalizeRecentlyPlayed(
|
|
597
|
+
await request(
|
|
598
|
+
`/me/player/recently-played${queryString({ limit, after, before })}`,
|
|
599
|
+
),
|
|
600
|
+
);
|
|
601
|
+
},
|
|
602
|
+
|
|
603
|
+
async searchTracks({ query, limit, market } = {}) {
|
|
604
|
+
return normalizeTrackSearch(
|
|
605
|
+
await request(
|
|
606
|
+
`/search${queryString({ q: query, type: "track", limit, market })}`,
|
|
607
|
+
),
|
|
608
|
+
);
|
|
609
|
+
},
|
|
610
|
+
|
|
611
|
+
async getCurrentUserPlaylists({ limit, offset } = {}) {
|
|
612
|
+
return normalizePlaylistPage(
|
|
613
|
+
await request(
|
|
614
|
+
`/me/playlists${queryString({ limit, offset })}`,
|
|
615
|
+
),
|
|
616
|
+
);
|
|
617
|
+
},
|
|
618
|
+
|
|
619
|
+
async getPlaylist({ playlistId } = {}) {
|
|
620
|
+
return normalizePlaylist(
|
|
621
|
+
await request(`/playlists/${encodeURIComponent(playlistId)}`),
|
|
622
|
+
{ includeControlMetadata: true },
|
|
623
|
+
);
|
|
624
|
+
},
|
|
625
|
+
|
|
626
|
+
async getPlaylistItems({ playlistId, limit, offset } = {}) {
|
|
627
|
+
return normalizePlaylistItemsPage(
|
|
628
|
+
await request(
|
|
629
|
+
`/playlists/${encodeURIComponent(playlistId)}/items${queryString({
|
|
630
|
+
limit,
|
|
631
|
+
offset,
|
|
632
|
+
})}`,
|
|
633
|
+
),
|
|
634
|
+
);
|
|
635
|
+
},
|
|
636
|
+
|
|
637
|
+
async createPlaylist({ name, description } = {}) {
|
|
638
|
+
return normalizePlaylist(
|
|
639
|
+
await request("/me/playlists", {
|
|
640
|
+
method: "POST",
|
|
641
|
+
body: {
|
|
642
|
+
name,
|
|
643
|
+
...(description ? { description } : {}),
|
|
644
|
+
public: false,
|
|
645
|
+
},
|
|
646
|
+
}),
|
|
647
|
+
);
|
|
648
|
+
},
|
|
649
|
+
|
|
650
|
+
async addPlaylistTracks({ playlistId, uris } = {}) {
|
|
651
|
+
const payload = await request(
|
|
652
|
+
`/playlists/${encodeURIComponent(playlistId)}/items`,
|
|
653
|
+
{ method: "POST", body: { uris } },
|
|
654
|
+
);
|
|
655
|
+
const snapshotId = safeText(payload?.snapshot_id, 128);
|
|
656
|
+
return snapshotId ? { snapshot_id: snapshotId } : {};
|
|
657
|
+
},
|
|
658
|
+
|
|
659
|
+
async replacePlaylistItems({ playlistId, uris } = {}) {
|
|
660
|
+
const payload = await request(
|
|
661
|
+
`/playlists/${encodeURIComponent(playlistId)}/items`,
|
|
662
|
+
{ method: "PUT", body: { uris } },
|
|
663
|
+
);
|
|
664
|
+
const snapshotId = safeText(payload?.snapshot_id, 128);
|
|
665
|
+
return snapshotId ? { snapshot_id: snapshotId } : {};
|
|
666
|
+
},
|
|
667
|
+
|
|
668
|
+
async checkSavedTracks({ uris } = {}) {
|
|
669
|
+
return normalizeSavedContains(
|
|
670
|
+
await request(
|
|
671
|
+
`/me/library/contains${queryString({ uris: uris.join(",") })}`,
|
|
672
|
+
),
|
|
673
|
+
);
|
|
674
|
+
},
|
|
675
|
+
|
|
676
|
+
async saveTracks({ uris } = {}) {
|
|
677
|
+
await request(`/me/library${queryString({ uris: uris.join(",") })}`, {
|
|
678
|
+
method: "PUT",
|
|
679
|
+
responseMode: "none",
|
|
680
|
+
});
|
|
681
|
+
return null;
|
|
682
|
+
},
|
|
683
|
+
|
|
684
|
+
async resume(input = {}) {
|
|
685
|
+
await request(
|
|
686
|
+
`/me/player/play${queryString({ device_id: input.deviceId })}`,
|
|
687
|
+
{ method: "PUT", body: playbackBody(input), responseMode: "none" },
|
|
688
|
+
);
|
|
689
|
+
},
|
|
690
|
+
|
|
691
|
+
async pause({ deviceId } = {}) {
|
|
692
|
+
await request(
|
|
693
|
+
`/me/player/pause${queryString({ device_id: deviceId })}`,
|
|
694
|
+
{ method: "PUT", responseMode: "none" },
|
|
695
|
+
);
|
|
696
|
+
},
|
|
697
|
+
|
|
698
|
+
async next({ deviceId } = {}) {
|
|
699
|
+
await request(
|
|
700
|
+
`/me/player/next${queryString({ device_id: deviceId })}`,
|
|
701
|
+
{ method: "POST", responseMode: "none" },
|
|
702
|
+
);
|
|
703
|
+
},
|
|
704
|
+
|
|
705
|
+
async previous({ deviceId } = {}) {
|
|
706
|
+
await request(
|
|
707
|
+
`/me/player/previous${queryString({ device_id: deviceId })}`,
|
|
708
|
+
{ method: "POST", responseMode: "none" },
|
|
709
|
+
);
|
|
710
|
+
},
|
|
711
|
+
|
|
712
|
+
async setVolume({ percent, deviceId }) {
|
|
713
|
+
await request(
|
|
714
|
+
`/me/player/volume${queryString({
|
|
715
|
+
volume_percent: percent,
|
|
716
|
+
device_id: deviceId,
|
|
717
|
+
})}`,
|
|
718
|
+
{ method: "PUT", responseMode: "none" },
|
|
719
|
+
);
|
|
720
|
+
},
|
|
721
|
+
|
|
722
|
+
async seek({ positionMs, deviceId }) {
|
|
723
|
+
await request(
|
|
724
|
+
`/me/player/seek${queryString({
|
|
725
|
+
position_ms: positionMs,
|
|
726
|
+
device_id: deviceId,
|
|
727
|
+
})}`,
|
|
728
|
+
{ method: "PUT", responseMode: "none" },
|
|
729
|
+
);
|
|
730
|
+
},
|
|
731
|
+
|
|
732
|
+
async setShuffle({ state, deviceId }) {
|
|
733
|
+
await request(
|
|
734
|
+
`/me/player/shuffle${queryString({ state, device_id: deviceId })}`,
|
|
735
|
+
{ method: "PUT", responseMode: "none" },
|
|
736
|
+
);
|
|
737
|
+
},
|
|
738
|
+
|
|
739
|
+
async setRepeat({ state, deviceId }) {
|
|
740
|
+
await request(
|
|
741
|
+
`/me/player/repeat${queryString({ state, device_id: deviceId })}`,
|
|
742
|
+
{ method: "PUT", responseMode: "none" },
|
|
743
|
+
);
|
|
744
|
+
},
|
|
745
|
+
|
|
746
|
+
async transfer({ deviceId, play }) {
|
|
747
|
+
await request("/me/player", {
|
|
748
|
+
method: "PUT",
|
|
749
|
+
body: { device_ids: [deviceId], play },
|
|
750
|
+
responseMode: "none",
|
|
751
|
+
});
|
|
752
|
+
},
|
|
753
|
+
|
|
754
|
+
async addToQueue({ uri, deviceId }) {
|
|
755
|
+
await request(
|
|
756
|
+
`/me/player/queue${queryString({ uri, device_id: deviceId })}`,
|
|
757
|
+
{ method: "POST", responseMode: "none" },
|
|
758
|
+
);
|
|
759
|
+
},
|
|
760
|
+
});
|
|
761
|
+
}
|