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