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