@audiofool/moondog 0.1.0 → 0.2.0-beta.2

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 (121) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +153 -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 +168 -36
  10. package/src/core/application-update.mjs +320 -0
  11. package/src/core/capability-catalog.mjs +278 -55
  12. package/src/core/listening-now.mjs +108 -0
  13. package/src/core/listening-profile-domain-services.mjs +153 -41
  14. package/src/core/listening-upkeep.mjs +31 -0
  15. package/src/core/moondog-application.mjs +1908 -104
  16. package/src/core/playback-preferences.mjs +97 -0
  17. package/src/core/spotify-listening-intent.mjs +121 -0
  18. package/src/core/synthetic-domain-services.mjs +24 -3
  19. package/src/demo/moondog-demo.mjs +4 -22
  20. package/src/i18n/index.mjs +285 -0
  21. package/src/i18n/messages/de.mjs +128 -0
  22. package/src/i18n/messages/en.mjs +129 -0
  23. package/src/i18n/messages/es.mjs +128 -0
  24. package/src/i18n/messages/fr.mjs +128 -0
  25. package/src/i18n/messages/it.mjs +128 -0
  26. package/src/i18n/messages/ja.mjs +128 -0
  27. package/src/i18n/messages/ko.mjs +128 -0
  28. package/src/i18n/messages/pt.mjs +128 -0
  29. package/src/i18n/messages/zh-Hant.mjs +128 -0
  30. package/src/i18n/messages/zh.mjs +128 -0
  31. package/src/i18n/preferences.mjs +34 -0
  32. package/src/i18n/screen/es.mjs +966 -0
  33. package/src/i18n/screen/ja.mjs +966 -0
  34. package/src/i18n/screen/pt.mjs +966 -0
  35. package/src/i18n/screen/zh.mjs +966 -0
  36. package/src/importers/apple-music-library/index.mjs +0 -16
  37. package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
  38. package/src/integrations/apple-music/catalog.mjs +123 -14
  39. package/src/integrations/apple-music/music-app.mjs +232 -0
  40. package/src/integrations/apple-music/storefront.mjs +28 -0
  41. package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
  42. package/src/integrations/lastfm/history.mjs +221 -0
  43. package/src/integrations/lastfm/settings.mjs +75 -0
  44. package/src/integrations/netease/ncm-cli-player.mjs +321 -0
  45. package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
  46. package/src/integrations/public-playlists.mjs +2 -1
  47. package/src/integrations/spotify/account-data-profile.mjs +14 -6
  48. package/src/integrations/spotify/authentication.mjs +149 -15
  49. package/src/integrations/spotify/catalog-resolver.mjs +8 -4
  50. package/src/integrations/spotify/connection.mjs +2 -1
  51. package/src/integrations/spotify/credential-store.mjs +4 -0
  52. package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
  53. package/src/integrations/spotify/history-archive.mjs +1 -1
  54. package/src/integrations/spotify/library-snapshot.mjs +227 -0
  55. package/src/integrations/spotify/recent-activity.mjs +2 -0
  56. package/src/integrations/spotify/service.mjs +873 -69
  57. package/src/integrations/spotify/web-api-client.mjs +407 -127
  58. package/src/integrations/web/brave-web.mjs +45 -0
  59. package/src/integrations/web/codex-web.mjs +15 -82
  60. package/src/integrations/web/deepseek-web.mjs +81 -0
  61. package/src/integrations/web/web-research.mjs +119 -0
  62. package/src/integrations/web/web-result.mjs +215 -0
  63. package/src/integrations/youtube-music/takeout.mjs +3 -1
  64. package/src/memory/local-memory-store.mjs +469 -41
  65. package/src/profile/history-import.mjs +42 -6
  66. package/src/profile/listener-profile-build.mjs +378 -0
  67. package/src/profile/listener-profile-store.mjs +130 -0
  68. package/src/profile/listener-profile-verification.mjs +68 -0
  69. package/src/profile/listener-profile-worker.mjs +172 -0
  70. package/src/profile/listening-history-store.mjs +424 -8
  71. package/src/profile/listening-profile-projection.mjs +242 -76
  72. package/src/profile/local-music-data.mjs +1 -1
  73. package/src/profile/music-providers.mjs +11 -7
  74. package/src/profile/profile-exploration.mjs +84 -0
  75. package/src/profile/recorded-measures.mjs +24 -0
  76. package/src/profile/spotify-archive-taste.mjs +0 -4
  77. package/src/runtime/pi/agent-runtime.mjs +2204 -605
  78. package/src/runtime/pi/authentication.mjs +34 -1
  79. package/src/runtime/pi/configured-runtime.mjs +22 -6
  80. package/src/runtime/pi/conversation-name.mjs +53 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
  82. package/src/runtime/pi/model-catalog.mjs +28 -5
  83. package/src/runtime/pi/model-settings.mjs +202 -0
  84. package/src/runtime/pi/model-transport.mjs +10 -0
  85. package/src/runtime/pi/persistent-credential-store.mjs +67 -31
  86. package/src/runtime/pi/profile-build-runtime.mjs +160 -0
  87. package/src/runtime/pi/public-model-catalog.mjs +288 -0
  88. package/src/runtime/pi/runtime-settings.mjs +10 -2
  89. package/src/runtime/pi/skills/listener-profile/SKILL.md +46 -0
  90. package/src/runtime/pi/tool-trace.mjs +37 -0
  91. package/src/surfaces/cli/auth-command.mjs +12 -4
  92. package/src/surfaces/cli/brand-components.mjs +310 -132
  93. package/src/surfaces/cli/brand-theme.mjs +51 -28
  94. package/src/surfaces/cli/command-completions.mjs +58 -42
  95. package/src/surfaces/cli/format-output.mjs +199 -388
  96. package/src/surfaces/cli/history-import-view.mjs +292 -139
  97. package/src/surfaces/cli/import-guides.mjs +2 -0
  98. package/src/surfaces/cli/listener-profile-command.mjs +92 -0
  99. package/src/surfaces/cli/listening-menu.mjs +7 -4
  100. package/src/surfaces/cli/profile-command.mjs +30 -21
  101. package/src/surfaces/cli/seasons-chart.mjs +87 -0
  102. package/src/surfaces/cli/spotify-command.mjs +54 -6
  103. package/src/surfaces/cli/taste-profile-model.mjs +78 -79
  104. package/src/surfaces/cli/taste-profile-view.mjs +33 -26
  105. package/src/surfaces/cli/terminal-art.mjs +140 -35
  106. package/src/surfaces/cli/tui.mjs +1066 -357
  107. package/src/surfaces/cli/update-command.mjs +32 -0
  108. package/src/surfaces/cli/web-command.mjs +53 -14
  109. package/src/surfaces/html/tasteprint.mjs +43 -70
  110. package/contracts/v1/capability-policy.schema.json +0 -572
  111. package/contracts/v1/profile-evidence.schema.json +0 -237
  112. package/contracts/v1/tool-invocation.schema.json +0 -318
  113. package/contracts/v1/tool-receipt.schema.json +0 -1066
  114. package/scripts/demo-studio.mjs +0 -58
  115. package/src/core/apple-data-migration.mjs +0 -50
  116. package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
  117. package/src/demo/moondog-tasteprint-demo.mjs +0 -631
  118. package/src/integrations/spotify/index.mjs +0 -80
  119. package/src/surfaces/web/showcase.mjs +0 -272
  120. package/src/surfaces/web/studio-page.mjs +0 -4148
  121. package/src/surfaces/web/studio.mjs +0 -2288
@@ -1,5 +1,6 @@
1
1
  import { uuidV5 } from "../core/uuid-v5.mjs";
2
2
  import { musicProviderLabel } from "./music-providers.mjs";
3
+ import { recordedMeasures } from "./recorded-measures.mjs";
3
4
 
4
5
  export const LISTENING_PROFILE_SCHEMA_VERSION = "listening-profile/1";
5
6
  export const LISTENING_PROFILE_EVIDENCE_NAMESPACE =
@@ -86,6 +87,60 @@ function signalNote(value) {
86
87
  return value === "historical attention only" ? "" : ` It helps ${signalPhrase(value)}.`;
87
88
  }
88
89
 
90
+ // A basis states each measure for the plays that recorded it, so an unrecorded
91
+ // one never reads as no time or no skips.
92
+ function listeningTime(aggregate) {
93
+ const { time } = recordedMeasures(aggregate);
94
+ if (time === "none") return "";
95
+ const minutesText = `${minutes(aggregate.playedMs)} minutes`;
96
+ return time === "all"
97
+ ? `, ${minutesText} in all`
98
+ : `, ${minutesText} across the ${plural(aggregate.timedPlayCount, "timed play")}`;
99
+ }
100
+
101
+ function skipSentence(aggregate) {
102
+ const { skips } = recordedMeasures(aggregate);
103
+ if (skips === "none") return "";
104
+ const skipped = aggregate.skippedCount === 0
105
+ ? "Never skipped"
106
+ : `Skipped ${plural(aggregate.skippedCount, "time")}`;
107
+ return skips === "all"
108
+ ? ` ${skipped}.`
109
+ : ` ${skipped} in the ${plural(aggregate.skipKnownCount, "play")} with skip data.`;
110
+ }
111
+
112
+ function unskippedNote(aggregate) {
113
+ const { skips } = recordedMeasures(aggregate);
114
+ if (skips === "none") return "";
115
+ return skips === "all"
116
+ ? ` (${aggregate.engagedPlayCount} not skipped)`
117
+ : ` (${aggregate.skipKnownCount - aggregate.skippedCount} of the ${aggregate.skipKnownCount} with skip data not skipped)`;
118
+ }
119
+
120
+ // Minutes gate and rank listen-again picks only when every play recorded its
121
+ // length; otherwise the play counts decide.
122
+ function fullyTimed(aggregate) {
123
+ return recordedMeasures(aggregate).time === "all";
124
+ }
125
+
126
+ // A row states minutes only when every play recorded its length, because a
127
+ // partial total undercounts and can't compare rows. It states skips, and plays
128
+ // not skipped, only when some play recorded whether it was skipped.
129
+ function recordedMinutes(aggregate, key = "listening_minutes") {
130
+ return fullyTimed(aggregate) ? { [key]: minutes(aggregate.playedMs) } : {};
131
+ }
132
+
133
+ function recordedSkips(aggregate, key = "explicit_skips") {
134
+ return recordedMeasures(aggregate).skips === "none" ? {} : { [key]: aggregate.skippedCount };
135
+ }
136
+
137
+ // Summary rows use this. Listen-again and back-to-back picks always state
138
+ // engaged_play_count: their play floors count a play without skip data as
139
+ // engaged, and the agent's candidate checks read it.
140
+ function recordedEngagedPlays(aggregate, key = "engaged_play_count") {
141
+ return recordedMeasures(aggregate).skips === "none" ? {} : { [key]: aggregate.engagedPlayCount };
142
+ }
143
+
89
144
  function aggregateEvidenceId(subjectId, kind, key) {
90
145
  return uuidV5(
91
146
  `${subjectId}\0${kind}\0${key}`,
@@ -164,13 +219,15 @@ function aggregateHistoryArc(rows) {
164
219
  }
165
220
 
166
221
  const aggregate = years.get(year) ?? {
167
- eventCount: 0,
222
+ playCount: 0,
168
223
  playedMs: 0,
224
+ timedPlayCount: 0,
169
225
  trackRefs: new Set(),
170
226
  artists: new Map(),
171
227
  };
172
- aggregate.eventCount += 1;
228
+ aggregate.playCount += 1;
173
229
  aggregate.playedMs += row.playedMs;
230
+ if (row.timed) aggregate.timedPlayCount += 1;
174
231
  aggregate.trackRefs.add(metadata.trackRefId);
175
232
 
176
233
  const artistKey = normalizedText(metadata.artist);
@@ -178,9 +235,11 @@ function aggregateHistoryArc(rows) {
178
235
  name: metadata.artist,
179
236
  playCount: 0,
180
237
  playedMs: 0,
238
+ timedPlayCount: 0,
181
239
  };
182
240
  artist.playCount += 1;
183
241
  artist.playedMs += row.playedMs;
242
+ if (row.timed) artist.timedPlayCount += 1;
184
243
  if (metadata.artistKnown) aggregate.artists.set(artistKey, artist);
185
244
  years.set(year, aggregate);
186
245
  }
@@ -199,8 +258,8 @@ function aggregateHistoryArc(rows) {
199
258
 
200
259
  return {
201
260
  year,
202
- event_count: aggregate.eventCount,
203
- listening_minutes: minutes(aggregate.playedMs),
261
+ event_count: aggregate.playCount,
262
+ ...recordedMinutes(aggregate),
204
263
  distinct_tracks: aggregate.trackRefs.size,
205
264
  first_observed_tracks: firstObservedTracks,
206
265
  ...(topArtist
@@ -208,7 +267,7 @@ function aggregateHistoryArc(rows) {
208
267
  top_artist: {
209
268
  name: topArtist.name,
210
269
  play_count: topArtist.playCount,
211
- listening_minutes: minutes(topArtist.playedMs),
270
+ ...recordedMinutes(topArtist),
212
271
  },
213
272
  }
214
273
  : {}),
@@ -288,46 +347,58 @@ function aggregateListeningSeasons(subjectId, eventAnalysis, explanations) {
288
347
  }
289
348
 
290
349
  const aggregate = aggregates.get(observedSeasonIndex) ?? {
291
- eventCount: 0,
350
+ playCount: 0,
292
351
  engagedPlayCount: 0,
293
352
  playedMs: 0,
353
+ timedPlayCount: 0,
354
+ skipKnownCount: 0,
294
355
  activeMonths: new Set(),
295
356
  trackRefs: new Set(),
296
357
  artists: new Map(),
297
358
  tracks: new Map(),
298
359
  };
299
- aggregate.eventCount += 1;
360
+ aggregate.playCount += 1;
300
361
  if (!row.skipped) aggregate.engagedPlayCount += 1;
301
362
  aggregate.playedMs += row.playedMs;
363
+ if (row.timed) aggregate.timedPlayCount += 1;
364
+ if (row.skipKnown) aggregate.skipKnownCount += 1;
302
365
  aggregate.activeMonths.add(observedMonthIndex);
303
366
  aggregate.trackRefs.add(trackKey);
304
367
 
305
368
  const artistKey = normalizedText(metadata.artist);
306
369
  const artist = aggregate.artists.get(artistKey) ?? {
307
370
  name: metadata.artist,
308
- eventCount: 0,
371
+ playCount: 0,
309
372
  engagedPlayCount: 0,
310
373
  playedMs: 0,
374
+ timedPlayCount: 0,
375
+ skipKnownCount: 0,
311
376
  trackRefs: new Set(),
312
377
  };
313
- artist.eventCount += 1;
378
+ artist.playCount += 1;
314
379
  if (!row.skipped) artist.engagedPlayCount += 1;
315
380
  artist.playedMs += row.playedMs;
381
+ if (row.timed) artist.timedPlayCount += 1;
382
+ if (row.skipKnown) artist.skipKnownCount += 1;
316
383
  artist.trackRefs.add(trackKey);
317
384
  if (metadata.artistKnown) aggregate.artists.set(artistKey, artist);
318
385
 
319
386
  const track = aggregate.tracks.get(trackKey) ?? {
320
387
  ...metadata,
321
388
  trackKey,
322
- eventCount: 0,
389
+ playCount: 0,
323
390
  engagedPlayCount: 0,
324
391
  skippedCount: 0,
325
392
  playedMs: 0,
393
+ timedPlayCount: 0,
394
+ skipKnownCount: 0,
326
395
  };
327
- track.eventCount += 1;
396
+ track.playCount += 1;
328
397
  if (row.skipped) track.skippedCount += 1;
329
398
  else track.engagedPlayCount += 1;
330
399
  track.playedMs += row.playedMs;
400
+ if (row.timed) track.timedPlayCount += 1;
401
+ if (row.skipKnown) track.skipKnownCount += 1;
331
402
  aggregate.tracks.set(trackKey, track);
332
403
  aggregates.set(observedSeasonIndex, aggregate);
333
404
  }
@@ -361,8 +432,8 @@ function aggregateListeningSeasons(subjectId, eventAnalysis, explanations) {
361
432
  if (right.engagedPlayCount !== left.engagedPlayCount) {
362
433
  return right.engagedPlayCount - left.engagedPlayCount;
363
434
  }
364
- if (right.eventCount !== left.eventCount) {
365
- return right.eventCount - left.eventCount;
435
+ if (right.playCount !== left.playCount) {
436
+ return right.playCount - left.playCount;
366
437
  }
367
438
  return lexicalCompare(left.name, right.name);
368
439
  },
@@ -375,8 +446,8 @@ function aggregateListeningSeasons(subjectId, eventAnalysis, explanations) {
375
446
  if (right.engagedPlayCount !== left.engagedPlayCount) {
376
447
  return right.engagedPlayCount - left.engagedPlayCount;
377
448
  }
378
- if (right.eventCount !== left.eventCount) {
379
- return right.eventCount - left.eventCount;
449
+ if (right.playCount !== left.playCount) {
450
+ return right.playCount - left.playCount;
380
451
  }
381
452
  const titleOrder = lexicalCompare(left.title, right.title);
382
453
  if (titleOrder !== 0) return titleOrder;
@@ -392,9 +463,14 @@ function aggregateListeningSeasons(subjectId, eventAnalysis, explanations) {
392
463
  end_month: monthKey(lastMonthIndex),
393
464
  retained_month_count: lastMonthIndex - firstMonthIndex + 1,
394
465
  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),
466
+ event_count: aggregate?.playCount ?? 0,
467
+ // A season with no plays has a true zero for each measure the history records.
468
+ ...(aggregate
469
+ ? { ...recordedEngagedPlays(aggregate), ...recordedMinutes(aggregate) }
470
+ : {
471
+ ...(eventAnalysis.recorded.skips === "none" ? {} : { engaged_play_count: 0 }),
472
+ ...(eventAnalysis.recorded.time === "none" ? {} : { listening_minutes: 0 }),
473
+ }),
398
474
  distinct_tracks: trackRefs.size,
399
475
  first_observed_tracks: firstObservedTracks,
400
476
  returning_tracks: trackRefs.size - firstObservedTracks,
@@ -402,9 +478,9 @@ function aggregateListeningSeasons(subjectId, eventAnalysis, explanations) {
402
478
  ? {
403
479
  leading_artist: {
404
480
  name: leadingArtist.name,
405
- event_count: leadingArtist.eventCount,
406
- engaged_play_count: leadingArtist.engagedPlayCount,
407
- listening_minutes: minutes(leadingArtist.playedMs),
481
+ event_count: leadingArtist.playCount,
482
+ ...recordedEngagedPlays(leadingArtist),
483
+ ...recordedMinutes(leadingArtist),
408
484
  distinct_tracks: leadingArtist.trackRefs.size,
409
485
  },
410
486
  }
@@ -418,10 +494,10 @@ function aggregateListeningSeasons(subjectId, eventAnalysis, explanations) {
418
494
  ...(signatureTrack.release
419
495
  ? { release: signatureTrack.release }
420
496
  : {}),
421
- play_count: signatureTrack.eventCount,
422
- engaged_play_count: signatureTrack.engagedPlayCount,
423
- listening_minutes: minutes(signatureTrack.playedMs),
424
- explicit_skips: signatureTrack.skippedCount,
497
+ play_count: signatureTrack.playCount,
498
+ ...recordedEngagedPlays(signatureTrack),
499
+ ...recordedMinutes(signatureTrack),
500
+ ...recordedSkips(signatureTrack),
425
501
  },
426
502
  }
427
503
  : {}),
@@ -572,13 +648,19 @@ function aggregateEvents(subjectId, eventRows, explanations) {
572
648
  incognitoEventsExcluded += 1;
573
649
  continue;
574
650
  }
651
+ const skipped =
652
+ event?.event_type === "play_skipped" ||
653
+ event?.extensions?.["spotify.skipped"] === true;
575
654
  eligible.push({
576
655
  event,
577
656
  track: row.track,
578
657
  playedMs,
579
- skipped:
580
- event?.event_type === "play_skipped" ||
581
- event?.extensions?.["spotify.skipped"] === true,
658
+ timed: hasPlayedDuration,
659
+ skipped,
660
+ // Only Spotify's extended history says whether a play was skipped, and
661
+ // not for every play.
662
+ skipKnown:
663
+ skipped || typeof event?.extensions?.["spotify.skipped"] === "boolean",
582
664
  });
583
665
  }
584
666
  const earliest = eligible.reduce(
@@ -613,6 +695,8 @@ function aggregateEvents(subjectId, eventRows, explanations) {
613
695
  engagedPlayCount: 0,
614
696
  skippedCount: 0,
615
697
  playedMs: 0,
698
+ timedPlayCount: 0,
699
+ skipKnownCount: 0,
616
700
  firstPlayedAt: occurredAt,
617
701
  lastPlayedAt: occurredAt,
618
702
  engagedPlayedAt: [],
@@ -620,6 +704,8 @@ function aggregateEvents(subjectId, eventRows, explanations) {
620
704
  };
621
705
  track.playCount += 1;
622
706
  track.playedMs += row.playedMs;
707
+ if (row.timed) track.timedPlayCount += 1;
708
+ if (row.skipKnown) track.skipKnownCount += 1;
623
709
  if (row.skipped) track.skippedCount += 1;
624
710
  else {
625
711
  track.engagedPlayCount += 1;
@@ -634,9 +720,13 @@ function aggregateEvents(subjectId, eventRows, explanations) {
634
720
  engagedPlayCount: 0,
635
721
  skippedCount: 0,
636
722
  playedMs: 0,
723
+ timedPlayCount: 0,
724
+ skipKnownCount: 0,
637
725
  };
638
726
  yearAggregate.playCount += 1;
639
727
  yearAggregate.playedMs += row.playedMs;
728
+ if (row.timed) yearAggregate.timedPlayCount += 1;
729
+ if (row.skipKnown) yearAggregate.skipKnownCount += 1;
640
730
  if (row.skipped) yearAggregate.skippedCount += 1;
641
731
  else yearAggregate.engagedPlayCount += 1;
642
732
  track.years.set(year, yearAggregate);
@@ -649,6 +739,8 @@ function aggregateEvents(subjectId, eventRows, explanations) {
649
739
  engagedPlayCount: 0,
650
740
  skippedCount: 0,
651
741
  playedMs: 0,
742
+ timedPlayCount: 0,
743
+ skipKnownCount: 0,
652
744
  trackRefs: new Set(),
653
745
  years: new Map(),
654
746
  firstPlayedAt: occurredAt,
@@ -656,6 +748,8 @@ function aggregateEvents(subjectId, eventRows, explanations) {
656
748
  };
657
749
  artist.playCount += 1;
658
750
  artist.playedMs += row.playedMs;
751
+ if (row.timed) artist.timedPlayCount += 1;
752
+ if (row.skipKnown) artist.skipKnownCount += 1;
659
753
  if (row.skipped) artist.skippedCount += 1;
660
754
  else artist.engagedPlayCount += 1;
661
755
  artist.trackRefs.add(trackKey);
@@ -693,7 +787,7 @@ function aggregateEvents(subjectId, eventRows, explanations) {
693
787
  evidenceId,
694
788
  dimension: "taste.artist_familiarity",
695
789
  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)}`}.`,
790
+ 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
791
  name: `listening-artist-${windowKind}`,
698
792
  confidence: confidenceForCount(item.playCount),
699
793
  limitation:
@@ -703,10 +797,10 @@ function aggregateEvents(subjectId, eventRows, explanations) {
703
797
  return {
704
798
  name: item.name,
705
799
  play_count: item.playCount,
706
- engaged_play_count: item.engagedPlayCount,
707
- listening_minutes: minutes(item.playedMs),
800
+ ...recordedEngagedPlays(item),
801
+ ...recordedMinutes(item),
708
802
  distinct_tracks: item.trackRefs.size,
709
- explicit_skips: item.skippedCount,
803
+ ...recordedSkips(item),
710
804
  last_played_at: item.lastPlayedAt,
711
805
  evidence_id: evidenceId,
712
806
  };
@@ -733,7 +827,7 @@ function aggregateEvents(subjectId, eventRows, explanations) {
733
827
  evidenceId,
734
828
  dimension: "taste.track_familiarity",
735
829
  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")}`}.`,
830
+ 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
831
  name: `listening-track-${windowKind}`,
738
832
  confidence: confidenceForCount(item.playCount),
739
833
  limitation:
@@ -747,9 +841,9 @@ function aggregateEvents(subjectId, eventRows, explanations) {
747
841
  ...(item.release ? { release: item.release } : {}),
748
842
  identity_status: item.identityStatus,
749
843
  play_count: item.playCount,
750
- engaged_play_count: item.engagedPlayCount,
751
- listening_minutes: minutes(item.playedMs),
752
- explicit_skips: item.skippedCount,
844
+ ...recordedEngagedPlays(item),
845
+ ...recordedMinutes(item),
846
+ ...recordedSkips(item),
753
847
  last_played_at: item.lastPlayedAt,
754
848
  evidence_id: evidenceId,
755
849
  };
@@ -786,6 +880,11 @@ function aggregateEvents(subjectId, eventRows, explanations) {
786
880
  const rowsWithOfflineState = eligible.filter(
787
881
  (row) => typeof row.event.extensions?.["spotify.offline"] === "boolean",
788
882
  );
883
+ const recorded = recordedMeasures({
884
+ playCount: eligible.length,
885
+ timedPlayCount: eligible.filter((row) => row.timed).length,
886
+ skipKnownCount: eligible.filter((row) => row.skipKnown).length,
887
+ });
789
888
  const context = {
790
889
  reference_date: latest,
791
890
  recent_window_days: recentWindowDays,
@@ -802,7 +901,9 @@ function aggregateEvents(subjectId, eventRows, explanations) {
802
901
  ({ reason }) => normalizedText(reason.trim()) === "trackdone",
803
902
  ).length,
804
903
  skip_state_events: rowsWithSkipState.length,
805
- explicit_skips: eligible.filter((row) => row.skipped).length,
904
+ ...(recorded.skips === "none"
905
+ ? {}
906
+ : { explicit_skips: eligible.filter((row) => row.skipped).length }),
806
907
  shuffle_state_events: rowsWithShuffleState.length,
807
908
  shuffle_events: rowsWithShuffleState.filter(
808
909
  (row) => row.event.extensions?.["spotify.shuffle"] === true,
@@ -821,6 +922,7 @@ function aggregateEvents(subjectId, eventRows, explanations) {
821
922
  rawPlayedMs,
822
923
  eventsWithPlayedDuration,
823
924
  profiledPlayedMs: eligible.reduce((sum, row) => sum + row.playedMs, 0),
925
+ recorded,
824
926
  lifetime,
825
927
  recent,
826
928
  historyArc,
@@ -879,7 +981,7 @@ function artistRelationships(subjectId, eventAnalysis, explanations) {
879
981
  evidenceId,
880
982
  dimension: "taste.artist_continuity",
881
983
  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.`,
984
+ basis: `${artist.name} shows up in ${plural(activeYears, "year")} of your history, from ${firstYear} to ${lastYear}: ${plural(artist.playCount, "play")}${listeningTime(artist)}.`,
883
985
  name: "listening-artist-continuity",
884
986
  confidence: confidenceForCount(artist.playCount),
885
987
  limitation:
@@ -893,7 +995,7 @@ function artistRelationships(subjectId, eventAnalysis, explanations) {
893
995
  active_years: activeYears,
894
996
  span_years: spanYears,
895
997
  play_count: artist.playCount,
896
- listening_minutes: minutes(artist.playedMs),
998
+ ...recordedMinutes(artist),
897
999
  evidence_id: evidenceId,
898
1000
  };
899
1001
  });
@@ -1001,12 +1103,16 @@ function releaseDepth(subjectId, eventAnalysis, explanations) {
1001
1103
  playCount: 0,
1002
1104
  engagedPlayCount: 0,
1003
1105
  playedMs: 0,
1106
+ timedPlayCount: 0,
1107
+ skipKnownCount: 0,
1004
1108
  years: new Set(),
1005
1109
  };
1006
1110
  release.trackRefs.add(metadata.trackRefId);
1007
1111
  release.playCount += 1;
1008
1112
  if (!row.skipped) release.engagedPlayCount += 1;
1009
1113
  release.playedMs += row.playedMs;
1114
+ if (row.timed) release.timedPlayCount += 1;
1115
+ if (row.skipKnown) release.skipKnownCount += 1;
1010
1116
  if (Number.isInteger(year)) release.years.add(year);
1011
1117
  releases.set(key, release);
1012
1118
  }
@@ -1043,7 +1149,7 @@ function releaseDepth(subjectId, eventAnalysis, explanations) {
1043
1149
  evidenceId,
1044
1150
  dimension: "taste.release_depth",
1045
1151
  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")}` : ""}.`,
1152
+ 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
1153
  name: "listening-release-depth",
1048
1154
  confidence: confidenceForCount(release.engagedPlayCount),
1049
1155
  limitation:
@@ -1055,8 +1161,8 @@ function releaseDepth(subjectId, eventAnalysis, explanations) {
1055
1161
  artist_credit: release.artist,
1056
1162
  distinct_tracks: release.trackRefs.size,
1057
1163
  play_count: release.playCount,
1058
- engaged_play_count: release.engagedPlayCount,
1059
- listening_minutes: minutes(release.playedMs),
1164
+ ...recordedEngagedPlays(release),
1165
+ ...recordedMinutes(release),
1060
1166
  ...(Number.isInteger(firstYear) ? { first_year: firstYear } : {}),
1061
1167
  ...(Number.isInteger(lastYear) ? { last_year: lastYear } : {}),
1062
1168
  active_years: years.length,
@@ -1307,7 +1413,7 @@ function backToBackTracks(
1307
1413
  play_count: lifetime?.playCount ?? item.playsInBursts,
1308
1414
  engaged_play_count:
1309
1415
  lifetime?.engagedPlayCount ?? item.playsInBursts,
1310
- explicit_skips: lifetime?.skippedCount ?? 0,
1416
+ ...(lifetime ? recordedSkips(lifetime) : {}),
1311
1417
  burst_count: item.burstCount,
1312
1418
  maximum_consecutive_plays: item.maximumConsecutivePlays,
1313
1419
  plays_in_bursts: item.playsInBursts,
@@ -1320,8 +1426,13 @@ function backToBackTracks(
1320
1426
  }
1321
1427
 
1322
1428
  function deduplicateProfileEvidence(records) {
1429
+ // A live library read is a complete snapshot: the newest one replaces older
1430
+ // reads, so an unsaved song or unfollowed artist drops out after a re-sync.
1431
+ const latestLibraryRead = records.reduce((latest, record) => record.provenance?.source_system === "spotify_web_api" &&
1432
+ (record.provenance.captured_at ?? "") > latest ? record.provenance.captured_at : latest, "");
1323
1433
  const selected = new Map();
1324
1434
  for (const record of records) {
1435
+ if (record.provenance?.source_system === "spotify_web_api" && record.provenance.captured_at !== latestLibraryRead) continue;
1325
1436
  const current = selected.get(record.evidence_key);
1326
1437
  const capturedAt = record.provenance?.captured_at ?? "";
1327
1438
  const currentCapturedAt = current?.provenance?.captured_at ?? "";
@@ -1357,14 +1468,17 @@ function persistedExplanation(record) {
1357
1468
  taste_artist_ranked: `Spotify's exported Taste Profile ranked this artist reference at position ${attributes.rank}.`,
1358
1469
  provider_interpretation: `Spotify supplied this provider-generated ${label.replaceAll("_", " ")} narrative in ${source}.`,
1359
1470
  sound_capsule_artist_ranked: `Spotify Sound Capsule ranked this artist at position ${attributes.rank} for ${attributes.period}.`,
1471
+ top_artist_ranked: `Spotify ranked this artist at position ${attributes.rank} of your top artists for ${attributes.period}, as of ${record.provenance.captured_at}.`,
1472
+ 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
1473
  sound_capsule_track_ranked: `Spotify Sound Capsule ranked this track label at position ${attributes.rank} for ${attributes.period}.`,
1361
1474
  sound_capsule_highlight: `Spotify Sound Capsule emitted a ${attributes.highlight_type} highlight dated ${record.observed_at}.`,
1362
1475
  wrapped_metric: `Spotify Wrapped reported ${attributes.value} ${attributes.unit} for ${label} in ${attributes.period}.`,
1363
1476
  sound_capsule_period_metric: `Spotify Sound Capsule reported ${attributes.stream_count} streams and ${attributes.played_seconds} played seconds for ${attributes.period}.`,
1364
1477
  };
1365
1478
  const limitationByClass = {
1366
- explicit:
1367
- "This is an explicit current provider state, but the export does not include the original action timestamp.",
1479
+ explicit: record.provenance?.source_system === "spotify_web_api"
1480
+ ? "This is your saved state in Spotify when Moondog read it; it shows what you keep, not how often you play it."
1481
+ : "This is an explicit current provider state, but the export does not include the original action timestamp.",
1368
1482
  curated:
1369
1483
  "Playlist inclusion is a curatorial signal, not proof of a permanent preference or the reason for inclusion.",
1370
1484
  behavioral:
@@ -1388,6 +1502,8 @@ function persistedExplanation(record) {
1388
1502
  provider_interpretation: "provider.interpretation",
1389
1503
  sound_capsule_artist_ranked: "provider.artist_rank",
1390
1504
  sound_capsule_track_ranked: "provider.track_rank",
1505
+ top_artist_ranked: "provider.artist_rank",
1506
+ top_track_ranked: "provider.track_rank",
1391
1507
  sound_capsule_highlight: "provider.highlight",
1392
1508
  wrapped_metric: "provider.metric",
1393
1509
  sound_capsule_period_metric: "provider.metric",
@@ -1462,7 +1578,7 @@ function evidenceGroups(records, eventAnalysis, explanations) {
1462
1578
  label: record.entity.label,
1463
1579
  artist_credit: record.entity.artist_credit,
1464
1580
  ...(record.entity.release ? { release: record.entity.release } : {}),
1465
- ...(metric ? { listening_minutes: minutes(metric.playedMs) } : {}),
1581
+ ...(metric ? recordedMinutes(metric) : {}),
1466
1582
  evidence_id: record.profile_evidence_id,
1467
1583
  }));
1468
1584
 
@@ -1484,7 +1600,7 @@ function evidenceGroups(records, eventAnalysis, explanations) {
1484
1600
  artist_credit: group.entity.artist_credit,
1485
1601
  ...(group.entity.release ? { release: group.entity.release } : {}),
1486
1602
  playlist_count: group.playlists.size,
1487
- playlist_names: [...group.playlists].sort(lexicalCompare).slice(0, 3),
1603
+ playlist_names: [...group.playlists].sort(lexicalCompare),
1488
1604
  last_added_at: group.lastAddedAt,
1489
1605
  evidence_id: group.evidenceId,
1490
1606
  }));
@@ -1500,7 +1616,7 @@ function evidenceGroups(records, eventAnalysis, explanations) {
1500
1616
  })
1501
1617
  .map(({ record, metric }) => ({
1502
1618
  name: record.entity.label,
1503
- ...(metric ? { listening_minutes: minutes(metric.playedMs) } : {}),
1619
+ ...(metric ? recordedMinutes(metric) : {}),
1504
1620
  evidence_id: record.profile_evidence_id,
1505
1621
  }));
1506
1622
 
@@ -1562,10 +1678,12 @@ function evidenceGroups(records, eventAnalysis, explanations) {
1562
1678
  "wrapped_artist_ranked",
1563
1679
  "taste_artist_ranked",
1564
1680
  "sound_capsule_artist_ranked",
1681
+ "top_artist_ranked",
1565
1682
  ]);
1566
1683
  const providerTrackKinds = new Set([
1567
1684
  "wrapped_track_ranked",
1568
1685
  "sound_capsule_track_ranked",
1686
+ "top_track_ranked",
1569
1687
  ]);
1570
1688
  function providerRanked(kinds, outputType) {
1571
1689
  const groups = new Map();
@@ -1817,9 +1935,10 @@ function rediscoverySignal(track, evidence, direct) {
1817
1935
  }
1818
1936
 
1819
1937
  function rediscoveryPeakYear(track) {
1938
+ const byTime = fullyTimed(track);
1820
1939
  return [...track.years.entries()]
1821
1940
  .sort(([leftYear, left], [rightYear, right]) => {
1822
- if (right.playedMs !== left.playedMs) {
1941
+ if (byTime && right.playedMs !== left.playedMs) {
1823
1942
  return right.playedMs - left.playedMs;
1824
1943
  }
1825
1944
  if (right.playCount !== left.playCount) {
@@ -1834,6 +1953,7 @@ function rediscoveryPeakYear(track) {
1834
1953
  explicit_skips: aggregate.skippedCount,
1835
1954
  listening_minutes: minutes(aggregate.playedMs),
1836
1955
  played_ms: aggregate.playedMs,
1956
+ plays: aggregate,
1837
1957
  }))[0] ?? null;
1838
1958
  }
1839
1959
 
@@ -1847,7 +1967,7 @@ function rediscoveryTracks(
1847
1967
  const latestMs = Date.parse(eventAnalysis.latest ?? "");
1848
1968
  if (!Number.isFinite(latestMs)) return [];
1849
1969
  const avoids = combinedAvoids(direct.avoids, evidence.avoids);
1850
- return [...eventAnalysis.lifetime.tracks.values()]
1970
+ const candidates = [...eventAnalysis.lifetime.tracks.values()]
1851
1971
  .map((track) => {
1852
1972
  const lastPlayedMs = Date.parse(track.lastPlayedAt);
1853
1973
  const quietDays = Math.floor((latestMs - lastPlayedMs) / dayMs);
@@ -1864,15 +1984,17 @@ function rediscoveryTracks(
1864
1984
  quietDays >= recentWindowDays &&
1865
1985
  track.playCount >= rediscoveryMinimumPlays &&
1866
1986
  track.engagedPlayCount >= rediscoveryMinimumEngagedPlays &&
1867
- track.playedMs >= rediscoveryMinimumPlayedMs &&
1987
+ (!fullyTimed(track) || track.playedMs >= rediscoveryMinimumPlayedMs) &&
1868
1988
  track.engagedPlayCount >= track.skippedCount &&
1869
1989
  !avoids.some((item) => assertionMatchesTrack(item, track)),
1870
- )
1990
+ );
1991
+ const byTime = candidates.every(({ track }) => fullyTimed(track));
1992
+ return candidates
1871
1993
  .sort((left, right) => {
1872
1994
  if (right.signal.rank !== left.signal.rank) {
1873
1995
  return right.signal.rank - left.signal.rank;
1874
1996
  }
1875
- if (right.track.playedMs !== left.track.playedMs) {
1997
+ if (byTime && right.track.playedMs !== left.track.playedMs) {
1876
1998
  return right.track.playedMs - left.track.playedMs;
1877
1999
  }
1878
2000
  if (right.track.engagedPlayCount !== left.track.engagedPlayCount) {
@@ -1896,7 +2018,7 @@ function rediscoveryTracks(
1896
2018
  evidenceId,
1897
2019
  dimension: "listening.rediscovery_candidate",
1898
2020
  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)}.`,
2021
+ 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
2022
  name: "listening-track-rediscovery",
1901
2023
  confidence:
1902
2024
  signal.rank === 4
@@ -1914,8 +2036,8 @@ function rediscoveryTracks(
1914
2036
  identity_status: track.identityStatus,
1915
2037
  play_count: track.playCount,
1916
2038
  engaged_play_count: track.engagedPlayCount,
1917
- listening_minutes: minutes(track.playedMs),
1918
- explicit_skips: track.skippedCount,
2039
+ ...recordedMinutes(track),
2040
+ ...recordedSkips(track),
1919
2041
  first_played_at: track.firstPlayedAt,
1920
2042
  last_played_at: track.lastPlayedAt,
1921
2043
  quiet_days: quietDays,
@@ -1924,7 +2046,7 @@ function rediscoveryTracks(
1924
2046
  ? {
1925
2047
  peak_year: peak.year,
1926
2048
  peak_year_play_count: peak.play_count,
1927
- peak_year_listening_minutes: peak.listening_minutes,
2049
+ ...recordedMinutes(peak.plays, "peak_year_listening_minutes"),
1928
2050
  }
1929
2051
  : {}),
1930
2052
  evidence_id: evidenceId,
@@ -1960,7 +2082,7 @@ function historicalReturnTracks(
1960
2082
  explanations,
1961
2083
  ) {
1962
2084
  const avoids = combinedAvoids(direct.avoids, evidence.avoids);
1963
- return [...eventAnalysis.lifetime.tracks.values()]
2085
+ const candidates = [...eventAnalysis.lifetime.tracks.values()]
1964
2086
  .map((track) => ({
1965
2087
  track,
1966
2088
  returns: historicalReturnGaps(track),
@@ -1971,10 +2093,12 @@ function historicalReturnTracks(
1971
2093
  returns.length > 0 &&
1972
2094
  track.playCount >= historicalReturnMinimumPlays &&
1973
2095
  track.engagedPlayCount >= historicalReturnMinimumEngagedPlays &&
1974
- track.playedMs >= historicalReturnMinimumPlayedMs &&
2096
+ (!fullyTimed(track) || track.playedMs >= historicalReturnMinimumPlayedMs) &&
1975
2097
  track.engagedPlayCount >= track.skippedCount &&
1976
2098
  !avoids.some((item) => assertionMatchesTrack(item, track)),
1977
- )
2099
+ );
2100
+ const byTime = candidates.every(({ track }) => fullyTimed(track));
2101
+ return candidates
1978
2102
  .sort((left, right) => {
1979
2103
  if (right.returns.length !== left.returns.length) {
1980
2104
  return right.returns.length - left.returns.length;
@@ -1989,7 +2113,7 @@ function historicalReturnTracks(
1989
2113
  if (right.signal.rank !== left.signal.rank) {
1990
2114
  return right.signal.rank - left.signal.rank;
1991
2115
  }
1992
- if (right.track.playedMs !== left.track.playedMs) {
2116
+ if (byTime && right.track.playedMs !== left.track.playedMs) {
1993
2117
  return right.track.playedMs - left.track.playedMs;
1994
2118
  }
1995
2119
  if (right.track.engagedPlayCount !== left.track.engagedPlayCount) {
@@ -2014,7 +2138,7 @@ function historicalReturnTracks(
2014
2138
  evidenceId,
2015
2139
  dimension: "listening.historical_return",
2016
2140
  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)}`,
2141
+ 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
2142
  name: "listening-track-historical-return",
2019
2143
  confidence: confidenceForCount(
2020
2144
  track.engagedPlayCount + returns.length,
@@ -2033,8 +2157,8 @@ function historicalReturnTracks(
2033
2157
  identity_status: track.identityStatus,
2034
2158
  play_count: track.playCount,
2035
2159
  engaged_play_count: track.engagedPlayCount,
2036
- listening_minutes: minutes(track.playedMs),
2037
- explicit_skips: track.skippedCount,
2160
+ ...recordedMinutes(track),
2161
+ ...recordedSkips(track),
2038
2162
  first_played_at: track.firstPlayedAt,
2039
2163
  last_played_at: track.lastPlayedAt,
2040
2164
  return_count: returns.length,
@@ -2084,7 +2208,8 @@ function timeCapsuleTracks(
2084
2208
  if (
2085
2209
  !peak ||
2086
2210
  peak.engaged_play_count < timeCapsuleMinimumEngagedPlays ||
2087
- peak.listening_minutes < minutes(timeCapsuleMinimumPlayedMs) ||
2211
+ (fullyTimed(peak.plays) &&
2212
+ peak.listening_minutes < minutes(timeCapsuleMinimumPlayedMs)) ||
2088
2213
  peak.engaged_play_count < peak.explicit_skips
2089
2214
  ) {
2090
2215
  continue;
@@ -2099,11 +2224,14 @@ function timeCapsuleTracks(
2099
2224
  }
2100
2225
  if (byYear.size < timeCapsuleMinimumYears) return [];
2101
2226
 
2227
+ const byTime = [...byYear.values()].every((values) =>
2228
+ values.every(({ track }) => fullyTimed(track)),
2229
+ );
2102
2230
  const rankedYears = [...byYear.entries()]
2103
2231
  .sort(([left], [right]) => left - right)
2104
2232
  .map(([year, values]) => {
2105
2233
  values.sort((left, right) => {
2106
- if (right.peak.played_ms !== left.peak.played_ms) {
2234
+ if (byTime && right.peak.played_ms !== left.peak.played_ms) {
2107
2235
  return right.peak.played_ms - left.peak.played_ms;
2108
2236
  }
2109
2237
  if (
@@ -2114,7 +2242,7 @@ function timeCapsuleTracks(
2114
2242
  if (right.signal.rank !== left.signal.rank) {
2115
2243
  return right.signal.rank - left.signal.rank;
2116
2244
  }
2117
- if (right.track.playedMs !== left.track.playedMs) {
2245
+ if (byTime && right.track.playedMs !== left.track.playedMs) {
2118
2246
  return right.track.playedMs - left.track.playedMs;
2119
2247
  }
2120
2248
  const titleOrder = lexicalCompare(left.track.title, right.track.title);
@@ -2123,7 +2251,9 @@ function timeCapsuleTracks(
2123
2251
  return {
2124
2252
  year,
2125
2253
  values,
2126
- strength: values[0].peak.played_ms,
2254
+ strength: byTime
2255
+ ? values[0].peak.played_ms
2256
+ : values[0].peak.engaged_play_count,
2127
2257
  };
2128
2258
  });
2129
2259
  const selectedYears = evenlySpaced(rankedYears, maximum).sort(
@@ -2136,6 +2266,7 @@ function timeCapsuleTracks(
2136
2266
  ({ track }) => !usedArtists.has(normalizedText(track.artist)),
2137
2267
  ) ?? values[0];
2138
2268
  const { track, peak, signal } = selected;
2269
+ const peakPlays = track.years.get(year);
2139
2270
  usedArtists.add(normalizedText(track.artist));
2140
2271
  const evidenceId = aggregateEvidenceId(
2141
2272
  subjectId,
@@ -2148,7 +2279,7 @@ function timeCapsuleTracks(
2148
2279
  evidenceId,
2149
2280
  dimension: "listening.time_capsule_representative",
2150
2281
  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)}`,
2282
+ 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
2283
  name: "listening-time-capsule",
2153
2284
  confidence: confidenceForCount(peak.engaged_play_count, 0.55, 0.88),
2154
2285
  limitation:
@@ -2164,10 +2295,10 @@ function timeCapsuleTracks(
2164
2295
  capsule_year: year,
2165
2296
  year_play_count: peak.play_count,
2166
2297
  year_engaged_play_count: peak.engaged_play_count,
2167
- year_listening_minutes: peak.listening_minutes,
2168
- year_explicit_skips: peak.explicit_skips,
2298
+ ...recordedMinutes(peakPlays, "year_listening_minutes"),
2299
+ ...recordedSkips(peakPlays, "year_explicit_skips"),
2169
2300
  lifetime_play_count: track.playCount,
2170
- lifetime_listening_minutes: minutes(track.playedMs),
2301
+ ...recordedMinutes(track, "lifetime_listening_minutes"),
2171
2302
  representative_signal: signal.value,
2172
2303
  evidence_id: evidenceId,
2173
2304
  };
@@ -2324,6 +2455,7 @@ export function projectListeningProfile({
2324
2455
  if (system.startsWith("spotify")) return "spotify";
2325
2456
  if (system.startsWith("listenbrainz")) return "listenbrainz";
2326
2457
  if (system === "youtube_music") return "youtube_music";
2458
+ if (system === "lastfm") return "lastfm";
2327
2459
  return "other";
2328
2460
  })
2329
2461
  .filter(Boolean),
@@ -2337,6 +2469,9 @@ export function projectListeningProfile({
2337
2469
  .filter(Boolean)
2338
2470
  .sort()
2339
2471
  .at(-1) ?? null;
2472
+ const lengthNote = events.recorded.time === "all"
2473
+ ? ""
2474
+ : " if every play has a recorded length";
2340
2475
  return {
2341
2476
  summary: {
2342
2477
  schema_version: LISTENING_PROFILE_SCHEMA_VERSION,
@@ -2350,7 +2485,9 @@ export function projectListeningProfile({
2350
2485
  cross_format_linked_events: crossFormatLinkedRows.length,
2351
2486
  cross_format_ambiguous_tracks: crossFormatAmbiguousTracks,
2352
2487
  cross_format_ambiguous_events: crossFormatAmbiguousRows.length,
2353
- listening_hours: hours(events.profiledPlayedMs),
2488
+ ...(events.recorded.time === "none"
2489
+ ? {}
2490
+ : { listening_hours: hours(events.profiledPlayedMs) }),
2354
2491
  earliest_played_at: events.earliest,
2355
2492
  latest_played_at: events.latest,
2356
2493
  profile_evidence_records: deduplicatedEvidence.length,
@@ -2432,7 +2569,9 @@ export function projectListeningProfile({
2432
2569
  },
2433
2570
  curated_preferences: {
2434
2571
  saved_tracks: slice(evidence.savedTracks, maxItems),
2435
- playlist_anchors: slice(evidence.playlistAnchors, maxItems),
2572
+ playlist_anchors: slice(evidence.playlistAnchors, maxItems).map((item) => ({
2573
+ ...item, playlist_names: item.playlist_names.slice(0, 3),
2574
+ })),
2436
2575
  followed_artists: slice(evidence.followedArtists, maxItems),
2437
2576
  saved_albums: slice(evidence.savedAlbums, maxItems),
2438
2577
  avoids: slice(combinedAvoids(direct.avoids, evidence.avoids), maxItems),
@@ -2490,8 +2629,8 @@ export function projectListeningProfile({
2490
2629
  ]
2491
2630
  : []),
2492
2631
  "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.",
2632
+ `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.`,
2633
+ `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
2634
  "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
2635
  "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
2636
  "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.",
@@ -2514,6 +2653,33 @@ export function projectListeningProfile({
2514
2653
  ],
2515
2654
  },
2516
2655
  explanations,
2656
+ // Host-only complete evidence catalogs. Bound pages after selecting a section.
2657
+ catalog: {
2658
+ history_artists: events.lifetime.rankedArtists,
2659
+ history_tracks: events.lifetime.rankedTracks,
2660
+ recent_artists: events.recent.rankedArtists,
2661
+ recent_tracks: events.recent.rankedTracks,
2662
+ history_years: events.historyArc,
2663
+ history_releases: releases,
2664
+ saved_tracks: evidence.savedTracks,
2665
+ playlist_tracks: evidence.playlistAnchors,
2666
+ saved_albums: evidence.savedAlbums,
2667
+ followed_artists: evidence.followedArtists,
2668
+ provider_genres: evidence.genres,
2669
+ listener_preferences: direct.preferences,
2670
+ listener_avoids: combinedAvoids(direct.avoids, evidence.avoids),
2671
+ provider_evidence: deduplicatedEvidence.map((record) => ({
2672
+ evidence_id: record.profile_evidence_id,
2673
+ evidence_kind: record.evidence_kind,
2674
+ entity_type: record.entity.entity_type,
2675
+ label: record.entity.label ?? "Unresolved provider label",
2676
+ ...(record.entity.artist_credit ? { artist_credit: record.entity.artist_credit } : {}),
2677
+ source_label: musicProviderLabel(record.provenance.source_system),
2678
+ observed_at: record.observed_at,
2679
+ ...Object.fromEntries(["rank", "period", "text", "playlist_name", "playlist_position", "value", "unit", "stream_count", "played_seconds"]
2680
+ .filter((key) => record.attributes[key] !== undefined).map((key) => [key, record.attributes[key]])),
2681
+ })),
2682
+ },
2517
2683
  // Host-only consumers need every exclusion, not the bounded display sample.
2518
2684
  lyricExclusions: combinedAvoids(direct.avoids, evidence.avoids),
2519
2685
  };