@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,2520 @@
1
+ import { uuidV5 } from "../core/uuid-v5.mjs";
2
+ import { musicProviderLabel } from "./music-providers.mjs";
3
+
4
+ export const LISTENING_PROFILE_SCHEMA_VERSION = "listening-profile/1";
5
+ export const LISTENING_PROFILE_EVIDENCE_NAMESPACE =
6
+ "5d9a8b8d-c66c-547f-98c2-c8a0559f56be";
7
+
8
+ const dayMs = 86_400_000;
9
+ const recentWindowDays = 90;
10
+ const rediscoveryMinimumPlays = 3;
11
+ const rediscoveryMinimumEngagedPlays = 2;
12
+ const rediscoveryMinimumPlayedMs = 10 * 60_000;
13
+ const historicalReturnMinimumGapDays = 180;
14
+ const historicalReturnMinimumPlays = 3;
15
+ const historicalReturnMinimumEngagedPlays = 3;
16
+ const historicalReturnMinimumPlayedMs = 10 * 60_000;
17
+ const timeCapsuleMinimumYears = 2;
18
+ const timeCapsuleMinimumEngagedPlays = 2;
19
+ const timeCapsuleMinimumPlayedMs = 5 * 60_000;
20
+ const relationshipMinimumYears = 2;
21
+ const continuityArtistLimit = 10;
22
+ const releaseMinimumDistinctTracks = 3;
23
+ const sessionGapMinutes = 30;
24
+ const extendedSequenceMinimumPlays = 5;
25
+ const backToBackMinimumConsecutivePlays = 2;
26
+ const backToBackMinimumPlayedMs = 30_000;
27
+ const backToBackMaximumGapMinutes = 30;
28
+ const monthlyActivityMaximumMonths = 240;
29
+ const listeningSeasonMaximumSeasons = 80;
30
+ const spotifyExtendedHistorySystem = "spotify-extended-history";
31
+ const directStartReasons = new Set([
32
+ "clickrow",
33
+ "clickside",
34
+ "playbtn",
35
+ "remote",
36
+ "search",
37
+ "uriopen",
38
+ ]);
39
+
40
+ function isPlainObject(value) {
41
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
42
+ const prototype = Object.getPrototypeOf(value);
43
+ return prototype === Object.prototype || prototype === null;
44
+ }
45
+
46
+ function normalizedText(value) {
47
+ return value.normalize("NFKC").toLocaleLowerCase("und");
48
+ }
49
+
50
+ function lexicalCompare(left, right) {
51
+ return left < right ? -1 : left > right ? 1 : 0;
52
+ }
53
+
54
+ function descendingTimestamp(left, right) {
55
+ return lexicalCompare(right ?? "", left ?? "");
56
+ }
57
+
58
+ function minutes(milliseconds) {
59
+ return Math.round(milliseconds / 60_000);
60
+ }
61
+
62
+ function hours(milliseconds) {
63
+ return Math.round(milliseconds / 360_000) / 10;
64
+ }
65
+
66
+ function plural(count, one, many = `${one}s`) {
67
+ return `${count} ${count === 1 ? one : many}`;
68
+ }
69
+
70
+ function day(timestamp) {
71
+ return typeof timestamp === "string" ? timestamp.slice(0, 10) : timestamp;
72
+ }
73
+
74
+ const signalPhrases = {
75
+ "historical attention only": "how much you played it",
76
+ "saved-library state": "that it is saved in your library",
77
+ "private playlist curation": "that it is on one of your playlists",
78
+ "explicit listener preference": "that you said you like it",
79
+ };
80
+
81
+ function signalPhrase(value) {
82
+ return signalPhrases[value] ?? value;
83
+ }
84
+
85
+ function signalNote(value) {
86
+ return value === "historical attention only" ? "" : ` It helps ${signalPhrase(value)}.`;
87
+ }
88
+
89
+ function aggregateEvidenceId(subjectId, kind, key) {
90
+ return uuidV5(
91
+ `${subjectId}\0${kind}\0${key}`,
92
+ LISTENING_PROFILE_EVIDENCE_NAMESPACE,
93
+ );
94
+ }
95
+
96
+ function explanation({
97
+ evidenceId,
98
+ dimension,
99
+ value,
100
+ direction = "supports",
101
+ basis,
102
+ name,
103
+ confidence,
104
+ limitation,
105
+ }) {
106
+ return {
107
+ evidence_id: evidenceId,
108
+ claim: { dimension, value, direction },
109
+ basis_summary: basis,
110
+ derivation: {
111
+ kind: "deterministic_projection",
112
+ name,
113
+ version: LISTENING_PROFILE_SCHEMA_VERSION,
114
+ },
115
+ confidence,
116
+ interpretation_limit: limitation,
117
+ };
118
+ }
119
+
120
+ function confidenceForCount(count, floor = 0.55, ceiling = 0.95) {
121
+ return Math.min(ceiling, floor + Math.log10(Math.max(1, count)) * 0.16);
122
+ }
123
+
124
+ function trackFields(track) {
125
+ const title =
126
+ typeof track?.title === "string" && track.title.trim()
127
+ ? track.title.trim()
128
+ : "Unknown track";
129
+ const artist =
130
+ Array.isArray(track?.artist_credits) &&
131
+ typeof track.artist_credits[0]?.name === "string" &&
132
+ track.artist_credits[0].name.trim()
133
+ ? track.artist_credits[0].name.trim()
134
+ : "Unknown artist";
135
+ const release =
136
+ typeof track?.release?.title === "string" && track.release.title.trim()
137
+ ? track.release.title.trim()
138
+ : null;
139
+ return {
140
+ trackRefId: track?.track_ref_id,
141
+ identityStatus:
142
+ track?.identity_status === "resolved" ? "resolved" : "provisional",
143
+ title,
144
+ artist,
145
+ artistKnown: Boolean(track?.artist_credits?.[0]?.name?.trim()),
146
+ release,
147
+ };
148
+ }
149
+
150
+ function aggregateHistoryArc(rows) {
151
+ const years = new Map();
152
+ const firstObservedYearByTrack = new Map();
153
+
154
+ for (const row of rows) {
155
+ const occurredAt = row.event?.occurred_at;
156
+ const yearText = typeof occurredAt === "string" ? occurredAt.slice(0, 4) : "";
157
+ if (!/^\d{4}$/u.test(yearText)) continue;
158
+
159
+ const year = Number(yearText);
160
+ const metadata = trackFields(row.track);
161
+ const currentFirstYear = firstObservedYearByTrack.get(metadata.trackRefId);
162
+ if (currentFirstYear === undefined || year < currentFirstYear) {
163
+ firstObservedYearByTrack.set(metadata.trackRefId, year);
164
+ }
165
+
166
+ const aggregate = years.get(year) ?? {
167
+ eventCount: 0,
168
+ playedMs: 0,
169
+ trackRefs: new Set(),
170
+ artists: new Map(),
171
+ };
172
+ aggregate.eventCount += 1;
173
+ aggregate.playedMs += row.playedMs;
174
+ aggregate.trackRefs.add(metadata.trackRefId);
175
+
176
+ const artistKey = normalizedText(metadata.artist);
177
+ const artist = aggregate.artists.get(artistKey) ?? {
178
+ name: metadata.artist,
179
+ playCount: 0,
180
+ playedMs: 0,
181
+ };
182
+ artist.playCount += 1;
183
+ artist.playedMs += row.playedMs;
184
+ if (metadata.artistKnown) aggregate.artists.set(artistKey, artist);
185
+ years.set(year, aggregate);
186
+ }
187
+
188
+ return [...years.entries()]
189
+ .sort(([left], [right]) => left - right)
190
+ .map(([year, aggregate]) => {
191
+ const topArtist = [...aggregate.artists.values()].sort((left, right) => {
192
+ if (right.playedMs !== left.playedMs) return right.playedMs - left.playedMs;
193
+ if (right.playCount !== left.playCount) return right.playCount - left.playCount;
194
+ return lexicalCompare(left.name, right.name);
195
+ })[0];
196
+ const firstObservedTracks = [...aggregate.trackRefs].filter(
197
+ (trackRefId) => firstObservedYearByTrack.get(trackRefId) === year,
198
+ ).length;
199
+
200
+ return {
201
+ year,
202
+ event_count: aggregate.eventCount,
203
+ listening_minutes: minutes(aggregate.playedMs),
204
+ distinct_tracks: aggregate.trackRefs.size,
205
+ first_observed_tracks: firstObservedTracks,
206
+ ...(topArtist
207
+ ? {
208
+ top_artist: {
209
+ name: topArtist.name,
210
+ play_count: topArtist.playCount,
211
+ listening_minutes: minutes(topArtist.playedMs),
212
+ },
213
+ }
214
+ : {}),
215
+ };
216
+ });
217
+ }
218
+
219
+ function monthIndex(monthKey) {
220
+ if (!/^\d{4}-(?:0[1-9]|1[0-2])$/u.test(monthKey)) return null;
221
+ const year = Number(monthKey.slice(0, 4));
222
+ const month = Number(monthKey.slice(5, 7));
223
+ if (!Number.isInteger(year) || year < 1900 || year > 9999) return null;
224
+ return year * 12 + month - 1;
225
+ }
226
+
227
+ function monthKey(index) {
228
+ const year = Math.floor(index / 12);
229
+ const month = index % 12 + 1;
230
+ return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}`;
231
+ }
232
+
233
+ function seasonIndexForMonth(index) {
234
+ return Math.floor(index / 3);
235
+ }
236
+
237
+ function seasonKey(index) {
238
+ const year = Math.floor(index / 4);
239
+ const quarter = index % 4 + 1;
240
+ return `${String(year).padStart(4, "0")}-Q${quarter}`;
241
+ }
242
+
243
+ function aggregateListeningSeasons(subjectId, eventAnalysis, explanations) {
244
+ const retainedFirstMonth = eventAnalysis.earliest?.slice(0, 7) ?? "";
245
+ const retainedLastMonth = eventAnalysis.latest?.slice(0, 7) ?? "";
246
+ const retainedFirstMonthIndex = monthIndex(retainedFirstMonth);
247
+ const retainedLastMonthIndex = monthIndex(retainedLastMonth);
248
+ if (
249
+ retainedFirstMonthIndex === null ||
250
+ retainedLastMonthIndex === null ||
251
+ retainedFirstMonthIndex > retainedLastMonthIndex
252
+ ) {
253
+ return null;
254
+ }
255
+
256
+ const retainedFirstSeasonIndex = seasonIndexForMonth(
257
+ retainedFirstMonthIndex,
258
+ );
259
+ const lastSeasonIndex = seasonIndexForMonth(retainedLastMonthIndex);
260
+ const retainedSeasonCount =
261
+ lastSeasonIndex - retainedFirstSeasonIndex + 1;
262
+ const representedFirstSeasonIndex = Math.max(
263
+ retainedFirstSeasonIndex,
264
+ lastSeasonIndex - listeningSeasonMaximumSeasons + 1,
265
+ );
266
+ const firstObservedSeasonByTrack = new Map();
267
+ const aggregates = new Map();
268
+
269
+ for (const row of eventAnalysis.eligible) {
270
+ const observedMonthIndex = monthIndex(
271
+ row.event?.occurred_at?.slice(0, 7) ?? "",
272
+ );
273
+ if (observedMonthIndex === null) continue;
274
+ const observedSeasonIndex = seasonIndexForMonth(observedMonthIndex);
275
+ const metadata = trackFields(row.track);
276
+ const trackKey =
277
+ typeof metadata.trackRefId === "string" && metadata.trackRefId
278
+ ? metadata.trackRefId
279
+ : [metadata.title, metadata.artist, metadata.release ?? ""]
280
+ .map(normalizedText)
281
+ .join("\0");
282
+ const firstObservedSeason = firstObservedSeasonByTrack.get(trackKey);
283
+ if (
284
+ firstObservedSeason === undefined ||
285
+ observedSeasonIndex < firstObservedSeason
286
+ ) {
287
+ firstObservedSeasonByTrack.set(trackKey, observedSeasonIndex);
288
+ }
289
+
290
+ const aggregate = aggregates.get(observedSeasonIndex) ?? {
291
+ eventCount: 0,
292
+ engagedPlayCount: 0,
293
+ playedMs: 0,
294
+ activeMonths: new Set(),
295
+ trackRefs: new Set(),
296
+ artists: new Map(),
297
+ tracks: new Map(),
298
+ };
299
+ aggregate.eventCount += 1;
300
+ if (!row.skipped) aggregate.engagedPlayCount += 1;
301
+ aggregate.playedMs += row.playedMs;
302
+ aggregate.activeMonths.add(observedMonthIndex);
303
+ aggregate.trackRefs.add(trackKey);
304
+
305
+ const artistKey = normalizedText(metadata.artist);
306
+ const artist = aggregate.artists.get(artistKey) ?? {
307
+ name: metadata.artist,
308
+ eventCount: 0,
309
+ engagedPlayCount: 0,
310
+ playedMs: 0,
311
+ trackRefs: new Set(),
312
+ };
313
+ artist.eventCount += 1;
314
+ if (!row.skipped) artist.engagedPlayCount += 1;
315
+ artist.playedMs += row.playedMs;
316
+ artist.trackRefs.add(trackKey);
317
+ if (metadata.artistKnown) aggregate.artists.set(artistKey, artist);
318
+
319
+ const track = aggregate.tracks.get(trackKey) ?? {
320
+ ...metadata,
321
+ trackKey,
322
+ eventCount: 0,
323
+ engagedPlayCount: 0,
324
+ skippedCount: 0,
325
+ playedMs: 0,
326
+ };
327
+ track.eventCount += 1;
328
+ if (row.skipped) track.skippedCount += 1;
329
+ else track.engagedPlayCount += 1;
330
+ track.playedMs += row.playedMs;
331
+ aggregate.tracks.set(trackKey, track);
332
+ aggregates.set(observedSeasonIndex, aggregate);
333
+ }
334
+
335
+ const seasons = [];
336
+ for (
337
+ let index = representedFirstSeasonIndex;
338
+ index <= lastSeasonIndex;
339
+ index += 1
340
+ ) {
341
+ const aggregate = aggregates.get(index);
342
+ const calendarFirstMonthIndex = index * 3;
343
+ const calendarLastMonthIndex = calendarFirstMonthIndex + 2;
344
+ const firstMonthIndex = Math.max(
345
+ retainedFirstMonthIndex,
346
+ calendarFirstMonthIndex,
347
+ );
348
+ const lastMonthIndex = Math.min(
349
+ retainedLastMonthIndex,
350
+ calendarLastMonthIndex,
351
+ );
352
+ const trackRefs = aggregate?.trackRefs ?? new Set();
353
+ const firstObservedTracks = [...trackRefs].filter(
354
+ (trackRef) => firstObservedSeasonByTrack.get(trackRef) === index,
355
+ ).length;
356
+ const leadingArtist = [...(aggregate?.artists.values() ?? [])].sort(
357
+ (left, right) => {
358
+ if (right.playedMs !== left.playedMs) {
359
+ return right.playedMs - left.playedMs;
360
+ }
361
+ if (right.engagedPlayCount !== left.engagedPlayCount) {
362
+ return right.engagedPlayCount - left.engagedPlayCount;
363
+ }
364
+ if (right.eventCount !== left.eventCount) {
365
+ return right.eventCount - left.eventCount;
366
+ }
367
+ return lexicalCompare(left.name, right.name);
368
+ },
369
+ )[0];
370
+ const signatureTrack = [...(aggregate?.tracks.values() ?? [])].sort(
371
+ (left, right) => {
372
+ if (right.playedMs !== left.playedMs) {
373
+ return right.playedMs - left.playedMs;
374
+ }
375
+ if (right.engagedPlayCount !== left.engagedPlayCount) {
376
+ return right.engagedPlayCount - left.engagedPlayCount;
377
+ }
378
+ if (right.eventCount !== left.eventCount) {
379
+ return right.eventCount - left.eventCount;
380
+ }
381
+ const titleOrder = lexicalCompare(left.title, right.title);
382
+ if (titleOrder !== 0) return titleOrder;
383
+ const artistOrder = lexicalCompare(left.artist, right.artist);
384
+ if (artistOrder !== 0) return artistOrder;
385
+ return lexicalCompare(left.trackKey, right.trackKey);
386
+ },
387
+ )[0];
388
+
389
+ seasons.push({
390
+ key: seasonKey(index),
391
+ start_month: monthKey(firstMonthIndex),
392
+ end_month: monthKey(lastMonthIndex),
393
+ retained_month_count: lastMonthIndex - firstMonthIndex + 1,
394
+ active_month_count: aggregate?.activeMonths.size ?? 0,
395
+ event_count: aggregate?.eventCount ?? 0,
396
+ engaged_play_count: aggregate?.engagedPlayCount ?? 0,
397
+ listening_minutes: minutes(aggregate?.playedMs ?? 0),
398
+ distinct_tracks: trackRefs.size,
399
+ first_observed_tracks: firstObservedTracks,
400
+ returning_tracks: trackRefs.size - firstObservedTracks,
401
+ ...(leadingArtist
402
+ ? {
403
+ leading_artist: {
404
+ name: leadingArtist.name,
405
+ event_count: leadingArtist.eventCount,
406
+ engaged_play_count: leadingArtist.engagedPlayCount,
407
+ listening_minutes: minutes(leadingArtist.playedMs),
408
+ distinct_tracks: leadingArtist.trackRefs.size,
409
+ },
410
+ }
411
+ : {}),
412
+ ...(signatureTrack
413
+ ? {
414
+ signature_track: {
415
+ track_ref_id: signatureTrack.trackRefId,
416
+ label: signatureTrack.title,
417
+ artist_credit: signatureTrack.artist,
418
+ ...(signatureTrack.release
419
+ ? { release: signatureTrack.release }
420
+ : {}),
421
+ play_count: signatureTrack.eventCount,
422
+ engaged_play_count: signatureTrack.engagedPlayCount,
423
+ listening_minutes: minutes(signatureTrack.playedMs),
424
+ explicit_skips: signatureTrack.skippedCount,
425
+ },
426
+ }
427
+ : {}),
428
+ });
429
+ }
430
+
431
+ const activeSeasonCount = aggregates.size;
432
+ const representedActiveSeasonCount = seasons.filter(
433
+ (season) => season.event_count > 0,
434
+ ).length;
435
+ const evidenceId = aggregateEvidenceId(
436
+ subjectId,
437
+ "listening-seasons",
438
+ `${seasonKey(retainedFirstSeasonIndex)}\0${seasonKey(lastSeasonIndex)}\0${eventAnalysis.eligible.length}`,
439
+ );
440
+ explanations.set(
441
+ evidenceId,
442
+ explanation({
443
+ evidenceId,
444
+ dimension: "listening.seasons",
445
+ value: `${activeSeasonCount} active retained UTC calendar quarters`,
446
+ basis: `${plural(eventAnalysis.eligible.length, "play")} across ${plural(activeSeasonCount, "active quarter")} from ${seasonKey(retainedFirstSeasonIndex)} to ${seasonKey(lastSeasonIndex)}. Showing ${seasons.length} of ${retainedSeasonCount} quarters.`,
447
+ name: "listening-seasons",
448
+ confidence: confidenceForCount(eventAnalysis.eligible.length, 0.65, 0.95),
449
+ limitation:
450
+ "Seasons are fixed three-month windows in UTC. A song counts as new the first time it shows up in your history, which is not always when you found it. An empty season means no history was kept, not that you stopped listening. The top artist or song describes that season only, not your mood or what was happening in your life.",
451
+ }),
452
+ );
453
+
454
+ return {
455
+ timezone: "UTC",
456
+ alignment: "calendar_quarter",
457
+ season_length_months: 3,
458
+ retained_first_season: seasonKey(retainedFirstSeasonIndex),
459
+ represented_first_season: seasonKey(representedFirstSeasonIndex),
460
+ last_season: seasonKey(lastSeasonIndex),
461
+ retained_season_count: retainedSeasonCount,
462
+ represented_season_count: seasons.length,
463
+ active_season_count: activeSeasonCount,
464
+ represented_active_season_count: representedActiveSeasonCount,
465
+ omitted_earlier_season_count:
466
+ representedFirstSeasonIndex - retainedFirstSeasonIndex,
467
+ omitted_earlier_active_season_count:
468
+ activeSeasonCount - representedActiveSeasonCount,
469
+ seasons,
470
+ evidence_id: evidenceId,
471
+ };
472
+ }
473
+
474
+ function aggregateMonthlyActivity(subjectId, eventAnalysis, explanations) {
475
+ const firstMonth = eventAnalysis.earliest?.slice(0, 7) ?? "";
476
+ const lastMonth = eventAnalysis.latest?.slice(0, 7) ?? "";
477
+ const firstIndex = monthIndex(firstMonth);
478
+ const lastIndex = monthIndex(lastMonth);
479
+ if (firstIndex === null || lastIndex === null || firstIndex > lastIndex) {
480
+ return null;
481
+ }
482
+
483
+ const retainedSpanMonths = lastIndex - firstIndex + 1;
484
+ const representedFirstIndex = Math.max(
485
+ firstIndex,
486
+ lastIndex - monthlyActivityMaximumMonths + 1,
487
+ );
488
+ const aggregates = new Map();
489
+ for (const row of eventAnalysis.eligible) {
490
+ const key = row.event?.occurred_at?.slice(0, 7) ?? "";
491
+ const index = monthIndex(key);
492
+ if (index === null || index < representedFirstIndex || index > lastIndex) {
493
+ continue;
494
+ }
495
+ const aggregate = aggregates.get(key) ?? {
496
+ eventCount: 0,
497
+ engagedPlayCount: 0,
498
+ playedMs: 0,
499
+ trackRefs: new Set(),
500
+ };
501
+ aggregate.eventCount += 1;
502
+ if (!row.skipped) aggregate.engagedPlayCount += 1;
503
+ aggregate.playedMs += row.playedMs;
504
+ const trackRefId = row.track?.track_ref_id;
505
+ if (typeof trackRefId === "string" && trackRefId) {
506
+ aggregate.trackRefs.add(trackRefId);
507
+ }
508
+ aggregates.set(key, aggregate);
509
+ }
510
+
511
+ const months = [];
512
+ for (let index = representedFirstIndex; index <= lastIndex; index += 1) {
513
+ const key = monthKey(index);
514
+ const aggregate = aggregates.get(key);
515
+ months.push({
516
+ month: key,
517
+ event_count: aggregate?.eventCount ?? 0,
518
+ engaged_play_count: aggregate?.engagedPlayCount ?? 0,
519
+ listening_minutes: minutes(aggregate?.playedMs ?? 0),
520
+ distinct_tracks: aggregate?.trackRefs.size ?? 0,
521
+ });
522
+ }
523
+ const activeMonths = months.filter((month) => month.event_count > 0);
524
+ const peakListeningMinutes = months.reduce(
525
+ (maximum, month) => Math.max(maximum, month.listening_minutes),
526
+ 0,
527
+ );
528
+ const evidenceId = aggregateEvidenceId(
529
+ subjectId,
530
+ "monthly-activity",
531
+ `${firstMonth}\0${lastMonth}\0${eventAnalysis.eligible.length}`,
532
+ );
533
+ explanations.set(
534
+ evidenceId,
535
+ explanation({
536
+ evidenceId,
537
+ dimension: "listening.monthly_activity",
538
+ value: `${activeMonths.length} active retained UTC months`,
539
+ basis: `${plural(eventAnalysis.eligible.length, "play")} across ${plural(activeMonths.length, "active month")} from ${firstMonth} to ${lastMonth}. Showing ${months.length} of the ${retainedSpanMonths} months in that span.`,
540
+ name: "listening-monthly-activity",
541
+ confidence: confidenceForCount(eventAnalysis.eligible.length, 0.65, 0.95),
542
+ limitation:
543
+ "Months follow UTC. A blank month means no history was kept for it, not that you stopped listening.",
544
+ }),
545
+ );
546
+ return {
547
+ timezone: "UTC",
548
+ first_month: monthKey(representedFirstIndex),
549
+ last_month: monthKey(lastIndex),
550
+ retained_span_months: retainedSpanMonths,
551
+ represented_month_count: months.length,
552
+ active_month_count: activeMonths.length,
553
+ omitted_earlier_month_count: representedFirstIndex - firstIndex,
554
+ peak_listening_minutes: peakListeningMinutes,
555
+ months,
556
+ evidence_id: evidenceId,
557
+ };
558
+ }
559
+
560
+ function aggregateEvents(subjectId, eventRows, explanations) {
561
+ const eligible = [];
562
+ let incognitoEventsExcluded = 0;
563
+ let rawPlayedMs = 0;
564
+ let eventsWithPlayedDuration = 0;
565
+ for (const row of eventRows) {
566
+ const event = row.event;
567
+ const hasPlayedDuration = Number.isSafeInteger(event?.played_ms);
568
+ const playedMs = hasPlayedDuration ? event.played_ms : 0;
569
+ if (hasPlayedDuration) eventsWithPlayedDuration += 1;
570
+ rawPlayedMs += playedMs;
571
+ if (event?.extensions?.["spotify.incognito_mode"] === true) {
572
+ incognitoEventsExcluded += 1;
573
+ continue;
574
+ }
575
+ eligible.push({
576
+ event,
577
+ track: row.track,
578
+ playedMs,
579
+ skipped:
580
+ event?.event_type === "play_skipped" ||
581
+ event?.extensions?.["spotify.skipped"] === true,
582
+ });
583
+ }
584
+ const earliest = eligible.reduce(
585
+ (value, row) =>
586
+ value === null || row.event.occurred_at < value
587
+ ? row.event.occurred_at
588
+ : value,
589
+ null,
590
+ );
591
+ const latest = eligible.reduce(
592
+ (value, row) =>
593
+ value === null || row.event.occurred_at > value
594
+ ? row.event.occurred_at
595
+ : value,
596
+ null,
597
+ );
598
+ const recentCutoff = latest
599
+ ? new Date(Date.parse(latest) - recentWindowDays * dayMs).toISOString()
600
+ : null;
601
+
602
+ function aggregate(rows, windowKind) {
603
+ const artists = new Map();
604
+ const tracks = new Map();
605
+ for (const row of rows) {
606
+ const metadata = trackFields(row.track);
607
+ const trackKey = metadata.trackRefId;
608
+ const artistKey = normalizedText(metadata.artist);
609
+ const occurredAt = row.event.occurred_at;
610
+ const track = tracks.get(trackKey) ?? {
611
+ ...metadata,
612
+ playCount: 0,
613
+ engagedPlayCount: 0,
614
+ skippedCount: 0,
615
+ playedMs: 0,
616
+ firstPlayedAt: occurredAt,
617
+ lastPlayedAt: occurredAt,
618
+ engagedPlayedAt: [],
619
+ years: new Map(),
620
+ };
621
+ track.playCount += 1;
622
+ track.playedMs += row.playedMs;
623
+ if (row.skipped) track.skippedCount += 1;
624
+ else {
625
+ track.engagedPlayCount += 1;
626
+ track.engagedPlayedAt.push(occurredAt);
627
+ }
628
+ if (occurredAt < track.firstPlayedAt) track.firstPlayedAt = occurredAt;
629
+ if (occurredAt > track.lastPlayedAt) track.lastPlayedAt = occurredAt;
630
+ const year = Number(occurredAt.slice(0, 4));
631
+ if (Number.isInteger(year)) {
632
+ const yearAggregate = track.years.get(year) ?? {
633
+ playCount: 0,
634
+ engagedPlayCount: 0,
635
+ skippedCount: 0,
636
+ playedMs: 0,
637
+ };
638
+ yearAggregate.playCount += 1;
639
+ yearAggregate.playedMs += row.playedMs;
640
+ if (row.skipped) yearAggregate.skippedCount += 1;
641
+ else yearAggregate.engagedPlayCount += 1;
642
+ track.years.set(year, yearAggregate);
643
+ }
644
+ tracks.set(trackKey, track);
645
+
646
+ const artist = artists.get(artistKey) ?? {
647
+ name: metadata.artist,
648
+ playCount: 0,
649
+ engagedPlayCount: 0,
650
+ skippedCount: 0,
651
+ playedMs: 0,
652
+ trackRefs: new Set(),
653
+ years: new Map(),
654
+ firstPlayedAt: occurredAt,
655
+ lastPlayedAt: occurredAt,
656
+ };
657
+ artist.playCount += 1;
658
+ artist.playedMs += row.playedMs;
659
+ if (row.skipped) artist.skippedCount += 1;
660
+ else artist.engagedPlayCount += 1;
661
+ artist.trackRefs.add(trackKey);
662
+ if (Number.isInteger(year)) {
663
+ const artistYear = artist.years.get(year) ?? {
664
+ playCount: 0,
665
+ playedMs: 0,
666
+ };
667
+ artistYear.playCount += 1;
668
+ artistYear.playedMs += row.playedMs;
669
+ artist.years.set(year, artistYear);
670
+ }
671
+ if (occurredAt < artist.firstPlayedAt) artist.firstPlayedAt = occurredAt;
672
+ if (occurredAt > artist.lastPlayedAt) artist.lastPlayedAt = occurredAt;
673
+ if (metadata.artistKnown) artists.set(artistKey, artist);
674
+ }
675
+
676
+ const rankedArtists = [...artists.entries()]
677
+ .sort(([, left], [, right]) => {
678
+ if (right.playedMs !== left.playedMs) return right.playedMs - left.playedMs;
679
+ if (right.engagedPlayCount !== left.engagedPlayCount) {
680
+ return right.engagedPlayCount - left.engagedPlayCount;
681
+ }
682
+ return lexicalCompare(left.name, right.name);
683
+ })
684
+ .map(([key, item]) => {
685
+ const evidenceId = aggregateEvidenceId(
686
+ subjectId,
687
+ `artist-${windowKind}`,
688
+ `${key}\0${windowKind === "recent" ? latest : "lifetime"}`,
689
+ );
690
+ explanations.set(
691
+ evidenceId,
692
+ explanation({
693
+ evidenceId,
694
+ dimension: "taste.artist_familiarity",
695
+ value: item.name,
696
+ basis: `${plural(item.playCount, "play")} across ${plural(item.trackRefs.size, "track")}, ${minutes(item.playedMs)} minutes in all${windowKind === "recent" ? `, in the ${recentWindowDays} days up to ${day(latest)}` : `, from ${day(item.firstPlayedAt)} to ${day(item.lastPlayedAt)}`}.`,
697
+ name: `listening-artist-${windowKind}`,
698
+ confidence: confidenceForCount(item.playCount),
699
+ limitation:
700
+ "Playing an artist a lot shows you know them well, not that you always will love them. A skip is a moment, not a verdict.",
701
+ }),
702
+ );
703
+ return {
704
+ name: item.name,
705
+ play_count: item.playCount,
706
+ engaged_play_count: item.engagedPlayCount,
707
+ listening_minutes: minutes(item.playedMs),
708
+ distinct_tracks: item.trackRefs.size,
709
+ explicit_skips: item.skippedCount,
710
+ last_played_at: item.lastPlayedAt,
711
+ evidence_id: evidenceId,
712
+ };
713
+ });
714
+
715
+ const rankedTracks = [...tracks.values()]
716
+ .sort((left, right) => {
717
+ if (right.playedMs !== left.playedMs) return right.playedMs - left.playedMs;
718
+ if (right.engagedPlayCount !== left.engagedPlayCount) {
719
+ return right.engagedPlayCount - left.engagedPlayCount;
720
+ }
721
+ const titleOrder = lexicalCompare(left.title, right.title);
722
+ return titleOrder || lexicalCompare(left.artist, right.artist);
723
+ })
724
+ .map((item) => {
725
+ const evidenceId = aggregateEvidenceId(
726
+ subjectId,
727
+ `track-${windowKind}`,
728
+ `${item.trackRefId}\0${windowKind === "recent" ? latest : "lifetime"}`,
729
+ );
730
+ explanations.set(
731
+ evidenceId,
732
+ explanation({
733
+ evidenceId,
734
+ dimension: "taste.track_familiarity",
735
+ value: `${item.title} - ${item.artist}`,
736
+ basis: `${plural(item.playCount, "play")}, ${minutes(item.playedMs)} minutes in all${windowKind === "recent" ? `, in the ${recentWindowDays} days up to ${day(latest)}` : `, from ${day(item.firstPlayedAt)} to ${day(item.lastPlayedAt)}`}. ${item.skippedCount === 0 ? "Never skipped" : `Skipped ${plural(item.skippedCount, "time")}`}.`,
737
+ name: `listening-track-${windowKind}`,
738
+ confidence: confidenceForCount(item.playCount),
739
+ limitation:
740
+ "This shows how well you know the song. Playing something a lot doesn't prove you like it.",
741
+ }),
742
+ );
743
+ return {
744
+ track_ref_id: item.trackRefId,
745
+ label: item.title,
746
+ artist_credit: item.artist,
747
+ ...(item.release ? { release: item.release } : {}),
748
+ identity_status: item.identityStatus,
749
+ play_count: item.playCount,
750
+ engaged_play_count: item.engagedPlayCount,
751
+ listening_minutes: minutes(item.playedMs),
752
+ explicit_skips: item.skippedCount,
753
+ last_played_at: item.lastPlayedAt,
754
+ evidence_id: evidenceId,
755
+ };
756
+ });
757
+ return { artists, tracks, rankedArtists, rankedTracks };
758
+ }
759
+
760
+ const lifetime = aggregate(eligible, "lifetime");
761
+ const recent = aggregate(
762
+ recentCutoff
763
+ ? eligible.filter((row) => row.event.occurred_at >= recentCutoff)
764
+ : [],
765
+ "recent",
766
+ );
767
+ const historyArc = aggregateHistoryArc(eligible);
768
+ const rowsWithStartReason = eligible
769
+ .map((row) => ({
770
+ row,
771
+ reason: row.event.extensions?.["spotify.reason_start"],
772
+ }))
773
+ .filter(({ reason }) => typeof reason === "string" && reason.trim());
774
+ const rowsWithEndReason = eligible
775
+ .map((row) => ({
776
+ row,
777
+ reason: row.event.extensions?.["spotify.reason_end"],
778
+ }))
779
+ .filter(({ reason }) => typeof reason === "string" && reason.trim());
780
+ const rowsWithSkipState = eligible.filter(
781
+ (row) => typeof row.event.extensions?.["spotify.skipped"] === "boolean",
782
+ );
783
+ const rowsWithShuffleState = eligible.filter(
784
+ (row) => typeof row.event.extensions?.["spotify.shuffle"] === "boolean",
785
+ );
786
+ const rowsWithOfflineState = eligible.filter(
787
+ (row) => typeof row.event.extensions?.["spotify.offline"] === "boolean",
788
+ );
789
+ const context = {
790
+ reference_date: latest,
791
+ recent_window_days: recentWindowDays,
792
+ effective_events_profiled: eligible.length,
793
+ start_reason_events: rowsWithStartReason.length,
794
+ direct_selection_starts: rowsWithStartReason.filter(({ reason }) =>
795
+ directStartReasons.has(normalizedText(reason.trim())),
796
+ ).length,
797
+ trackdone_starts: rowsWithStartReason.filter(
798
+ ({ reason }) => normalizedText(reason.trim()) === "trackdone",
799
+ ).length,
800
+ end_reason_events: rowsWithEndReason.length,
801
+ trackdone_endings: rowsWithEndReason.filter(
802
+ ({ reason }) => normalizedText(reason.trim()) === "trackdone",
803
+ ).length,
804
+ skip_state_events: rowsWithSkipState.length,
805
+ explicit_skips: eligible.filter((row) => row.skipped).length,
806
+ shuffle_state_events: rowsWithShuffleState.length,
807
+ shuffle_events: rowsWithShuffleState.filter(
808
+ (row) => row.event.extensions?.["spotify.shuffle"] === true,
809
+ ).length,
810
+ offline_state_events: rowsWithOfflineState.length,
811
+ offline_events: rowsWithOfflineState.filter(
812
+ (row) => row.event.extensions?.["spotify.offline"] === true,
813
+ ).length,
814
+ incognito_events_excluded: incognitoEventsExcluded,
815
+ };
816
+ return {
817
+ eligible,
818
+ earliest,
819
+ latest,
820
+ recentCutoff,
821
+ rawPlayedMs,
822
+ eventsWithPlayedDuration,
823
+ profiledPlayedMs: eligible.reduce((sum, row) => sum + row.playedMs, 0),
824
+ lifetime,
825
+ recent,
826
+ historyArc,
827
+ context,
828
+ };
829
+ }
830
+
831
+ function artistRelationships(subjectId, eventAnalysis, explanations) {
832
+ const latestYear = Number(eventAnalysis.latest?.slice(0, 4));
833
+ if (!Number.isInteger(latestYear)) return [];
834
+
835
+ return [...eventAnalysis.lifetime.artists.values()]
836
+ .map((artist) => {
837
+ const years = [...artist.years.keys()].sort((left, right) => left - right);
838
+ const firstYear = years[0];
839
+ const lastYear = years.at(-1);
840
+ if (
841
+ years.length < relationshipMinimumYears ||
842
+ lastYear !== latestYear
843
+ ) {
844
+ return null;
845
+ }
846
+ return {
847
+ artist,
848
+ firstYear,
849
+ lastYear,
850
+ activeYears: years.length,
851
+ spanYears: lastYear - firstYear + 1,
852
+ };
853
+ })
854
+ .filter(Boolean)
855
+ .sort((left, right) => {
856
+ if (right.activeYears !== left.activeYears) {
857
+ return right.activeYears - left.activeYears;
858
+ }
859
+ if (right.spanYears !== left.spanYears) {
860
+ return right.spanYears - left.spanYears;
861
+ }
862
+ if (right.artist.playedMs !== left.artist.playedMs) {
863
+ return right.artist.playedMs - left.artist.playedMs;
864
+ }
865
+ if (right.artist.playCount !== left.artist.playCount) {
866
+ return right.artist.playCount - left.artist.playCount;
867
+ }
868
+ return lexicalCompare(left.artist.name, right.artist.name);
869
+ })
870
+ .map(({ artist, firstYear, lastYear, activeYears, spanYears }) => {
871
+ const evidenceId = aggregateEvidenceId(
872
+ subjectId,
873
+ "artist-continuity",
874
+ `${normalizedText(artist.name)}\0${firstYear}\0${lastYear}`,
875
+ );
876
+ explanations.set(
877
+ evidenceId,
878
+ explanation({
879
+ evidenceId,
880
+ dimension: "taste.artist_continuity",
881
+ value: artist.name,
882
+ basis: `${artist.name} shows up in ${plural(activeYears, "year")} of your history, from ${firstYear} to ${lastYear}: ${plural(artist.playCount, "play")}, ${minutes(artist.playedMs)} minutes in all.`,
883
+ name: "listening-artist-continuity",
884
+ confidence: confidenceForCount(artist.playCount),
885
+ limitation:
886
+ "This says they kept coming back in your history. It doesn't say you never drifted apart, or how you feel about them now.",
887
+ }),
888
+ );
889
+ return {
890
+ name: artist.name,
891
+ first_year: firstYear,
892
+ last_year: lastYear,
893
+ active_years: activeYears,
894
+ span_years: spanYears,
895
+ play_count: artist.playCount,
896
+ listening_minutes: minutes(artist.playedMs),
897
+ evidence_id: evidenceId,
898
+ };
899
+ });
900
+ }
901
+
902
+ function yearlyArtistTransitions(subjectId, eventAnalysis, explanations) {
903
+ const years = new Map();
904
+ for (const row of eventAnalysis.eligible) {
905
+ const year = Number(row.event?.occurred_at?.slice(0, 4));
906
+ if (!Number.isInteger(year)) continue;
907
+ const metadata = trackFields(row.track);
908
+ if (!metadata.artistKnown) continue;
909
+ const artistName = metadata.artist;
910
+ const artistKey = normalizedText(artistName);
911
+ const artists = years.get(year) ?? new Map();
912
+ const artist = artists.get(artistKey) ?? {
913
+ key: artistKey,
914
+ name: artistName,
915
+ playCount: 0,
916
+ playedMs: 0,
917
+ };
918
+ artist.playCount += 1;
919
+ artist.playedMs += row.playedMs;
920
+ artists.set(artistKey, artist);
921
+ years.set(year, artists);
922
+ }
923
+
924
+ const rankedYears = [...years.entries()]
925
+ .sort(([left], [right]) => left - right)
926
+ .map(([year, artists]) => ({
927
+ year,
928
+ artists: [...artists.values()]
929
+ .sort((left, right) => {
930
+ if (right.playedMs !== left.playedMs) {
931
+ return right.playedMs - left.playedMs;
932
+ }
933
+ if (right.playCount !== left.playCount) {
934
+ return right.playCount - left.playCount;
935
+ }
936
+ return lexicalCompare(left.name, right.name);
937
+ })
938
+ .slice(0, continuityArtistLimit),
939
+ }));
940
+
941
+ return rankedYears.slice(1).map((current, index) => {
942
+ const previous = rankedYears[index];
943
+ const previousKeys = new Set(previous.artists.map((artist) => artist.key));
944
+ const retained = current.artists.filter((artist) =>
945
+ previousKeys.has(artist.key),
946
+ );
947
+ const introduced = current.artists.filter(
948
+ (artist) => !previousKeys.has(artist.key),
949
+ );
950
+ const continuityPercent = current.artists.length > 0
951
+ ? Math.round((retained.length / current.artists.length) * 1_000) / 10
952
+ : 0;
953
+ const evidenceId = aggregateEvidenceId(
954
+ subjectId,
955
+ "artist-turnover",
956
+ `${previous.year}\0${current.year}\0${continuityArtistLimit}`,
957
+ );
958
+ explanations.set(
959
+ evidenceId,
960
+ explanation({
961
+ evidenceId,
962
+ dimension: "taste.artist_turnover",
963
+ value: `${previous.year} to ${current.year}`,
964
+ basis: `Of your ${current.artists.length} most played artists in ${current.year}, ${retained.length} were also near the top in ${previous.year} and ${introduced.length} were not.`,
965
+ name: "listening-yearly-artist-turnover",
966
+ confidence: confidenceForCount(
967
+ Math.min(previous.artists.length, current.artists.length),
968
+ ),
969
+ limitation:
970
+ "This compares your top artists year to year. It doesn't measure how wide your taste is, or whether it has really changed.",
971
+ }),
972
+ );
973
+ return {
974
+ from_year: previous.year,
975
+ to_year: current.year,
976
+ artist_limit: continuityArtistLimit,
977
+ from_artist_count: previous.artists.length,
978
+ to_artist_count: current.artists.length,
979
+ retained_artist_count: retained.length,
980
+ new_artist_count: introduced.length,
981
+ continuity_percent: continuityPercent,
982
+ retained_artists: retained.map((artist) => artist.name),
983
+ new_artists: introduced.map((artist) => artist.name),
984
+ evidence_id: evidenceId,
985
+ };
986
+ });
987
+ }
988
+
989
+ function releaseDepth(subjectId, eventAnalysis, explanations) {
990
+ const releases = new Map();
991
+ for (const row of eventAnalysis.eligible) {
992
+ const metadata = trackFields(row.track);
993
+ if (!metadata.release || typeof metadata.trackRefId !== "string") continue;
994
+ const key = `${normalizedText(metadata.artist)}\0${normalizedText(metadata.release)}`;
995
+ const occurredAt = row.event?.occurred_at;
996
+ const year = Number(occurredAt?.slice(0, 4));
997
+ const release = releases.get(key) ?? {
998
+ title: metadata.release,
999
+ artist: metadata.artist,
1000
+ trackRefs: new Set(),
1001
+ playCount: 0,
1002
+ engagedPlayCount: 0,
1003
+ playedMs: 0,
1004
+ years: new Set(),
1005
+ };
1006
+ release.trackRefs.add(metadata.trackRefId);
1007
+ release.playCount += 1;
1008
+ if (!row.skipped) release.engagedPlayCount += 1;
1009
+ release.playedMs += row.playedMs;
1010
+ if (Number.isInteger(year)) release.years.add(year);
1011
+ releases.set(key, release);
1012
+ }
1013
+
1014
+ return [...releases.entries()]
1015
+ .filter(([, release]) =>
1016
+ release.trackRefs.size >= releaseMinimumDistinctTracks,
1017
+ )
1018
+ .sort(([, left], [, right]) => {
1019
+ if (right.playedMs !== left.playedMs) {
1020
+ return right.playedMs - left.playedMs;
1021
+ }
1022
+ if (right.trackRefs.size !== left.trackRefs.size) {
1023
+ return right.trackRefs.size - left.trackRefs.size;
1024
+ }
1025
+ if (right.engagedPlayCount !== left.engagedPlayCount) {
1026
+ return right.engagedPlayCount - left.engagedPlayCount;
1027
+ }
1028
+ const artistOrder = lexicalCompare(left.artist, right.artist);
1029
+ return artistOrder || lexicalCompare(left.title, right.title);
1030
+ })
1031
+ .map(([key, release]) => {
1032
+ const years = [...release.years].sort((left, right) => left - right);
1033
+ const firstYear = years[0];
1034
+ const lastYear = years.at(-1);
1035
+ const evidenceId = aggregateEvidenceId(
1036
+ subjectId,
1037
+ "release-depth",
1038
+ `${key}\0${firstYear ?? "unknown"}\0${lastYear ?? "unknown"}`,
1039
+ );
1040
+ explanations.set(
1041
+ evidenceId,
1042
+ explanation({
1043
+ evidenceId,
1044
+ dimension: "taste.release_depth",
1045
+ value: `${release.title} - ${release.artist}`,
1046
+ basis: `You played ${plural(release.trackRefs.size, "different track")} from ${release.title}: ${plural(release.playCount, "play")}, ${minutes(release.playedMs)} minutes in all${years.length > 0 ? `, across ${plural(years.length, "year")}` : ""}.`,
1047
+ name: "listening-release-depth",
1048
+ confidence: confidenceForCount(release.engagedPlayCount),
1049
+ limitation:
1050
+ "Playing several tracks from a record shows you went deeper than one song. It doesn't mean you played the whole album in order, own it, or like it.",
1051
+ }),
1052
+ );
1053
+ return {
1054
+ title: release.title,
1055
+ artist_credit: release.artist,
1056
+ distinct_tracks: release.trackRefs.size,
1057
+ play_count: release.playCount,
1058
+ engaged_play_count: release.engagedPlayCount,
1059
+ listening_minutes: minutes(release.playedMs),
1060
+ ...(Number.isInteger(firstYear) ? { first_year: firstYear } : {}),
1061
+ ...(Number.isInteger(lastYear) ? { last_year: lastYear } : {}),
1062
+ active_years: years.length,
1063
+ evidence_id: evidenceId,
1064
+ };
1065
+ });
1066
+ }
1067
+
1068
+ function median(values) {
1069
+ if (values.length === 0) return 0;
1070
+ const ordered = [...values].sort((left, right) => left - right);
1071
+ const middle = Math.floor(ordered.length / 2);
1072
+ const value = ordered.length % 2 === 0
1073
+ ? (ordered[middle - 1] + ordered[middle]) / 2
1074
+ : ordered[middle];
1075
+ return Math.round(value * 10) / 10;
1076
+ }
1077
+
1078
+ function listeningSessionSummary(subjectId, eventAnalysis, explanations) {
1079
+ const rows = eventAnalysis.eligible
1080
+ .filter(
1081
+ (row) =>
1082
+ row.event?.provenance?.external_ref?.system ===
1083
+ spotifyExtendedHistorySystem,
1084
+ )
1085
+ .sort((left, right) => {
1086
+ const timeOrder = lexicalCompare(
1087
+ left.event.occurred_at,
1088
+ right.event.occurred_at,
1089
+ );
1090
+ if (timeOrder) return timeOrder;
1091
+ return lexicalCompare(
1092
+ String(left.track?.track_ref_id ?? ""),
1093
+ String(right.track?.track_ref_id ?? ""),
1094
+ );
1095
+ });
1096
+ if (rows.length === 0) return null;
1097
+
1098
+ const sessions = [];
1099
+ let current = [];
1100
+ for (const row of rows) {
1101
+ const previous = current.at(-1);
1102
+ const gap = previous
1103
+ ? Date.parse(row.event.occurred_at) -
1104
+ Date.parse(previous.event.occurred_at)
1105
+ : 0;
1106
+ if (previous && gap > sessionGapMinutes * 60_000) {
1107
+ sessions.push(current);
1108
+ current = [];
1109
+ }
1110
+ current.push(row);
1111
+ }
1112
+ if (current.length > 0) sessions.push(current);
1113
+
1114
+ const sessionPlayCounts = sessions.map((session) => session.length);
1115
+ const sessionListeningMinutes = sessions.map((session) =>
1116
+ minutes(session.reduce((sum, row) => sum + row.playedMs, 0)),
1117
+ );
1118
+ const singlePlaySessions = sessionPlayCounts.filter(
1119
+ (count) => count === 1,
1120
+ ).length;
1121
+ const shortSequenceSessions = sessionPlayCounts.filter(
1122
+ (count) => count >= 2 && count < extendedSequenceMinimumPlays,
1123
+ ).length;
1124
+ const extendedSequenceSessions = sessionPlayCounts.filter(
1125
+ (count) => count >= extendedSequenceMinimumPlays,
1126
+ ).length;
1127
+ const extendedSequencePercent = Math.round(
1128
+ (extendedSequenceSessions / sessions.length) * 1_000,
1129
+ ) / 10;
1130
+ const evidenceId = aggregateEvidenceId(
1131
+ subjectId,
1132
+ "listening-session-shape",
1133
+ `${rows[0].event.occurred_at}\0${rows.at(-1).event.occurred_at}\0${rows.length}\0${sessionGapMinutes}`,
1134
+ );
1135
+ explanations.set(
1136
+ evidenceId,
1137
+ explanation({
1138
+ evidenceId,
1139
+ dimension: "listening.session_shape",
1140
+ value: `${sessions.length} approximate listening sessions`,
1141
+ basis: `${plural(rows.length, "play")} fall into about ${plural(sessions.length, "session")}, counting a new session after a break of more than ${sessionGapMinutes} minutes. A typical session is ${median(sessionPlayCounts)} plays and ${median(sessionListeningMinutes)} minutes.`,
1142
+ name: "listening-session-shape",
1143
+ confidence: confidenceForCount(rows.length),
1144
+ limitation:
1145
+ "Sessions are estimated from when each song stopped. They say nothing about what you were doing, where you were, or how you felt.",
1146
+ }),
1147
+ );
1148
+ return {
1149
+ source: "spotify_extended_history",
1150
+ method: "track_stop_gap",
1151
+ gap_minutes: sessionGapMinutes,
1152
+ event_count: rows.length,
1153
+ session_count: sessions.length,
1154
+ median_plays: median(sessionPlayCounts),
1155
+ median_listening_minutes: median(sessionListeningMinutes),
1156
+ single_play_sessions: singlePlaySessions,
1157
+ short_sequence_sessions: shortSequenceSessions,
1158
+ extended_sequence_sessions: extendedSequenceSessions,
1159
+ extended_sequence_minimum_plays: extendedSequenceMinimumPlays,
1160
+ extended_sequence_percent: extendedSequencePercent,
1161
+ evidence_id: evidenceId,
1162
+ };
1163
+ }
1164
+
1165
+ function backToBackTracks(
1166
+ subjectId,
1167
+ eventAnalysis,
1168
+ evidence,
1169
+ direct,
1170
+ explanations,
1171
+ ) {
1172
+ const rows = eventAnalysis.eligible
1173
+ .filter(
1174
+ (row) =>
1175
+ row.event?.provenance?.external_ref?.system ===
1176
+ spotifyExtendedHistorySystem,
1177
+ )
1178
+ .sort((left, right) => {
1179
+ const timeOrder = lexicalCompare(
1180
+ left.event.occurred_at,
1181
+ right.event.occurred_at,
1182
+ );
1183
+ if (timeOrder) return timeOrder;
1184
+ return lexicalCompare(
1185
+ String(left.event?.listening_event_id ?? ""),
1186
+ String(right.event?.listening_event_id ?? ""),
1187
+ );
1188
+ });
1189
+ const bursts = [];
1190
+ let current = [];
1191
+ for (const row of rows) {
1192
+ const occurredAtMs = Date.parse(row.event?.occurred_at ?? "");
1193
+ const metadata = trackFields(row.track);
1194
+ const eligible =
1195
+ Number.isFinite(occurredAtMs) &&
1196
+ typeof metadata.trackRefId === "string" &&
1197
+ !row.skipped &&
1198
+ row.playedMs >= backToBackMinimumPlayedMs;
1199
+ const previous = current.at(-1);
1200
+ const previousMs = previous
1201
+ ? Date.parse(previous.event.occurred_at)
1202
+ : Number.NaN;
1203
+ const gapMs = occurredAtMs - previousMs;
1204
+ const continues =
1205
+ previous &&
1206
+ eligible &&
1207
+ metadata.trackRefId === trackFields(previous.track).trackRefId &&
1208
+ gapMs >= 0 &&
1209
+ gapMs <= backToBackMaximumGapMinutes * 60_000;
1210
+ if (continues) {
1211
+ current.push(row);
1212
+ continue;
1213
+ }
1214
+ if (current.length >= backToBackMinimumConsecutivePlays) {
1215
+ bursts.push(current);
1216
+ }
1217
+ current = eligible ? [row] : [];
1218
+ }
1219
+ if (current.length >= backToBackMinimumConsecutivePlays) {
1220
+ bursts.push(current);
1221
+ }
1222
+
1223
+ const byTrack = new Map();
1224
+ for (const burst of bursts) {
1225
+ const metadata = trackFields(burst[0].track);
1226
+ const burstListeningMs = burst.reduce(
1227
+ (sum, row) => sum + row.playedMs,
1228
+ 0,
1229
+ );
1230
+ const latestBurstAt = burst.at(-1).event.occurred_at;
1231
+ const aggregate = byTrack.get(metadata.trackRefId) ?? {
1232
+ ...metadata,
1233
+ burstCount: 0,
1234
+ maximumConsecutivePlays: 0,
1235
+ playsInBursts: 0,
1236
+ listeningMsInBursts: 0,
1237
+ latestBurstAt,
1238
+ };
1239
+ aggregate.burstCount += 1;
1240
+ aggregate.maximumConsecutivePlays = Math.max(
1241
+ aggregate.maximumConsecutivePlays,
1242
+ burst.length,
1243
+ );
1244
+ aggregate.playsInBursts += burst.length;
1245
+ aggregate.listeningMsInBursts += burstListeningMs;
1246
+ if (latestBurstAt > aggregate.latestBurstAt) {
1247
+ aggregate.latestBurstAt = latestBurstAt;
1248
+ }
1249
+ byTrack.set(metadata.trackRefId, aggregate);
1250
+ }
1251
+
1252
+ const avoids = combinedAvoids(direct.avoids, evidence.avoids);
1253
+ return [...byTrack.values()]
1254
+ .filter(
1255
+ (item) => !avoids.some((avoid) => assertionMatchesTrack(avoid, item)),
1256
+ )
1257
+ .sort((left, right) => {
1258
+ if (
1259
+ right.maximumConsecutivePlays !== left.maximumConsecutivePlays
1260
+ ) {
1261
+ return (
1262
+ right.maximumConsecutivePlays - left.maximumConsecutivePlays
1263
+ );
1264
+ }
1265
+ if (right.burstCount !== left.burstCount) {
1266
+ return right.burstCount - left.burstCount;
1267
+ }
1268
+ if (right.playsInBursts !== left.playsInBursts) {
1269
+ return right.playsInBursts - left.playsInBursts;
1270
+ }
1271
+ if (right.listeningMsInBursts !== left.listeningMsInBursts) {
1272
+ return right.listeningMsInBursts - left.listeningMsInBursts;
1273
+ }
1274
+ const titleOrder = lexicalCompare(left.title, right.title);
1275
+ return titleOrder || lexicalCompare(left.artist, right.artist);
1276
+ })
1277
+ .map((item) => {
1278
+ const lifetime = eventAnalysis.lifetime.tracks.get(item.trackRefId);
1279
+ const evidenceId = aggregateEvidenceId(
1280
+ subjectId,
1281
+ "track-back-to-back",
1282
+ `${item.trackRefId}\0${item.burstCount}\0${item.maximumConsecutivePlays}\0${item.latestBurstAt}`,
1283
+ );
1284
+ explanations.set(
1285
+ evidenceId,
1286
+ explanation({
1287
+ evidenceId,
1288
+ dimension: "listening.back_to_back",
1289
+ value: `${item.title} - ${item.artist}`,
1290
+ basis: `You played it again right away ${item.burstCount === 1 ? "once" : `${item.burstCount} times`}, ${plural(item.playsInBursts, "play")} in all, with up to ${item.maximumConsecutivePlays} in a row. Each counted play lasted at least ${backToBackMinimumPlayedMs / 1_000} seconds, wasn't skipped, and started within ${backToBackMaximumGapMinutes} minutes of the last.`,
1291
+ name: "listening-track-back-to-back",
1292
+ confidence: confidenceForCount(
1293
+ item.playsInBursts,
1294
+ 0.58,
1295
+ 0.9,
1296
+ ),
1297
+ limitation:
1298
+ "Playing a song twice in a row could be love, or repeat mode, or falling asleep. This can't tell which.",
1299
+ }),
1300
+ );
1301
+ return {
1302
+ track_ref_id: item.trackRefId,
1303
+ label: item.title,
1304
+ artist_credit: item.artist,
1305
+ ...(item.release ? { release: item.release } : {}),
1306
+ identity_status: item.identityStatus,
1307
+ play_count: lifetime?.playCount ?? item.playsInBursts,
1308
+ engaged_play_count:
1309
+ lifetime?.engagedPlayCount ?? item.playsInBursts,
1310
+ explicit_skips: lifetime?.skippedCount ?? 0,
1311
+ burst_count: item.burstCount,
1312
+ maximum_consecutive_plays: item.maximumConsecutivePlays,
1313
+ plays_in_bursts: item.playsInBursts,
1314
+ listening_minutes_in_bursts: minutes(item.listeningMsInBursts),
1315
+ latest_burst_at: item.latestBurstAt,
1316
+ sequence_signal: "adjacent retained plays",
1317
+ evidence_id: evidenceId,
1318
+ };
1319
+ });
1320
+ }
1321
+
1322
+ function deduplicateProfileEvidence(records) {
1323
+ const selected = new Map();
1324
+ for (const record of records) {
1325
+ const current = selected.get(record.evidence_key);
1326
+ const capturedAt = record.provenance?.captured_at ?? "";
1327
+ const currentCapturedAt = current?.provenance?.captured_at ?? "";
1328
+ if (
1329
+ !current ||
1330
+ capturedAt > currentCapturedAt ||
1331
+ (capturedAt === currentCapturedAt &&
1332
+ record.profile_evidence_id > current.profile_evidence_id)
1333
+ ) {
1334
+ selected.set(record.evidence_key, record);
1335
+ }
1336
+ }
1337
+ return [...selected.values()];
1338
+ }
1339
+
1340
+ function persistedExplanation(record) {
1341
+ const provider = musicProviderLabel(record.provenance?.source_system);
1342
+ const label = record.entity?.label ?? "Unresolved provider entity";
1343
+ const attributes = record.attributes ?? {};
1344
+ const source = record.provenance?.source_member ?? "Spotify account data";
1345
+ const basisByKind = {
1346
+ library_track_saved: `The track appears in the saved ${provider} library snapshot captured at ${record.provenance.captured_at}.`,
1347
+ library_album_saved: `The album appears in the saved ${provider} library snapshot captured at ${record.provenance.captured_at}.`,
1348
+ library_artist_followed: `The artist appears in the followed-artists snapshot captured at ${record.provenance.captured_at}.`,
1349
+ library_track_banned: `The track appears in the explicitly banned-tracks snapshot captured at ${record.provenance.captured_at}.`,
1350
+ library_artist_banned: `The artist appears in the explicitly banned-artists snapshot captured at ${record.provenance.captured_at}.`,
1351
+ playlist_track_added: `The track appears in the ${provider} playlist named ${JSON.stringify(attributes.playlist_name)} at position ${attributes.playlist_position}.` + (attributes.selected_public_playlist ? " You selected this public playlist; ownership and listening are not established." : ""),
1352
+ search_result_interacted: `A Spotify search for ${JSON.stringify(label)} led to an interaction with a ${attributes.result_entity_type} result at ${record.observed_at}.`,
1353
+ wrapped_track_ranked: `Spotify Wrapped ranked this track at position ${attributes.rank} for ${attributes.period}.`,
1354
+ wrapped_artist_ranked: `Spotify Wrapped ranked this artist at position ${attributes.rank} for ${attributes.period}.`,
1355
+ wrapped_album_ranked: `Spotify Wrapped ranked this album at position ${attributes.rank} for ${attributes.period}.`,
1356
+ wrapped_genre_ranked: `Spotify Wrapped ranked this genre concept at position ${attributes.rank} for ${attributes.period}.`,
1357
+ taste_artist_ranked: `Spotify's exported Taste Profile ranked this artist reference at position ${attributes.rank}.`,
1358
+ provider_interpretation: `Spotify supplied this provider-generated ${label.replaceAll("_", " ")} narrative in ${source}.`,
1359
+ sound_capsule_artist_ranked: `Spotify Sound Capsule ranked this artist at position ${attributes.rank} for ${attributes.period}.`,
1360
+ sound_capsule_track_ranked: `Spotify Sound Capsule ranked this track label at position ${attributes.rank} for ${attributes.period}.`,
1361
+ sound_capsule_highlight: `Spotify Sound Capsule emitted a ${attributes.highlight_type} highlight dated ${record.observed_at}.`,
1362
+ wrapped_metric: `Spotify Wrapped reported ${attributes.value} ${attributes.unit} for ${label} in ${attributes.period}.`,
1363
+ sound_capsule_period_metric: `Spotify Sound Capsule reported ${attributes.stream_count} streams and ${attributes.played_seconds} played seconds for ${attributes.period}.`,
1364
+ };
1365
+ const limitationByClass = {
1366
+ explicit:
1367
+ "This is an explicit current provider state, but the export does not include the original action timestamp.",
1368
+ curated:
1369
+ "Playlist inclusion is a curatorial signal, not proof of a permanent preference or the reason for inclusion.",
1370
+ behavioral:
1371
+ "The query and interacted result show bounded search intent, not durable preference.",
1372
+ provider_derived:
1373
+ "This is Spotify-generated interpretation or aggregation, not a direct user assertion or instruction.",
1374
+ };
1375
+ const dimensions = {
1376
+ library_track_saved: "taste.track_preference",
1377
+ library_album_saved: "taste.album_preference",
1378
+ library_artist_followed: "taste.artist_preference",
1379
+ library_track_banned: "taste.track_avoidance",
1380
+ library_artist_banned: "taste.artist_avoidance",
1381
+ playlist_track_added: "taste.track_curation",
1382
+ search_result_interacted: "intent.search",
1383
+ wrapped_track_ranked: "provider.track_rank",
1384
+ wrapped_artist_ranked: "provider.artist_rank",
1385
+ wrapped_album_ranked: "provider.album_rank",
1386
+ wrapped_genre_ranked: "provider.genre_rank",
1387
+ taste_artist_ranked: "provider.artist_rank",
1388
+ provider_interpretation: "provider.interpretation",
1389
+ sound_capsule_artist_ranked: "provider.artist_rank",
1390
+ sound_capsule_track_ranked: "provider.track_rank",
1391
+ sound_capsule_highlight: "provider.highlight",
1392
+ wrapped_metric: "provider.metric",
1393
+ sound_capsule_period_metric: "provider.metric",
1394
+ };
1395
+ const confidenceByClass = {
1396
+ explicit: 0.95,
1397
+ curated: 0.85,
1398
+ behavioral: 0.65,
1399
+ provider_derived: 0.5,
1400
+ };
1401
+ return explanation({
1402
+ evidenceId: record.profile_evidence_id,
1403
+ dimension: dimensions[record.evidence_kind] ?? "profile.context",
1404
+ value: label,
1405
+ direction: record.direction,
1406
+ basis: basisByKind[record.evidence_kind] ?? `The evidence came from ${source}.`,
1407
+ name: `${record.provenance.source_system}-${record.evidence_kind}`,
1408
+ confidence: confidenceByClass[record.strength_class] ?? 0.5,
1409
+ limitation:
1410
+ limitationByClass[record.strength_class] ??
1411
+ "This evidence must be interpreted within its provider-export context.",
1412
+ });
1413
+ }
1414
+
1415
+ function evidenceGroups(records, eventAnalysis, explanations) {
1416
+ const byKind = new Map();
1417
+ for (const record of records) {
1418
+ const values = byKind.get(record.evidence_kind) ?? [];
1419
+ values.push(record);
1420
+ byKind.set(record.evidence_kind, values);
1421
+ explanations.set(record.profile_evidence_id, persistedExplanation(record));
1422
+ }
1423
+ const kind = (name) => byKind.get(name) ?? [];
1424
+ const trackMetrics = eventAnalysis.lifetime.tracks;
1425
+ const artistMetrics = eventAnalysis.lifetime.artists;
1426
+
1427
+ const playlistGroups = new Map();
1428
+ for (const record of kind("playlist_track_added")) {
1429
+ const trackRefId = record.entity.track_ref_id;
1430
+ const group = playlistGroups.get(trackRefId) ?? {
1431
+ entity: record.entity,
1432
+ evidenceId: record.profile_evidence_id,
1433
+ playlists: new Set(),
1434
+ lastAddedAt: record.observed_at,
1435
+ sourceLabel: musicProviderLabel(record.provenance.source_system),
1436
+ };
1437
+ group.playlists.add(record.attributes.playlist_name);
1438
+ if (record.observed_at > group.lastAddedAt) {
1439
+ group.lastAddedAt = record.observed_at;
1440
+ group.evidenceId = record.profile_evidence_id;
1441
+ }
1442
+ playlistGroups.set(trackRefId, group);
1443
+ }
1444
+
1445
+ const savedTracks = kind("library_track_saved")
1446
+ .map((record) => ({
1447
+ record,
1448
+ metric: trackMetrics.get(record.entity.track_ref_id),
1449
+ }))
1450
+ .sort((left, right) => {
1451
+ const duration = (right.metric?.playedMs ?? 0) - (left.metric?.playedMs ?? 0);
1452
+ if (duration) return duration;
1453
+ const playlists =
1454
+ (playlistGroups.get(right.record.entity.track_ref_id)?.playlists.size ?? 0) -
1455
+ (playlistGroups.get(left.record.entity.track_ref_id)?.playlists.size ?? 0);
1456
+ if (playlists) return playlists;
1457
+ return lexicalCompare(left.record.entity.label, right.record.entity.label);
1458
+ })
1459
+ .map(({ record, metric }) => ({
1460
+ track_ref_id: record.entity.track_ref_id,
1461
+ source_label: musicProviderLabel(record.provenance.source_system),
1462
+ label: record.entity.label,
1463
+ artist_credit: record.entity.artist_credit,
1464
+ ...(record.entity.release ? { release: record.entity.release } : {}),
1465
+ ...(metric ? { listening_minutes: minutes(metric.playedMs) } : {}),
1466
+ evidence_id: record.profile_evidence_id,
1467
+ }));
1468
+
1469
+ const playlistAnchors = [...playlistGroups.values()]
1470
+ .sort((left, right) => {
1471
+ if (right.playlists.size !== left.playlists.size) {
1472
+ return right.playlists.size - left.playlists.size;
1473
+ }
1474
+ const duration =
1475
+ (trackMetrics.get(right.entity.track_ref_id)?.playedMs ?? 0) -
1476
+ (trackMetrics.get(left.entity.track_ref_id)?.playedMs ?? 0);
1477
+ if (duration) return duration;
1478
+ return lexicalCompare(left.entity.label, right.entity.label);
1479
+ })
1480
+ .map((group) => ({
1481
+ track_ref_id: group.entity.track_ref_id,
1482
+ source_label: group.sourceLabel,
1483
+ label: group.entity.label,
1484
+ artist_credit: group.entity.artist_credit,
1485
+ ...(group.entity.release ? { release: group.entity.release } : {}),
1486
+ playlist_count: group.playlists.size,
1487
+ playlist_names: [...group.playlists].sort(lexicalCompare).slice(0, 3),
1488
+ last_added_at: group.lastAddedAt,
1489
+ evidence_id: group.evidenceId,
1490
+ }));
1491
+
1492
+ const followedArtists = kind("library_artist_followed")
1493
+ .map((record) => ({
1494
+ record,
1495
+ metric: artistMetrics.get(normalizedText(record.entity.label)),
1496
+ }))
1497
+ .sort((left, right) => {
1498
+ const duration = (right.metric?.playedMs ?? 0) - (left.metric?.playedMs ?? 0);
1499
+ return duration || lexicalCompare(left.record.entity.label, right.record.entity.label);
1500
+ })
1501
+ .map(({ record, metric }) => ({
1502
+ name: record.entity.label,
1503
+ ...(metric ? { listening_minutes: minutes(metric.playedMs) } : {}),
1504
+ evidence_id: record.profile_evidence_id,
1505
+ }));
1506
+
1507
+ const savedAlbums = kind("library_album_saved")
1508
+ .sort((left, right) => lexicalCompare(left.entity.label, right.entity.label))
1509
+ .map((record) => ({
1510
+ label: record.entity.label,
1511
+ artist_credit: record.entity.artist_credit,
1512
+ evidence_id: record.profile_evidence_id,
1513
+ }));
1514
+
1515
+ const avoids = [
1516
+ ...kind("library_track_banned"),
1517
+ ...kind("library_artist_banned"),
1518
+ ].map((record) => ({
1519
+ entity_type: record.entity.entity_type,
1520
+ label: record.entity.label,
1521
+ ...(record.entity.artist_credit
1522
+ ? { artist_credit: record.entity.artist_credit }
1523
+ : {}),
1524
+ evidence_id: record.profile_evidence_id,
1525
+ }));
1526
+
1527
+ const searchGroups = new Map();
1528
+ for (const record of kind("search_result_interacted")) {
1529
+ const key = normalizedText(record.entity.label);
1530
+ const group = searchGroups.get(key) ?? {
1531
+ query: record.entity.label,
1532
+ interactions: 0,
1533
+ entityTypes: new Set(),
1534
+ lastSearchedAt: record.observed_at,
1535
+ evidenceId: record.profile_evidence_id,
1536
+ };
1537
+ group.interactions += 1;
1538
+ group.entityTypes.add(record.attributes.result_entity_type);
1539
+ if (record.observed_at > group.lastSearchedAt) {
1540
+ group.lastSearchedAt = record.observed_at;
1541
+ group.evidenceId = record.profile_evidence_id;
1542
+ }
1543
+ searchGroups.set(key, group);
1544
+ }
1545
+ const searches = [...searchGroups.values()]
1546
+ .sort((left, right) => {
1547
+ if (right.interactions !== left.interactions) {
1548
+ return right.interactions - left.interactions;
1549
+ }
1550
+ return descendingTimestamp(left.lastSearchedAt, right.lastSearchedAt);
1551
+ })
1552
+ .map((group) => ({
1553
+ query: group.query,
1554
+ quoted_data: true,
1555
+ interactions: group.interactions,
1556
+ result_entity_types: [...group.entityTypes].sort(lexicalCompare),
1557
+ last_searched_at: group.lastSearchedAt,
1558
+ evidence_id: group.evidenceId,
1559
+ }));
1560
+
1561
+ const providerArtistKinds = new Set([
1562
+ "wrapped_artist_ranked",
1563
+ "taste_artist_ranked",
1564
+ "sound_capsule_artist_ranked",
1565
+ ]);
1566
+ const providerTrackKinds = new Set([
1567
+ "wrapped_track_ranked",
1568
+ "sound_capsule_track_ranked",
1569
+ ]);
1570
+ function providerRanked(kinds, outputType) {
1571
+ const groups = new Map();
1572
+ for (const record of records.filter((item) => kinds.has(item.evidence_kind))) {
1573
+ if (!record.entity.label) continue;
1574
+ const key = normalizedText(record.entity.label);
1575
+ const group = groups.get(key) ?? {
1576
+ label: record.entity.label,
1577
+ artistCredit: record.entity.artist_credit,
1578
+ bestRank: Number.MAX_SAFE_INTEGER,
1579
+ periods: new Set(),
1580
+ playedSeconds: 0,
1581
+ evidenceId: record.profile_evidence_id,
1582
+ };
1583
+ const rank = Number.isSafeInteger(record.attributes.rank)
1584
+ ? record.attributes.rank
1585
+ : Number.MAX_SAFE_INTEGER;
1586
+ if (rank < group.bestRank) {
1587
+ group.bestRank = rank;
1588
+ group.evidenceId = record.profile_evidence_id;
1589
+ }
1590
+ if (record.attributes.period) group.periods.add(record.attributes.period);
1591
+ if (Number.isSafeInteger(record.attributes.played_seconds)) {
1592
+ group.playedSeconds += record.attributes.played_seconds;
1593
+ }
1594
+ groups.set(key, group);
1595
+ }
1596
+ return [...groups.values()]
1597
+ .sort((left, right) => {
1598
+ if (right.playedSeconds !== left.playedSeconds) {
1599
+ return right.playedSeconds - left.playedSeconds;
1600
+ }
1601
+ if (left.bestRank !== right.bestRank) return left.bestRank - right.bestRank;
1602
+ return lexicalCompare(left.label, right.label);
1603
+ })
1604
+ .map((group) => ({
1605
+ ...(outputType === "artist"
1606
+ ? { name: group.label }
1607
+ : {
1608
+ label: group.label,
1609
+ ...(group.artistCredit
1610
+ ? { artist_credit: group.artistCredit }
1611
+ : {}),
1612
+ }),
1613
+ ...(group.bestRank < Number.MAX_SAFE_INTEGER
1614
+ ? { best_rank: group.bestRank }
1615
+ : {}),
1616
+ periods: [...group.periods].sort(lexicalCompare),
1617
+ ...(group.playedSeconds > 0
1618
+ ? { listening_minutes: minutes(group.playedSeconds * 1_000) }
1619
+ : {}),
1620
+ evidence_id: group.evidenceId,
1621
+ }));
1622
+ }
1623
+
1624
+ const genres = kind("wrapped_genre_ranked")
1625
+ .filter((record) => record.entity.label)
1626
+ .sort((left, right) => left.attributes.rank - right.attributes.rank)
1627
+ .map((record) => ({
1628
+ name: record.entity.label,
1629
+ rank: record.attributes.rank,
1630
+ period: record.attributes.period,
1631
+ evidence_id: record.profile_evidence_id,
1632
+ }));
1633
+ const interpretations = kind("provider_interpretation").map((record) => ({
1634
+ kind: record.entity.label,
1635
+ text: record.attributes.text,
1636
+ quoted_data: true,
1637
+ evidence_id: record.profile_evidence_id,
1638
+ }));
1639
+ const highlights = kind("sound_capsule_highlight")
1640
+ .sort((left, right) => descendingTimestamp(left.observed_at, right.observed_at))
1641
+ .map((record) => ({
1642
+ kind: record.attributes.highlight_type,
1643
+ label: record.entity.label,
1644
+ ...(record.attributes.related_label
1645
+ ? { related_label: record.attributes.related_label }
1646
+ : {}),
1647
+ ...(record.attributes.metric_name
1648
+ ? {
1649
+ metric_name: record.attributes.metric_name,
1650
+ metric_value: record.attributes.metric_value,
1651
+ }
1652
+ : {}),
1653
+ observed_at: record.observed_at,
1654
+ evidence_id: record.profile_evidence_id,
1655
+ }));
1656
+ const metrics = [
1657
+ ...kind("wrapped_metric"),
1658
+ ...kind("sound_capsule_period_metric"),
1659
+ ].map((record) => ({
1660
+ name: record.entity.label,
1661
+ ...(record.attributes.value !== undefined
1662
+ ? { value: record.attributes.value, unit: record.attributes.unit }
1663
+ : {
1664
+ stream_count: record.attributes.stream_count,
1665
+ played_seconds: record.attributes.played_seconds,
1666
+ }),
1667
+ period: record.attributes.period,
1668
+ evidence_id: record.profile_evidence_id,
1669
+ }));
1670
+
1671
+ return {
1672
+ byKind,
1673
+ savedTracks,
1674
+ playlistAnchors,
1675
+ followedArtists,
1676
+ savedAlbums,
1677
+ avoids,
1678
+ searches,
1679
+ providerArtists: providerRanked(providerArtistKinds, "artist"),
1680
+ providerTracks: providerRanked(providerTrackKinds, "track"),
1681
+ genres,
1682
+ interpretations,
1683
+ highlights,
1684
+ metrics,
1685
+ };
1686
+ }
1687
+
1688
+ function slice(value, maximum) {
1689
+ return value.slice(0, maximum);
1690
+ }
1691
+
1692
+ function listenerAssertions(subjectId, records, explanations) {
1693
+ const retracted = new Set(
1694
+ records
1695
+ .filter((record) => record?.operation === "retract")
1696
+ .map((record) => record.retracts_taste_event_id)
1697
+ .filter((value) => typeof value === "string"),
1698
+ );
1699
+ const superseded = new Set(
1700
+ records
1701
+ .filter((record) => record?.operation === "assert")
1702
+ .map((record) => record.supersedes_taste_event_id)
1703
+ .filter((value) => typeof value === "string"),
1704
+ );
1705
+ const assertions = records.filter(
1706
+ (record) =>
1707
+ record?.operation === "assert" &&
1708
+ record.subject_id?.toLowerCase() === subjectId &&
1709
+ !retracted.has(record.taste_event_id) &&
1710
+ !superseded.has(record.taste_event_id),
1711
+ );
1712
+ const items = assertions
1713
+ .sort((left, right) => {
1714
+ const time = descendingTimestamp(left.recorded_at, right.recorded_at);
1715
+ return time || lexicalCompare(right.taste_event_id, left.taste_event_id);
1716
+ })
1717
+ .map((record) => {
1718
+ const stance = record.signal_type === "avoidance" ? "avoid" : "like";
1719
+ const entityType = record.target?.entity_type;
1720
+ const label = record.target?.label;
1721
+ if (
1722
+ !new Set(["artist", "track"]).has(entityType) ||
1723
+ typeof label !== "string" ||
1724
+ !label.trim()
1725
+ ) {
1726
+ throw new TypeError("Stored listener correction target is invalid");
1727
+ }
1728
+ const artistCredit = record.extensions?.["moondog.artist_credit"];
1729
+ const value = artistCredit ? `${label} - ${artistCredit}` : label;
1730
+ explanations.set(
1731
+ record.taste_event_id,
1732
+ explanation({
1733
+ evidenceId: record.taste_event_id,
1734
+ dimension: `taste.${entityType}_${
1735
+ stance === "avoid" ? "avoidance" : "preference"
1736
+ }`,
1737
+ value,
1738
+ direction: "supports",
1739
+ basis: `You said ${stance === "avoid" ? `to keep ${JSON.stringify(value)} out` : `you like ${JSON.stringify(value)}`} on ${day(record.occurred_at)}.`,
1740
+ name: "explicit-listener-correction",
1741
+ confidence: 1,
1742
+ limitation:
1743
+ "This is your own choice. You can change or undo it any time, and it doesn't rewrite your listening history.",
1744
+ }),
1745
+ );
1746
+ return {
1747
+ correction_id: record.taste_event_id,
1748
+ evidence_id: record.taste_event_id,
1749
+ entity_type: entityType,
1750
+ label: label.trim(),
1751
+ ...(typeof artistCredit === "string" && artistCredit.trim()
1752
+ ? { artist_credit: artistCredit.trim() }
1753
+ : {}),
1754
+ stance,
1755
+ strength: record.strength,
1756
+ asserted_at: record.occurred_at,
1757
+ ...(typeof record.note === "string" && record.note.trim()
1758
+ ? { note: record.note.trim() }
1759
+ : {}),
1760
+ };
1761
+ });
1762
+ return {
1763
+ active: items,
1764
+ preferences: items.filter((item) => item.stance === "like"),
1765
+ avoids: items.filter((item) => item.stance === "avoid"),
1766
+ total_assertions: records.filter((record) => record?.operation === "assert")
1767
+ .length,
1768
+ retractions: records.filter((record) => record?.operation === "retract")
1769
+ .length,
1770
+ };
1771
+ }
1772
+
1773
+ function combinedAvoids(listenerAvoids, providerAvoids) {
1774
+ const selected = new Map();
1775
+ for (const item of [...listenerAvoids, ...providerAvoids]) {
1776
+ const key = [
1777
+ item.entity_type,
1778
+ normalizedText(item.label),
1779
+ normalizedText(item.artist_credit ?? ""),
1780
+ ].join("\0");
1781
+ if (!selected.has(key)) selected.set(key, item);
1782
+ }
1783
+ return [...selected.values()];
1784
+ }
1785
+
1786
+ function assertionMatchesTrack(assertion, track) {
1787
+ if (assertion.entity_type === "artist") {
1788
+ return normalizedText(assertion.label) === normalizedText(track.artist);
1789
+ }
1790
+ if (assertion.entity_type !== "track") return false;
1791
+ if (normalizedText(assertion.label) !== normalizedText(track.title)) {
1792
+ return false;
1793
+ }
1794
+ return !assertion.artist_credit ||
1795
+ normalizedText(assertion.artist_credit) === normalizedText(track.artist);
1796
+ }
1797
+
1798
+ function rediscoverySignal(track, evidence, direct) {
1799
+ if (direct.preferences.some((item) => assertionMatchesTrack(item, track))) {
1800
+ return { rank: 4, value: "explicit listener preference" };
1801
+ }
1802
+ if (
1803
+ evidence.savedTracks.some(
1804
+ (item) => item.track_ref_id === track.trackRefId,
1805
+ )
1806
+ ) {
1807
+ return { rank: 3, value: "saved-library state" };
1808
+ }
1809
+ if (
1810
+ evidence.playlistAnchors.some(
1811
+ (item) => item.track_ref_id === track.trackRefId,
1812
+ )
1813
+ ) {
1814
+ return { rank: 2, value: "private playlist curation" };
1815
+ }
1816
+ return { rank: 1, value: "historical attention only" };
1817
+ }
1818
+
1819
+ function rediscoveryPeakYear(track) {
1820
+ return [...track.years.entries()]
1821
+ .sort(([leftYear, left], [rightYear, right]) => {
1822
+ if (right.playedMs !== left.playedMs) {
1823
+ return right.playedMs - left.playedMs;
1824
+ }
1825
+ if (right.playCount !== left.playCount) {
1826
+ return right.playCount - left.playCount;
1827
+ }
1828
+ return leftYear - rightYear;
1829
+ })
1830
+ .map(([year, aggregate]) => ({
1831
+ year,
1832
+ play_count: aggregate.playCount,
1833
+ engaged_play_count: aggregate.engagedPlayCount,
1834
+ explicit_skips: aggregate.skippedCount,
1835
+ listening_minutes: minutes(aggregate.playedMs),
1836
+ played_ms: aggregate.playedMs,
1837
+ }))[0] ?? null;
1838
+ }
1839
+
1840
+ function rediscoveryTracks(
1841
+ subjectId,
1842
+ eventAnalysis,
1843
+ evidence,
1844
+ direct,
1845
+ explanations,
1846
+ ) {
1847
+ const latestMs = Date.parse(eventAnalysis.latest ?? "");
1848
+ if (!Number.isFinite(latestMs)) return [];
1849
+ const avoids = combinedAvoids(direct.avoids, evidence.avoids);
1850
+ return [...eventAnalysis.lifetime.tracks.values()]
1851
+ .map((track) => {
1852
+ const lastPlayedMs = Date.parse(track.lastPlayedAt);
1853
+ const quietDays = Math.floor((latestMs - lastPlayedMs) / dayMs);
1854
+ const signal = rediscoverySignal(track, evidence, direct);
1855
+ return {
1856
+ track,
1857
+ quietDays,
1858
+ signal,
1859
+ peak: rediscoveryPeakYear(track),
1860
+ };
1861
+ })
1862
+ .filter(
1863
+ ({ track, quietDays }) =>
1864
+ quietDays >= recentWindowDays &&
1865
+ track.playCount >= rediscoveryMinimumPlays &&
1866
+ track.engagedPlayCount >= rediscoveryMinimumEngagedPlays &&
1867
+ track.playedMs >= rediscoveryMinimumPlayedMs &&
1868
+ track.engagedPlayCount >= track.skippedCount &&
1869
+ !avoids.some((item) => assertionMatchesTrack(item, track)),
1870
+ )
1871
+ .sort((left, right) => {
1872
+ if (right.signal.rank !== left.signal.rank) {
1873
+ return right.signal.rank - left.signal.rank;
1874
+ }
1875
+ if (right.track.playedMs !== left.track.playedMs) {
1876
+ return right.track.playedMs - left.track.playedMs;
1877
+ }
1878
+ if (right.track.engagedPlayCount !== left.track.engagedPlayCount) {
1879
+ return right.track.engagedPlayCount - left.track.engagedPlayCount;
1880
+ }
1881
+ if (right.quietDays !== left.quietDays) {
1882
+ return right.quietDays - left.quietDays;
1883
+ }
1884
+ const titleOrder = lexicalCompare(left.track.title, right.track.title);
1885
+ return titleOrder || lexicalCompare(left.track.artist, right.track.artist);
1886
+ })
1887
+ .map(({ track, quietDays, signal, peak }) => {
1888
+ const evidenceId = aggregateEvidenceId(
1889
+ subjectId,
1890
+ "track-rediscovery",
1891
+ `${track.trackRefId}\0${eventAnalysis.latest}`,
1892
+ );
1893
+ explanations.set(
1894
+ evidenceId,
1895
+ explanation({
1896
+ evidenceId,
1897
+ dimension: "listening.rediscovery_candidate",
1898
+ value: `${track.title} - ${track.artist}`,
1899
+ basis: `${plural(track.playCount, "play")} (${track.engagedPlayCount} not skipped), ${minutes(track.playedMs)} minutes in all, from ${day(track.firstPlayedAt)} to ${day(track.lastPlayedAt)}. Then nothing for ${plural(quietDays, "day")}, up to the latest play in your history. The strongest reason to bring it back is ${signalPhrase(signal.value)}.`,
1900
+ name: "listening-track-rediscovery",
1901
+ confidence:
1902
+ signal.rank === 4
1903
+ ? 0.95
1904
+ : confidenceForCount(track.engagedPlayCount, 0.58, 0.88),
1905
+ limitation:
1906
+ "This is a nudge to listen again, not proof you liked it or stopped on purpose. Gaps in your history can make the quiet look longer than it was.",
1907
+ }),
1908
+ );
1909
+ return {
1910
+ track_ref_id: track.trackRefId,
1911
+ label: track.title,
1912
+ artist_credit: track.artist,
1913
+ ...(track.release ? { release: track.release } : {}),
1914
+ identity_status: track.identityStatus,
1915
+ play_count: track.playCount,
1916
+ engaged_play_count: track.engagedPlayCount,
1917
+ listening_minutes: minutes(track.playedMs),
1918
+ explicit_skips: track.skippedCount,
1919
+ first_played_at: track.firstPlayedAt,
1920
+ last_played_at: track.lastPlayedAt,
1921
+ quiet_days: quietDays,
1922
+ rediscovery_signal: signal.value,
1923
+ ...(peak
1924
+ ? {
1925
+ peak_year: peak.year,
1926
+ peak_year_play_count: peak.play_count,
1927
+ peak_year_listening_minutes: peak.listening_minutes,
1928
+ }
1929
+ : {}),
1930
+ evidence_id: evidenceId,
1931
+ };
1932
+ });
1933
+ }
1934
+
1935
+ function historicalReturnGaps(track) {
1936
+ const timestamps = [...track.engagedPlayedAt]
1937
+ .map((value) => ({ value, milliseconds: Date.parse(value) }))
1938
+ .filter(({ milliseconds }) => Number.isFinite(milliseconds))
1939
+ .sort((left, right) => left.milliseconds - right.milliseconds);
1940
+ const returns = [];
1941
+ for (let index = 1; index < timestamps.length; index += 1) {
1942
+ const gapDays = Math.floor(
1943
+ (timestamps[index].milliseconds - timestamps[index - 1].milliseconds) /
1944
+ dayMs,
1945
+ );
1946
+ if (gapDays < historicalReturnMinimumGapDays) continue;
1947
+ returns.push({
1948
+ gap_days: gapDays,
1949
+ returned_at: timestamps[index].value,
1950
+ });
1951
+ }
1952
+ return returns;
1953
+ }
1954
+
1955
+ function historicalReturnTracks(
1956
+ subjectId,
1957
+ eventAnalysis,
1958
+ evidence,
1959
+ direct,
1960
+ explanations,
1961
+ ) {
1962
+ const avoids = combinedAvoids(direct.avoids, evidence.avoids);
1963
+ return [...eventAnalysis.lifetime.tracks.values()]
1964
+ .map((track) => ({
1965
+ track,
1966
+ returns: historicalReturnGaps(track),
1967
+ signal: rediscoverySignal(track, evidence, direct),
1968
+ }))
1969
+ .filter(
1970
+ ({ track, returns }) =>
1971
+ returns.length > 0 &&
1972
+ track.playCount >= historicalReturnMinimumPlays &&
1973
+ track.engagedPlayCount >= historicalReturnMinimumEngagedPlays &&
1974
+ track.playedMs >= historicalReturnMinimumPlayedMs &&
1975
+ track.engagedPlayCount >= track.skippedCount &&
1976
+ !avoids.some((item) => assertionMatchesTrack(item, track)),
1977
+ )
1978
+ .sort((left, right) => {
1979
+ if (right.returns.length !== left.returns.length) {
1980
+ return right.returns.length - left.returns.length;
1981
+ }
1982
+ const leftLongest = Math.max(
1983
+ ...left.returns.map((item) => item.gap_days),
1984
+ );
1985
+ const rightLongest = Math.max(
1986
+ ...right.returns.map((item) => item.gap_days),
1987
+ );
1988
+ if (rightLongest !== leftLongest) return rightLongest - leftLongest;
1989
+ if (right.signal.rank !== left.signal.rank) {
1990
+ return right.signal.rank - left.signal.rank;
1991
+ }
1992
+ if (right.track.playedMs !== left.track.playedMs) {
1993
+ return right.track.playedMs - left.track.playedMs;
1994
+ }
1995
+ if (right.track.engagedPlayCount !== left.track.engagedPlayCount) {
1996
+ return right.track.engagedPlayCount - left.track.engagedPlayCount;
1997
+ }
1998
+ const titleOrder = lexicalCompare(left.track.title, right.track.title);
1999
+ return titleOrder || lexicalCompare(left.track.artist, right.track.artist);
2000
+ })
2001
+ .map(({ track, returns, signal }) => {
2002
+ const longestGapDays = Math.max(
2003
+ ...returns.map((item) => item.gap_days),
2004
+ );
2005
+ const latestReturn = returns.at(-1);
2006
+ const evidenceId = aggregateEvidenceId(
2007
+ subjectId,
2008
+ "track-historical-return",
2009
+ `${track.trackRefId}\0${returns.length}\0${longestGapDays}\0${latestReturn.returned_at}`,
2010
+ );
2011
+ explanations.set(
2012
+ evidenceId,
2013
+ explanation({
2014
+ evidenceId,
2015
+ dimension: "listening.historical_return",
2016
+ value: `${track.title} - ${track.artist}`,
2017
+ basis: `${plural(track.playCount, "play")} (${track.engagedPlayCount} not skipped), ${minutes(track.playedMs)} minutes in all, from ${day(track.firstPlayedAt)} to ${day(track.lastPlayedAt)}. It came back ${returns.length === 1 ? "once" : `${returns.length} times`} after ${historicalReturnMinimumGapDays}+ days away; the longest time away was ${plural(longestGapDays, "day")}.${signalNote(signal.value)}`,
2018
+ name: "listening-track-historical-return",
2019
+ confidence: confidenceForCount(
2020
+ track.engagedPlayCount + returns.length,
2021
+ 0.58,
2022
+ 0.9,
2023
+ ),
2024
+ limitation:
2025
+ "A song coming back after a long time is a pattern. It doesn't prove you like it, miss it, or left it on purpose. Gaps in your history or renamed tracks can stretch or split the pattern.",
2026
+ }),
2027
+ );
2028
+ return {
2029
+ track_ref_id: track.trackRefId,
2030
+ label: track.title,
2031
+ artist_credit: track.artist,
2032
+ ...(track.release ? { release: track.release } : {}),
2033
+ identity_status: track.identityStatus,
2034
+ play_count: track.playCount,
2035
+ engaged_play_count: track.engagedPlayCount,
2036
+ listening_minutes: minutes(track.playedMs),
2037
+ explicit_skips: track.skippedCount,
2038
+ first_played_at: track.firstPlayedAt,
2039
+ last_played_at: track.lastPlayedAt,
2040
+ return_count: returns.length,
2041
+ longest_gap_days: longestGapDays,
2042
+ latest_return_at: latestReturn.returned_at,
2043
+ latest_return_gap_days: latestReturn.gap_days,
2044
+ historical_return_signal: signal.value,
2045
+ evidence_id: evidenceId,
2046
+ };
2047
+ });
2048
+ }
2049
+
2050
+ function evenlySpaced(values, maximum) {
2051
+ if (values.length <= maximum) return [...values];
2052
+ if (maximum === 1) {
2053
+ return [
2054
+ [...values].sort((left, right) => {
2055
+ if (right.strength !== left.strength) {
2056
+ return right.strength - left.strength;
2057
+ }
2058
+ return right.year - left.year;
2059
+ })[0],
2060
+ ];
2061
+ }
2062
+ const selected = [];
2063
+ for (let index = 0; index < maximum; index += 1) {
2064
+ const position = Math.round((index * (values.length - 1)) / (maximum - 1));
2065
+ const value = values[position];
2066
+ if (!selected.includes(value)) selected.push(value);
2067
+ }
2068
+ return selected;
2069
+ }
2070
+
2071
+ function timeCapsuleTracks(
2072
+ subjectId,
2073
+ eventAnalysis,
2074
+ evidence,
2075
+ direct,
2076
+ explanations,
2077
+ maximum,
2078
+ ) {
2079
+ const avoids = combinedAvoids(direct.avoids, evidence.avoids);
2080
+ const byYear = new Map();
2081
+ for (const track of eventAnalysis.lifetime.tracks.values()) {
2082
+ if (avoids.some((item) => assertionMatchesTrack(item, track))) continue;
2083
+ const peak = rediscoveryPeakYear(track);
2084
+ if (
2085
+ !peak ||
2086
+ peak.engaged_play_count < timeCapsuleMinimumEngagedPlays ||
2087
+ peak.listening_minutes < minutes(timeCapsuleMinimumPlayedMs) ||
2088
+ peak.engaged_play_count < peak.explicit_skips
2089
+ ) {
2090
+ continue;
2091
+ }
2092
+ const values = byYear.get(peak.year) ?? [];
2093
+ values.push({
2094
+ track,
2095
+ peak,
2096
+ signal: rediscoverySignal(track, evidence, direct),
2097
+ });
2098
+ byYear.set(peak.year, values);
2099
+ }
2100
+ if (byYear.size < timeCapsuleMinimumYears) return [];
2101
+
2102
+ const rankedYears = [...byYear.entries()]
2103
+ .sort(([left], [right]) => left - right)
2104
+ .map(([year, values]) => {
2105
+ values.sort((left, right) => {
2106
+ if (right.peak.played_ms !== left.peak.played_ms) {
2107
+ return right.peak.played_ms - left.peak.played_ms;
2108
+ }
2109
+ if (
2110
+ right.peak.engaged_play_count !== left.peak.engaged_play_count
2111
+ ) {
2112
+ return right.peak.engaged_play_count - left.peak.engaged_play_count;
2113
+ }
2114
+ if (right.signal.rank !== left.signal.rank) {
2115
+ return right.signal.rank - left.signal.rank;
2116
+ }
2117
+ if (right.track.playedMs !== left.track.playedMs) {
2118
+ return right.track.playedMs - left.track.playedMs;
2119
+ }
2120
+ const titleOrder = lexicalCompare(left.track.title, right.track.title);
2121
+ return titleOrder || lexicalCompare(left.track.artist, right.track.artist);
2122
+ });
2123
+ return {
2124
+ year,
2125
+ values,
2126
+ strength: values[0].peak.played_ms,
2127
+ };
2128
+ });
2129
+ const selectedYears = evenlySpaced(rankedYears, maximum).sort(
2130
+ (left, right) => left.year - right.year,
2131
+ );
2132
+ const usedArtists = new Set();
2133
+ return selectedYears.map(({ year, values }) => {
2134
+ const selected =
2135
+ values.find(
2136
+ ({ track }) => !usedArtists.has(normalizedText(track.artist)),
2137
+ ) ?? values[0];
2138
+ const { track, peak, signal } = selected;
2139
+ usedArtists.add(normalizedText(track.artist));
2140
+ const evidenceId = aggregateEvidenceId(
2141
+ subjectId,
2142
+ "track-time-capsule",
2143
+ `${track.trackRefId}\0${year}`,
2144
+ );
2145
+ explanations.set(
2146
+ evidenceId,
2147
+ explanation({
2148
+ evidenceId,
2149
+ dimension: "listening.time_capsule_representative",
2150
+ value: `${track.title} - ${track.artist}`,
2151
+ basis: `${year} is this song's biggest year in your history: ${plural(peak.play_count, "play")} (${peak.engaged_play_count} not skipped), ${peak.listening_minutes} minutes in all. Songs you asked me to keep out are never picked.${signalNote(signal.value)}`,
2152
+ name: "listening-time-capsule",
2153
+ confidence: confidenceForCount(peak.engaged_play_count, 0.55, 0.88),
2154
+ limitation:
2155
+ "One song stands in for the year. That doesn't mean it defined the year, that you found it then, or that you still love it. Gaps in your history can change the pick.",
2156
+ }),
2157
+ );
2158
+ return {
2159
+ track_ref_id: track.trackRefId,
2160
+ label: track.title,
2161
+ artist_credit: track.artist,
2162
+ ...(track.release ? { release: track.release } : {}),
2163
+ identity_status: track.identityStatus,
2164
+ capsule_year: year,
2165
+ year_play_count: peak.play_count,
2166
+ year_engaged_play_count: peak.engaged_play_count,
2167
+ year_listening_minutes: peak.listening_minutes,
2168
+ year_explicit_skips: peak.explicit_skips,
2169
+ lifetime_play_count: track.playCount,
2170
+ lifetime_listening_minutes: minutes(track.playedMs),
2171
+ representative_signal: signal.value,
2172
+ evidence_id: evidenceId,
2173
+ };
2174
+ });
2175
+ }
2176
+
2177
+ export function projectListeningProfile({
2178
+ subjectId,
2179
+ eventRows = [],
2180
+ profileEvidence = [],
2181
+ tasteEvents = [],
2182
+ maxItems = 10,
2183
+ } = {}) {
2184
+ if (typeof subjectId !== "string" || !subjectId.trim()) {
2185
+ throw new TypeError("Listening profile subject ID is required");
2186
+ }
2187
+ if (!Number.isInteger(maxItems) || maxItems < 1 || maxItems > 50) {
2188
+ throw new TypeError("Listening profile maxItems is invalid");
2189
+ }
2190
+ if (
2191
+ !Array.isArray(eventRows) ||
2192
+ !Array.isArray(profileEvidence) ||
2193
+ !Array.isArray(tasteEvents)
2194
+ ) {
2195
+ throw new TypeError("Listening profile inputs are invalid");
2196
+ }
2197
+ const normalizedSubjectId = subjectId.toLowerCase();
2198
+ const explanations = new Map();
2199
+ const events = aggregateEvents(normalizedSubjectId, eventRows, explanations);
2200
+ const monthlyActivity = aggregateMonthlyActivity(
2201
+ normalizedSubjectId,
2202
+ events,
2203
+ explanations,
2204
+ );
2205
+ const listeningSeasons = aggregateListeningSeasons(
2206
+ normalizedSubjectId,
2207
+ events,
2208
+ explanations,
2209
+ );
2210
+ const deduplicatedEvidence = deduplicateProfileEvidence(profileEvidence);
2211
+ const evidence = evidenceGroups(
2212
+ deduplicatedEvidence,
2213
+ events,
2214
+ explanations,
2215
+ );
2216
+ const direct = listenerAssertions(
2217
+ normalizedSubjectId,
2218
+ tasteEvents,
2219
+ explanations,
2220
+ );
2221
+ const rediscovery = rediscoveryTracks(
2222
+ normalizedSubjectId,
2223
+ events,
2224
+ evidence,
2225
+ direct,
2226
+ explanations,
2227
+ );
2228
+ const historicalReturns = historicalReturnTracks(
2229
+ normalizedSubjectId,
2230
+ events,
2231
+ evidence,
2232
+ direct,
2233
+ explanations,
2234
+ );
2235
+ const timeCapsule = timeCapsuleTracks(
2236
+ normalizedSubjectId,
2237
+ events,
2238
+ evidence,
2239
+ direct,
2240
+ explanations,
2241
+ maxItems,
2242
+ );
2243
+ const relationships = artistRelationships(
2244
+ normalizedSubjectId,
2245
+ events,
2246
+ explanations,
2247
+ );
2248
+ const transitions = yearlyArtistTransitions(
2249
+ normalizedSubjectId,
2250
+ events,
2251
+ explanations,
2252
+ );
2253
+ const releases = releaseDepth(
2254
+ normalizedSubjectId,
2255
+ events,
2256
+ explanations,
2257
+ );
2258
+ const sessions = listeningSessionSummary(
2259
+ normalizedSubjectId,
2260
+ events,
2261
+ explanations,
2262
+ );
2263
+ const backToBack = backToBackTracks(
2264
+ normalizedSubjectId,
2265
+ events,
2266
+ evidence,
2267
+ direct,
2268
+ explanations,
2269
+ );
2270
+ const kindCounts = Object.fromEntries(
2271
+ [...evidence.byKind.entries()]
2272
+ .map(([kind, values]) => [kind, values.length])
2273
+ .sort(([left], [right]) => lexicalCompare(left, right)),
2274
+ );
2275
+ const distinctTracks = events.lifetime.tracks.size;
2276
+ const resolvedTracks = [...events.lifetime.tracks.values()].filter(
2277
+ (track) => track.identityStatus === "resolved",
2278
+ ).length;
2279
+ const crossFormatLinkedRows = eventRows.filter(
2280
+ (row) => typeof row?.cross_format_source_track_ref_id === "string",
2281
+ );
2282
+ const crossFormatTrackLinks = new Set(
2283
+ crossFormatLinkedRows.map((row) => row.cross_format_source_track_ref_id),
2284
+ ).size;
2285
+ const crossFormatAmbiguousRows = eventRows.filter(
2286
+ (row) => typeof row?.cross_format_ambiguous_track_ref_id === "string",
2287
+ );
2288
+ const crossFormatAmbiguousTracks = new Set(
2289
+ crossFormatAmbiguousRows.map(
2290
+ (row) => row.cross_format_ambiguous_track_ref_id,
2291
+ ),
2292
+ ).size;
2293
+ const latestProviderCapture = deduplicatedEvidence.reduce(
2294
+ (latest, record) =>
2295
+ !latest || record.provenance.captured_at > latest
2296
+ ? record.provenance.captured_at
2297
+ : latest,
2298
+ null,
2299
+ );
2300
+ const latestCorrectionCapture = tasteEvents.reduce(
2301
+ (latest, record) =>
2302
+ typeof record?.recorded_at === "string" &&
2303
+ (!latest || record.recorded_at > latest)
2304
+ ? record.recorded_at
2305
+ : latest,
2306
+ null,
2307
+ );
2308
+ const latestListeningCapture = eventRows.reduce(
2309
+ (latest, row) => {
2310
+ const capturedAt = row?.event?.provenance?.captured_at;
2311
+ return typeof capturedAt === "string" &&
2312
+ (!latest || capturedAt > latest)
2313
+ ? capturedAt
2314
+ : latest;
2315
+ },
2316
+ null,
2317
+ );
2318
+ const listeningProviders = [
2319
+ ...new Set(
2320
+ eventRows
2321
+ .map((row) => row?.event?.provenance?.external_ref?.system)
2322
+ .map((system) => {
2323
+ if (typeof system !== "string") return null;
2324
+ if (system.startsWith("spotify")) return "spotify";
2325
+ if (system.startsWith("listenbrainz")) return "listenbrainz";
2326
+ if (system === "youtube_music") return "youtube_music";
2327
+ return "other";
2328
+ })
2329
+ .filter(Boolean),
2330
+ ),
2331
+ ].sort(lexicalCompare);
2332
+ const latestProfileCapture = [
2333
+ latestProviderCapture,
2334
+ latestCorrectionCapture,
2335
+ latestListeningCapture,
2336
+ ]
2337
+ .filter(Boolean)
2338
+ .sort()
2339
+ .at(-1) ?? null;
2340
+ return {
2341
+ summary: {
2342
+ schema_version: LISTENING_PROFILE_SCHEMA_VERSION,
2343
+ coverage: {
2344
+ effective_listening_events: eventRows.length,
2345
+ profiled_listening_events: events.eligible.length,
2346
+ events_with_played_duration: events.eventsWithPlayedDuration,
2347
+ distinct_tracks: distinctTracks,
2348
+ resolved_tracks: resolvedTracks,
2349
+ cross_format_track_links: crossFormatTrackLinks,
2350
+ cross_format_linked_events: crossFormatLinkedRows.length,
2351
+ cross_format_ambiguous_tracks: crossFormatAmbiguousTracks,
2352
+ cross_format_ambiguous_events: crossFormatAmbiguousRows.length,
2353
+ listening_hours: hours(events.profiledPlayedMs),
2354
+ earliest_played_at: events.earliest,
2355
+ latest_played_at: events.latest,
2356
+ profile_evidence_records: deduplicatedEvidence.length,
2357
+ collection_tracks: new Set(deduplicatedEvidence.filter((record) => ["library_track_saved", "playlist_track_added"].includes(record.evidence_kind)).map((record) => record.entity.track_ref_id)).size,
2358
+ collection_sources: [...new Set(deduplicatedEvidence.map((record) => record.provenance.source_system))].sort().map((system) => {
2359
+ const records = deduplicatedEvidence.filter((record) => record.provenance.source_system === system);
2360
+ const count = (kind) => records.filter((record) => record.evidence_kind === kind).length;
2361
+ return { system, label: musicProviderLabel(system), evidence_records: records.length,
2362
+ tracks: new Set(records.filter((record) => ["library_track_saved", "playlist_track_added"].includes(record.evidence_kind)).map((record) => record.entity.track_ref_id)).size,
2363
+ saved_tracks: count("library_track_saved"), saved_albums: count("library_album_saved"),
2364
+ followed_artists: count("library_artist_followed"), playlist_memberships: count("playlist_track_added") };
2365
+ }),
2366
+ listener_assertion_events: direct.total_assertions,
2367
+ active_listener_assertions: direct.active.length,
2368
+ listener_retractions: direct.retractions,
2369
+ saved_tracks: evidence.byKind.get("library_track_saved")?.length ?? 0,
2370
+ saved_albums: evidence.byKind.get("library_album_saved")?.length ?? 0,
2371
+ followed_artists:
2372
+ evidence.byKind.get("library_artist_followed")?.length ?? 0,
2373
+ playlist_memberships:
2374
+ evidence.byKind.get("playlist_track_added")?.length ?? 0,
2375
+ verified_search_interactions:
2376
+ evidence.byKind.get("search_result_interacted")?.length ?? 0,
2377
+ evidence_by_kind: kindCounts,
2378
+ },
2379
+ listening_behavior: {
2380
+ enduring_artists: slice(events.lifetime.rankedArtists, maxItems),
2381
+ recent_artists: slice(events.recent.rankedArtists, maxItems),
2382
+ repeat_tracks: slice(events.lifetime.rankedTracks, maxItems),
2383
+ recent_tracks: slice(events.recent.rankedTracks, maxItems),
2384
+ rediscovery_tracks: slice(rediscovery, maxItems),
2385
+ historical_return_tracks: slice(historicalReturns, maxItems),
2386
+ time_capsule_tracks: timeCapsule,
2387
+ history_arc: events.historyArc,
2388
+ monthly_activity: monthlyActivity,
2389
+ listening_seasons: listeningSeasons,
2390
+ artist_relationships: slice(relationships, maxItems),
2391
+ year_transitions: slice(transitions, 12),
2392
+ release_depth: slice(releases, maxItems),
2393
+ session_summary: sessions,
2394
+ back_to_back_tracks: slice(backToBack, maxItems),
2395
+ context: {
2396
+ ...events.context,
2397
+ rediscovery_quiet_days: recentWindowDays,
2398
+ rediscovery_minimum_plays: rediscoveryMinimumPlays,
2399
+ rediscovery_minimum_engaged_plays: rediscoveryMinimumEngagedPlays,
2400
+ rediscovery_minimum_listening_minutes: minutes(
2401
+ rediscoveryMinimumPlayedMs,
2402
+ ),
2403
+ historical_return_minimum_gap_days:
2404
+ historicalReturnMinimumGapDays,
2405
+ historical_return_minimum_plays: historicalReturnMinimumPlays,
2406
+ historical_return_minimum_engaged_plays:
2407
+ historicalReturnMinimumEngagedPlays,
2408
+ historical_return_minimum_listening_minutes: minutes(
2409
+ historicalReturnMinimumPlayedMs,
2410
+ ),
2411
+ time_capsule_minimum_years: timeCapsuleMinimumYears,
2412
+ time_capsule_minimum_engaged_plays:
2413
+ timeCapsuleMinimumEngagedPlays,
2414
+ time_capsule_minimum_listening_minutes: minutes(
2415
+ timeCapsuleMinimumPlayedMs,
2416
+ ),
2417
+ relationship_minimum_years: relationshipMinimumYears,
2418
+ continuity_artist_limit: continuityArtistLimit,
2419
+ release_minimum_distinct_tracks: releaseMinimumDistinctTracks,
2420
+ session_gap_minutes: sessionGapMinutes,
2421
+ extended_sequence_minimum_plays: extendedSequenceMinimumPlays,
2422
+ back_to_back_minimum_consecutive_plays:
2423
+ backToBackMinimumConsecutivePlays,
2424
+ back_to_back_minimum_played_seconds:
2425
+ backToBackMinimumPlayedMs / 1_000,
2426
+ back_to_back_maximum_gap_minutes:
2427
+ backToBackMaximumGapMinutes,
2428
+ monthly_activity_maximum_months: monthlyActivityMaximumMonths,
2429
+ listening_season_maximum_seasons:
2430
+ listeningSeasonMaximumSeasons,
2431
+ },
2432
+ },
2433
+ curated_preferences: {
2434
+ saved_tracks: slice(evidence.savedTracks, maxItems),
2435
+ playlist_anchors: slice(evidence.playlistAnchors, maxItems),
2436
+ followed_artists: slice(evidence.followedArtists, maxItems),
2437
+ saved_albums: slice(evidence.savedAlbums, maxItems),
2438
+ avoids: slice(combinedAvoids(direct.avoids, evidence.avoids), maxItems),
2439
+ },
2440
+ listener_assertions: {
2441
+ active: slice(direct.active, maxItems),
2442
+ preferences: slice(direct.preferences, maxItems),
2443
+ avoids: slice(direct.avoids, maxItems),
2444
+ retractions: direct.retractions,
2445
+ },
2446
+ search_intent: slice(evidence.searches, maxItems),
2447
+ provider_signals: {
2448
+ artists: slice(evidence.providerArtists, maxItems),
2449
+ tracks: slice(evidence.providerTracks, maxItems),
2450
+ genres: slice(evidence.genres, maxItems),
2451
+ interpretations: slice(evidence.interpretations, 2),
2452
+ highlights: slice(evidence.highlights, Math.min(maxItems, 4)),
2453
+ metrics: slice(evidence.metrics, Math.min(maxItems, 8)),
2454
+ },
2455
+ source: {
2456
+ kind: "private_effective_listening_evidence",
2457
+ providers: listeningProviders,
2458
+ listening_range: {
2459
+ earliest: events.earliest,
2460
+ latest: events.latest,
2461
+ },
2462
+ profile_captured_at: latestProfileCapture,
2463
+ },
2464
+ limitations: [
2465
+ "Plays and listening time show attention, not love. A song can be on repeat because it was stuck in your head.",
2466
+ ...(listeningProviders.includes("spotify")
2467
+ ? [
2468
+ "A skip is a moment, not a verdict. Private-session plays are left out of the rankings.",
2469
+ ]
2470
+ : []),
2471
+ ...(listeningProviders.includes("listenbrainz")
2472
+ ? [
2473
+ "ListenBrainz records when a song started. Listens without a duration count as plays but add no minutes, and a song is only matched when ListenBrainz linked it to MusicBrainz.",
2474
+ ]
2475
+ : []),
2476
+ ...(listeningProviders.length > 1
2477
+ ? [
2478
+ "Plays from different services are combined, but the same song on two services stays two songs unless both point to the same recording.",
2479
+ ]
2480
+ : []),
2481
+ "Years follow UTC, so a late night can land on the next day.",
2482
+ ...(monthlyActivity
2483
+ ? [
2484
+ "Months follow UTC, and at most 240 are shown. A blank month means no history was kept, not that you stopped listening.",
2485
+ ]
2486
+ : []),
2487
+ ...(listeningSeasons
2488
+ ? [
2489
+ "Seasons are three-month windows in UTC, up to 80 of them. The top artist and song describe those months only, not your mood or what was happening in your life.",
2490
+ ]
2491
+ : []),
2492
+ "A song counts as new the first time it shows up in your history, which is not always when you found it.",
2493
+ "Songs worth another listen need at least 3 plays, 2 of them not skipped, 10 minutes in all, and nothing in the last 90 days. They are nudges to listen again, not claims that you love them.",
2494
+ "Songs that came back need at least 3 plays, none skipped, 10 minutes in all, and one gap of at least 180 days. That is a pattern, not proof you missed them or left them on purpose.",
2495
+ "The Time Machine needs at least two years with enough listening and picks at most one song per year. A pick does not mean the song defined that year, or that you still love it.",
2496
+ "An artist counts as staying with you after at least two years in your history, including the latest. How much your top artists change year to year says nothing about whether your taste really changed.",
2497
+ "A record counts as one you went deep on after at least three of its tracks. That does not mean you played the whole album, in order, or that you own or like it.",
2498
+ ...(sessions
2499
+ ? [
2500
+ "Listening stretches are estimated from when each Spotify song stopped, with a new one after 30 quiet minutes. They say nothing about what you were doing, where you were, or how you felt.",
2501
+ ]
2502
+ : []),
2503
+ ...(backToBack.length > 0
2504
+ ? [
2505
+ "Played back to back means the same song twice or more in a row, each play at least 30 seconds, not skipped, and within 30 minutes of the last. It could be love, or repeat mode, or falling asleep.",
2506
+ ]
2507
+ : []),
2508
+ ...(deduplicatedEvidence.length > 0
2509
+ ? [
2510
+ "Taste Profile, Wrapped, and Sound Capsule are Spotify's reading of you, not something you said.",
2511
+ ]
2512
+ : []),
2513
+ "What you tell Moondog counts for more than what the data suggests, you can undo it any time, and it never rewrites your history.",
2514
+ ],
2515
+ },
2516
+ explanations,
2517
+ // Host-only consumers need every exclusion, not the bounded display sample.
2518
+ lyricExclusions: combinedAvoids(direct.avoids, evidence.avoids),
2519
+ };
2520
+ }