@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.
- package/LICENSE +202 -0
- package/README.md +153 -68
- package/contracts/v1/definitions.schema.json +1 -169
- package/package.json +7 -23
- package/scripts/contract-lib.mjs +0 -7
- package/scripts/contract-semantics.mjs +0 -1904
- package/scripts/moondog.mjs +285 -74
- package/src/core/apple-library-source-status.mjs +0 -16
- package/src/core/apple-projection-domain-services.mjs +168 -36
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +278 -55
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +153 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +1908 -104
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +121 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +128 -0
- package/src/i18n/messages/en.mjs +129 -0
- package/src/i18n/messages/es.mjs +128 -0
- package/src/i18n/messages/fr.mjs +128 -0
- package/src/i18n/messages/it.mjs +128 -0
- package/src/i18n/messages/ja.mjs +128 -0
- package/src/i18n/messages/ko.mjs +128 -0
- package/src/i18n/messages/pt.mjs +128 -0
- package/src/i18n/messages/zh-Hant.mjs +128 -0
- package/src/i18n/messages/zh.mjs +128 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +966 -0
- package/src/i18n/screen/ja.mjs +966 -0
- package/src/i18n/screen/pt.mjs +966 -0
- package/src/i18n/screen/zh.mjs +966 -0
- package/src/importers/apple-music-library/index.mjs +0 -16
- package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
- package/src/integrations/apple-music/catalog.mjs +123 -14
- package/src/integrations/apple-music/music-app.mjs +232 -0
- package/src/integrations/apple-music/storefront.mjs +28 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
- package/src/integrations/lastfm/history.mjs +221 -0
- package/src/integrations/lastfm/settings.mjs +75 -0
- package/src/integrations/netease/ncm-cli-player.mjs +321 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
- package/src/integrations/public-playlists.mjs +2 -1
- package/src/integrations/spotify/account-data-profile.mjs +14 -6
- package/src/integrations/spotify/authentication.mjs +149 -15
- package/src/integrations/spotify/catalog-resolver.mjs +8 -4
- package/src/integrations/spotify/connection.mjs +2 -1
- package/src/integrations/spotify/credential-store.mjs +4 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
- package/src/integrations/spotify/history-archive.mjs +1 -1
- package/src/integrations/spotify/library-snapshot.mjs +227 -0
- package/src/integrations/spotify/recent-activity.mjs +2 -0
- package/src/integrations/spotify/service.mjs +873 -69
- package/src/integrations/spotify/web-api-client.mjs +407 -127
- package/src/integrations/web/brave-web.mjs +45 -0
- package/src/integrations/web/codex-web.mjs +15 -82
- package/src/integrations/web/deepseek-web.mjs +81 -0
- package/src/integrations/web/web-research.mjs +119 -0
- package/src/integrations/web/web-result.mjs +215 -0
- package/src/integrations/youtube-music/takeout.mjs +3 -1
- package/src/memory/local-memory-store.mjs +469 -41
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-profile-build.mjs +378 -0
- package/src/profile/listener-profile-store.mjs +130 -0
- package/src/profile/listener-profile-verification.mjs +68 -0
- package/src/profile/listener-profile-worker.mjs +172 -0
- package/src/profile/listening-history-store.mjs +424 -8
- package/src/profile/listening-profile-projection.mjs +242 -76
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +84 -0
- package/src/profile/recorded-measures.mjs +24 -0
- package/src/profile/spotify-archive-taste.mjs +0 -4
- package/src/runtime/pi/agent-runtime.mjs +2204 -605
- package/src/runtime/pi/authentication.mjs +34 -1
- package/src/runtime/pi/configured-runtime.mjs +22 -6
- package/src/runtime/pi/conversation-name.mjs +53 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
- package/src/runtime/pi/model-catalog.mjs +28 -5
- package/src/runtime/pi/model-settings.mjs +202 -0
- package/src/runtime/pi/model-transport.mjs +10 -0
- package/src/runtime/pi/persistent-credential-store.mjs +67 -31
- package/src/runtime/pi/profile-build-runtime.mjs +160 -0
- package/src/runtime/pi/public-model-catalog.mjs +288 -0
- package/src/runtime/pi/runtime-settings.mjs +10 -2
- package/src/runtime/pi/skills/listener-profile/SKILL.md +46 -0
- package/src/runtime/pi/tool-trace.mjs +37 -0
- package/src/surfaces/cli/auth-command.mjs +12 -4
- package/src/surfaces/cli/brand-components.mjs +310 -132
- package/src/surfaces/cli/brand-theme.mjs +51 -28
- package/src/surfaces/cli/command-completions.mjs +58 -42
- package/src/surfaces/cli/format-output.mjs +199 -388
- package/src/surfaces/cli/history-import-view.mjs +292 -139
- package/src/surfaces/cli/import-guides.mjs +2 -0
- package/src/surfaces/cli/listener-profile-command.mjs +92 -0
- package/src/surfaces/cli/listening-menu.mjs +7 -4
- package/src/surfaces/cli/profile-command.mjs +30 -21
- package/src/surfaces/cli/seasons-chart.mjs +87 -0
- package/src/surfaces/cli/spotify-command.mjs +54 -6
- package/src/surfaces/cli/taste-profile-model.mjs +78 -79
- package/src/surfaces/cli/taste-profile-view.mjs +33 -26
- package/src/surfaces/cli/terminal-art.mjs +140 -35
- package/src/surfaces/cli/tui.mjs +1066 -357
- package/src/surfaces/cli/update-command.mjs +32 -0
- package/src/surfaces/cli/web-command.mjs +53 -14
- package/src/surfaces/html/tasteprint.mjs +43 -70
- package/contracts/v1/capability-policy.schema.json +0 -572
- package/contracts/v1/profile-evidence.schema.json +0 -237
- package/contracts/v1/tool-invocation.schema.json +0 -318
- package/contracts/v1/tool-receipt.schema.json +0 -1066
- package/scripts/demo-studio.mjs +0 -58
- package/src/core/apple-data-migration.mjs +0 -50
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
- package/src/demo/moondog-tasteprint-demo.mjs +0 -631
- package/src/integrations/spotify/index.mjs +0 -80
- package/src/surfaces/web/showcase.mjs +0 -272
- package/src/surfaces/web/studio-page.mjs +0 -4148
- 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
|
-
|
|
222
|
+
playCount: 0,
|
|
168
223
|
playedMs: 0,
|
|
224
|
+
timedPlayCount: 0,
|
|
169
225
|
trackRefs: new Set(),
|
|
170
226
|
artists: new Map(),
|
|
171
227
|
};
|
|
172
|
-
aggregate.
|
|
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.
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
365
|
-
return right.
|
|
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.
|
|
379
|
-
return right.
|
|
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?.
|
|
396
|
-
|
|
397
|
-
|
|
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.
|
|
406
|
-
|
|
407
|
-
|
|
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.
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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")}
|
|
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
|
-
|
|
707
|
-
|
|
800
|
+
...recordedEngagedPlays(item),
|
|
801
|
+
...recordedMinutes(item),
|
|
708
802
|
distinct_tracks: item.trackRefs.size,
|
|
709
|
-
|
|
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")}
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
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")}
|
|
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
|
-
|
|
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")}
|
|
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
|
-
|
|
1059
|
-
|
|
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
|
-
|
|
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
|
|
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 ?
|
|
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)
|
|
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 ?
|
|
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
|
-
|
|
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")}
|
|
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
|
-
|
|
1918
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")}
|
|
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
|
-
|
|
2037
|
-
|
|
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.
|
|
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:
|
|
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")}
|
|
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
|
|
2168
|
-
year_explicit_skips
|
|
2298
|
+
...recordedMinutes(peakPlays, "year_listening_minutes"),
|
|
2299
|
+
...recordedSkips(peakPlays, "year_explicit_skips"),
|
|
2169
2300
|
lifetime_play_count: track.playCount,
|
|
2170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2494
|
-
|
|
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
|
};
|