@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,482 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { collectionCoverage } from "./music-providers.mjs";
3
+
4
+ import { readSpotifyHistoryArchive } from "../integrations/spotify/history-archive.mjs";
5
+ import { openEphemeralListeningHistoryStore } from "./listening-history-store.mjs";
6
+
7
+ export const SPOTIFY_ARCHIVE_SET_MAXIMUM = 2;
8
+
9
+ function uniqueByLabel(items, maximum) {
10
+ const seen = new Set();
11
+ const values = [];
12
+ for (const item of items) {
13
+ const label = typeof item?.label === "string" ? item.label.trim() : "";
14
+ const key = label.normalize("NFKC").toLocaleLowerCase("und");
15
+ if (!label || seen.has(key)) continue;
16
+ seen.add(key);
17
+ values.push(item);
18
+ if (values.length === maximum) break;
19
+ }
20
+ return values;
21
+ }
22
+
23
+ function normalizedLabel(value) {
24
+ return typeof value === "string"
25
+ ? value.normalize("NFKC").toLocaleLowerCase("und").trim()
26
+ : "";
27
+ }
28
+
29
+ function assertionAvoids(item, entityType, label, artistCredit = "") {
30
+ const avoids = item?.listener_assertions?.avoids ?? [];
31
+ const normalizedItemLabel = normalizedLabel(label);
32
+ const normalizedArtist = normalizedLabel(artistCredit);
33
+ return avoids.some((avoid) => {
34
+ if (avoid.entity_type === "artist") {
35
+ const avoidedArtist = normalizedLabel(avoid.label);
36
+ return entityType === "artist"
37
+ ? avoidedArtist === normalizedItemLabel
38
+ : avoidedArtist === normalizedArtist;
39
+ }
40
+ return entityType === "track" &&
41
+ normalizedLabel(avoid.label) === normalizedItemLabel &&
42
+ normalizedLabel(avoid.artist_credit) === normalizedArtist;
43
+ });
44
+ }
45
+
46
+ function uniqueArtistFacets(listening, maximum) {
47
+ const candidates = [
48
+ ...(listening.curated_preferences?.followed_artists ?? []),
49
+ ...(listening.listening_behavior?.enduring_artists ?? []),
50
+ ...(listening.provider_signals?.artists ?? []),
51
+ ];
52
+ const seen = new Set();
53
+ const values = [];
54
+ for (const item of candidates) {
55
+ const name = typeof item?.name === "string" ? item.name.trim() : "";
56
+ const key = name.normalize("NFKC").toLocaleLowerCase("und");
57
+ if (
58
+ !name ||
59
+ seen.has(key) ||
60
+ assertionAvoids(listening, "artist", name)
61
+ ) {
62
+ continue;
63
+ }
64
+ seen.add(key);
65
+ values.push({ name, ...(item.evidence_id ? { evidence_id: item.evidence_id } : {}) });
66
+ if (values.length === maximum) break;
67
+ }
68
+ return values;
69
+ }
70
+
71
+ function strongPreferences(listening, maximum) {
72
+ const curated = listening.curated_preferences ?? {};
73
+ return uniqueByLabel(
74
+ [
75
+ ...(listening.listener_assertions?.preferences ?? []).map((item) => ({
76
+ ...item,
77
+ signal: `You explicitly said you like this ${item.entity_type}`,
78
+ })),
79
+ ...(curated.saved_tracks ?? [])
80
+ .filter(
81
+ (item) =>
82
+ !assertionAvoids(
83
+ listening,
84
+ "track",
85
+ item.label,
86
+ item.artist_credit,
87
+ ),
88
+ )
89
+ .map((item) => ({
90
+ ...item,
91
+ signal: `Saved in ${item.source_label ?? "Spotify"} library`,
92
+ })),
93
+ ...(curated.followed_artists ?? [])
94
+ .filter(
95
+ (item) => !assertionAvoids(listening, "artist", item.name),
96
+ )
97
+ .map((item) => ({
98
+ ...item,
99
+ label: item.name,
100
+ signal: "Followed artist",
101
+ })),
102
+ ...(curated.saved_albums ?? [])
103
+ .filter(
104
+ (item) =>
105
+ !assertionAvoids(
106
+ listening,
107
+ "album",
108
+ item.label,
109
+ item.artist_credit,
110
+ ),
111
+ )
112
+ .map((item) => ({
113
+ ...item,
114
+ signal: "Saved album",
115
+ })),
116
+ ...(curated.playlist_anchors ?? [])
117
+ .filter(
118
+ (item) =>
119
+ !assertionAvoids(
120
+ listening,
121
+ "track",
122
+ item.label,
123
+ item.artist_credit,
124
+ ),
125
+ )
126
+ .map((item) => ({
127
+ ...item,
128
+ signal: `${item.source_label ?? "Spotify"} playlist anchor`,
129
+ })),
130
+ ],
131
+ maximum,
132
+ );
133
+ }
134
+
135
+ function createListeningProfileProjection(
136
+ listening,
137
+ { maxItems = 10, persistent = false, archiveCount = 1 } = {},
138
+ ) {
139
+ if (!listening || typeof listening !== "object" || Array.isArray(listening)) {
140
+ throw new TypeError("A bounded listening-history projection is required.");
141
+ }
142
+ if (!Number.isInteger(maxItems) || maxItems < 1 || maxItems > 50) {
143
+ throw new TypeError("Listening-history Tasteprint maxItems is invalid.");
144
+ }
145
+ if (typeof persistent !== "boolean") {
146
+ throw new TypeError("Listening projection persistence is invalid.");
147
+ }
148
+ if (
149
+ !Number.isInteger(archiveCount) ||
150
+ archiveCount < 1 ||
151
+ archiveCount > SPOTIFY_ARCHIVE_SET_MAXIMUM
152
+ ) {
153
+ throw new TypeError("Listening projection archive count is invalid.");
154
+ }
155
+ const coverage = listening.coverage ?? {};
156
+ const providerGenres = listening.provider_signals?.genres ?? [];
157
+ return {
158
+ profile_version: persistent
159
+ ? "profile-projection/listening-history/1"
160
+ : "profile-projection/spotify-archive-preview-1",
161
+ max_items_applied: maxItems,
162
+ strong_preferences: strongPreferences(listening, maxItems),
163
+ familiarity: (listening.listening_behavior?.repeat_tracks ?? [])
164
+ .slice(0, maxItems)
165
+ .map((item) => ({
166
+ label: item.label,
167
+ level:
168
+ Number.isInteger(item.play_count) && item.play_count >= 20
169
+ ? "high"
170
+ : Number.isInteger(item.play_count) && item.play_count >= 5
171
+ ? "medium"
172
+ : "low",
173
+ ...(Number.isInteger(item.play_count)
174
+ ? { play_count: item.play_count }
175
+ : {}),
176
+ ...(item.evidence_id ? { evidence_id: item.evidence_id } : {}),
177
+ })),
178
+ artist_facets: uniqueArtistFacets(listening, maxItems),
179
+ genre_facets: providerGenres.slice(0, maxItems).map((item) => ({
180
+ name: item.name,
181
+ ...(item.evidence_id ? { evidence_id: item.evidence_id } : {}),
182
+ })),
183
+ coverage: {
184
+ tracks_observed: 0,
185
+ loved_or_favorited: 0,
186
+ effective_listening_events: coverage.effective_listening_events,
187
+ profiled_listening_events: coverage.profiled_listening_events,
188
+ events_with_played_duration: coverage.events_with_played_duration,
189
+ listening_hours: coverage.listening_hours,
190
+ listening_tracks: coverage.distinct_tracks,
191
+ resolved_listening_tracks: coverage.resolved_tracks,
192
+ cross_format_track_links: coverage.cross_format_track_links,
193
+ cross_format_linked_events: coverage.cross_format_linked_events,
194
+ cross_format_ambiguous_tracks: coverage.cross_format_ambiguous_tracks,
195
+ cross_format_ambiguous_events: coverage.cross_format_ambiguous_events,
196
+ ...collectionCoverage(coverage),
197
+ verified_search_interactions: coverage.verified_search_interactions,
198
+ listener_assertion_events: coverage.listener_assertion_events,
199
+ active_listener_assertions: coverage.active_listener_assertions,
200
+ listener_retractions: coverage.listener_retractions,
201
+ },
202
+ listening_behavior: listening.listening_behavior ?? {},
203
+ curated_preferences: listening.curated_preferences ?? {},
204
+ listener_assertions: listening.listener_assertions ?? {
205
+ active: [],
206
+ preferences: [],
207
+ avoids: [],
208
+ retractions: 0,
209
+ },
210
+ search_intent: listening.search_intent ?? [],
211
+ provider_signals: listening.provider_signals ?? {},
212
+ listening_source: listening.source ?? {},
213
+ limitations: [
214
+ persistent
215
+ ? "Read from the listening history saved on this machine."
216
+ : archiveCount === 1
217
+ ? "A one-off reading of the Spotify ZIP you chose. Nothing was saved to your profile."
218
+ : `A one-off reading of the ${archiveCount} Spotify ZIPs you chose, combined. Nothing was saved to your profile.`,
219
+ ...(listening.limitations ?? []),
220
+ ].slice(0, 8),
221
+ };
222
+ }
223
+
224
+ export function createListeningHistoryProfileProjection(
225
+ listening,
226
+ { maxItems = 10 } = {},
227
+ ) {
228
+ return createListeningProfileProjection(listening, {
229
+ maxItems,
230
+ persistent: true,
231
+ });
232
+ }
233
+
234
+ export function createSpotifyListeningProfileProjection(listening, options) {
235
+ return createListeningProfileProjection(listening, options);
236
+ }
237
+
238
+ export function createSpotifyArchiveProfileProjection(listening, options) {
239
+ return createSpotifyListeningProfileProjection(listening, options);
240
+ }
241
+
242
+ function validatedArchivePaths(value) {
243
+ if (
244
+ !Array.isArray(value) ||
245
+ value.length < 1 ||
246
+ value.length > SPOTIFY_ARCHIVE_SET_MAXIMUM ||
247
+ value.some((item) => typeof item !== "string" || !item.trim())
248
+ ) {
249
+ throw new TypeError(
250
+ `Choose between 1 and ${SPOTIFY_ARCHIVE_SET_MAXIMUM} Spotify history ZIP archives.`,
251
+ );
252
+ }
253
+ if (new Set(value.map((item) => item.trim())).size !== value.length) {
254
+ throw new TypeError("Each selected Spotify history ZIP must be distinct.");
255
+ }
256
+ return value.map((item) => item.trim());
257
+ }
258
+
259
+ function archiveSetSource(receipts, listening, { persistent }) {
260
+ if (receipts.length === 1) {
261
+ const [receipt] = receipts;
262
+ return {
263
+ source_format: receipt.source_format,
264
+ data_scope: receipt.data_scope,
265
+ input_records: receipt.input_records,
266
+ profile_input_records: receipt.profile_input_records ?? 0,
267
+ persistent_import: persistent,
268
+ ...(persistent
269
+ ? {
270
+ already_imported: receipt.already_imported,
271
+ effective_event_delta: receipt.effective_event_delta,
272
+ superseded_events: receipt.superseded_events,
273
+ }
274
+ : {}),
275
+ };
276
+ }
277
+
278
+ const sources = receipts
279
+ .map((receipt) => ({
280
+ source_format: receipt.source_format,
281
+ data_scope: receipt.data_scope,
282
+ input_records: receipt.input_records,
283
+ profile_input_records: receipt.profile_input_records ?? 0,
284
+ }))
285
+ .sort((left, right) => {
286
+ const leftKey = JSON.stringify(left);
287
+ const rightKey = JSON.stringify(right);
288
+ if (leftKey === rightKey) return 0;
289
+ return leftKey < rightKey ? -1 : 1;
290
+ });
291
+ return {
292
+ source_format: "spotify_history_archive_set_v1",
293
+ data_scope: "combined_spotify_history",
294
+ archive_count: receipts.length,
295
+ sources,
296
+ input_records: sources.reduce((total, source) => total + source.input_records, 0),
297
+ profile_input_records: sources.reduce(
298
+ (total, source) => total + source.profile_input_records,
299
+ 0,
300
+ ),
301
+ effective_listening_events:
302
+ listening.coverage?.effective_listening_events ?? 0,
303
+ reconciled_overlap_events: receipts.reduce(
304
+ (total, receipt) => total + receipt.superseded_events,
305
+ 0,
306
+ ),
307
+ persistent_import: persistent,
308
+ ...(persistent
309
+ ? {
310
+ already_imported: receipts.every(
311
+ (receipt) => receipt.already_imported === true,
312
+ ),
313
+ effective_event_delta: receipts.reduce(
314
+ (total, receipt) => total + receipt.effective_event_delta,
315
+ 0,
316
+ ),
317
+ superseded_events: receipts.reduce(
318
+ (total, receipt) => total + receipt.superseded_events,
319
+ 0,
320
+ ),
321
+ }
322
+ : {}),
323
+ };
324
+ }
325
+
326
+ async function ingestSpotifyArchiveSet({
327
+ archivePaths,
328
+ subjectId,
329
+ store,
330
+ capturedAt,
331
+ historyArchiveImporter,
332
+ }) {
333
+ const archiveHashes = new Set();
334
+ const receipts = [];
335
+ for (const archivePath of archivePaths) {
336
+ const bundle = await historyArchiveImporter({
337
+ archivePath,
338
+ subjectId,
339
+ capturedAt,
340
+ });
341
+ const archiveSha256 = bundle?.import_batch?.archive_sha256;
342
+ if (
343
+ typeof archiveSha256 === "string" &&
344
+ archiveHashes.has(archiveSha256)
345
+ ) {
346
+ throw new TypeError("Each selected Spotify history ZIP must be distinct.");
347
+ }
348
+ if (typeof archiveSha256 === "string") archiveHashes.add(archiveSha256);
349
+ receipts.push(store.ingestImport(bundle));
350
+ }
351
+ return receipts;
352
+ }
353
+
354
+ export async function projectTasteFromSpotifyArchives({
355
+ archivePaths,
356
+ capturedAt = new Date().toISOString(),
357
+ maxItems = 10,
358
+ historyArchiveImporter = readSpotifyHistoryArchive,
359
+ openStore = openEphemeralListeningHistoryStore,
360
+ } = {}) {
361
+ const selectedArchivePaths = validatedArchivePaths(archivePaths);
362
+ if (!Number.isInteger(maxItems) || maxItems < 1 || maxItems > 50) {
363
+ throw new TypeError("Spotify archive Tasteprint maxItems is invalid.");
364
+ }
365
+ if (typeof capturedAt !== "string" || Number.isNaN(Date.parse(capturedAt))) {
366
+ throw new TypeError("Spotify archive Tasteprint capturedAt is invalid.");
367
+ }
368
+
369
+ const normalizedCapturedAt = new Date(capturedAt).toISOString();
370
+ const subjectId = randomUUID();
371
+ let store;
372
+ try {
373
+ store = await openStore();
374
+ const receipts = await ingestSpotifyArchiveSet({
375
+ archivePaths: selectedArchivePaths,
376
+ subjectId,
377
+ store,
378
+ capturedAt: normalizedCapturedAt,
379
+ historyArchiveImporter,
380
+ });
381
+ const listening = store.profileSummary({ subjectId, maxItems });
382
+ return {
383
+ profile: createSpotifyListeningProfileProjection(listening, {
384
+ maxItems,
385
+ archiveCount: selectedArchivePaths.length,
386
+ }),
387
+ source: archiveSetSource(receipts, listening, { persistent: false }),
388
+ };
389
+ } finally {
390
+ store?.close?.();
391
+ }
392
+ }
393
+
394
+ export async function projectTasteFromSpotifyArchive({
395
+ archivePath,
396
+ capturedAt = new Date().toISOString(),
397
+ maxItems = 10,
398
+ historyArchiveImporter = readSpotifyHistoryArchive,
399
+ openStore = openEphemeralListeningHistoryStore,
400
+ } = {}) {
401
+ if (typeof archivePath !== "string" || !archivePath.trim()) {
402
+ throw new TypeError("A Spotify history ZIP path is required.");
403
+ }
404
+ return projectTasteFromSpotifyArchives({
405
+ archivePaths: [archivePath],
406
+ capturedAt,
407
+ maxItems,
408
+ historyArchiveImporter,
409
+ openStore,
410
+ });
411
+ }
412
+
413
+ export async function persistTasteFromSpotifyArchive({
414
+ archivePath,
415
+ subjectId,
416
+ store,
417
+ capturedAt = new Date().toISOString(),
418
+ maxItems = 10,
419
+ historyArchiveImporter = readSpotifyHistoryArchive,
420
+ } = {}) {
421
+ if (typeof archivePath !== "string" || !archivePath.trim()) {
422
+ throw new TypeError("A Spotify history ZIP path is required.");
423
+ }
424
+ const result = await persistTasteFromSpotifyArchives({
425
+ archivePaths: [archivePath],
426
+ subjectId,
427
+ store,
428
+ capturedAt,
429
+ maxItems,
430
+ historyArchiveImporter,
431
+ });
432
+ const { receipts, ...projection } = result;
433
+ return {
434
+ ...projection,
435
+ receipt: receipts[0],
436
+ };
437
+ }
438
+
439
+ async function persistTasteFromSpotifyArchives({
440
+ archivePaths,
441
+ subjectId,
442
+ store,
443
+ capturedAt = new Date().toISOString(),
444
+ maxItems = 10,
445
+ historyArchiveImporter = readSpotifyHistoryArchive,
446
+ } = {}) {
447
+ const selectedArchivePaths = validatedArchivePaths(archivePaths);
448
+ if (typeof subjectId !== "string" || !subjectId.trim()) {
449
+ throw new TypeError("A trusted local music subject is required.");
450
+ }
451
+ if (!Number.isInteger(maxItems) || maxItems < 1 || maxItems > 50) {
452
+ throw new TypeError("Spotify archive Tasteprint maxItems is invalid.");
453
+ }
454
+ if (
455
+ !store ||
456
+ typeof store.ingestImport !== "function" ||
457
+ typeof store.profileSummary !== "function"
458
+ ) {
459
+ throw new TypeError("A persistent listening-history store is required.");
460
+ }
461
+ if (typeof capturedAt !== "string" || Number.isNaN(Date.parse(capturedAt))) {
462
+ throw new TypeError("Spotify archive Tasteprint capturedAt is invalid.");
463
+ }
464
+ const normalizedCapturedAt = new Date(capturedAt).toISOString();
465
+ const receipts = await ingestSpotifyArchiveSet({
466
+ archivePaths: selectedArchivePaths,
467
+ subjectId,
468
+ store,
469
+ capturedAt: normalizedCapturedAt,
470
+ historyArchiveImporter,
471
+ });
472
+ const listening = store.profileSummary({ subjectId, maxItems });
473
+ return {
474
+ profile: createSpotifyListeningProfileProjection(listening, {
475
+ maxItems,
476
+ persistent: true,
477
+ archiveCount: selectedArchivePaths.length,
478
+ }),
479
+ source: archiveSetSource(receipts, listening, { persistent: true }),
480
+ receipts,
481
+ };
482
+ }