@audiofool/moondog 0.1.0 → 0.2.0-beta.3

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 (125) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +156 -68
  3. package/contracts/v1/definitions.schema.json +1 -169
  4. package/package.json +7 -23
  5. package/scripts/contract-lib.mjs +0 -7
  6. package/scripts/contract-semantics.mjs +0 -1904
  7. package/scripts/moondog.mjs +285 -74
  8. package/src/core/apple-library-source-status.mjs +0 -16
  9. package/src/core/apple-projection-domain-services.mjs +253 -53
  10. package/src/core/application-update.mjs +320 -0
  11. package/src/core/capability-catalog.mjs +290 -55
  12. package/src/core/listener-avoids.mjs +101 -0
  13. package/src/core/listening-now.mjs +108 -0
  14. package/src/core/listening-profile-domain-services.mjs +189 -41
  15. package/src/core/listening-upkeep.mjs +31 -0
  16. package/src/core/moondog-application.mjs +2115 -107
  17. package/src/core/playback-preferences.mjs +97 -0
  18. package/src/core/spotify-listening-intent.mjs +124 -0
  19. package/src/core/synthetic-domain-services.mjs +24 -3
  20. package/src/core/track-title.mjs +107 -0
  21. package/src/demo/moondog-demo.mjs +4 -22
  22. package/src/i18n/index.mjs +285 -0
  23. package/src/i18n/messages/de.mjs +135 -0
  24. package/src/i18n/messages/en.mjs +136 -0
  25. package/src/i18n/messages/es.mjs +135 -0
  26. package/src/i18n/messages/fr.mjs +135 -0
  27. package/src/i18n/messages/it.mjs +135 -0
  28. package/src/i18n/messages/ja.mjs +135 -0
  29. package/src/i18n/messages/ko.mjs +135 -0
  30. package/src/i18n/messages/pt.mjs +135 -0
  31. package/src/i18n/messages/zh-Hant.mjs +135 -0
  32. package/src/i18n/messages/zh.mjs +135 -0
  33. package/src/i18n/preferences.mjs +34 -0
  34. package/src/i18n/screen/es.mjs +967 -0
  35. package/src/i18n/screen/ja.mjs +967 -0
  36. package/src/i18n/screen/pt.mjs +967 -0
  37. package/src/i18n/screen/zh.mjs +967 -0
  38. package/src/importers/apple-music-library/index.mjs +0 -16
  39. package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
  40. package/src/integrations/apple-music/catalog.mjs +166 -14
  41. package/src/integrations/apple-music/music-app.mjs +232 -0
  42. package/src/integrations/apple-music/storefront.mjs +28 -0
  43. package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
  44. package/src/integrations/lastfm/history.mjs +221 -0
  45. package/src/integrations/lastfm/settings.mjs +75 -0
  46. package/src/integrations/netease/ncm-cli-player.mjs +328 -0
  47. package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
  48. package/src/integrations/public-playlists.mjs +2 -1
  49. package/src/integrations/spotify/account-data-profile.mjs +14 -6
  50. package/src/integrations/spotify/authentication.mjs +149 -15
  51. package/src/integrations/spotify/catalog-resolver.mjs +9 -6
  52. package/src/integrations/spotify/connection.mjs +2 -1
  53. package/src/integrations/spotify/credential-store.mjs +4 -0
  54. package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
  55. package/src/integrations/spotify/history-archive.mjs +1 -1
  56. package/src/integrations/spotify/library-snapshot.mjs +227 -0
  57. package/src/integrations/spotify/recent-activity.mjs +2 -0
  58. package/src/integrations/spotify/service.mjs +873 -69
  59. package/src/integrations/spotify/web-api-client.mjs +410 -127
  60. package/src/integrations/web/brave-web.mjs +45 -0
  61. package/src/integrations/web/codex-web.mjs +15 -82
  62. package/src/integrations/web/deepseek-web.mjs +81 -0
  63. package/src/integrations/web/web-research.mjs +119 -0
  64. package/src/integrations/web/web-result.mjs +215 -0
  65. package/src/integrations/youtube-music/takeout.mjs +3 -1
  66. package/src/memory/local-memory-store.mjs +542 -57
  67. package/src/profile/history-import.mjs +42 -6
  68. package/src/profile/listener-corrections.mjs +22 -0
  69. package/src/profile/listener-profile-build.mjs +461 -0
  70. package/src/profile/listener-profile-store.mjs +130 -0
  71. package/src/profile/listener-profile-verification.mjs +68 -0
  72. package/src/profile/listener-profile-worker.mjs +179 -0
  73. package/src/profile/listening-history-store.mjs +640 -17
  74. package/src/profile/listening-profile-projection.mjs +454 -86
  75. package/src/profile/local-music-data.mjs +1 -1
  76. package/src/profile/moondog-suggestions.mjs +184 -0
  77. package/src/profile/music-providers.mjs +11 -7
  78. package/src/profile/profile-exploration.mjs +92 -0
  79. package/src/profile/recorded-measures.mjs +24 -0
  80. package/src/profile/spotify-archive-taste.mjs +0 -4
  81. package/src/runtime/pi/agent-runtime.mjs +2536 -692
  82. package/src/runtime/pi/authentication.mjs +34 -1
  83. package/src/runtime/pi/configured-runtime.mjs +22 -6
  84. package/src/runtime/pi/conversation-name.mjs +53 -0
  85. package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
  86. package/src/runtime/pi/model-catalog.mjs +28 -5
  87. package/src/runtime/pi/model-settings.mjs +202 -0
  88. package/src/runtime/pi/model-transport.mjs +10 -0
  89. package/src/runtime/pi/persistent-credential-store.mjs +67 -31
  90. package/src/runtime/pi/profile-build-runtime.mjs +161 -0
  91. package/src/runtime/pi/public-model-catalog.mjs +288 -0
  92. package/src/runtime/pi/runtime-settings.mjs +10 -2
  93. package/src/runtime/pi/skills/listener-profile/SKILL.md +48 -0
  94. package/src/runtime/pi/tool-trace.mjs +37 -0
  95. package/src/surfaces/cli/auth-command.mjs +12 -4
  96. package/src/surfaces/cli/brand-components.mjs +310 -132
  97. package/src/surfaces/cli/brand-theme.mjs +51 -28
  98. package/src/surfaces/cli/command-completions.mjs +58 -42
  99. package/src/surfaces/cli/format-output.mjs +199 -388
  100. package/src/surfaces/cli/history-import-view.mjs +292 -139
  101. package/src/surfaces/cli/import-guides.mjs +2 -0
  102. package/src/surfaces/cli/listener-profile-command.mjs +92 -0
  103. package/src/surfaces/cli/listening-menu.mjs +7 -4
  104. package/src/surfaces/cli/profile-command.mjs +30 -21
  105. package/src/surfaces/cli/seasons-chart.mjs +87 -0
  106. package/src/surfaces/cli/spotify-command.mjs +54 -6
  107. package/src/surfaces/cli/taste-profile-model.mjs +78 -79
  108. package/src/surfaces/cli/taste-profile-view.mjs +33 -26
  109. package/src/surfaces/cli/terminal-art.mjs +140 -35
  110. package/src/surfaces/cli/tui.mjs +1066 -357
  111. package/src/surfaces/cli/update-command.mjs +32 -0
  112. package/src/surfaces/cli/web-command.mjs +53 -14
  113. package/src/surfaces/html/tasteprint.mjs +43 -70
  114. package/contracts/v1/capability-policy.schema.json +0 -572
  115. package/contracts/v1/profile-evidence.schema.json +0 -237
  116. package/contracts/v1/tool-invocation.schema.json +0 -318
  117. package/contracts/v1/tool-receipt.schema.json +0 -1066
  118. package/scripts/demo-studio.mjs +0 -58
  119. package/src/core/apple-data-migration.mjs +0 -50
  120. package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
  121. package/src/demo/moondog-tasteprint-demo.mjs +0 -631
  122. package/src/integrations/spotify/index.mjs +0 -80
  123. package/src/surfaces/web/showcase.mjs +0 -272
  124. package/src/surfaces/web/studio-page.mjs +0 -4148
  125. package/src/surfaces/web/studio.mjs +0 -2288
@@ -1,5 +1,7 @@
1
+ import { createAvoidGate } from "../core/listener-avoids.mjs";
1
2
  import { uuidV5 } from "../core/uuid-v5.mjs";
2
3
  import { musicProviderLabel } from "./music-providers.mjs";
4
+ import { recordedMeasures } from "./recorded-measures.mjs";
3
5
 
4
6
  export const LISTENING_PROFILE_SCHEMA_VERSION = "listening-profile/1";
5
7
  export const LISTENING_PROFILE_EVIDENCE_NAMESPACE =
@@ -25,6 +27,27 @@ const extendedSequenceMinimumPlays = 5;
25
27
  const backToBackMinimumConsecutivePlays = 2;
26
28
  const backToBackMinimumPlayedMs = 30_000;
27
29
  const backToBackMaximumGapMinutes = 30;
30
+ // Movement compares an artist's share of each year's plays, so a year with
31
+ // less listening overall, or fewer records, does not read as fading. Play
32
+ // counts are the measure because every dated play records one.
33
+ const movementMinimumPlays = 20;
34
+ const movementMinimumYears = 3;
35
+ // A year counts only when the history holds this many plays in it.
36
+ const movementMinimumYearPlays = 50;
37
+ // The latest year is read only when the artist has, or at its earlier share
38
+ // would have, this many plays in it, so a few plays more or less, such as in a
39
+ // barely started partial year, cannot swing a label.
40
+ const movementMinimumLatestPlays = 10;
41
+ const movementSeriesYears = 12;
42
+ const movementViewLimit = 5;
43
+ const returningMinimumGapYears = 2;
44
+ const returningMinimumPlays = 3;
45
+ const burstMinimumPeakShare = 0.7;
46
+ // A burst drops off at once: the next counted year holds under a tenth of the
47
+ // peak year's share. A decline that tapers is fading.
48
+ const burstMaximumAfterRatio = 0.1;
49
+ const fadingMaximumRatio = 0.5;
50
+ const risingMinimumRatio = 2;
28
51
  const monthlyActivityMaximumMonths = 240;
29
52
  const listeningSeasonMaximumSeasons = 80;
30
53
  const spotifyExtendedHistorySystem = "spotify-extended-history";
@@ -86,6 +109,60 @@ function signalNote(value) {
86
109
  return value === "historical attention only" ? "" : ` It helps ${signalPhrase(value)}.`;
87
110
  }
88
111
 
112
+ // A basis states each measure for the plays that recorded it, so an unrecorded
113
+ // one never reads as no time or no skips.
114
+ function listeningTime(aggregate) {
115
+ const { time } = recordedMeasures(aggregate);
116
+ if (time === "none") return "";
117
+ const minutesText = `${minutes(aggregate.playedMs)} minutes`;
118
+ return time === "all"
119
+ ? `, ${minutesText} in all`
120
+ : `, ${minutesText} across the ${plural(aggregate.timedPlayCount, "timed play")}`;
121
+ }
122
+
123
+ function skipSentence(aggregate) {
124
+ const { skips } = recordedMeasures(aggregate);
125
+ if (skips === "none") return "";
126
+ const skipped = aggregate.skippedCount === 0
127
+ ? "Never skipped"
128
+ : `Skipped ${plural(aggregate.skippedCount, "time")}`;
129
+ return skips === "all"
130
+ ? ` ${skipped}.`
131
+ : ` ${skipped} in the ${plural(aggregate.skipKnownCount, "play")} with skip data.`;
132
+ }
133
+
134
+ function unskippedNote(aggregate) {
135
+ const { skips } = recordedMeasures(aggregate);
136
+ if (skips === "none") return "";
137
+ return skips === "all"
138
+ ? ` (${aggregate.engagedPlayCount} not skipped)`
139
+ : ` (${aggregate.skipKnownCount - aggregate.skippedCount} of the ${aggregate.skipKnownCount} with skip data not skipped)`;
140
+ }
141
+
142
+ // Minutes gate and rank listen-again picks only when every play recorded its
143
+ // length; otherwise the play counts decide.
144
+ function fullyTimed(aggregate) {
145
+ return recordedMeasures(aggregate).time === "all";
146
+ }
147
+
148
+ // A row states minutes only when every play recorded its length, because a
149
+ // partial total undercounts and can't compare rows. It states skips, and plays
150
+ // not skipped, only when some play recorded whether it was skipped.
151
+ function recordedMinutes(aggregate, key = "listening_minutes") {
152
+ return fullyTimed(aggregate) ? { [key]: minutes(aggregate.playedMs) } : {};
153
+ }
154
+
155
+ function recordedSkips(aggregate, key = "explicit_skips") {
156
+ return recordedMeasures(aggregate).skips === "none" ? {} : { [key]: aggregate.skippedCount };
157
+ }
158
+
159
+ // Summary rows use this. Listen-again and back-to-back picks always state
160
+ // engaged_play_count: their play floors count a play without skip data as
161
+ // engaged, and the agent's candidate checks read it.
162
+ function recordedEngagedPlays(aggregate, key = "engaged_play_count") {
163
+ return recordedMeasures(aggregate).skips === "none" ? {} : { [key]: aggregate.engagedPlayCount };
164
+ }
165
+
89
166
  function aggregateEvidenceId(subjectId, kind, key) {
90
167
  return uuidV5(
91
168
  `${subjectId}\0${kind}\0${key}`,
@@ -142,6 +219,10 @@ function trackFields(track) {
142
219
  track?.identity_status === "resolved" ? "resolved" : "provisional",
143
220
  title,
144
221
  artist,
222
+ // Every credited artist, so an Avoid on a featured artist also keeps the track out.
223
+ artists: (Array.isArray(track?.artist_credits) ? track.artist_credits : [])
224
+ .map((credit) => (typeof credit?.name === "string" ? credit.name.trim() : ""))
225
+ .filter(Boolean),
145
226
  artistKnown: Boolean(track?.artist_credits?.[0]?.name?.trim()),
146
227
  release,
147
228
  };
@@ -164,13 +245,15 @@ function aggregateHistoryArc(rows) {
164
245
  }
165
246
 
166
247
  const aggregate = years.get(year) ?? {
167
- eventCount: 0,
248
+ playCount: 0,
168
249
  playedMs: 0,
250
+ timedPlayCount: 0,
169
251
  trackRefs: new Set(),
170
252
  artists: new Map(),
171
253
  };
172
- aggregate.eventCount += 1;
254
+ aggregate.playCount += 1;
173
255
  aggregate.playedMs += row.playedMs;
256
+ if (row.timed) aggregate.timedPlayCount += 1;
174
257
  aggregate.trackRefs.add(metadata.trackRefId);
175
258
 
176
259
  const artistKey = normalizedText(metadata.artist);
@@ -178,9 +261,11 @@ function aggregateHistoryArc(rows) {
178
261
  name: metadata.artist,
179
262
  playCount: 0,
180
263
  playedMs: 0,
264
+ timedPlayCount: 0,
181
265
  };
182
266
  artist.playCount += 1;
183
267
  artist.playedMs += row.playedMs;
268
+ if (row.timed) artist.timedPlayCount += 1;
184
269
  if (metadata.artistKnown) aggregate.artists.set(artistKey, artist);
185
270
  years.set(year, aggregate);
186
271
  }
@@ -199,8 +284,8 @@ function aggregateHistoryArc(rows) {
199
284
 
200
285
  return {
201
286
  year,
202
- event_count: aggregate.eventCount,
203
- listening_minutes: minutes(aggregate.playedMs),
287
+ event_count: aggregate.playCount,
288
+ ...recordedMinutes(aggregate),
204
289
  distinct_tracks: aggregate.trackRefs.size,
205
290
  first_observed_tracks: firstObservedTracks,
206
291
  ...(topArtist
@@ -208,7 +293,7 @@ function aggregateHistoryArc(rows) {
208
293
  top_artist: {
209
294
  name: topArtist.name,
210
295
  play_count: topArtist.playCount,
211
- listening_minutes: minutes(topArtist.playedMs),
296
+ ...recordedMinutes(topArtist),
212
297
  },
213
298
  }
214
299
  : {}),
@@ -216,6 +301,109 @@ function aggregateHistoryArc(rows) {
216
301
  });
217
302
  }
218
303
 
304
+ // Years with plays, oldest first. Years before the most recent twelve roll
305
+ // into one entry so a long history stays small.
306
+ function yearlyPlays(years) {
307
+ const series = [...years.entries()]
308
+ .sort(([left], [right]) => left - right)
309
+ .map(([year, value]) => ({ year, plays: value.playCount }));
310
+ if (series.length <= movementSeriesYears) return series;
311
+ const earlier = series.slice(0, -movementSeriesYears);
312
+ return [
313
+ {
314
+ from_year: earlier[0].year,
315
+ to_year: earlier.at(-1).year,
316
+ plays: earlier.reduce((sum, item) => sum + item.plays, 0),
317
+ },
318
+ ...series.slice(-movementSeriesYears),
319
+ ];
320
+ }
321
+
322
+ function meanShare(items) {
323
+ return items.reduce((sum, item) => sum + item.share, 0) / items.length;
324
+ }
325
+
326
+ // A deterministic label for how an artist's share of yearly plays moved, from
327
+ // its first year to the latest counted year. Checked in order: returning (at
328
+ // least two counted years with no plays, then back in the latest two), burst
329
+ // (one year that is not the latest holds 70% of the share and the next year
330
+ // drops off at once), then fading or rising (the latest share is at most half,
331
+ // or at least twice, the mean share of the years before it), else steady.
332
+ // Fading also needs the recent window to agree: an artist played again at its
333
+ // earlier share there is returning. Too few plays or years is unknown.
334
+ function artistTrajectory(artist, yearTotals, referenceYear, recentTotal) {
335
+ const firstYear = Math.min(...artist.years.keys());
336
+ let counted = [...yearTotals.entries()]
337
+ .filter(([year, total]) => year >= firstYear && total >= movementMinimumYearPlays)
338
+ .sort(([left], [right]) => left - right)
339
+ .map(([year, total]) => {
340
+ const plays = artist.years.get(year)?.playCount ?? 0;
341
+ return { year, plays, total, share: plays / total };
342
+ });
343
+ const readable = (item, before) =>
344
+ Math.max(item.plays, meanShare(before) * item.total) >= movementMinimumLatestPlays;
345
+ if (counted.length > 1 && counted.at(-1).year === referenceYear &&
346
+ !readable(counted.at(-1), counted.slice(0, -1))) {
347
+ counted = counted.slice(0, -1);
348
+ }
349
+ const plays = counted.reduce((sum, item) => sum + item.plays, 0);
350
+ if (counted.length < movementMinimumYears || plays < movementMinimumPlays) {
351
+ return "unknown";
352
+ }
353
+ const latest = counted.at(-1);
354
+ const lastAbsent = counted.slice(0, -1).findLastIndex((item) => item.plays === 0);
355
+ if (lastAbsent >= 0 && lastAbsent >= counted.length - 3) {
356
+ let gapStart = lastAbsent;
357
+ while (gapStart > 0 && counted[gapStart - 1].plays === 0) gapStart -= 1;
358
+ const returnedPlays = counted
359
+ .slice(lastAbsent + 1)
360
+ .reduce((sum, item) => sum + item.plays, 0);
361
+ if (
362
+ lastAbsent - gapStart + 1 >= returningMinimumGapYears &&
363
+ counted.slice(0, gapStart).some((item) => item.plays > 0) &&
364
+ latest.plays > 0 &&
365
+ returnedPlays >= returningMinimumPlays
366
+ ) {
367
+ return "returning";
368
+ }
369
+ }
370
+ const shareSum = counted.reduce((sum, item) => sum + item.share, 0);
371
+ const peakIndex = counted.reduce(
372
+ (best, item, index) => (item.share > counted[best].share ? index : best),
373
+ 0,
374
+ );
375
+ const peak = counted[peakIndex];
376
+ if (
377
+ peak !== latest &&
378
+ peak.share >= shareSum * burstMinimumPeakShare &&
379
+ counted[peakIndex + 1].share < peak.share * burstMaximumAfterRatio
380
+ ) {
381
+ return "burst";
382
+ }
383
+ const before = counted.slice(0, -1);
384
+ if (!readable(latest, before)) return "unknown";
385
+ const beforeShare = meanShare(before);
386
+ if (latest.share <= beforeShare * fadingMaximumRatio) {
387
+ const recentShare = recentTotal > 0 ? artist.recentPlayCount / recentTotal : 0;
388
+ if (
389
+ artist.recentPlayCount < movementMinimumLatestPlays ||
390
+ recentShare <= beforeShare * fadingMaximumRatio
391
+ ) {
392
+ return "fading";
393
+ }
394
+ return recentShare >= beforeShare ? "returning" : "steady";
395
+ }
396
+ if (latest.share >= beforeShare * risingMinimumRatio) return "rising";
397
+ return "steady";
398
+ }
399
+
400
+ // ACT-R base-level activation at the history's latest play: the log of the
401
+ // summed play ages in days (at least one) to the power -0.5. Recent plays
402
+ // weigh more, and an old archive is read against its own end, not today.
403
+ function activation(sum) {
404
+ return Math.round(Math.log(sum) * 100) / 100;
405
+ }
406
+
219
407
  function monthIndex(monthKey) {
220
408
  if (!/^\d{4}-(?:0[1-9]|1[0-2])$/u.test(monthKey)) return null;
221
409
  const year = Number(monthKey.slice(0, 4));
@@ -288,46 +476,58 @@ function aggregateListeningSeasons(subjectId, eventAnalysis, explanations) {
288
476
  }
289
477
 
290
478
  const aggregate = aggregates.get(observedSeasonIndex) ?? {
291
- eventCount: 0,
479
+ playCount: 0,
292
480
  engagedPlayCount: 0,
293
481
  playedMs: 0,
482
+ timedPlayCount: 0,
483
+ skipKnownCount: 0,
294
484
  activeMonths: new Set(),
295
485
  trackRefs: new Set(),
296
486
  artists: new Map(),
297
487
  tracks: new Map(),
298
488
  };
299
- aggregate.eventCount += 1;
489
+ aggregate.playCount += 1;
300
490
  if (!row.skipped) aggregate.engagedPlayCount += 1;
301
491
  aggregate.playedMs += row.playedMs;
492
+ if (row.timed) aggregate.timedPlayCount += 1;
493
+ if (row.skipKnown) aggregate.skipKnownCount += 1;
302
494
  aggregate.activeMonths.add(observedMonthIndex);
303
495
  aggregate.trackRefs.add(trackKey);
304
496
 
305
497
  const artistKey = normalizedText(metadata.artist);
306
498
  const artist = aggregate.artists.get(artistKey) ?? {
307
499
  name: metadata.artist,
308
- eventCount: 0,
500
+ playCount: 0,
309
501
  engagedPlayCount: 0,
310
502
  playedMs: 0,
503
+ timedPlayCount: 0,
504
+ skipKnownCount: 0,
311
505
  trackRefs: new Set(),
312
506
  };
313
- artist.eventCount += 1;
507
+ artist.playCount += 1;
314
508
  if (!row.skipped) artist.engagedPlayCount += 1;
315
509
  artist.playedMs += row.playedMs;
510
+ if (row.timed) artist.timedPlayCount += 1;
511
+ if (row.skipKnown) artist.skipKnownCount += 1;
316
512
  artist.trackRefs.add(trackKey);
317
513
  if (metadata.artistKnown) aggregate.artists.set(artistKey, artist);
318
514
 
319
515
  const track = aggregate.tracks.get(trackKey) ?? {
320
516
  ...metadata,
321
517
  trackKey,
322
- eventCount: 0,
518
+ playCount: 0,
323
519
  engagedPlayCount: 0,
324
520
  skippedCount: 0,
325
521
  playedMs: 0,
522
+ timedPlayCount: 0,
523
+ skipKnownCount: 0,
326
524
  };
327
- track.eventCount += 1;
525
+ track.playCount += 1;
328
526
  if (row.skipped) track.skippedCount += 1;
329
527
  else track.engagedPlayCount += 1;
330
528
  track.playedMs += row.playedMs;
529
+ if (row.timed) track.timedPlayCount += 1;
530
+ if (row.skipKnown) track.skipKnownCount += 1;
331
531
  aggregate.tracks.set(trackKey, track);
332
532
  aggregates.set(observedSeasonIndex, aggregate);
333
533
  }
@@ -361,8 +561,8 @@ function aggregateListeningSeasons(subjectId, eventAnalysis, explanations) {
361
561
  if (right.engagedPlayCount !== left.engagedPlayCount) {
362
562
  return right.engagedPlayCount - left.engagedPlayCount;
363
563
  }
364
- if (right.eventCount !== left.eventCount) {
365
- return right.eventCount - left.eventCount;
564
+ if (right.playCount !== left.playCount) {
565
+ return right.playCount - left.playCount;
366
566
  }
367
567
  return lexicalCompare(left.name, right.name);
368
568
  },
@@ -375,8 +575,8 @@ function aggregateListeningSeasons(subjectId, eventAnalysis, explanations) {
375
575
  if (right.engagedPlayCount !== left.engagedPlayCount) {
376
576
  return right.engagedPlayCount - left.engagedPlayCount;
377
577
  }
378
- if (right.eventCount !== left.eventCount) {
379
- return right.eventCount - left.eventCount;
578
+ if (right.playCount !== left.playCount) {
579
+ return right.playCount - left.playCount;
380
580
  }
381
581
  const titleOrder = lexicalCompare(left.title, right.title);
382
582
  if (titleOrder !== 0) return titleOrder;
@@ -392,9 +592,14 @@ function aggregateListeningSeasons(subjectId, eventAnalysis, explanations) {
392
592
  end_month: monthKey(lastMonthIndex),
393
593
  retained_month_count: lastMonthIndex - firstMonthIndex + 1,
394
594
  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),
595
+ event_count: aggregate?.playCount ?? 0,
596
+ // A season with no plays has a true zero for each measure the history records.
597
+ ...(aggregate
598
+ ? { ...recordedEngagedPlays(aggregate), ...recordedMinutes(aggregate) }
599
+ : {
600
+ ...(eventAnalysis.recorded.skips === "none" ? {} : { engaged_play_count: 0 }),
601
+ ...(eventAnalysis.recorded.time === "none" ? {} : { listening_minutes: 0 }),
602
+ }),
398
603
  distinct_tracks: trackRefs.size,
399
604
  first_observed_tracks: firstObservedTracks,
400
605
  returning_tracks: trackRefs.size - firstObservedTracks,
@@ -402,9 +607,9 @@ function aggregateListeningSeasons(subjectId, eventAnalysis, explanations) {
402
607
  ? {
403
608
  leading_artist: {
404
609
  name: leadingArtist.name,
405
- event_count: leadingArtist.eventCount,
406
- engaged_play_count: leadingArtist.engagedPlayCount,
407
- listening_minutes: minutes(leadingArtist.playedMs),
610
+ event_count: leadingArtist.playCount,
611
+ ...recordedEngagedPlays(leadingArtist),
612
+ ...recordedMinutes(leadingArtist),
408
613
  distinct_tracks: leadingArtist.trackRefs.size,
409
614
  },
410
615
  }
@@ -418,10 +623,10 @@ function aggregateListeningSeasons(subjectId, eventAnalysis, explanations) {
418
623
  ...(signatureTrack.release
419
624
  ? { release: signatureTrack.release }
420
625
  : {}),
421
- play_count: signatureTrack.eventCount,
422
- engaged_play_count: signatureTrack.engagedPlayCount,
423
- listening_minutes: minutes(signatureTrack.playedMs),
424
- explicit_skips: signatureTrack.skippedCount,
626
+ play_count: signatureTrack.playCount,
627
+ ...recordedEngagedPlays(signatureTrack),
628
+ ...recordedMinutes(signatureTrack),
629
+ ...recordedSkips(signatureTrack),
425
630
  },
426
631
  }
427
632
  : {}),
@@ -559,6 +764,7 @@ function aggregateMonthlyActivity(subjectId, eventAnalysis, explanations) {
559
764
 
560
765
  function aggregateEvents(subjectId, eventRows, explanations) {
561
766
  const eligible = [];
767
+ const moondogStarted = [];
562
768
  let incognitoEventsExcluded = 0;
563
769
  let rawPlayedMs = 0;
564
770
  let eventsWithPlayedDuration = 0;
@@ -572,13 +778,24 @@ function aggregateEvents(subjectId, eventRows, explanations) {
572
778
  incognitoEventsExcluded += 1;
573
779
  continue;
574
780
  }
781
+ // Moondog chose these, so they are not the listener's own attention.
782
+ if (row.moondog_suggestion_id) {
783
+ moondogStarted.push(row);
784
+ continue;
785
+ }
786
+ const skipped =
787
+ event?.event_type === "play_skipped" ||
788
+ event?.extensions?.["spotify.skipped"] === true;
575
789
  eligible.push({
576
790
  event,
577
791
  track: row.track,
578
792
  playedMs,
579
- skipped:
580
- event?.event_type === "play_skipped" ||
581
- event?.extensions?.["spotify.skipped"] === true,
793
+ timed: hasPlayedDuration,
794
+ skipped,
795
+ // Only Spotify's extended history says whether a play was skipped, and
796
+ // not for every play.
797
+ skipKnown:
798
+ skipped || typeof event?.extensions?.["spotify.skipped"] === "boolean",
582
799
  });
583
800
  }
584
801
  const earliest = eligible.reduce(
@@ -598,6 +815,15 @@ function aggregateEvents(subjectId, eventRows, explanations) {
598
815
  const recentCutoff = latest
599
816
  ? new Date(Date.parse(latest) - recentWindowDays * dayMs).toISOString()
600
817
  : null;
818
+ const latestMs = latest ? Date.parse(latest) : null;
819
+ const referenceYear = latest ? Number(latest.slice(0, 4)) : null;
820
+ const yearTotals = new Map();
821
+ let recentTotal = 0;
822
+ for (const row of eligible) {
823
+ const year = Number(row.event.occurred_at.slice(0, 4));
824
+ if (Number.isInteger(year)) yearTotals.set(year, (yearTotals.get(year) ?? 0) + 1);
825
+ if (row.event.occurred_at >= recentCutoff) recentTotal += 1;
826
+ }
601
827
 
602
828
  function aggregate(rows, windowKind) {
603
829
  const artists = new Map();
@@ -613,6 +839,8 @@ function aggregateEvents(subjectId, eventRows, explanations) {
613
839
  engagedPlayCount: 0,
614
840
  skippedCount: 0,
615
841
  playedMs: 0,
842
+ timedPlayCount: 0,
843
+ skipKnownCount: 0,
616
844
  firstPlayedAt: occurredAt,
617
845
  lastPlayedAt: occurredAt,
618
846
  engagedPlayedAt: [],
@@ -620,6 +848,8 @@ function aggregateEvents(subjectId, eventRows, explanations) {
620
848
  };
621
849
  track.playCount += 1;
622
850
  track.playedMs += row.playedMs;
851
+ if (row.timed) track.timedPlayCount += 1;
852
+ if (row.skipKnown) track.skipKnownCount += 1;
623
853
  if (row.skipped) track.skippedCount += 1;
624
854
  else {
625
855
  track.engagedPlayCount += 1;
@@ -634,9 +864,13 @@ function aggregateEvents(subjectId, eventRows, explanations) {
634
864
  engagedPlayCount: 0,
635
865
  skippedCount: 0,
636
866
  playedMs: 0,
867
+ timedPlayCount: 0,
868
+ skipKnownCount: 0,
637
869
  };
638
870
  yearAggregate.playCount += 1;
639
871
  yearAggregate.playedMs += row.playedMs;
872
+ if (row.timed) yearAggregate.timedPlayCount += 1;
873
+ if (row.skipKnown) yearAggregate.skipKnownCount += 1;
640
874
  if (row.skipped) yearAggregate.skippedCount += 1;
641
875
  else yearAggregate.engagedPlayCount += 1;
642
876
  track.years.set(year, yearAggregate);
@@ -649,13 +883,22 @@ function aggregateEvents(subjectId, eventRows, explanations) {
649
883
  engagedPlayCount: 0,
650
884
  skippedCount: 0,
651
885
  playedMs: 0,
886
+ timedPlayCount: 0,
887
+ skipKnownCount: 0,
652
888
  trackRefs: new Set(),
653
889
  years: new Map(),
654
890
  firstPlayedAt: occurredAt,
655
891
  lastPlayedAt: occurredAt,
892
+ activationSum: 0,
893
+ recentPlayCount: 0,
656
894
  };
657
895
  artist.playCount += 1;
896
+ if (occurredAt >= recentCutoff) artist.recentPlayCount += 1;
897
+ artist.activationSum +=
898
+ Math.max((latestMs - Date.parse(occurredAt)) / dayMs, 1) ** -0.5;
658
899
  artist.playedMs += row.playedMs;
900
+ if (row.timed) artist.timedPlayCount += 1;
901
+ if (row.skipKnown) artist.skipKnownCount += 1;
659
902
  if (row.skipped) artist.skippedCount += 1;
660
903
  else artist.engagedPlayCount += 1;
661
904
  artist.trackRefs.add(trackKey);
@@ -693,7 +936,7 @@ function aggregateEvents(subjectId, eventRows, explanations) {
693
936
  evidenceId,
694
937
  dimension: "taste.artist_familiarity",
695
938
  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)}`}.`,
939
+ basis: `${plural(item.playCount, "play")} across ${plural(item.trackRefs.size, "track")}${listeningTime(item)}${windowKind === "recent" ? `, in the ${recentWindowDays} days up to ${day(latest)}` : `, from ${day(item.firstPlayedAt)} to ${day(item.lastPlayedAt)}`}.`,
697
940
  name: `listening-artist-${windowKind}`,
698
941
  confidence: confidenceForCount(item.playCount),
699
942
  limitation:
@@ -703,10 +946,18 @@ function aggregateEvents(subjectId, eventRows, explanations) {
703
946
  return {
704
947
  name: item.name,
705
948
  play_count: item.playCount,
706
- engaged_play_count: item.engagedPlayCount,
707
- listening_minutes: minutes(item.playedMs),
949
+ ...recordedEngagedPlays(item),
950
+ ...recordedMinutes(item),
708
951
  distinct_tracks: item.trackRefs.size,
709
- explicit_skips: item.skippedCount,
952
+ ...recordedSkips(item),
953
+ ...(windowKind === "lifetime"
954
+ ? {
955
+ first_played_at: item.firstPlayedAt,
956
+ yearly_plays: yearlyPlays(item.years),
957
+ activation: activation(item.activationSum),
958
+ trajectory: artistTrajectory(item, yearTotals, referenceYear, recentTotal),
959
+ }
960
+ : {}),
710
961
  last_played_at: item.lastPlayedAt,
711
962
  evidence_id: evidenceId,
712
963
  };
@@ -733,7 +984,7 @@ function aggregateEvents(subjectId, eventRows, explanations) {
733
984
  evidenceId,
734
985
  dimension: "taste.track_familiarity",
735
986
  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")}`}.`,
987
+ basis: `${plural(item.playCount, "play")}${listeningTime(item)}${windowKind === "recent" ? `, in the ${recentWindowDays} days up to ${day(latest)}` : `, from ${day(item.firstPlayedAt)} to ${day(item.lastPlayedAt)}`}.${skipSentence(item)}`,
737
988
  name: `listening-track-${windowKind}`,
738
989
  confidence: confidenceForCount(item.playCount),
739
990
  limitation:
@@ -747,9 +998,9 @@ function aggregateEvents(subjectId, eventRows, explanations) {
747
998
  ...(item.release ? { release: item.release } : {}),
748
999
  identity_status: item.identityStatus,
749
1000
  play_count: item.playCount,
750
- engaged_play_count: item.engagedPlayCount,
751
- listening_minutes: minutes(item.playedMs),
752
- explicit_skips: item.skippedCount,
1001
+ ...recordedEngagedPlays(item),
1002
+ ...recordedMinutes(item),
1003
+ ...recordedSkips(item),
753
1004
  last_played_at: item.lastPlayedAt,
754
1005
  evidence_id: evidenceId,
755
1006
  };
@@ -786,6 +1037,11 @@ function aggregateEvents(subjectId, eventRows, explanations) {
786
1037
  const rowsWithOfflineState = eligible.filter(
787
1038
  (row) => typeof row.event.extensions?.["spotify.offline"] === "boolean",
788
1039
  );
1040
+ const recorded = recordedMeasures({
1041
+ playCount: eligible.length,
1042
+ timedPlayCount: eligible.filter((row) => row.timed).length,
1043
+ skipKnownCount: eligible.filter((row) => row.skipKnown).length,
1044
+ });
789
1045
  const context = {
790
1046
  reference_date: latest,
791
1047
  recent_window_days: recentWindowDays,
@@ -802,7 +1058,9 @@ function aggregateEvents(subjectId, eventRows, explanations) {
802
1058
  ({ reason }) => normalizedText(reason.trim()) === "trackdone",
803
1059
  ).length,
804
1060
  skip_state_events: rowsWithSkipState.length,
805
- explicit_skips: eligible.filter((row) => row.skipped).length,
1061
+ ...(recorded.skips === "none"
1062
+ ? {}
1063
+ : { explicit_skips: eligible.filter((row) => row.skipped).length }),
806
1064
  shuffle_state_events: rowsWithShuffleState.length,
807
1065
  shuffle_events: rowsWithShuffleState.filter(
808
1066
  (row) => row.event.extensions?.["spotify.shuffle"] === true,
@@ -812,15 +1070,18 @@ function aggregateEvents(subjectId, eventRows, explanations) {
812
1070
  (row) => row.event.extensions?.["spotify.offline"] === true,
813
1071
  ).length,
814
1072
  incognito_events_excluded: incognitoEventsExcluded,
1073
+ ...(moondogStarted.length ? { moondog_started_events_excluded: moondogStarted.length } : {}),
815
1074
  };
816
1075
  return {
817
1076
  eligible,
1077
+ moondogStarted,
818
1078
  earliest,
819
1079
  latest,
820
1080
  recentCutoff,
821
1081
  rawPlayedMs,
822
1082
  eventsWithPlayedDuration,
823
1083
  profiledPlayedMs: eligible.reduce((sum, row) => sum + row.playedMs, 0),
1084
+ recorded,
824
1085
  lifetime,
825
1086
  recent,
826
1087
  historyArc,
@@ -879,7 +1140,7 @@ function artistRelationships(subjectId, eventAnalysis, explanations) {
879
1140
  evidenceId,
880
1141
  dimension: "taste.artist_continuity",
881
1142
  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.`,
1143
+ basis: `${artist.name} shows up in ${plural(activeYears, "year")} of your history, from ${firstYear} to ${lastYear}: ${plural(artist.playCount, "play")}${listeningTime(artist)}.`,
883
1144
  name: "listening-artist-continuity",
884
1145
  confidence: confidenceForCount(artist.playCount),
885
1146
  limitation:
@@ -893,7 +1154,7 @@ function artistRelationships(subjectId, eventAnalysis, explanations) {
893
1154
  active_years: activeYears,
894
1155
  span_years: spanYears,
895
1156
  play_count: artist.playCount,
896
- listening_minutes: minutes(artist.playedMs),
1157
+ ...recordedMinutes(artist),
897
1158
  evidence_id: evidenceId,
898
1159
  };
899
1160
  });
@@ -1001,12 +1262,16 @@ function releaseDepth(subjectId, eventAnalysis, explanations) {
1001
1262
  playCount: 0,
1002
1263
  engagedPlayCount: 0,
1003
1264
  playedMs: 0,
1265
+ timedPlayCount: 0,
1266
+ skipKnownCount: 0,
1004
1267
  years: new Set(),
1005
1268
  };
1006
1269
  release.trackRefs.add(metadata.trackRefId);
1007
1270
  release.playCount += 1;
1008
1271
  if (!row.skipped) release.engagedPlayCount += 1;
1009
1272
  release.playedMs += row.playedMs;
1273
+ if (row.timed) release.timedPlayCount += 1;
1274
+ if (row.skipKnown) release.skipKnownCount += 1;
1010
1275
  if (Number.isInteger(year)) release.years.add(year);
1011
1276
  releases.set(key, release);
1012
1277
  }
@@ -1043,7 +1308,7 @@ function releaseDepth(subjectId, eventAnalysis, explanations) {
1043
1308
  evidenceId,
1044
1309
  dimension: "taste.release_depth",
1045
1310
  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")}` : ""}.`,
1311
+ basis: `You played ${plural(release.trackRefs.size, "different track")} from ${release.title}: ${plural(release.playCount, "play")}${listeningTime(release)}${years.length > 0 ? `, across ${plural(years.length, "year")}` : ""}.`,
1047
1312
  name: "listening-release-depth",
1048
1313
  confidence: confidenceForCount(release.engagedPlayCount),
1049
1314
  limitation:
@@ -1055,8 +1320,8 @@ function releaseDepth(subjectId, eventAnalysis, explanations) {
1055
1320
  artist_credit: release.artist,
1056
1321
  distinct_tracks: release.trackRefs.size,
1057
1322
  play_count: release.playCount,
1058
- engaged_play_count: release.engagedPlayCount,
1059
- listening_minutes: minutes(release.playedMs),
1323
+ ...recordedEngagedPlays(release),
1324
+ ...recordedMinutes(release),
1060
1325
  ...(Number.isInteger(firstYear) ? { first_year: firstYear } : {}),
1061
1326
  ...(Number.isInteger(lastYear) ? { last_year: lastYear } : {}),
1062
1327
  active_years: years.length,
@@ -1249,11 +1514,9 @@ function backToBackTracks(
1249
1514
  byTrack.set(metadata.trackRefId, aggregate);
1250
1515
  }
1251
1516
 
1252
- const avoids = combinedAvoids(direct.avoids, evidence.avoids);
1517
+ const avoided = historyAvoidCheck(direct, evidence);
1253
1518
  return [...byTrack.values()]
1254
- .filter(
1255
- (item) => !avoids.some((avoid) => assertionMatchesTrack(avoid, item)),
1256
- )
1519
+ .filter((item) => !avoided(item))
1257
1520
  .sort((left, right) => {
1258
1521
  if (
1259
1522
  right.maximumConsecutivePlays !== left.maximumConsecutivePlays
@@ -1307,7 +1570,7 @@ function backToBackTracks(
1307
1570
  play_count: lifetime?.playCount ?? item.playsInBursts,
1308
1571
  engaged_play_count:
1309
1572
  lifetime?.engagedPlayCount ?? item.playsInBursts,
1310
- explicit_skips: lifetime?.skippedCount ?? 0,
1573
+ ...(lifetime ? recordedSkips(lifetime) : {}),
1311
1574
  burst_count: item.burstCount,
1312
1575
  maximum_consecutive_plays: item.maximumConsecutivePlays,
1313
1576
  plays_in_bursts: item.playsInBursts,
@@ -1320,8 +1583,13 @@ function backToBackTracks(
1320
1583
  }
1321
1584
 
1322
1585
  function deduplicateProfileEvidence(records) {
1586
+ // A live library read is a complete snapshot: the newest one replaces older
1587
+ // reads, so an unsaved song or unfollowed artist drops out after a re-sync.
1588
+ const latestLibraryRead = records.reduce((latest, record) => record.provenance?.source_system === "spotify_web_api" &&
1589
+ (record.provenance.captured_at ?? "") > latest ? record.provenance.captured_at : latest, "");
1323
1590
  const selected = new Map();
1324
1591
  for (const record of records) {
1592
+ if (record.provenance?.source_system === "spotify_web_api" && record.provenance.captured_at !== latestLibraryRead) continue;
1325
1593
  const current = selected.get(record.evidence_key);
1326
1594
  const capturedAt = record.provenance?.captured_at ?? "";
1327
1595
  const currentCapturedAt = current?.provenance?.captured_at ?? "";
@@ -1357,14 +1625,17 @@ function persistedExplanation(record) {
1357
1625
  taste_artist_ranked: `Spotify's exported Taste Profile ranked this artist reference at position ${attributes.rank}.`,
1358
1626
  provider_interpretation: `Spotify supplied this provider-generated ${label.replaceAll("_", " ")} narrative in ${source}.`,
1359
1627
  sound_capsule_artist_ranked: `Spotify Sound Capsule ranked this artist at position ${attributes.rank} for ${attributes.period}.`,
1628
+ top_artist_ranked: `Spotify ranked this artist at position ${attributes.rank} of your top artists for ${attributes.period}, as of ${record.provenance.captured_at}.`,
1629
+ top_track_ranked: `Spotify ranked this track at position ${attributes.rank} of your top tracks for ${attributes.period}, as of ${record.provenance.captured_at}.`,
1360
1630
  sound_capsule_track_ranked: `Spotify Sound Capsule ranked this track label at position ${attributes.rank} for ${attributes.period}.`,
1361
1631
  sound_capsule_highlight: `Spotify Sound Capsule emitted a ${attributes.highlight_type} highlight dated ${record.observed_at}.`,
1362
1632
  wrapped_metric: `Spotify Wrapped reported ${attributes.value} ${attributes.unit} for ${label} in ${attributes.period}.`,
1363
1633
  sound_capsule_period_metric: `Spotify Sound Capsule reported ${attributes.stream_count} streams and ${attributes.played_seconds} played seconds for ${attributes.period}.`,
1364
1634
  };
1365
1635
  const limitationByClass = {
1366
- explicit:
1367
- "This is an explicit current provider state, but the export does not include the original action timestamp.",
1636
+ explicit: record.provenance?.source_system === "spotify_web_api"
1637
+ ? "This is your saved state in Spotify when Moondog read it; it shows what you keep, not how often you play it."
1638
+ : "This is an explicit current provider state, but the export does not include the original action timestamp.",
1368
1639
  curated:
1369
1640
  "Playlist inclusion is a curatorial signal, not proof of a permanent preference or the reason for inclusion.",
1370
1641
  behavioral:
@@ -1388,6 +1659,8 @@ function persistedExplanation(record) {
1388
1659
  provider_interpretation: "provider.interpretation",
1389
1660
  sound_capsule_artist_ranked: "provider.artist_rank",
1390
1661
  sound_capsule_track_ranked: "provider.track_rank",
1662
+ top_artist_ranked: "provider.artist_rank",
1663
+ top_track_ranked: "provider.track_rank",
1391
1664
  sound_capsule_highlight: "provider.highlight",
1392
1665
  wrapped_metric: "provider.metric",
1393
1666
  sound_capsule_period_metric: "provider.metric",
@@ -1462,7 +1735,7 @@ function evidenceGroups(records, eventAnalysis, explanations) {
1462
1735
  label: record.entity.label,
1463
1736
  artist_credit: record.entity.artist_credit,
1464
1737
  ...(record.entity.release ? { release: record.entity.release } : {}),
1465
- ...(metric ? { listening_minutes: minutes(metric.playedMs) } : {}),
1738
+ ...(metric ? recordedMinutes(metric) : {}),
1466
1739
  evidence_id: record.profile_evidence_id,
1467
1740
  }));
1468
1741
 
@@ -1484,7 +1757,7 @@ function evidenceGroups(records, eventAnalysis, explanations) {
1484
1757
  artist_credit: group.entity.artist_credit,
1485
1758
  ...(group.entity.release ? { release: group.entity.release } : {}),
1486
1759
  playlist_count: group.playlists.size,
1487
- playlist_names: [...group.playlists].sort(lexicalCompare).slice(0, 3),
1760
+ playlist_names: [...group.playlists].sort(lexicalCompare),
1488
1761
  last_added_at: group.lastAddedAt,
1489
1762
  evidence_id: group.evidenceId,
1490
1763
  }));
@@ -1500,7 +1773,7 @@ function evidenceGroups(records, eventAnalysis, explanations) {
1500
1773
  })
1501
1774
  .map(({ record, metric }) => ({
1502
1775
  name: record.entity.label,
1503
- ...(metric ? { listening_minutes: minutes(metric.playedMs) } : {}),
1776
+ ...(metric ? recordedMinutes(metric) : {}),
1504
1777
  evidence_id: record.profile_evidence_id,
1505
1778
  }));
1506
1779
 
@@ -1562,10 +1835,12 @@ function evidenceGroups(records, eventAnalysis, explanations) {
1562
1835
  "wrapped_artist_ranked",
1563
1836
  "taste_artist_ranked",
1564
1837
  "sound_capsule_artist_ranked",
1838
+ "top_artist_ranked",
1565
1839
  ]);
1566
1840
  const providerTrackKinds = new Set([
1567
1841
  "wrapped_track_ranked",
1568
1842
  "sound_capsule_track_ranked",
1843
+ "top_track_ranked",
1569
1844
  ]);
1570
1845
  function providerRanked(kinds, outputType) {
1571
1846
  const groups = new Map();
@@ -1783,6 +2058,13 @@ function combinedAvoids(listenerAvoids, providerAvoids) {
1783
2058
  return [...selected.values()];
1784
2059
  }
1785
2060
 
2061
+ // The shared Avoid check of every play path (src/core/listener-avoids.mjs), for history tracks.
2062
+ function historyAvoidCheck(direct, evidence) {
2063
+ const gate = createAvoidGate(combinedAvoids(direct.avoids, evidence.avoids));
2064
+ return (track) => gate.size > 0 &&
2065
+ gate.avoidsTrack({ title: track.title, artist_credit: track.artist, artists: track.artists?.length ? track.artists : [track.artist] });
2066
+ }
2067
+
1786
2068
  function assertionMatchesTrack(assertion, track) {
1787
2069
  if (assertion.entity_type === "artist") {
1788
2070
  return normalizedText(assertion.label) === normalizedText(track.artist);
@@ -1817,9 +2099,10 @@ function rediscoverySignal(track, evidence, direct) {
1817
2099
  }
1818
2100
 
1819
2101
  function rediscoveryPeakYear(track) {
2102
+ const byTime = fullyTimed(track);
1820
2103
  return [...track.years.entries()]
1821
2104
  .sort(([leftYear, left], [rightYear, right]) => {
1822
- if (right.playedMs !== left.playedMs) {
2105
+ if (byTime && right.playedMs !== left.playedMs) {
1823
2106
  return right.playedMs - left.playedMs;
1824
2107
  }
1825
2108
  if (right.playCount !== left.playCount) {
@@ -1834,6 +2117,7 @@ function rediscoveryPeakYear(track) {
1834
2117
  explicit_skips: aggregate.skippedCount,
1835
2118
  listening_minutes: minutes(aggregate.playedMs),
1836
2119
  played_ms: aggregate.playedMs,
2120
+ plays: aggregate,
1837
2121
  }))[0] ?? null;
1838
2122
  }
1839
2123
 
@@ -1846,8 +2130,8 @@ function rediscoveryTracks(
1846
2130
  ) {
1847
2131
  const latestMs = Date.parse(eventAnalysis.latest ?? "");
1848
2132
  if (!Number.isFinite(latestMs)) return [];
1849
- const avoids = combinedAvoids(direct.avoids, evidence.avoids);
1850
- return [...eventAnalysis.lifetime.tracks.values()]
2133
+ const avoided = historyAvoidCheck(direct, evidence);
2134
+ const candidates = [...eventAnalysis.lifetime.tracks.values()]
1851
2135
  .map((track) => {
1852
2136
  const lastPlayedMs = Date.parse(track.lastPlayedAt);
1853
2137
  const quietDays = Math.floor((latestMs - lastPlayedMs) / dayMs);
@@ -1864,15 +2148,17 @@ function rediscoveryTracks(
1864
2148
  quietDays >= recentWindowDays &&
1865
2149
  track.playCount >= rediscoveryMinimumPlays &&
1866
2150
  track.engagedPlayCount >= rediscoveryMinimumEngagedPlays &&
1867
- track.playedMs >= rediscoveryMinimumPlayedMs &&
2151
+ (!fullyTimed(track) || track.playedMs >= rediscoveryMinimumPlayedMs) &&
1868
2152
  track.engagedPlayCount >= track.skippedCount &&
1869
- !avoids.some((item) => assertionMatchesTrack(item, track)),
1870
- )
2153
+ !avoided(track),
2154
+ );
2155
+ const byTime = candidates.every(({ track }) => fullyTimed(track));
2156
+ return candidates
1871
2157
  .sort((left, right) => {
1872
2158
  if (right.signal.rank !== left.signal.rank) {
1873
2159
  return right.signal.rank - left.signal.rank;
1874
2160
  }
1875
- if (right.track.playedMs !== left.track.playedMs) {
2161
+ if (byTime && right.track.playedMs !== left.track.playedMs) {
1876
2162
  return right.track.playedMs - left.track.playedMs;
1877
2163
  }
1878
2164
  if (right.track.engagedPlayCount !== left.track.engagedPlayCount) {
@@ -1896,7 +2182,7 @@ function rediscoveryTracks(
1896
2182
  evidenceId,
1897
2183
  dimension: "listening.rediscovery_candidate",
1898
2184
  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)}.`,
2185
+ basis: `${plural(track.playCount, "play")}${unskippedNote(track)}${listeningTime(track)}, 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
2186
  name: "listening-track-rediscovery",
1901
2187
  confidence:
1902
2188
  signal.rank === 4
@@ -1914,8 +2200,8 @@ function rediscoveryTracks(
1914
2200
  identity_status: track.identityStatus,
1915
2201
  play_count: track.playCount,
1916
2202
  engaged_play_count: track.engagedPlayCount,
1917
- listening_minutes: minutes(track.playedMs),
1918
- explicit_skips: track.skippedCount,
2203
+ ...recordedMinutes(track),
2204
+ ...recordedSkips(track),
1919
2205
  first_played_at: track.firstPlayedAt,
1920
2206
  last_played_at: track.lastPlayedAt,
1921
2207
  quiet_days: quietDays,
@@ -1924,7 +2210,7 @@ function rediscoveryTracks(
1924
2210
  ? {
1925
2211
  peak_year: peak.year,
1926
2212
  peak_year_play_count: peak.play_count,
1927
- peak_year_listening_minutes: peak.listening_minutes,
2213
+ ...recordedMinutes(peak.plays, "peak_year_listening_minutes"),
1928
2214
  }
1929
2215
  : {}),
1930
2216
  evidence_id: evidenceId,
@@ -1959,8 +2245,8 @@ function historicalReturnTracks(
1959
2245
  direct,
1960
2246
  explanations,
1961
2247
  ) {
1962
- const avoids = combinedAvoids(direct.avoids, evidence.avoids);
1963
- return [...eventAnalysis.lifetime.tracks.values()]
2248
+ const avoided = historyAvoidCheck(direct, evidence);
2249
+ const candidates = [...eventAnalysis.lifetime.tracks.values()]
1964
2250
  .map((track) => ({
1965
2251
  track,
1966
2252
  returns: historicalReturnGaps(track),
@@ -1971,10 +2257,12 @@ function historicalReturnTracks(
1971
2257
  returns.length > 0 &&
1972
2258
  track.playCount >= historicalReturnMinimumPlays &&
1973
2259
  track.engagedPlayCount >= historicalReturnMinimumEngagedPlays &&
1974
- track.playedMs >= historicalReturnMinimumPlayedMs &&
2260
+ (!fullyTimed(track) || track.playedMs >= historicalReturnMinimumPlayedMs) &&
1975
2261
  track.engagedPlayCount >= track.skippedCount &&
1976
- !avoids.some((item) => assertionMatchesTrack(item, track)),
1977
- )
2262
+ !avoided(track),
2263
+ );
2264
+ const byTime = candidates.every(({ track }) => fullyTimed(track));
2265
+ return candidates
1978
2266
  .sort((left, right) => {
1979
2267
  if (right.returns.length !== left.returns.length) {
1980
2268
  return right.returns.length - left.returns.length;
@@ -1989,7 +2277,7 @@ function historicalReturnTracks(
1989
2277
  if (right.signal.rank !== left.signal.rank) {
1990
2278
  return right.signal.rank - left.signal.rank;
1991
2279
  }
1992
- if (right.track.playedMs !== left.track.playedMs) {
2280
+ if (byTime && right.track.playedMs !== left.track.playedMs) {
1993
2281
  return right.track.playedMs - left.track.playedMs;
1994
2282
  }
1995
2283
  if (right.track.engagedPlayCount !== left.track.engagedPlayCount) {
@@ -2014,7 +2302,7 @@ function historicalReturnTracks(
2014
2302
  evidenceId,
2015
2303
  dimension: "listening.historical_return",
2016
2304
  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)}`,
2305
+ basis: `${plural(track.playCount, "play")}${unskippedNote(track)}${listeningTime(track)}, 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
2306
  name: "listening-track-historical-return",
2019
2307
  confidence: confidenceForCount(
2020
2308
  track.engagedPlayCount + returns.length,
@@ -2033,8 +2321,8 @@ function historicalReturnTracks(
2033
2321
  identity_status: track.identityStatus,
2034
2322
  play_count: track.playCount,
2035
2323
  engaged_play_count: track.engagedPlayCount,
2036
- listening_minutes: minutes(track.playedMs),
2037
- explicit_skips: track.skippedCount,
2324
+ ...recordedMinutes(track),
2325
+ ...recordedSkips(track),
2038
2326
  first_played_at: track.firstPlayedAt,
2039
2327
  last_played_at: track.lastPlayedAt,
2040
2328
  return_count: returns.length,
@@ -2076,15 +2364,16 @@ function timeCapsuleTracks(
2076
2364
  explanations,
2077
2365
  maximum,
2078
2366
  ) {
2079
- const avoids = combinedAvoids(direct.avoids, evidence.avoids);
2367
+ const avoided = historyAvoidCheck(direct, evidence);
2080
2368
  const byYear = new Map();
2081
2369
  for (const track of eventAnalysis.lifetime.tracks.values()) {
2082
- if (avoids.some((item) => assertionMatchesTrack(item, track))) continue;
2370
+ if (avoided(track)) continue;
2083
2371
  const peak = rediscoveryPeakYear(track);
2084
2372
  if (
2085
2373
  !peak ||
2086
2374
  peak.engaged_play_count < timeCapsuleMinimumEngagedPlays ||
2087
- peak.listening_minutes < minutes(timeCapsuleMinimumPlayedMs) ||
2375
+ (fullyTimed(peak.plays) &&
2376
+ peak.listening_minutes < minutes(timeCapsuleMinimumPlayedMs)) ||
2088
2377
  peak.engaged_play_count < peak.explicit_skips
2089
2378
  ) {
2090
2379
  continue;
@@ -2099,11 +2388,14 @@ function timeCapsuleTracks(
2099
2388
  }
2100
2389
  if (byYear.size < timeCapsuleMinimumYears) return [];
2101
2390
 
2391
+ const byTime = [...byYear.values()].every((values) =>
2392
+ values.every(({ track }) => fullyTimed(track)),
2393
+ );
2102
2394
  const rankedYears = [...byYear.entries()]
2103
2395
  .sort(([left], [right]) => left - right)
2104
2396
  .map(([year, values]) => {
2105
2397
  values.sort((left, right) => {
2106
- if (right.peak.played_ms !== left.peak.played_ms) {
2398
+ if (byTime && right.peak.played_ms !== left.peak.played_ms) {
2107
2399
  return right.peak.played_ms - left.peak.played_ms;
2108
2400
  }
2109
2401
  if (
@@ -2114,7 +2406,7 @@ function timeCapsuleTracks(
2114
2406
  if (right.signal.rank !== left.signal.rank) {
2115
2407
  return right.signal.rank - left.signal.rank;
2116
2408
  }
2117
- if (right.track.playedMs !== left.track.playedMs) {
2409
+ if (byTime && right.track.playedMs !== left.track.playedMs) {
2118
2410
  return right.track.playedMs - left.track.playedMs;
2119
2411
  }
2120
2412
  const titleOrder = lexicalCompare(left.track.title, right.track.title);
@@ -2123,7 +2415,9 @@ function timeCapsuleTracks(
2123
2415
  return {
2124
2416
  year,
2125
2417
  values,
2126
- strength: values[0].peak.played_ms,
2418
+ strength: byTime
2419
+ ? values[0].peak.played_ms
2420
+ : values[0].peak.engaged_play_count,
2127
2421
  };
2128
2422
  });
2129
2423
  const selectedYears = evenlySpaced(rankedYears, maximum).sort(
@@ -2136,6 +2430,7 @@ function timeCapsuleTracks(
2136
2430
  ({ track }) => !usedArtists.has(normalizedText(track.artist)),
2137
2431
  ) ?? values[0];
2138
2432
  const { track, peak, signal } = selected;
2433
+ const peakPlays = track.years.get(year);
2139
2434
  usedArtists.add(normalizedText(track.artist));
2140
2435
  const evidenceId = aggregateEvidenceId(
2141
2436
  subjectId,
@@ -2148,7 +2443,7 @@ function timeCapsuleTracks(
2148
2443
  evidenceId,
2149
2444
  dimension: "listening.time_capsule_representative",
2150
2445
  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)}`,
2446
+ basis: `${year} is this song's biggest year in your history: ${plural(peak.play_count, "play")}${unskippedNote(peakPlays)}${listeningTime(peakPlays)}. Songs you asked me to keep out are never picked.${signalNote(signal.value)}`,
2152
2447
  name: "listening-time-capsule",
2153
2448
  confidence: confidenceForCount(peak.engaged_play_count, 0.55, 0.88),
2154
2449
  limitation:
@@ -2164,22 +2459,42 @@ function timeCapsuleTracks(
2164
2459
  capsule_year: year,
2165
2460
  year_play_count: peak.play_count,
2166
2461
  year_engaged_play_count: peak.engaged_play_count,
2167
- year_listening_minutes: peak.listening_minutes,
2168
- year_explicit_skips: peak.explicit_skips,
2462
+ ...recordedMinutes(peakPlays, "year_listening_minutes"),
2463
+ ...recordedSkips(peakPlays, "year_explicit_skips"),
2169
2464
  lifetime_play_count: track.playCount,
2170
- lifetime_listening_minutes: minutes(track.playedMs),
2465
+ ...recordedMinutes(track, "lifetime_listening_minutes"),
2171
2466
  representative_signal: signal.value,
2172
2467
  evidence_id: evidenceId,
2173
2468
  };
2174
2469
  });
2175
2470
  }
2176
2471
 
2472
+ // Plays Moondog started, one row per song, kept apart from the listener's own rankings.
2473
+ // Adopted means the listener later played the song again on their own.
2474
+ function moondogSuggestionTracks(rows, adoptedSuggestionIds) {
2475
+ const tracks = new Map();
2476
+ for (const row of rows) {
2477
+ const metadata = trackFields(row.track);
2478
+ const track = tracks.get(metadata.trackRefId) ?? {
2479
+ label: metadata.title, artist_credit: metadata.artist, play_count: 0,
2480
+ last_played_at: row.event.occurred_at, adopted: false,
2481
+ };
2482
+ track.play_count += 1;
2483
+ if (row.event.occurred_at > track.last_played_at) track.last_played_at = row.event.occurred_at;
2484
+ track.adopted ||= adoptedSuggestionIds.has(row.moondog_suggestion_id);
2485
+ tracks.set(metadata.trackRefId, track);
2486
+ }
2487
+ return [...tracks.values()].sort((left, right) => right.play_count - left.play_count ||
2488
+ descendingTimestamp(left.last_played_at, right.last_played_at) || lexicalCompare(left.label, right.label));
2489
+ }
2490
+
2177
2491
  export function projectListeningProfile({
2178
2492
  subjectId,
2179
2493
  eventRows = [],
2180
2494
  profileEvidence = [],
2181
2495
  tasteEvents = [],
2182
2496
  maxItems = 10,
2497
+ adoptedSuggestionIds = new Set(),
2183
2498
  } = {}) {
2184
2499
  if (typeof subjectId !== "string" || !subjectId.trim()) {
2185
2500
  throw new TypeError("Listening profile subject ID is required");
@@ -2324,6 +2639,7 @@ export function projectListeningProfile({
2324
2639
  if (system.startsWith("spotify")) return "spotify";
2325
2640
  if (system.startsWith("listenbrainz")) return "listenbrainz";
2326
2641
  if (system === "youtube_music") return "youtube_music";
2642
+ if (system === "lastfm") return "lastfm";
2327
2643
  return "other";
2328
2644
  })
2329
2645
  .filter(Boolean),
@@ -2337,6 +2653,9 @@ export function projectListeningProfile({
2337
2653
  .filter(Boolean)
2338
2654
  .sort()
2339
2655
  .at(-1) ?? null;
2656
+ const lengthNote = events.recorded.time === "all"
2657
+ ? ""
2658
+ : " if every play has a recorded length";
2340
2659
  return {
2341
2660
  summary: {
2342
2661
  schema_version: LISTENING_PROFILE_SCHEMA_VERSION,
@@ -2350,7 +2669,9 @@ export function projectListeningProfile({
2350
2669
  cross_format_linked_events: crossFormatLinkedRows.length,
2351
2670
  cross_format_ambiguous_tracks: crossFormatAmbiguousTracks,
2352
2671
  cross_format_ambiguous_events: crossFormatAmbiguousRows.length,
2353
- listening_hours: hours(events.profiledPlayedMs),
2672
+ ...(events.recorded.time === "none"
2673
+ ? {}
2674
+ : { listening_hours: hours(events.profiledPlayedMs) }),
2354
2675
  earliest_played_at: events.earliest,
2355
2676
  latest_played_at: events.latest,
2356
2677
  profile_evidence_records: deduplicatedEvidence.length,
@@ -2387,6 +2708,14 @@ export function projectListeningProfile({
2387
2708
  history_arc: events.historyArc,
2388
2709
  monthly_activity: monthlyActivity,
2389
2710
  listening_seasons: listeningSeasons,
2711
+ movement: Object.fromEntries(
2712
+ ["fading", "returning", "rising"].map((label) => [
2713
+ label,
2714
+ events.lifetime.rankedArtists
2715
+ .filter((artist) => artist.trajectory === label)
2716
+ .slice(0, Math.min(maxItems, movementViewLimit)),
2717
+ ]),
2718
+ ),
2390
2719
  artist_relationships: slice(relationships, maxItems),
2391
2720
  year_transitions: slice(transitions, 12),
2392
2721
  release_depth: slice(releases, maxItems),
@@ -2415,6 +2744,10 @@ export function projectListeningProfile({
2415
2744
  timeCapsuleMinimumPlayedMs,
2416
2745
  ),
2417
2746
  relationship_minimum_years: relationshipMinimumYears,
2747
+ movement_minimum_plays: movementMinimumPlays,
2748
+ movement_minimum_years: movementMinimumYears,
2749
+ movement_minimum_year_plays: movementMinimumYearPlays,
2750
+ movement_minimum_latest_plays: movementMinimumLatestPlays,
2418
2751
  continuity_artist_limit: continuityArtistLimit,
2419
2752
  release_minimum_distinct_tracks: releaseMinimumDistinctTracks,
2420
2753
  session_gap_minutes: sessionGapMinutes,
@@ -2432,7 +2765,9 @@ export function projectListeningProfile({
2432
2765
  },
2433
2766
  curated_preferences: {
2434
2767
  saved_tracks: slice(evidence.savedTracks, maxItems),
2435
- playlist_anchors: slice(evidence.playlistAnchors, maxItems),
2768
+ playlist_anchors: slice(evidence.playlistAnchors, maxItems).map((item) => ({
2769
+ ...item, playlist_names: item.playlist_names.slice(0, 3),
2770
+ })),
2436
2771
  followed_artists: slice(evidence.followedArtists, maxItems),
2437
2772
  saved_albums: slice(evidence.savedAlbums, maxItems),
2438
2773
  avoids: slice(combinedAvoids(direct.avoids, evidence.avoids), maxItems),
@@ -2490,8 +2825,8 @@ export function projectListeningProfile({
2490
2825
  ]
2491
2826
  : []),
2492
2827
  "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.",
2828
+ `Songs worth another listen need at least 3 plays, 2 of them not skipped, 10 minutes in all${lengthNote}, and nothing in the last 90 days. They are nudges to listen again, not claims that you love them.`,
2829
+ `Songs that came back need at least 3 plays, none skipped, 10 minutes in all${lengthNote}, and one gap of at least 180 days. That is a pattern, not proof you missed them or left them on purpose.`,
2495
2830
  "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
2831
  "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
2832
  "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.",
@@ -2510,10 +2845,43 @@ export function projectListeningProfile({
2510
2845
  "Taste Profile, Wrapped, and Sound Capsule are Spotify's reading of you, not something you said.",
2511
2846
  ]
2512
2847
  : []),
2848
+ ...(events.moondogStarted.length > 0
2849
+ ? [
2850
+ "Songs Moondog queued or played for you are left out of the rankings. Playing one again later on your own counts as yours.",
2851
+ ]
2852
+ : []),
2513
2853
  "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
2854
  ],
2515
2855
  },
2516
2856
  explanations,
2857
+ // Host-only complete evidence catalogs. Bound pages after selecting a section.
2858
+ catalog: {
2859
+ history_artists: events.lifetime.rankedArtists,
2860
+ history_tracks: events.lifetime.rankedTracks,
2861
+ recent_artists: events.recent.rankedArtists,
2862
+ recent_tracks: events.recent.rankedTracks,
2863
+ history_years: events.historyArc,
2864
+ history_releases: releases,
2865
+ saved_tracks: evidence.savedTracks,
2866
+ playlist_tracks: evidence.playlistAnchors,
2867
+ saved_albums: evidence.savedAlbums,
2868
+ followed_artists: evidence.followedArtists,
2869
+ provider_genres: evidence.genres,
2870
+ listener_preferences: direct.preferences,
2871
+ listener_avoids: combinedAvoids(direct.avoids, evidence.avoids),
2872
+ ...(events.moondogStarted.length ? { moondog_suggestions: moondogSuggestionTracks(events.moondogStarted, adoptedSuggestionIds) } : {}),
2873
+ provider_evidence: deduplicatedEvidence.map((record) => ({
2874
+ evidence_id: record.profile_evidence_id,
2875
+ evidence_kind: record.evidence_kind,
2876
+ entity_type: record.entity.entity_type,
2877
+ label: record.entity.label ?? "Unresolved provider label",
2878
+ ...(record.entity.artist_credit ? { artist_credit: record.entity.artist_credit } : {}),
2879
+ source_label: musicProviderLabel(record.provenance.source_system),
2880
+ observed_at: record.observed_at,
2881
+ ...Object.fromEntries(["rank", "period", "text", "playlist_name", "playlist_position", "value", "unit", "stream_count", "played_seconds"]
2882
+ .filter((key) => record.attributes[key] !== undefined).map((key) => [key, record.attributes[key]])),
2883
+ })),
2884
+ },
2517
2885
  // Host-only consumers need every exclusion, not the bounded display sample.
2518
2886
  lyricExclusions: combinedAvoids(direct.avoids, evidence.avoids),
2519
2887
  };