@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,557 @@
1
+ import {
2
+ contractRecordKey,
3
+ canonicalizeJson,
4
+ sha256Hex,
5
+ validateLibraryTrackObservationSemantics,
6
+ validateProfileEvidenceSemantics,
7
+ validateTrackRefSemantics,
8
+ } from "../../../scripts/contract-semantics.mjs";
9
+ import {
10
+ contractSchemaId,
11
+ createContractValidator,
12
+ formatValidationErrors,
13
+ } from "../../../scripts/contract-lib.mjs";
14
+ import { validateAppleMusicImportRecords } from "./normalize.mjs";
15
+ import { AppleMusicImportError } from "./parse-plist.mjs";
16
+ import {
17
+ APPLE_LIBRARY_CANONICAL_TRACK_NAMESPACE,
18
+ APPLE_LIBRARY_OBSERVATION_NAMESPACE,
19
+ APPLE_LIBRARY_PROFILE_EVIDENCE_NAMESPACE,
20
+ isUuid,
21
+ uuidV5,
22
+ } from "./stable-ids.mjs";
23
+
24
+ export const APPLE_PROFILE_RULES_VERSION = "apple-profile-rules/1";
25
+ export const APPLE_PROFILE_RULES = Object.freeze({
26
+ curation: {
27
+ one_positive_state_strength: 0.8,
28
+ multiple_positive_states_strength: 0.9,
29
+ confidence: 0.85,
30
+ },
31
+ rating: {
32
+ positive_minimum: 60,
33
+ negative_maximum: 40,
34
+ confidence: 0.9,
35
+ },
36
+ familiarity: {
37
+ moderate_minimum: 5,
38
+ strong_minimum: 20,
39
+ strengths: {
40
+ low: 0.35,
41
+ moderate: 0.6,
42
+ strong: 0.85,
43
+ },
44
+ confidence: 0.9,
45
+ },
46
+ });
47
+
48
+ let validatorsPromise;
49
+
50
+ async function validators() {
51
+ validatorsPromise ??= createContractValidator().then(({ ajv }) => ({
52
+ observation: ajv.getSchema(contractSchemaId("library-track-observation", 1)),
53
+ profileEvidence: ajv.getSchema(contractSchemaId("profile-evidence", 2)),
54
+ trackRef: ajv.getSchema(contractSchemaId("track-ref", 1)),
55
+ }));
56
+ return validatorsPromise;
57
+ }
58
+
59
+ function fail(code, message) {
60
+ throw new AppleMusicImportError(code, message);
61
+ }
62
+
63
+ function compareCanonical(left, right) {
64
+ const leftValue = canonicalizeJson(left);
65
+ const rightValue = canonicalizeJson(right);
66
+ return leftValue < rightValue ? -1 : leftValue > rightValue ? 1 : 0;
67
+ }
68
+
69
+ function compareText(left, right) {
70
+ return left < right ? -1 : left > right ? 1 : 0;
71
+ }
72
+
73
+ function canonicalTrackMetadata(trackRef) {
74
+ return Object.fromEntries(
75
+ Object.entries({
76
+ identity_status: trackRef.identity_status,
77
+ display_label: trackRef.display_label,
78
+ title: trackRef.title,
79
+ artist_credits: trackRef.artist_credits,
80
+ release: trackRef.release,
81
+ duration_ms: trackRef.duration_ms,
82
+ isrc: trackRef.isrc,
83
+ external_refs: trackRef.external_refs,
84
+ extensions: trackRef.extensions,
85
+ }).filter(([, value]) => value !== undefined),
86
+ );
87
+ }
88
+
89
+ function externalAnchorKey(subjectId, externalRef) {
90
+ return canonicalizeJson({
91
+ subject_id: subjectId,
92
+ system: externalRef.system,
93
+ entity_type: externalRef.entity_type,
94
+ external_id: externalRef.external_id,
95
+ });
96
+ }
97
+
98
+ function canonicalTrackId(subjectId, externalRef) {
99
+ return uuidV5(
100
+ canonicalizeJson({
101
+ kind: "moondog.exact_external_track_anchor",
102
+ version: 1,
103
+ subject_id: subjectId,
104
+ system: externalRef.system,
105
+ entity_type: externalRef.entity_type,
106
+ external_id: externalRef.external_id,
107
+ }),
108
+ APPLE_LIBRARY_CANONICAL_TRACK_NAMESPACE,
109
+ );
110
+ }
111
+
112
+ function sourceRecordDigest(trackRef, snapshot) {
113
+ return sha256Hex({
114
+ kind: "moondog.apple_music_library.normalized_source_record",
115
+ version: 1,
116
+ track_ref_candidate: trackRef,
117
+ aggregate_snapshot: snapshot,
118
+ });
119
+ }
120
+
121
+ function observationId(observationIdentity) {
122
+ return uuidV5(
123
+ canonicalizeJson({
124
+ kind: "moondog.library_track_observation",
125
+ version: 1,
126
+ ...observationIdentity,
127
+ }),
128
+ APPLE_LIBRARY_OBSERVATION_NAMESPACE,
129
+ );
130
+ }
131
+
132
+ function profileRuleParametersDigest(ruleName) {
133
+ return sha256Hex({
134
+ kind: "moondog.profile_rule_parameters",
135
+ version: 1,
136
+ rule_name: ruleName,
137
+ rules_version: APPLE_PROFILE_RULES_VERSION,
138
+ rules: APPLE_PROFILE_RULES,
139
+ });
140
+ }
141
+
142
+ function evidenceId(evidence) {
143
+ return uuidV5(
144
+ canonicalizeJson({
145
+ kind: "moondog.profile_evidence_identity",
146
+ version: 2,
147
+ subject_id: evidence.subject_id,
148
+ basis_refs: [...evidence.basis_refs].sort(compareCanonical),
149
+ claim: evidence.claim,
150
+ direction: evidence.direction,
151
+ strength: evidence.strength,
152
+ confidence: evidence.confidence,
153
+ derivation: {
154
+ kind: evidence.derivation.kind,
155
+ name: evidence.derivation.name,
156
+ version: evidence.derivation.version,
157
+ parameters_digest: evidence.derivation.parameters_digest,
158
+ },
159
+ }),
160
+ APPLE_LIBRARY_PROFILE_EVIDENCE_NAMESPACE,
161
+ );
162
+ }
163
+
164
+ function entityClaim(trackRef, dimension) {
165
+ return {
166
+ dimension,
167
+ value: {
168
+ value_type: "entity",
169
+ entity_ref: {
170
+ entity_type: "track",
171
+ entity_id: trackRef.track_ref_id,
172
+ entity_revision: trackRef.revision,
173
+ label: trackRef.display_label,
174
+ },
175
+ },
176
+ };
177
+ }
178
+
179
+ function makeEvidence({
180
+ observation,
181
+ trackRef,
182
+ dimension,
183
+ direction,
184
+ strength,
185
+ confidence,
186
+ ruleName,
187
+ }) {
188
+ const record = {
189
+ schema_version: 2,
190
+ profile_evidence_id: "00000000-0000-4000-8000-000000000000",
191
+ subject_id: observation.subject_id,
192
+ claim: entityClaim(trackRef, dimension),
193
+ direction,
194
+ strength,
195
+ confidence,
196
+ basis_refs: [
197
+ {
198
+ record_type: "library_track_observation",
199
+ record_id: observation.library_track_observation_id,
200
+ },
201
+ ],
202
+ derivation: {
203
+ kind: "rule",
204
+ name: ruleName,
205
+ version: APPLE_PROFILE_RULES_VERSION,
206
+ parameters_digest: profileRuleParametersDigest(ruleName),
207
+ },
208
+ evidence_window: {
209
+ start_at: observation.observed_at,
210
+ end_at: observation.observed_at,
211
+ },
212
+ created_at: observation.observed_at,
213
+ };
214
+ record.profile_evidence_id = evidenceId(record);
215
+ return record;
216
+ }
217
+
218
+ function familiarityStrength(playCount) {
219
+ if (playCount >= APPLE_PROFILE_RULES.familiarity.strong_minimum) {
220
+ return APPLE_PROFILE_RULES.familiarity.strengths.strong;
221
+ }
222
+ if (playCount >= APPLE_PROFILE_RULES.familiarity.moderate_minimum) {
223
+ return APPLE_PROFILE_RULES.familiarity.strengths.moderate;
224
+ }
225
+ return APPLE_PROFILE_RULES.familiarity.strengths.low;
226
+ }
227
+
228
+ function evidenceForObservation(observation, trackRef) {
229
+ const records = [];
230
+ const positiveCurationStates = [
231
+ observation.aggregate_state.loved,
232
+ observation.aggregate_state.favorited,
233
+ ].filter((value) => value === true).length;
234
+ if (positiveCurationStates > 0) {
235
+ records.push(
236
+ makeEvidence({
237
+ observation,
238
+ trackRef,
239
+ dimension: "taste.track_preference",
240
+ direction: "supports",
241
+ strength:
242
+ positiveCurationStates > 1
243
+ ? APPLE_PROFILE_RULES.curation.multiple_positive_states_strength
244
+ : APPLE_PROFILE_RULES.curation.one_positive_state_strength,
245
+ confidence: APPLE_PROFILE_RULES.curation.confidence,
246
+ ruleName: "provider-curation-track-preference",
247
+ }),
248
+ );
249
+ }
250
+
251
+ const rating = observation.aggregate_state.rating;
252
+ if (rating && rating.computed !== true) {
253
+ let direction;
254
+ if (rating.value >= APPLE_PROFILE_RULES.rating.positive_minimum) {
255
+ direction = "supports";
256
+ } else if (rating.value <= APPLE_PROFILE_RULES.rating.negative_maximum) {
257
+ direction = "contradicts";
258
+ }
259
+ if (direction) {
260
+ records.push(
261
+ makeEvidence({
262
+ observation,
263
+ trackRef,
264
+ dimension: "taste.track_preference",
265
+ direction,
266
+ strength: Math.abs(rating.value - 50) / 50,
267
+ confidence: APPLE_PROFILE_RULES.rating.confidence,
268
+ ruleName: "provider-rating-track-preference",
269
+ }),
270
+ );
271
+ }
272
+ }
273
+
274
+ const playCount = observation.aggregate_state.play_count;
275
+ if (Number.isSafeInteger(playCount) && playCount > 0) {
276
+ records.push(
277
+ makeEvidence({
278
+ observation,
279
+ trackRef,
280
+ dimension: "taste.track_familiarity",
281
+ direction: "supports",
282
+ strength: familiarityStrength(playCount),
283
+ confidence: APPLE_PROFILE_RULES.familiarity.confidence,
284
+ ruleName: "aggregate-play-count-track-familiarity",
285
+ }),
286
+ );
287
+ }
288
+ return records;
289
+ }
290
+
291
+ function normalizeLibraryState(snapshot) {
292
+ const libraryState = structuredClone(snapshot.library_state);
293
+ if (libraryState.apple_music !== undefined) {
294
+ libraryState.subscription_catalog_item = libraryState.apple_music;
295
+ delete libraryState.apple_music;
296
+ }
297
+ return libraryState;
298
+ }
299
+
300
+ function assertValidRecord(record, validate, label) {
301
+ if (!validate(record)) {
302
+ fail(
303
+ "projection_schema_invalid",
304
+ `${label} failed validation: ${formatValidationErrors(validate.errors)}`,
305
+ );
306
+ }
307
+ }
308
+
309
+ export function computeAppleProjectionInputDigest(batch) {
310
+ return sha256Hex({
311
+ kind: "moondog.apple_projection_input",
312
+ version: 1,
313
+ manifest: batch.manifest,
314
+ track_refs: batch.trackRefs,
315
+ track_snapshots: batch.trackSnapshots,
316
+ warnings: batch.warnings,
317
+ });
318
+ }
319
+
320
+ export async function promoteAppleMusicImportBatches(
321
+ inputBatches,
322
+ { subjectId } = {},
323
+ ) {
324
+ if (!Array.isArray(inputBatches) || inputBatches.length === 0) {
325
+ fail("projection_input_missing", "Apple projection requires an import batch");
326
+ }
327
+ if (subjectId !== undefined && !isUuid(subjectId)) {
328
+ throw new TypeError("Apple projection requires a valid subject UUID");
329
+ }
330
+ const sortedBatches = [...inputBatches].sort((left, right) => {
331
+ const capturedOrder = compareText(
332
+ left.manifest.source.captured_at,
333
+ right.manifest.source.captured_at,
334
+ );
335
+ if (capturedOrder !== 0) return capturedOrder;
336
+ return compareText(
337
+ left.manifest.import_batch_id,
338
+ right.manifest.import_batch_id,
339
+ );
340
+ });
341
+ const normalizedSubjectId = (
342
+ subjectId ?? sortedBatches[0].manifest.subject_id
343
+ ).toLowerCase();
344
+ const seenBatchIds = new Map();
345
+ const batches = [];
346
+ for (const batch of sortedBatches) {
347
+ await validateAppleMusicImportRecords(batch);
348
+ if (batch.manifest.subject_id !== normalizedSubjectId) {
349
+ fail("projection_subject_mismatch", "Apple projection cannot cross subjects");
350
+ }
351
+ const inputDigest = computeAppleProjectionInputDigest(batch);
352
+ const priorDigest = seenBatchIds.get(batch.manifest.import_batch_id);
353
+ if (priorDigest && priorDigest !== inputDigest) {
354
+ fail("projection_batch_conflict", "Apple projection batch identity conflicts");
355
+ }
356
+ if (priorDigest) continue;
357
+ seenBatchIds.set(batch.manifest.import_batch_id, inputDigest);
358
+ batches.push(batch);
359
+ }
360
+
361
+ const { observation: validateObservation, profileEvidence: validateEvidence, trackRef: validateTrackRef } =
362
+ await validators();
363
+ const anchorStates = new Map();
364
+ const trackStateById = new Map();
365
+ const trackRefs = [];
366
+ const observationsById = new Map();
367
+ const currentTrackIds = new Set();
368
+ const currentObservationByTrack = new Map();
369
+ const latestBatchId = batches.at(-1).manifest.import_batch_id;
370
+ let unresolvedIdentityCount = 0;
371
+
372
+ for (const batch of batches) {
373
+ const snapshotByCandidateId = new Map(
374
+ batch.trackSnapshots.map((snapshot) => [snapshot.track_ref_id, snapshot]),
375
+ );
376
+ const fallbackSourceKeys = new Set(
377
+ batch.warnings
378
+ .filter((warning) => warning.code === "identity_fallback_unstable")
379
+ .map((warning) => warning.source_record_key_sha256),
380
+ );
381
+ unresolvedIdentityCount += fallbackSourceKeys.size;
382
+
383
+ for (const candidate of batch.trackRefs) {
384
+ const snapshot = snapshotByCandidateId.get(candidate.track_ref_id);
385
+ const externalRef = candidate.external_refs[0];
386
+ const anchorKey = externalAnchorKey(normalizedSubjectId, externalRef);
387
+ let state = anchorStates.get(anchorKey);
388
+ if (!state) {
389
+ state = {
390
+ trackRefId: canonicalTrackId(normalizedSubjectId, externalRef),
391
+ revision: 0,
392
+ metadataDigest: null,
393
+ currentTrackRef: null,
394
+ };
395
+ anchorStates.set(anchorKey, state);
396
+ trackStateById.set(state.trackRefId, state);
397
+ }
398
+
399
+ const metadata = canonicalTrackMetadata(candidate);
400
+ const metadataDigest = sha256Hex({
401
+ kind: "moondog.track_ref_metadata",
402
+ version: 1,
403
+ metadata,
404
+ });
405
+ if (metadataDigest !== state.metadataDigest) {
406
+ const revision = state.revision + 1;
407
+ const canonicalTrackRef = {
408
+ schema_version: 1,
409
+ track_ref_id: state.trackRefId,
410
+ revision,
411
+ ...structuredClone(metadata),
412
+ created_at: batch.manifest.source.captured_at,
413
+ };
414
+ if (revision > 1) {
415
+ canonicalTrackRef.supersedes = {
416
+ track_ref_id: state.trackRefId,
417
+ revision: revision - 1,
418
+ };
419
+ }
420
+ assertValidRecord(canonicalTrackRef, validateTrackRef, "TrackRef");
421
+ if (!validateTrackRefSemantics(canonicalTrackRef).ok) {
422
+ fail("projection_semantics_invalid", "TrackRef semantics are invalid");
423
+ }
424
+ trackRefs.push(canonicalTrackRef);
425
+ state.revision = revision;
426
+ state.metadataDigest = metadataDigest;
427
+ state.currentTrackRef = canonicalTrackRef;
428
+ }
429
+
430
+ const digest = sourceRecordDigest(candidate, snapshot);
431
+ const identity = {
432
+ subject_id: normalizedSubjectId,
433
+ source_batch_ref: batch.manifest.import_batch_id,
434
+ source_record_digest: digest,
435
+ track_ref_id: state.trackRefId,
436
+ track_ref_revision: state.revision,
437
+ observed_at: snapshot.captured_at,
438
+ };
439
+ const observation = {
440
+ schema_version: 1,
441
+ library_track_observation_id: observationId(identity),
442
+ ...identity,
443
+ source_kind: "provider_library_snapshot",
444
+ aggregate_state: structuredClone(snapshot.aggregate_state),
445
+ library_state: normalizeLibraryState(snapshot),
446
+ provenance: {
447
+ source_kind: "import",
448
+ captured_at: snapshot.captured_at,
449
+ import_batch_id: batch.manifest.import_batch_id,
450
+ },
451
+ };
452
+ assertValidRecord(
453
+ observation,
454
+ validateObservation,
455
+ "LibraryTrackObservation",
456
+ );
457
+ const observationSemantics = validateLibraryTrackObservationSemantics(
458
+ observation,
459
+ { trackRef: state.currentTrackRef },
460
+ );
461
+ if (!observationSemantics.ok) {
462
+ fail(
463
+ "projection_semantics_invalid",
464
+ "LibraryTrackObservation semantics are invalid",
465
+ );
466
+ }
467
+ const priorObservation = observationsById.get(
468
+ observation.library_track_observation_id,
469
+ );
470
+ if (
471
+ priorObservation &&
472
+ canonicalizeJson(priorObservation) !== canonicalizeJson(observation)
473
+ ) {
474
+ fail("projection_observation_conflict", "Observation identity conflicts");
475
+ }
476
+ observationsById.set(observation.library_track_observation_id, observation);
477
+
478
+ if (batch.manifest.import_batch_id === latestBatchId) {
479
+ currentTrackIds.add(state.trackRefId);
480
+ currentObservationByTrack.set(state.trackRefId, observation);
481
+ }
482
+ }
483
+ }
484
+
485
+ const currentTrackRefs = [...currentTrackIds]
486
+ .map((trackRefId) => trackStateById.get(trackRefId).currentTrackRef)
487
+ .sort((left, right) => compareText(left.track_ref_id, right.track_ref_id));
488
+ const trackRefById = new Map(
489
+ currentTrackRefs.map((trackRef) => [trackRef.track_ref_id, trackRef]),
490
+ );
491
+ const profileEvidence = [];
492
+ const evidenceRecords = new Map(
493
+ [...observationsById.values()].map((observation) => [
494
+ contractRecordKey(
495
+ "library_track_observation",
496
+ observation.library_track_observation_id,
497
+ ),
498
+ observation,
499
+ ]),
500
+ );
501
+ for (const [trackRefId, observation] of currentObservationByTrack) {
502
+ for (const evidence of evidenceForObservation(
503
+ observation,
504
+ trackRefById.get(trackRefId),
505
+ )) {
506
+ assertValidRecord(evidence, validateEvidence, "ProfileEvidence");
507
+ const semanticResult = validateProfileEvidenceSemantics({
508
+ evidence,
509
+ records: evidenceRecords,
510
+ });
511
+ if (!semanticResult.ok) {
512
+ fail("projection_semantics_invalid", "ProfileEvidence semantics are invalid");
513
+ }
514
+ profileEvidence.push(evidence);
515
+ evidenceRecords.set(
516
+ contractRecordKey("profile_evidence", evidence.profile_evidence_id),
517
+ evidence,
518
+ );
519
+ }
520
+ }
521
+
522
+ trackRefs.sort((left, right) => {
523
+ const idOrder = compareText(left.track_ref_id, right.track_ref_id);
524
+ return idOrder !== 0 ? idOrder : left.revision - right.revision;
525
+ });
526
+ const observations = [...observationsById.values()].sort((left, right) =>
527
+ compareText(
528
+ left.library_track_observation_id,
529
+ right.library_track_observation_id,
530
+ ),
531
+ );
532
+ profileEvidence.sort((left, right) =>
533
+ compareText(left.profile_evidence_id, right.profile_evidence_id),
534
+ );
535
+
536
+ return {
537
+ schema_version: "apple-projection-records/1",
538
+ subject_id: normalizedSubjectId,
539
+ inputs: batches.map((batch) => ({
540
+ import_batch_id: batch.manifest.import_batch_id,
541
+ captured_at: batch.manifest.source.captured_at,
542
+ digest: computeAppleProjectionInputDigest(batch),
543
+ })),
544
+ trackRefs,
545
+ currentTrackRefs,
546
+ observations,
547
+ profileEvidence,
548
+ counts: {
549
+ current_tracks: currentTrackRefs.length,
550
+ observations: observations.length,
551
+ profile_evidence: profileEvidence.length,
552
+ listening_events: 0,
553
+ taste_events: 0,
554
+ unresolved_identities: unresolvedIdentityCount,
555
+ },
556
+ };
557
+ }