@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,918 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { isUuid, uuidV5 } from "../../core/uuid-v5.mjs";
4
+ import { SPOTIFY_TRACK_NAMESPACE } from "./recent-activity.mjs";
5
+
6
+ export const SPOTIFY_ACCOUNT_PROFILE_SOURCE =
7
+ "spotify.account_data.music_profile";
8
+ export const SPOTIFY_ACCOUNT_PROFILE_FORMAT =
9
+ "spotify_account_data_music_profile_v1";
10
+ export const SPOTIFY_ACCOUNT_PROFILE_IMPORT_NAMESPACE =
11
+ "643b1d15-9104-5ddd-9c76-9427b43e4729";
12
+ export const SPOTIFY_ACCOUNT_PROFILE_EVIDENCE_NAMESPACE =
13
+ "96ebeff8-f432-55aa-80d2-415986d7795f";
14
+ export const SPOTIFY_ACCOUNT_PROFILE_ENTITY_NAMESPACE =
15
+ "df2fe6d6-3769-5dfd-a4cb-47bd33da4035";
16
+
17
+ export const SPOTIFY_ACCOUNT_PROFILE_MEMBERS = Object.freeze([
18
+ "Playlist1.json",
19
+ "SearchQueries.json",
20
+ "TasteProfile.json",
21
+ "Wrapped2025.json",
22
+ "YourLibrary.json",
23
+ "YourSoundCapsule.json",
24
+ ]);
25
+
26
+ const memberNames = new Set(SPOTIFY_ACCOUNT_PROFILE_MEMBERS);
27
+ const spotifyUriPattern =
28
+ /^spotify:(track|artist|album|playlist|show|episode):([A-Za-z0-9]{22})$/u;
29
+ const spotifyConceptUriPattern = /^spotify:concept:([A-Za-z0-9]{22})$/u;
30
+ const unsafeTextPattern =
31
+ /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu;
32
+ const sha256Pattern = /^[a-f0-9]{64}$/u;
33
+
34
+ export class SpotifyAccountProfileError extends Error {
35
+ constructor(message, code = "spotify_account_profile_invalid") {
36
+ super(message);
37
+ this.name = "SpotifyAccountProfileError";
38
+ this.code = code;
39
+ }
40
+ }
41
+
42
+ function fail(message, code) {
43
+ throw new SpotifyAccountProfileError(message, code);
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 cleanText(value, maximum, label, { optional = false } = {}) {
53
+ if (optional && (value === null || value === undefined || value === "")) {
54
+ return null;
55
+ }
56
+ if (typeof value !== "string") fail(`${label} is invalid.`);
57
+ const cleaned = value
58
+ .normalize("NFC")
59
+ .replace(unsafeTextPattern, " ")
60
+ .replace(/\s+/gu, " ")
61
+ .trim();
62
+ if (!cleaned || Array.from(cleaned).length > maximum) {
63
+ fail(`${label} is invalid.`);
64
+ }
65
+ return cleaned;
66
+ }
67
+
68
+ function timestamp(value, label, capturedAt, { dateOnly = false } = {}) {
69
+ if (value === null || value === undefined || value === "") return null;
70
+ if (typeof value !== "string") fail(`${label} is invalid.`);
71
+ const candidate = dateOnly
72
+ ? `${value}T00:00:00.000Z`
73
+ : value.replace(/\[UTC\]$/u, "");
74
+ const milliseconds = Date.parse(candidate);
75
+ if (!Number.isFinite(milliseconds)) fail(`${label} is invalid.`);
76
+ const normalized = new Date(milliseconds).toISOString();
77
+ if (milliseconds > Date.parse(capturedAt)) fail(`${label} is in the future.`);
78
+ return normalized;
79
+ }
80
+
81
+ function requiredArray(value, label, maximum = 100_000) {
82
+ if (!Array.isArray(value) || value.length > maximum) {
83
+ fail(`${label} is invalid.`);
84
+ }
85
+ return value;
86
+ }
87
+
88
+ function hashParts(...parts) {
89
+ const hash = createHash("sha256");
90
+ for (const part of parts) hash.update(String(part)).update("\0");
91
+ return hash.digest("hex");
92
+ }
93
+
94
+ function normalizedText(value) {
95
+ return value.normalize("NFKC").toLocaleLowerCase("und");
96
+ }
97
+
98
+ function uri(value, expectedType, label) {
99
+ const cleaned = cleanText(value, 128, label);
100
+ const match = spotifyUriPattern.exec(cleaned);
101
+ if (!match || (expectedType && match[1] !== expectedType)) {
102
+ fail(`${label} is invalid.`);
103
+ }
104
+ return { type: match[1], id: match[2] };
105
+ }
106
+
107
+ function optionalUri(value) {
108
+ if (typeof value !== "string") return null;
109
+ const match = spotifyUriPattern.exec(value.trim());
110
+ return match ? { type: match[1], id: match[2] } : null;
111
+ }
112
+
113
+ function entityRefId(type, externalId) {
114
+ if (type === "track") return uuidV5(externalId, SPOTIFY_TRACK_NAMESPACE);
115
+ return uuidV5(
116
+ `${type}\0${externalId}`,
117
+ SPOTIFY_ACCOUNT_PROFILE_ENTITY_NAMESPACE,
118
+ );
119
+ }
120
+
121
+ function labelEntityRefId(type, label) {
122
+ return uuidV5(
123
+ `${type}\0label\0${normalizedText(label)}`,
124
+ SPOTIFY_ACCOUNT_PROFILE_ENTITY_NAMESPACE,
125
+ );
126
+ }
127
+
128
+ function clippedDisplayLabel(title, artist) {
129
+ return Array.from(`${title} - ${artist}`).slice(0, 512).join("");
130
+ }
131
+
132
+ function resolvedTrackRef({ id, title, artist, album, observedAt }) {
133
+ const result = {
134
+ schema_version: 1,
135
+ track_ref_id: entityRefId("track", id),
136
+ revision: 1,
137
+ identity_status: "resolved",
138
+ display_label: clippedDisplayLabel(title, artist),
139
+ title,
140
+ artist_credits: [{ name: artist, role: "unknown" }],
141
+ external_refs: [
142
+ {
143
+ system: "spotify",
144
+ entity_type: "spotify.track",
145
+ external_id: id,
146
+ },
147
+ ],
148
+ created_at: observedAt,
149
+ resolved_at: observedAt,
150
+ };
151
+ if (album) result.release = { title: album };
152
+ return result;
153
+ }
154
+
155
+ function numeric(value, label, { minimum = 0, maximum = Number.MAX_SAFE_INTEGER } = {}) {
156
+ if (!Number.isFinite(value) || value < minimum || value > maximum) {
157
+ fail(`${label} is invalid.`);
158
+ }
159
+ return value;
160
+ }
161
+
162
+ function integer(value, label, options) {
163
+ numeric(value, label, options);
164
+ if (!Number.isSafeInteger(value)) fail(`${label} is invalid.`);
165
+ return value;
166
+ }
167
+
168
+ function profileBuilder({ subjectId, archiveSha256, capturedAt, profileImportId }) {
169
+ const records = [];
170
+ const tracks = new Map();
171
+
172
+ function addTrack(track) {
173
+ const key = `${track.track_ref_id}:${track.revision}`;
174
+ if (!tracks.has(key)) tracks.set(key, track);
175
+ }
176
+
177
+ function addEvidence({
178
+ logicalKey,
179
+ kind,
180
+ direction = "supports",
181
+ strengthClass,
182
+ entity,
183
+ observedAt = capturedAt,
184
+ sourceMember,
185
+ attributes = {},
186
+ }) {
187
+ const evidenceKey = hashParts(
188
+ "spotify-account-profile-evidence-key-v1",
189
+ subjectId,
190
+ kind,
191
+ logicalKey,
192
+ );
193
+ records.push({
194
+ schema_version: 1,
195
+ profile_evidence_id: uuidV5(
196
+ `${archiveSha256}\0${evidenceKey}`,
197
+ SPOTIFY_ACCOUNT_PROFILE_EVIDENCE_NAMESPACE,
198
+ ),
199
+ evidence_key: evidenceKey,
200
+ subject_id: subjectId,
201
+ evidence_kind: kind,
202
+ direction,
203
+ strength_class: strengthClass,
204
+ entity,
205
+ observed_at: observedAt,
206
+ attributes,
207
+ provenance: {
208
+ source_kind: "import",
209
+ source_system: "spotify_account_data",
210
+ source_member: sourceMember,
211
+ captured_at: capturedAt,
212
+ profile_import_id: profileImportId,
213
+ },
214
+ });
215
+ }
216
+
217
+ return { records, tracks, addTrack, addEvidence };
218
+ }
219
+
220
+ function libraryEvidence(document, context) {
221
+ if (!isPlainObject(document)) fail("YourLibrary.json is invalid.");
222
+ const tracks = requiredArray(document.tracks, "Spotify saved tracks");
223
+ const albums = requiredArray(document.albums, "Spotify saved albums");
224
+ const artists = requiredArray(document.artists, "Spotify followed artists");
225
+ const bannedTracks = requiredArray(
226
+ document.bannedTracks ?? [],
227
+ "Spotify banned tracks",
228
+ );
229
+ const bannedArtists = requiredArray(
230
+ document.bannedArtists ?? [],
231
+ "Spotify banned artists",
232
+ );
233
+
234
+ for (const [index, item] of tracks.entries()) {
235
+ if (!isPlainObject(item)) fail("Spotify saved track is invalid.");
236
+ const parsedUri = uri(item.uri, "track", "Spotify saved track URI");
237
+ const title = cleanText(item.track, 512, "Spotify saved track title");
238
+ const artist = cleanText(item.artist, 512, "Spotify saved track artist");
239
+ const album = cleanText(item.album, 512, "Spotify saved track album", {
240
+ optional: true,
241
+ });
242
+ const track = resolvedTrackRef({
243
+ id: parsedUri.id,
244
+ title,
245
+ artist,
246
+ album,
247
+ observedAt: context.capturedAt,
248
+ });
249
+ context.addTrack(track);
250
+ context.trackLabels.set(parsedUri.id, { title, artist, album });
251
+ context.addEvidence({
252
+ logicalKey: `saved-track\0${parsedUri.id}`,
253
+ kind: "library_track_saved",
254
+ strengthClass: "explicit",
255
+ entity: {
256
+ entity_type: "track",
257
+ entity_ref_id: track.track_ref_id,
258
+ track_ref_id: track.track_ref_id,
259
+ track_ref_revision: 1,
260
+ label: title,
261
+ artist_credit: artist,
262
+ ...(album ? { release: album } : {}),
263
+ },
264
+ sourceMember: "YourLibrary.json",
265
+ attributes: { source_position: index + 1 },
266
+ });
267
+ }
268
+
269
+ for (const [index, item] of albums.entries()) {
270
+ if (!isPlainObject(item)) fail("Spotify saved album is invalid.");
271
+ const parsedUri = uri(item.uri, "album", "Spotify saved album URI");
272
+ const label = cleanText(item.album, 512, "Spotify saved album title");
273
+ const artist = cleanText(item.artist, 512, "Spotify saved album artist");
274
+ context.albumLabels.set(parsedUri.id, { label, artist });
275
+ context.addEvidence({
276
+ logicalKey: `saved-album\0${parsedUri.id}`,
277
+ kind: "library_album_saved",
278
+ strengthClass: "explicit",
279
+ entity: {
280
+ entity_type: "album",
281
+ entity_ref_id: entityRefId("album", parsedUri.id),
282
+ label,
283
+ artist_credit: artist,
284
+ },
285
+ sourceMember: "YourLibrary.json",
286
+ attributes: { source_position: index + 1 },
287
+ });
288
+ }
289
+
290
+ for (const [index, item] of artists.entries()) {
291
+ if (!isPlainObject(item)) fail("Spotify followed artist is invalid.");
292
+ const parsedUri = uri(item.uri, "artist", "Spotify followed artist URI");
293
+ const name = cleanText(item.name, 512, "Spotify followed artist name");
294
+ context.artistLabels.set(parsedUri.id, name);
295
+ context.addEvidence({
296
+ logicalKey: `followed-artist\0${parsedUri.id}`,
297
+ kind: "library_artist_followed",
298
+ strengthClass: "explicit",
299
+ entity: {
300
+ entity_type: "artist",
301
+ entity_ref_id: entityRefId("artist", parsedUri.id),
302
+ label: name,
303
+ },
304
+ sourceMember: "YourLibrary.json",
305
+ attributes: { source_position: index + 1 },
306
+ });
307
+ }
308
+
309
+ for (const [index, item] of bannedTracks.entries()) {
310
+ if (!isPlainObject(item)) fail("Spotify banned track is invalid.");
311
+ const parsedUri = uri(item.uri, "track", "Spotify banned track URI");
312
+ const title = cleanText(item.track, 512, "Spotify banned track title");
313
+ const artist = cleanText(item.artist, 512, "Spotify banned track artist");
314
+ context.addEvidence({
315
+ logicalKey: `banned-track\0${parsedUri.id}`,
316
+ kind: "library_track_banned",
317
+ direction: "contradicts",
318
+ strengthClass: "explicit",
319
+ entity: {
320
+ entity_type: "track",
321
+ entity_ref_id: entityRefId("track", parsedUri.id),
322
+ track_ref_id: entityRefId("track", parsedUri.id),
323
+ track_ref_revision: 1,
324
+ label: title,
325
+ artist_credit: artist,
326
+ },
327
+ sourceMember: "YourLibrary.json",
328
+ attributes: { source_position: index + 1 },
329
+ });
330
+ }
331
+
332
+ for (const [index, item] of bannedArtists.entries()) {
333
+ if (!isPlainObject(item)) fail("Spotify banned artist is invalid.");
334
+ const parsedUri = uri(item.uri, "artist", "Spotify banned artist URI");
335
+ const name = cleanText(item.name, 512, "Spotify banned artist name");
336
+ context.addEvidence({
337
+ logicalKey: `banned-artist\0${parsedUri.id}`,
338
+ kind: "library_artist_banned",
339
+ direction: "contradicts",
340
+ strengthClass: "explicit",
341
+ entity: {
342
+ entity_type: "artist",
343
+ entity_ref_id: entityRefId("artist", parsedUri.id),
344
+ label: name,
345
+ },
346
+ sourceMember: "YourLibrary.json",
347
+ attributes: { source_position: index + 1 },
348
+ });
349
+ }
350
+ }
351
+
352
+ function playlistEvidence(document, context) {
353
+ if (!isPlainObject(document)) fail("Playlist1.json is invalid.");
354
+ const playlists = requiredArray(document.playlists, "Spotify playlists", 10_000);
355
+ for (const [playlistIndex, playlist] of playlists.entries()) {
356
+ if (!isPlainObject(playlist)) fail("Spotify playlist is invalid.");
357
+ const name = cleanText(playlist.name, 512, "Spotify playlist name");
358
+ const modifiedAt = timestamp(
359
+ playlist.lastModifiedDate,
360
+ "Spotify playlist modification date",
361
+ context.capturedAt,
362
+ { dateOnly: true },
363
+ );
364
+ const playlistRef = labelEntityRefId(
365
+ "playlist",
366
+ `${playlistIndex + 1}\0${name}`,
367
+ );
368
+ const items = requiredArray(
369
+ playlist.items,
370
+ "Spotify playlist items",
371
+ 100_000,
372
+ );
373
+ for (const [itemIndex, item] of items.entries()) {
374
+ if (!isPlainObject(item) || !isPlainObject(item.track)) {
375
+ fail("Spotify playlist item is invalid.");
376
+ }
377
+ const parsedUri = uri(
378
+ item.track.trackUri,
379
+ "track",
380
+ "Spotify playlist track URI",
381
+ );
382
+ const title = cleanText(
383
+ item.track.trackName,
384
+ 512,
385
+ "Spotify playlist track title",
386
+ );
387
+ const artist = cleanText(
388
+ item.track.artistName,
389
+ 512,
390
+ "Spotify playlist track artist",
391
+ );
392
+ const album = cleanText(
393
+ item.track.albumName,
394
+ 512,
395
+ "Spotify playlist track album",
396
+ { optional: true },
397
+ );
398
+ const addedAt = timestamp(
399
+ item.addedDate,
400
+ "Spotify playlist added date",
401
+ context.capturedAt,
402
+ { dateOnly: true },
403
+ );
404
+ const observedAt = addedAt ?? modifiedAt ?? context.capturedAt;
405
+ const track = resolvedTrackRef({
406
+ id: parsedUri.id,
407
+ title,
408
+ artist,
409
+ album,
410
+ observedAt,
411
+ });
412
+ context.addTrack(track);
413
+ if (!context.trackLabels.has(parsedUri.id)) {
414
+ context.trackLabels.set(parsedUri.id, { title, artist, album });
415
+ }
416
+ context.addEvidence({
417
+ logicalKey: `playlist-track\0${playlistRef}\0${parsedUri.id}\0${itemIndex + 1}`,
418
+ kind: "playlist_track_added",
419
+ strengthClass: "curated",
420
+ entity: {
421
+ entity_type: "track",
422
+ entity_ref_id: track.track_ref_id,
423
+ track_ref_id: track.track_ref_id,
424
+ track_ref_revision: 1,
425
+ label: title,
426
+ artist_credit: artist,
427
+ ...(album ? { release: album } : {}),
428
+ },
429
+ observedAt,
430
+ sourceMember: "Playlist1.json",
431
+ attributes: {
432
+ playlist_ref_id: playlistRef,
433
+ playlist_name: name,
434
+ playlist_position: itemIndex + 1,
435
+ ...(modifiedAt ? { playlist_modified_at: modifiedAt } : {}),
436
+ },
437
+ });
438
+ }
439
+ }
440
+ }
441
+
442
+ function searchEvidence(document, context) {
443
+ const rows = requiredArray(document, "Spotify search queries", 100_000);
444
+ for (const [rowIndex, row] of rows.entries()) {
445
+ if (!isPlainObject(row)) fail("Spotify search query is invalid.");
446
+ const query = cleanText(row.searchQuery, 512, "Spotify search query");
447
+ const searchedAt = timestamp(
448
+ row.searchTime,
449
+ "Spotify search timestamp",
450
+ context.capturedAt,
451
+ );
452
+ const interactions = requiredArray(
453
+ row.searchInteractionURIs,
454
+ "Spotify search interactions",
455
+ 1_000,
456
+ );
457
+ for (const [interactionIndex, value] of interactions.entries()) {
458
+ const parsedUri = optionalUri(value);
459
+ if (
460
+ !parsedUri ||
461
+ !new Set(["track", "artist", "album", "playlist"]).has(parsedUri.type)
462
+ ) {
463
+ continue;
464
+ }
465
+ const resultRefId = entityRefId(parsedUri.type, parsedUri.id);
466
+ context.addEvidence({
467
+ logicalKey: `search\0${searchedAt}\0${rowIndex + 1}\0${interactionIndex + 1}\0${resultRefId}`,
468
+ kind: "search_result_interacted",
469
+ direction: "context",
470
+ strengthClass: "behavioral",
471
+ entity: {
472
+ entity_type: "search_query",
473
+ entity_ref_id: labelEntityRefId("search_query", query),
474
+ label: query,
475
+ },
476
+ observedAt: searchedAt,
477
+ sourceMember: "SearchQueries.json",
478
+ attributes: {
479
+ result_entity_type: parsedUri.type,
480
+ result_ref_id: resultRefId,
481
+ },
482
+ });
483
+ }
484
+ }
485
+ }
486
+
487
+ function tasteProfileEvidence(document, context) {
488
+ if (!isPlainObject(document) || !isPlainObject(document.tasteProfile)) {
489
+ fail("TasteProfile.json is invalid.");
490
+ }
491
+ const profile = document.tasteProfile;
492
+ const artistUris = requiredArray(
493
+ profile.artistUris,
494
+ "Spotify taste-profile artists",
495
+ 1_000,
496
+ );
497
+ for (const [index, value] of artistUris.entries()) {
498
+ const parsedUri = uri(value, "artist", "Spotify taste-profile artist URI");
499
+ const label = context.artistLabels.get(parsedUri.id) ?? null;
500
+ context.addEvidence({
501
+ logicalKey: `taste-artist\0${parsedUri.id}\0${index + 1}`,
502
+ kind: "taste_artist_ranked",
503
+ strengthClass: "provider_derived",
504
+ entity: {
505
+ entity_type: "artist",
506
+ entity_ref_id: entityRefId("artist", parsedUri.id),
507
+ ...(label ? { label } : {}),
508
+ },
509
+ sourceMember: "TasteProfile.json",
510
+ attributes: { rank: index + 1 },
511
+ });
512
+ }
513
+ for (const [kind, value] of [
514
+ ["musical_identity", profile.musicalIdentity],
515
+ ["content_rhythms", profile.contentRhythms],
516
+ ]) {
517
+ const text = cleanText(
518
+ value,
519
+ 2_000,
520
+ `Spotify ${kind.replaceAll("_", " ")}`,
521
+ { optional: true },
522
+ );
523
+ if (!text) continue;
524
+ context.addEvidence({
525
+ logicalKey: `taste-narrative\0${kind}`,
526
+ kind: "provider_interpretation",
527
+ direction: "context",
528
+ strengthClass: "provider_derived",
529
+ entity: {
530
+ entity_type: "provider_interpretation",
531
+ entity_ref_id: labelEntityRefId("provider_interpretation", kind),
532
+ label: kind,
533
+ },
534
+ sourceMember: "TasteProfile.json",
535
+ attributes: { text },
536
+ });
537
+ }
538
+ }
539
+
540
+ function rankedUriEvidence({
541
+ values,
542
+ expectedType,
543
+ kind,
544
+ sourceMember,
545
+ context,
546
+ labelMap,
547
+ metrics = () => ({}),
548
+ }) {
549
+ for (const [index, value] of requiredArray(values, kind, 10_000).entries()) {
550
+ const rawUri = isPlainObject(value)
551
+ ? value.trackUri ?? value.artistUri ?? value.uri
552
+ : value;
553
+ const parsedUri = uri(rawUri, expectedType, `Spotify ${kind} URI`);
554
+ const entity = {
555
+ entity_type: expectedType,
556
+ entity_ref_id: entityRefId(expectedType, parsedUri.id),
557
+ };
558
+ const label = labelMap?.get(parsedUri.id);
559
+ if (typeof label === "string") entity.label = label;
560
+ else if (isPlainObject(label)) {
561
+ entity.label = label.title ?? label.label;
562
+ if (label.artist) entity.artist_credit = label.artist;
563
+ if (label.album) entity.release = label.album;
564
+ }
565
+ if (expectedType === "track") {
566
+ entity.track_ref_id = entity.entity_ref_id;
567
+ entity.track_ref_revision = 1;
568
+ }
569
+ context.addEvidence({
570
+ logicalKey: `${kind}\0${parsedUri.id}\0${index + 1}`,
571
+ kind,
572
+ strengthClass: "provider_derived",
573
+ entity,
574
+ sourceMember,
575
+ attributes: { rank: index + 1, ...metrics(value, index) },
576
+ });
577
+ }
578
+ }
579
+
580
+ function wrappedEvidence(document, context) {
581
+ if (!isPlainObject(document)) fail("Wrapped2025.json is invalid.");
582
+ rankedUriEvidence({
583
+ values: document.topTracks?.topTracks ?? [],
584
+ expectedType: "track",
585
+ kind: "wrapped_track_ranked",
586
+ sourceMember: "Wrapped2025.json",
587
+ context,
588
+ labelMap: context.trackLabels,
589
+ metrics: (value) => ({
590
+ stream_count: integer(value.count, "Spotify Wrapped track count"),
591
+ played_ms: integer(value.msPlayed, "Spotify Wrapped track duration"),
592
+ period: "2025",
593
+ }),
594
+ });
595
+ rankedUriEvidence({
596
+ values: document.topArtists?.topArtistUris ?? [],
597
+ expectedType: "artist",
598
+ kind: "wrapped_artist_ranked",
599
+ sourceMember: "Wrapped2025.json",
600
+ context,
601
+ labelMap: context.artistLabels,
602
+ metrics: () => ({ period: "2025" }),
603
+ });
604
+ rankedUriEvidence({
605
+ values: document.topAlbums?.topAlbums ?? [],
606
+ expectedType: "album",
607
+ kind: "wrapped_album_ranked",
608
+ sourceMember: "Wrapped2025.json",
609
+ context,
610
+ labelMap: context.albumLabels,
611
+ metrics: () => ({ period: "2025" }),
612
+ });
613
+ const genres = requiredArray(
614
+ document.topGenres?.topGenres ?? [],
615
+ "Spotify Wrapped genres",
616
+ 1_000,
617
+ );
618
+ for (const [index, value] of genres.entries()) {
619
+ const raw = cleanText(value, 256, "Spotify Wrapped genre");
620
+ const concept = spotifyConceptUriPattern.exec(raw);
621
+ const label = concept ? null : raw;
622
+ const ref = concept
623
+ ? entityRefId("genre_concept", concept[1])
624
+ : labelEntityRefId("genre", label);
625
+ context.addEvidence({
626
+ logicalKey: `wrapped-genre\0${ref}\0${index + 1}`,
627
+ kind: "wrapped_genre_ranked",
628
+ strengthClass: "provider_derived",
629
+ entity: {
630
+ entity_type: concept ? "genre_concept" : "genre",
631
+ entity_ref_id: ref,
632
+ ...(label ? { label } : {}),
633
+ },
634
+ sourceMember: "Wrapped2025.json",
635
+ attributes: { rank: index + 1, period: "2025" },
636
+ });
637
+ }
638
+
639
+ const metricValues = [
640
+ ["total_listening_ms", document.yearlyMetrics?.totalMsListened, "ms"],
641
+ ["unique_tracks", document.topTracks?.numUniqueTracks, "count"],
642
+ ["unique_artists", document.topArtists?.numUniqueArtists, "count"],
643
+ ["unique_genres", document.topGenres?.totalNumGenres, "count"],
644
+ ["night_listening_percent", document.party?.percentListenedNight, "percent"],
645
+ ["average_tempo_bpm", document.party?.weightedMsAvgTempo, "bpm"],
646
+ ["artists_discovered", document.party?.numArtistsDiscovered, "count"],
647
+ ["listening_day_streak", document.party?.streakNumListeningDays, "days"],
648
+ ];
649
+ for (const [name, value, unit] of metricValues) {
650
+ if (value === null || value === undefined) continue;
651
+ const normalized = numeric(value, `Spotify Wrapped ${name}`, {
652
+ maximum: Number.MAX_SAFE_INTEGER,
653
+ });
654
+ context.addEvidence({
655
+ logicalKey: `wrapped-metric\0${name}`,
656
+ kind: "wrapped_metric",
657
+ direction: "context",
658
+ strengthClass: "provider_derived",
659
+ entity: {
660
+ entity_type: "metric",
661
+ entity_ref_id: labelEntityRefId("metric", `wrapped-2025-${name}`),
662
+ label: name,
663
+ },
664
+ sourceMember: "Wrapped2025.json",
665
+ attributes: { value: normalized, unit, period: "2025" },
666
+ });
667
+ }
668
+ }
669
+
670
+ function capsuleRankedEvidence(values, kind, entityType, period, context) {
671
+ for (const [index, value] of requiredArray(values, kind, 1_000).entries()) {
672
+ if (!isPlainObject(value)) fail(`Spotify ${kind} item is invalid.`);
673
+ const label = cleanText(value.name, 512, `Spotify ${kind} label`);
674
+ context.addEvidence({
675
+ logicalKey: `${kind}\0${period}\0${normalizedText(label)}\0${index + 1}`,
676
+ kind,
677
+ strengthClass: "provider_derived",
678
+ entity: {
679
+ entity_type: entityType,
680
+ entity_ref_id: labelEntityRefId(entityType, label),
681
+ label,
682
+ },
683
+ sourceMember: "YourSoundCapsule.json",
684
+ attributes: {
685
+ rank: index + 1,
686
+ period,
687
+ stream_count: integer(value.streamCount, `Spotify ${kind} stream count`),
688
+ played_seconds: integer(
689
+ value.secondsPlayed,
690
+ `Spotify ${kind} played seconds`,
691
+ ),
692
+ },
693
+ });
694
+ }
695
+ }
696
+
697
+ function soundCapsuleEvidence(document, context) {
698
+ if (!isPlainObject(document)) fail("YourSoundCapsule.json is invalid.");
699
+ const stats = requiredArray(document.stats, "Spotify Sound Capsule stats", 1_000);
700
+ for (const [index, stat] of stats.entries()) {
701
+ if (!isPlainObject(stat)) fail("Spotify Sound Capsule stat is invalid.");
702
+ const period = cleanText(stat.date, 64, "Spotify Sound Capsule period");
703
+ capsuleRankedEvidence(
704
+ stat.topArtists ?? [],
705
+ "sound_capsule_artist_ranked",
706
+ "artist",
707
+ period,
708
+ context,
709
+ );
710
+ capsuleRankedEvidence(
711
+ stat.topTracks ?? [],
712
+ "sound_capsule_track_ranked",
713
+ "track_label",
714
+ period,
715
+ context,
716
+ );
717
+ context.addEvidence({
718
+ logicalKey: `sound-capsule-period\0${period}\0${index + 1}`,
719
+ kind: "sound_capsule_period_metric",
720
+ direction: "context",
721
+ strengthClass: "provider_derived",
722
+ entity: {
723
+ entity_type: "metric",
724
+ entity_ref_id: labelEntityRefId("metric", `sound-capsule-${period}`),
725
+ label: "period_listening",
726
+ },
727
+ observedAt: timestamp(
728
+ stat.date,
729
+ "Spotify Sound Capsule stat date",
730
+ context.capturedAt,
731
+ { dateOnly: true },
732
+ ),
733
+ sourceMember: "YourSoundCapsule.json",
734
+ attributes: {
735
+ period,
736
+ stream_count: integer(
737
+ stat.streamCount,
738
+ "Spotify Sound Capsule stream count",
739
+ ),
740
+ played_seconds: integer(
741
+ stat.secondsPlayed,
742
+ "Spotify Sound Capsule played seconds",
743
+ ),
744
+ },
745
+ });
746
+ }
747
+
748
+ const highlights = requiredArray(
749
+ document.highlights,
750
+ "Spotify Sound Capsule highlights",
751
+ 10_000,
752
+ );
753
+ for (const [index, highlight] of highlights.entries()) {
754
+ if (!isPlainObject(highlight)) fail("Spotify Sound Capsule highlight is invalid.");
755
+ const kind = cleanText(
756
+ highlight.highlightType,
757
+ 128,
758
+ "Spotify Sound Capsule highlight type",
759
+ );
760
+ const observedAt = timestamp(
761
+ highlight.date,
762
+ "Spotify Sound Capsule highlight date",
763
+ context.capturedAt,
764
+ { dateOnly: true },
765
+ );
766
+ const payload =
767
+ highlight.milestoneHighlight ??
768
+ highlight.multiEntityMilestoneHighlight ??
769
+ highlight.onRepeatHighlight ??
770
+ highlight.proportionListeningHighlight ??
771
+ highlight.streaksHighlight ??
772
+ highlight.unlikeCombinationHighlight ??
773
+ highlight.youStandOutHighlight;
774
+ if (!isPlainObject(payload)) continue;
775
+ const labels = [
776
+ payload.entity,
777
+ ...(Array.isArray(payload.entities) ? payload.entities : []),
778
+ payload.firstEntity,
779
+ payload.secondEntity,
780
+ ]
781
+ .filter((value) => typeof value === "string" && value.trim())
782
+ .map((value) => cleanText(value, 512, "Spotify Sound Capsule highlight label"));
783
+ if (labels.length < 1) continue;
784
+ const metricEntry = Object.entries(payload).find(
785
+ ([key, value]) => key !== "country" && typeof value === "number",
786
+ );
787
+ context.addEvidence({
788
+ logicalKey: `sound-capsule-highlight\0${observedAt}\0${kind}\0${index + 1}`,
789
+ kind: "sound_capsule_highlight",
790
+ direction: "context",
791
+ strengthClass: "provider_derived",
792
+ entity: {
793
+ entity_type: "provider_highlight",
794
+ entity_ref_id: labelEntityRefId(
795
+ "provider_highlight",
796
+ `${observedAt}\0${kind}\0${labels.join("\0")}`,
797
+ ),
798
+ label: labels[0],
799
+ },
800
+ observedAt,
801
+ sourceMember: "YourSoundCapsule.json",
802
+ attributes: {
803
+ highlight_type: kind,
804
+ ...(labels[1] ? { related_label: labels[1] } : {}),
805
+ ...(metricEntry
806
+ ? {
807
+ metric_name: metricEntry[0],
808
+ metric_value: numeric(
809
+ metricEntry[1],
810
+ "Spotify Sound Capsule highlight metric",
811
+ ),
812
+ }
813
+ : {}),
814
+ },
815
+ });
816
+ }
817
+ }
818
+
819
+ export function projectSpotifyAccountDataProfile({
820
+ subjectId,
821
+ archiveSha256,
822
+ archiveSizeBytes,
823
+ documents,
824
+ capturedAt = new Date().toISOString(),
825
+ } = {}) {
826
+ if (!isUuid(subjectId)) fail("A trusted subject ID is required.");
827
+ const normalizedSubjectId = subjectId.toLowerCase();
828
+ const normalizedCapturedAt = new Date(capturedAt).toISOString();
829
+ if (
830
+ typeof archiveSha256 !== "string" ||
831
+ !sha256Pattern.test(archiveSha256)
832
+ ) {
833
+ fail("Spotify account-profile archive SHA-256 is invalid.");
834
+ }
835
+ if (!Number.isSafeInteger(archiveSizeBytes) || archiveSizeBytes < 1) {
836
+ fail("Spotify account-profile archive size is invalid.");
837
+ }
838
+ if (!isPlainObject(documents)) fail("Spotify account-profile documents are invalid.");
839
+ const selectedMembers = Object.keys(documents).sort();
840
+ if (
841
+ selectedMembers.length < 1 ||
842
+ selectedMembers.some((name) => !memberNames.has(name))
843
+ ) {
844
+ fail("Spotify account-profile document selection is invalid.");
845
+ }
846
+ const profileImportId = uuidV5(
847
+ `${normalizedSubjectId}\0${archiveSha256}\0${SPOTIFY_ACCOUNT_PROFILE_FORMAT}`,
848
+ SPOTIFY_ACCOUNT_PROFILE_IMPORT_NAMESPACE,
849
+ );
850
+ const builder = profileBuilder({
851
+ subjectId: normalizedSubjectId,
852
+ archiveSha256,
853
+ capturedAt: normalizedCapturedAt,
854
+ profileImportId,
855
+ });
856
+ const context = {
857
+ ...builder,
858
+ capturedAt: normalizedCapturedAt,
859
+ trackLabels: new Map(),
860
+ artistLabels: new Map(),
861
+ albumLabels: new Map(),
862
+ };
863
+
864
+ if (documents["YourLibrary.json"] !== undefined) {
865
+ libraryEvidence(documents["YourLibrary.json"], context);
866
+ }
867
+ if (documents["Playlist1.json"] !== undefined) {
868
+ playlistEvidence(documents["Playlist1.json"], context);
869
+ }
870
+ if (documents["SearchQueries.json"] !== undefined) {
871
+ searchEvidence(documents["SearchQueries.json"], context);
872
+ }
873
+ if (documents["TasteProfile.json"] !== undefined) {
874
+ tasteProfileEvidence(documents["TasteProfile.json"], context);
875
+ }
876
+ if (documents["Wrapped2025.json"] !== undefined) {
877
+ wrappedEvidence(documents["Wrapped2025.json"], context);
878
+ }
879
+ if (documents["YourSoundCapsule.json"] !== undefined) {
880
+ soundCapsuleEvidence(documents["YourSoundCapsule.json"], context);
881
+ }
882
+
883
+ return {
884
+ track_refs: [...builder.tracks.values()],
885
+ profile_evidence: builder.records,
886
+ profile_import: {
887
+ profile_import_id: profileImportId,
888
+ subject_id: normalizedSubjectId,
889
+ source_key: SPOTIFY_ACCOUNT_PROFILE_SOURCE,
890
+ source_format: SPOTIFY_ACCOUNT_PROFILE_FORMAT,
891
+ archive_sha256: archiveSha256,
892
+ archive_size_bytes: archiveSizeBytes,
893
+ member_names: selectedMembers,
894
+ input_records: builder.records.length,
895
+ imported_at: normalizedCapturedAt,
896
+ },
897
+ };
898
+ }
899
+
900
+ export function mergeSpotifyAccountProfile(historyBundle, profileBundle) {
901
+ if (!isPlainObject(historyBundle) || !isPlainObject(profileBundle)) {
902
+ fail("Spotify account-profile merge input is invalid.");
903
+ }
904
+ const trackRefs = new Map();
905
+ for (const record of [
906
+ ...(historyBundle.track_refs ?? []),
907
+ ...(profileBundle.track_refs ?? []),
908
+ ]) {
909
+ const key = `${record.track_ref_id}:${record.revision}`;
910
+ if (!trackRefs.has(key)) trackRefs.set(key, record);
911
+ }
912
+ return {
913
+ ...historyBundle,
914
+ track_refs: [...trackRefs.values()],
915
+ profile_evidence: profileBundle.profile_evidence,
916
+ profile_import: profileBundle.profile_import,
917
+ };
918
+ }