@audiofool/moondog 0.2.0-beta.2 → 0.2.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/package.json +1 -1
- package/src/core/apple-projection-domain-services.mjs +95 -27
- package/src/core/capability-catalog.mjs +12 -0
- package/src/core/listener-avoids.mjs +101 -0
- package/src/core/listening-profile-domain-services.mjs +44 -8
- package/src/core/moondog-application.mjs +238 -34
- package/src/core/spotify-listening-intent.mjs +5 -2
- package/src/core/track-title.mjs +107 -0
- package/src/i18n/messages/de.mjs +7 -0
- package/src/i18n/messages/en.mjs +7 -0
- package/src/i18n/messages/es.mjs +7 -0
- package/src/i18n/messages/fr.mjs +7 -0
- package/src/i18n/messages/it.mjs +7 -0
- package/src/i18n/messages/ja.mjs +7 -0
- package/src/i18n/messages/ko.mjs +7 -0
- package/src/i18n/messages/pt.mjs +7 -0
- package/src/i18n/messages/zh-Hant.mjs +7 -0
- package/src/i18n/messages/zh.mjs +7 -0
- package/src/i18n/screen/es.mjs +1 -0
- package/src/i18n/screen/ja.mjs +1 -0
- package/src/i18n/screen/pt.mjs +1 -0
- package/src/i18n/screen/zh.mjs +1 -0
- package/src/integrations/apple-music/catalog.mjs +52 -9
- package/src/integrations/netease/ncm-cli-player.mjs +10 -3
- package/src/integrations/spotify/catalog-resolver.mjs +1 -2
- package/src/integrations/spotify/web-api-client.mjs +3 -0
- package/src/memory/local-memory-store.mjs +73 -16
- package/src/profile/listener-corrections.mjs +22 -0
- package/src/profile/listener-profile-build.mjs +92 -9
- package/src/profile/listener-profile-worker.mjs +7 -0
- package/src/profile/listening-history-store.mjs +231 -24
- package/src/profile/listening-profile-projection.mjs +212 -10
- package/src/profile/moondog-suggestions.mjs +184 -0
- package/src/profile/profile-exploration.mjs +16 -8
- package/src/runtime/pi/agent-runtime.mjs +363 -118
- package/src/runtime/pi/profile-build-runtime.mjs +2 -1
- package/src/runtime/pi/skills/listener-profile/SKILL.md +2 -0
- package/src/surfaces/cli/web-command.mjs +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createAvoidGate } from "../core/listener-avoids.mjs";
|
|
1
2
|
import { uuidV5 } from "../core/uuid-v5.mjs";
|
|
2
3
|
import { musicProviderLabel } from "./music-providers.mjs";
|
|
3
4
|
import { recordedMeasures } from "./recorded-measures.mjs";
|
|
@@ -26,6 +27,27 @@ const extendedSequenceMinimumPlays = 5;
|
|
|
26
27
|
const backToBackMinimumConsecutivePlays = 2;
|
|
27
28
|
const backToBackMinimumPlayedMs = 30_000;
|
|
28
29
|
const backToBackMaximumGapMinutes = 30;
|
|
30
|
+
// Movement compares an artist's share of each year's plays, so a year with
|
|
31
|
+
// less listening overall, or fewer records, does not read as fading. Play
|
|
32
|
+
// counts are the measure because every dated play records one.
|
|
33
|
+
const movementMinimumPlays = 20;
|
|
34
|
+
const movementMinimumYears = 3;
|
|
35
|
+
// A year counts only when the history holds this many plays in it.
|
|
36
|
+
const movementMinimumYearPlays = 50;
|
|
37
|
+
// The latest year is read only when the artist has, or at its earlier share
|
|
38
|
+
// would have, this many plays in it, so a few plays more or less, such as in a
|
|
39
|
+
// barely started partial year, cannot swing a label.
|
|
40
|
+
const movementMinimumLatestPlays = 10;
|
|
41
|
+
const movementSeriesYears = 12;
|
|
42
|
+
const movementViewLimit = 5;
|
|
43
|
+
const returningMinimumGapYears = 2;
|
|
44
|
+
const returningMinimumPlays = 3;
|
|
45
|
+
const burstMinimumPeakShare = 0.7;
|
|
46
|
+
// A burst drops off at once: the next counted year holds under a tenth of the
|
|
47
|
+
// peak year's share. A decline that tapers is fading.
|
|
48
|
+
const burstMaximumAfterRatio = 0.1;
|
|
49
|
+
const fadingMaximumRatio = 0.5;
|
|
50
|
+
const risingMinimumRatio = 2;
|
|
29
51
|
const monthlyActivityMaximumMonths = 240;
|
|
30
52
|
const listeningSeasonMaximumSeasons = 80;
|
|
31
53
|
const spotifyExtendedHistorySystem = "spotify-extended-history";
|
|
@@ -197,6 +219,10 @@ function trackFields(track) {
|
|
|
197
219
|
track?.identity_status === "resolved" ? "resolved" : "provisional",
|
|
198
220
|
title,
|
|
199
221
|
artist,
|
|
222
|
+
// Every credited artist, so an Avoid on a featured artist also keeps the track out.
|
|
223
|
+
artists: (Array.isArray(track?.artist_credits) ? track.artist_credits : [])
|
|
224
|
+
.map((credit) => (typeof credit?.name === "string" ? credit.name.trim() : ""))
|
|
225
|
+
.filter(Boolean),
|
|
200
226
|
artistKnown: Boolean(track?.artist_credits?.[0]?.name?.trim()),
|
|
201
227
|
release,
|
|
202
228
|
};
|
|
@@ -275,6 +301,109 @@ function aggregateHistoryArc(rows) {
|
|
|
275
301
|
});
|
|
276
302
|
}
|
|
277
303
|
|
|
304
|
+
// Years with plays, oldest first. Years before the most recent twelve roll
|
|
305
|
+
// into one entry so a long history stays small.
|
|
306
|
+
function yearlyPlays(years) {
|
|
307
|
+
const series = [...years.entries()]
|
|
308
|
+
.sort(([left], [right]) => left - right)
|
|
309
|
+
.map(([year, value]) => ({ year, plays: value.playCount }));
|
|
310
|
+
if (series.length <= movementSeriesYears) return series;
|
|
311
|
+
const earlier = series.slice(0, -movementSeriesYears);
|
|
312
|
+
return [
|
|
313
|
+
{
|
|
314
|
+
from_year: earlier[0].year,
|
|
315
|
+
to_year: earlier.at(-1).year,
|
|
316
|
+
plays: earlier.reduce((sum, item) => sum + item.plays, 0),
|
|
317
|
+
},
|
|
318
|
+
...series.slice(-movementSeriesYears),
|
|
319
|
+
];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function meanShare(items) {
|
|
323
|
+
return items.reduce((sum, item) => sum + item.share, 0) / items.length;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// A deterministic label for how an artist's share of yearly plays moved, from
|
|
327
|
+
// its first year to the latest counted year. Checked in order: returning (at
|
|
328
|
+
// least two counted years with no plays, then back in the latest two), burst
|
|
329
|
+
// (one year that is not the latest holds 70% of the share and the next year
|
|
330
|
+
// drops off at once), then fading or rising (the latest share is at most half,
|
|
331
|
+
// or at least twice, the mean share of the years before it), else steady.
|
|
332
|
+
// Fading also needs the recent window to agree: an artist played again at its
|
|
333
|
+
// earlier share there is returning. Too few plays or years is unknown.
|
|
334
|
+
function artistTrajectory(artist, yearTotals, referenceYear, recentTotal) {
|
|
335
|
+
const firstYear = Math.min(...artist.years.keys());
|
|
336
|
+
let counted = [...yearTotals.entries()]
|
|
337
|
+
.filter(([year, total]) => year >= firstYear && total >= movementMinimumYearPlays)
|
|
338
|
+
.sort(([left], [right]) => left - right)
|
|
339
|
+
.map(([year, total]) => {
|
|
340
|
+
const plays = artist.years.get(year)?.playCount ?? 0;
|
|
341
|
+
return { year, plays, total, share: plays / total };
|
|
342
|
+
});
|
|
343
|
+
const readable = (item, before) =>
|
|
344
|
+
Math.max(item.plays, meanShare(before) * item.total) >= movementMinimumLatestPlays;
|
|
345
|
+
if (counted.length > 1 && counted.at(-1).year === referenceYear &&
|
|
346
|
+
!readable(counted.at(-1), counted.slice(0, -1))) {
|
|
347
|
+
counted = counted.slice(0, -1);
|
|
348
|
+
}
|
|
349
|
+
const plays = counted.reduce((sum, item) => sum + item.plays, 0);
|
|
350
|
+
if (counted.length < movementMinimumYears || plays < movementMinimumPlays) {
|
|
351
|
+
return "unknown";
|
|
352
|
+
}
|
|
353
|
+
const latest = counted.at(-1);
|
|
354
|
+
const lastAbsent = counted.slice(0, -1).findLastIndex((item) => item.plays === 0);
|
|
355
|
+
if (lastAbsent >= 0 && lastAbsent >= counted.length - 3) {
|
|
356
|
+
let gapStart = lastAbsent;
|
|
357
|
+
while (gapStart > 0 && counted[gapStart - 1].plays === 0) gapStart -= 1;
|
|
358
|
+
const returnedPlays = counted
|
|
359
|
+
.slice(lastAbsent + 1)
|
|
360
|
+
.reduce((sum, item) => sum + item.plays, 0);
|
|
361
|
+
if (
|
|
362
|
+
lastAbsent - gapStart + 1 >= returningMinimumGapYears &&
|
|
363
|
+
counted.slice(0, gapStart).some((item) => item.plays > 0) &&
|
|
364
|
+
latest.plays > 0 &&
|
|
365
|
+
returnedPlays >= returningMinimumPlays
|
|
366
|
+
) {
|
|
367
|
+
return "returning";
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const shareSum = counted.reduce((sum, item) => sum + item.share, 0);
|
|
371
|
+
const peakIndex = counted.reduce(
|
|
372
|
+
(best, item, index) => (item.share > counted[best].share ? index : best),
|
|
373
|
+
0,
|
|
374
|
+
);
|
|
375
|
+
const peak = counted[peakIndex];
|
|
376
|
+
if (
|
|
377
|
+
peak !== latest &&
|
|
378
|
+
peak.share >= shareSum * burstMinimumPeakShare &&
|
|
379
|
+
counted[peakIndex + 1].share < peak.share * burstMaximumAfterRatio
|
|
380
|
+
) {
|
|
381
|
+
return "burst";
|
|
382
|
+
}
|
|
383
|
+
const before = counted.slice(0, -1);
|
|
384
|
+
if (!readable(latest, before)) return "unknown";
|
|
385
|
+
const beforeShare = meanShare(before);
|
|
386
|
+
if (latest.share <= beforeShare * fadingMaximumRatio) {
|
|
387
|
+
const recentShare = recentTotal > 0 ? artist.recentPlayCount / recentTotal : 0;
|
|
388
|
+
if (
|
|
389
|
+
artist.recentPlayCount < movementMinimumLatestPlays ||
|
|
390
|
+
recentShare <= beforeShare * fadingMaximumRatio
|
|
391
|
+
) {
|
|
392
|
+
return "fading";
|
|
393
|
+
}
|
|
394
|
+
return recentShare >= beforeShare ? "returning" : "steady";
|
|
395
|
+
}
|
|
396
|
+
if (latest.share >= beforeShare * risingMinimumRatio) return "rising";
|
|
397
|
+
return "steady";
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ACT-R base-level activation at the history's latest play: the log of the
|
|
401
|
+
// summed play ages in days (at least one) to the power -0.5. Recent plays
|
|
402
|
+
// weigh more, and an old archive is read against its own end, not today.
|
|
403
|
+
function activation(sum) {
|
|
404
|
+
return Math.round(Math.log(sum) * 100) / 100;
|
|
405
|
+
}
|
|
406
|
+
|
|
278
407
|
function monthIndex(monthKey) {
|
|
279
408
|
if (!/^\d{4}-(?:0[1-9]|1[0-2])$/u.test(monthKey)) return null;
|
|
280
409
|
const year = Number(monthKey.slice(0, 4));
|
|
@@ -635,6 +764,7 @@ function aggregateMonthlyActivity(subjectId, eventAnalysis, explanations) {
|
|
|
635
764
|
|
|
636
765
|
function aggregateEvents(subjectId, eventRows, explanations) {
|
|
637
766
|
const eligible = [];
|
|
767
|
+
const moondogStarted = [];
|
|
638
768
|
let incognitoEventsExcluded = 0;
|
|
639
769
|
let rawPlayedMs = 0;
|
|
640
770
|
let eventsWithPlayedDuration = 0;
|
|
@@ -648,6 +778,11 @@ function aggregateEvents(subjectId, eventRows, explanations) {
|
|
|
648
778
|
incognitoEventsExcluded += 1;
|
|
649
779
|
continue;
|
|
650
780
|
}
|
|
781
|
+
// Moondog chose these, so they are not the listener's own attention.
|
|
782
|
+
if (row.moondog_suggestion_id) {
|
|
783
|
+
moondogStarted.push(row);
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
651
786
|
const skipped =
|
|
652
787
|
event?.event_type === "play_skipped" ||
|
|
653
788
|
event?.extensions?.["spotify.skipped"] === true;
|
|
@@ -680,6 +815,15 @@ function aggregateEvents(subjectId, eventRows, explanations) {
|
|
|
680
815
|
const recentCutoff = latest
|
|
681
816
|
? new Date(Date.parse(latest) - recentWindowDays * dayMs).toISOString()
|
|
682
817
|
: null;
|
|
818
|
+
const latestMs = latest ? Date.parse(latest) : null;
|
|
819
|
+
const referenceYear = latest ? Number(latest.slice(0, 4)) : null;
|
|
820
|
+
const yearTotals = new Map();
|
|
821
|
+
let recentTotal = 0;
|
|
822
|
+
for (const row of eligible) {
|
|
823
|
+
const year = Number(row.event.occurred_at.slice(0, 4));
|
|
824
|
+
if (Number.isInteger(year)) yearTotals.set(year, (yearTotals.get(year) ?? 0) + 1);
|
|
825
|
+
if (row.event.occurred_at >= recentCutoff) recentTotal += 1;
|
|
826
|
+
}
|
|
683
827
|
|
|
684
828
|
function aggregate(rows, windowKind) {
|
|
685
829
|
const artists = new Map();
|
|
@@ -745,8 +889,13 @@ function aggregateEvents(subjectId, eventRows, explanations) {
|
|
|
745
889
|
years: new Map(),
|
|
746
890
|
firstPlayedAt: occurredAt,
|
|
747
891
|
lastPlayedAt: occurredAt,
|
|
892
|
+
activationSum: 0,
|
|
893
|
+
recentPlayCount: 0,
|
|
748
894
|
};
|
|
749
895
|
artist.playCount += 1;
|
|
896
|
+
if (occurredAt >= recentCutoff) artist.recentPlayCount += 1;
|
|
897
|
+
artist.activationSum +=
|
|
898
|
+
Math.max((latestMs - Date.parse(occurredAt)) / dayMs, 1) ** -0.5;
|
|
750
899
|
artist.playedMs += row.playedMs;
|
|
751
900
|
if (row.timed) artist.timedPlayCount += 1;
|
|
752
901
|
if (row.skipKnown) artist.skipKnownCount += 1;
|
|
@@ -801,6 +950,14 @@ function aggregateEvents(subjectId, eventRows, explanations) {
|
|
|
801
950
|
...recordedMinutes(item),
|
|
802
951
|
distinct_tracks: item.trackRefs.size,
|
|
803
952
|
...recordedSkips(item),
|
|
953
|
+
...(windowKind === "lifetime"
|
|
954
|
+
? {
|
|
955
|
+
first_played_at: item.firstPlayedAt,
|
|
956
|
+
yearly_plays: yearlyPlays(item.years),
|
|
957
|
+
activation: activation(item.activationSum),
|
|
958
|
+
trajectory: artistTrajectory(item, yearTotals, referenceYear, recentTotal),
|
|
959
|
+
}
|
|
960
|
+
: {}),
|
|
804
961
|
last_played_at: item.lastPlayedAt,
|
|
805
962
|
evidence_id: evidenceId,
|
|
806
963
|
};
|
|
@@ -913,9 +1070,11 @@ function aggregateEvents(subjectId, eventRows, explanations) {
|
|
|
913
1070
|
(row) => row.event.extensions?.["spotify.offline"] === true,
|
|
914
1071
|
).length,
|
|
915
1072
|
incognito_events_excluded: incognitoEventsExcluded,
|
|
1073
|
+
...(moondogStarted.length ? { moondog_started_events_excluded: moondogStarted.length } : {}),
|
|
916
1074
|
};
|
|
917
1075
|
return {
|
|
918
1076
|
eligible,
|
|
1077
|
+
moondogStarted,
|
|
919
1078
|
earliest,
|
|
920
1079
|
latest,
|
|
921
1080
|
recentCutoff,
|
|
@@ -1355,11 +1514,9 @@ function backToBackTracks(
|
|
|
1355
1514
|
byTrack.set(metadata.trackRefId, aggregate);
|
|
1356
1515
|
}
|
|
1357
1516
|
|
|
1358
|
-
const
|
|
1517
|
+
const avoided = historyAvoidCheck(direct, evidence);
|
|
1359
1518
|
return [...byTrack.values()]
|
|
1360
|
-
.filter(
|
|
1361
|
-
(item) => !avoids.some((avoid) => assertionMatchesTrack(avoid, item)),
|
|
1362
|
-
)
|
|
1519
|
+
.filter((item) => !avoided(item))
|
|
1363
1520
|
.sort((left, right) => {
|
|
1364
1521
|
if (
|
|
1365
1522
|
right.maximumConsecutivePlays !== left.maximumConsecutivePlays
|
|
@@ -1901,6 +2058,13 @@ function combinedAvoids(listenerAvoids, providerAvoids) {
|
|
|
1901
2058
|
return [...selected.values()];
|
|
1902
2059
|
}
|
|
1903
2060
|
|
|
2061
|
+
// The shared Avoid check of every play path (src/core/listener-avoids.mjs), for history tracks.
|
|
2062
|
+
function historyAvoidCheck(direct, evidence) {
|
|
2063
|
+
const gate = createAvoidGate(combinedAvoids(direct.avoids, evidence.avoids));
|
|
2064
|
+
return (track) => gate.size > 0 &&
|
|
2065
|
+
gate.avoidsTrack({ title: track.title, artist_credit: track.artist, artists: track.artists?.length ? track.artists : [track.artist] });
|
|
2066
|
+
}
|
|
2067
|
+
|
|
1904
2068
|
function assertionMatchesTrack(assertion, track) {
|
|
1905
2069
|
if (assertion.entity_type === "artist") {
|
|
1906
2070
|
return normalizedText(assertion.label) === normalizedText(track.artist);
|
|
@@ -1966,7 +2130,7 @@ function rediscoveryTracks(
|
|
|
1966
2130
|
) {
|
|
1967
2131
|
const latestMs = Date.parse(eventAnalysis.latest ?? "");
|
|
1968
2132
|
if (!Number.isFinite(latestMs)) return [];
|
|
1969
|
-
const
|
|
2133
|
+
const avoided = historyAvoidCheck(direct, evidence);
|
|
1970
2134
|
const candidates = [...eventAnalysis.lifetime.tracks.values()]
|
|
1971
2135
|
.map((track) => {
|
|
1972
2136
|
const lastPlayedMs = Date.parse(track.lastPlayedAt);
|
|
@@ -1986,7 +2150,7 @@ function rediscoveryTracks(
|
|
|
1986
2150
|
track.engagedPlayCount >= rediscoveryMinimumEngagedPlays &&
|
|
1987
2151
|
(!fullyTimed(track) || track.playedMs >= rediscoveryMinimumPlayedMs) &&
|
|
1988
2152
|
track.engagedPlayCount >= track.skippedCount &&
|
|
1989
|
-
!
|
|
2153
|
+
!avoided(track),
|
|
1990
2154
|
);
|
|
1991
2155
|
const byTime = candidates.every(({ track }) => fullyTimed(track));
|
|
1992
2156
|
return candidates
|
|
@@ -2081,7 +2245,7 @@ function historicalReturnTracks(
|
|
|
2081
2245
|
direct,
|
|
2082
2246
|
explanations,
|
|
2083
2247
|
) {
|
|
2084
|
-
const
|
|
2248
|
+
const avoided = historyAvoidCheck(direct, evidence);
|
|
2085
2249
|
const candidates = [...eventAnalysis.lifetime.tracks.values()]
|
|
2086
2250
|
.map((track) => ({
|
|
2087
2251
|
track,
|
|
@@ -2095,7 +2259,7 @@ function historicalReturnTracks(
|
|
|
2095
2259
|
track.engagedPlayCount >= historicalReturnMinimumEngagedPlays &&
|
|
2096
2260
|
(!fullyTimed(track) || track.playedMs >= historicalReturnMinimumPlayedMs) &&
|
|
2097
2261
|
track.engagedPlayCount >= track.skippedCount &&
|
|
2098
|
-
!
|
|
2262
|
+
!avoided(track),
|
|
2099
2263
|
);
|
|
2100
2264
|
const byTime = candidates.every(({ track }) => fullyTimed(track));
|
|
2101
2265
|
return candidates
|
|
@@ -2200,10 +2364,10 @@ function timeCapsuleTracks(
|
|
|
2200
2364
|
explanations,
|
|
2201
2365
|
maximum,
|
|
2202
2366
|
) {
|
|
2203
|
-
const
|
|
2367
|
+
const avoided = historyAvoidCheck(direct, evidence);
|
|
2204
2368
|
const byYear = new Map();
|
|
2205
2369
|
for (const track of eventAnalysis.lifetime.tracks.values()) {
|
|
2206
|
-
if (
|
|
2370
|
+
if (avoided(track)) continue;
|
|
2207
2371
|
const peak = rediscoveryPeakYear(track);
|
|
2208
2372
|
if (
|
|
2209
2373
|
!peak ||
|
|
@@ -2305,12 +2469,32 @@ function timeCapsuleTracks(
|
|
|
2305
2469
|
});
|
|
2306
2470
|
}
|
|
2307
2471
|
|
|
2472
|
+
// Plays Moondog started, one row per song, kept apart from the listener's own rankings.
|
|
2473
|
+
// Adopted means the listener later played the song again on their own.
|
|
2474
|
+
function moondogSuggestionTracks(rows, adoptedSuggestionIds) {
|
|
2475
|
+
const tracks = new Map();
|
|
2476
|
+
for (const row of rows) {
|
|
2477
|
+
const metadata = trackFields(row.track);
|
|
2478
|
+
const track = tracks.get(metadata.trackRefId) ?? {
|
|
2479
|
+
label: metadata.title, artist_credit: metadata.artist, play_count: 0,
|
|
2480
|
+
last_played_at: row.event.occurred_at, adopted: false,
|
|
2481
|
+
};
|
|
2482
|
+
track.play_count += 1;
|
|
2483
|
+
if (row.event.occurred_at > track.last_played_at) track.last_played_at = row.event.occurred_at;
|
|
2484
|
+
track.adopted ||= adoptedSuggestionIds.has(row.moondog_suggestion_id);
|
|
2485
|
+
tracks.set(metadata.trackRefId, track);
|
|
2486
|
+
}
|
|
2487
|
+
return [...tracks.values()].sort((left, right) => right.play_count - left.play_count ||
|
|
2488
|
+
descendingTimestamp(left.last_played_at, right.last_played_at) || lexicalCompare(left.label, right.label));
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2308
2491
|
export function projectListeningProfile({
|
|
2309
2492
|
subjectId,
|
|
2310
2493
|
eventRows = [],
|
|
2311
2494
|
profileEvidence = [],
|
|
2312
2495
|
tasteEvents = [],
|
|
2313
2496
|
maxItems = 10,
|
|
2497
|
+
adoptedSuggestionIds = new Set(),
|
|
2314
2498
|
} = {}) {
|
|
2315
2499
|
if (typeof subjectId !== "string" || !subjectId.trim()) {
|
|
2316
2500
|
throw new TypeError("Listening profile subject ID is required");
|
|
@@ -2524,6 +2708,14 @@ export function projectListeningProfile({
|
|
|
2524
2708
|
history_arc: events.historyArc,
|
|
2525
2709
|
monthly_activity: monthlyActivity,
|
|
2526
2710
|
listening_seasons: listeningSeasons,
|
|
2711
|
+
movement: Object.fromEntries(
|
|
2712
|
+
["fading", "returning", "rising"].map((label) => [
|
|
2713
|
+
label,
|
|
2714
|
+
events.lifetime.rankedArtists
|
|
2715
|
+
.filter((artist) => artist.trajectory === label)
|
|
2716
|
+
.slice(0, Math.min(maxItems, movementViewLimit)),
|
|
2717
|
+
]),
|
|
2718
|
+
),
|
|
2527
2719
|
artist_relationships: slice(relationships, maxItems),
|
|
2528
2720
|
year_transitions: slice(transitions, 12),
|
|
2529
2721
|
release_depth: slice(releases, maxItems),
|
|
@@ -2552,6 +2744,10 @@ export function projectListeningProfile({
|
|
|
2552
2744
|
timeCapsuleMinimumPlayedMs,
|
|
2553
2745
|
),
|
|
2554
2746
|
relationship_minimum_years: relationshipMinimumYears,
|
|
2747
|
+
movement_minimum_plays: movementMinimumPlays,
|
|
2748
|
+
movement_minimum_years: movementMinimumYears,
|
|
2749
|
+
movement_minimum_year_plays: movementMinimumYearPlays,
|
|
2750
|
+
movement_minimum_latest_plays: movementMinimumLatestPlays,
|
|
2555
2751
|
continuity_artist_limit: continuityArtistLimit,
|
|
2556
2752
|
release_minimum_distinct_tracks: releaseMinimumDistinctTracks,
|
|
2557
2753
|
session_gap_minutes: sessionGapMinutes,
|
|
@@ -2649,6 +2845,11 @@ export function projectListeningProfile({
|
|
|
2649
2845
|
"Taste Profile, Wrapped, and Sound Capsule are Spotify's reading of you, not something you said.",
|
|
2650
2846
|
]
|
|
2651
2847
|
: []),
|
|
2848
|
+
...(events.moondogStarted.length > 0
|
|
2849
|
+
? [
|
|
2850
|
+
"Songs Moondog queued or played for you are left out of the rankings. Playing one again later on your own counts as yours.",
|
|
2851
|
+
]
|
|
2852
|
+
: []),
|
|
2652
2853
|
"What you tell Moondog counts for more than what the data suggests, you can undo it any time, and it never rewrites your history.",
|
|
2653
2854
|
],
|
|
2654
2855
|
},
|
|
@@ -2668,6 +2869,7 @@ export function projectListeningProfile({
|
|
|
2668
2869
|
provider_genres: evidence.genres,
|
|
2669
2870
|
listener_preferences: direct.preferences,
|
|
2670
2871
|
listener_avoids: combinedAvoids(direct.avoids, evidence.avoids),
|
|
2872
|
+
...(events.moondogStarted.length ? { moondog_suggestions: moondogSuggestionTracks(events.moondogStarted, adoptedSuggestionIds) } : {}),
|
|
2671
2873
|
provider_evidence: deduplicatedEvidence.map((record) => ({
|
|
2672
2874
|
evidence_id: record.profile_evidence_id,
|
|
2673
2875
|
evidence_kind: record.evidence_kind,
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { normalizedArtistName, trackMatchKey } from "../core/track-title.mjs";
|
|
2
|
+
|
|
3
|
+
const HOUR_MS = 3_600_000;
|
|
4
|
+
const DAY_MS = 86_400_000;
|
|
5
|
+
|
|
6
|
+
// A stored play is one Moondog started when it is the same recording as a suggestion (the same
|
|
7
|
+
// Spotify track or ISRC) or the same song by title and primary artist, and it lands from 60
|
|
8
|
+
// seconds before the request to six hours after it: long enough for a queued batch to play out,
|
|
9
|
+
// short enough that a later return is the listener's own. A play's time is when it ended, so one
|
|
10
|
+
// that ended before the request was the listener's; the 60 seconds only absorb clock skew. A
|
|
11
|
+
// matching play after the window is the listener's choice and marks the suggestion adopted. The
|
|
12
|
+
// match runs whenever the profile is read, so a later history import is matched the same way.
|
|
13
|
+
export const SUGGESTION_WINDOW = Object.freeze({ beforeMs: 60_000, afterMs: 6 * HOUR_MS });
|
|
14
|
+
|
|
15
|
+
export const SUGGESTION_SERVICES = Object.freeze(["spotify", "netease", "apple_music"]);
|
|
16
|
+
|
|
17
|
+
// One artist's name can hold a separator ("AC/DC", "Simon & Garfunkel", "Tyler, The Creator"),
|
|
18
|
+
// so a bare credit is tried whole, then by its comma parts, then by every part.
|
|
19
|
+
function creditSplits(credit) {
|
|
20
|
+
const whole = String(credit ?? "").trim();
|
|
21
|
+
const split = (pattern) => whole.split(pattern).map((name) => name.trim()).filter(Boolean);
|
|
22
|
+
return [[whole].filter(Boolean), split(/[,;]/u), split(/\s*(?:,|&|;|\/|\bfeat\.?|\bfeaturing\b)\s*/iu)];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const creditNames = (credit) => [...new Set(creditSplits(credit).flat())];
|
|
26
|
+
|
|
27
|
+
// The names a track may be stored under: its first artist, or each reading of a bare credit's first one.
|
|
28
|
+
function primaryArtists(track) {
|
|
29
|
+
if (Array.isArray(track?.artists) && typeof track.artists[0] === "string") return [track.artists[0]];
|
|
30
|
+
if (Array.isArray(track?.artist_credits)) return track.artist_credits[0]?.name ? [track.artist_credits[0].name] : [];
|
|
31
|
+
return [...new Set(creditSplits(track?.artist_credit).map((names) => names[0]).filter(Boolean))];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One song by its primary artist, the same for every version of it; a bare credit can give more than one. */
|
|
35
|
+
export function songKeys(track) {
|
|
36
|
+
return [...new Set(primaryArtists(track).map((artist) => trackMatchKey(track?.title, artist)).filter(Boolean))];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const releaseKey = (track) => trackMatchKey(typeof track?.release === "string" ? track.release : track?.release?.title,
|
|
40
|
+
primaryArtists(track)[0]);
|
|
41
|
+
|
|
42
|
+
/** The ways a stored track can match a suggestion: its Spotify track, its ISRC, its song, its album. */
|
|
43
|
+
export function playedTrackIdentities(track) {
|
|
44
|
+
const identities = [];
|
|
45
|
+
for (const ref of Array.isArray(track?.external_refs) ? track.external_refs : []) {
|
|
46
|
+
if (ref?.system === "spotify" && ref.entity_type === "spotify.track" && typeof ref.external_id === "string") {
|
|
47
|
+
identities.push(`spotify:track:${ref.external_id}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (typeof track?.isrc === "string") identities.push(`isrc:${track.isrc.toUpperCase()}`);
|
|
51
|
+
for (const key of songKeys(track)) identities.push(`key:${key}`);
|
|
52
|
+
const release = releaseKey(track);
|
|
53
|
+
if (release) identities.push(`release:${release}`);
|
|
54
|
+
return identities;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function suggestionIdentities(suggestion) {
|
|
58
|
+
return [...new Set([
|
|
59
|
+
suggestion.provider_track_id,
|
|
60
|
+
suggestion.isrc ? `isrc:${suggestion.isrc}` : null,
|
|
61
|
+
suggestion.track_key ? `key:${suggestion.track_key}` : null,
|
|
62
|
+
...(suggestion.track_key ? songKeys({ title: suggestion.title, artist_credit: suggestion.artist_credit }).map((key) => `key:${key}`) : []),
|
|
63
|
+
suggestion.release_key ? `release:${suggestion.release_key}` : null,
|
|
64
|
+
// An album row keeps its release title, so its key is rebuilt too, as a song's is.
|
|
65
|
+
suggestion.release_key && releaseKey({ release: suggestion.title, artist_credit: suggestion.artist_credit })
|
|
66
|
+
? `release:${releaseKey({ release: suggestion.title, artist_credit: suggestion.artist_credit })}` : null,
|
|
67
|
+
].filter(Boolean))];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The ledger entry for one accepted host write, or null when it names no song. An album Moondog
|
|
72
|
+
* started names its release instead, so every song of it that plays matches.
|
|
73
|
+
*/
|
|
74
|
+
export function suggestionEntry({ service, action, providerTrackId = null, isrc = null, title, release = null, artists, artistCredit, mixSlot = null, requestedAt }) {
|
|
75
|
+
const credit = typeof artistCredit === "string" && artistCredit.trim() ? artistCredit.trim()
|
|
76
|
+
: Array.isArray(artists) ? artists.filter((name) => typeof name === "string").join(", ") : "";
|
|
77
|
+
const named = { title, release, artists, artist_credit: credit };
|
|
78
|
+
const key = title ? songKeys(named)[0] ?? null : null;
|
|
79
|
+
const album = !title && typeof release === "string" ? releaseKey(named) : null;
|
|
80
|
+
if (!SUGGESTION_SERVICES.includes(service) || !(key || album)) return null;
|
|
81
|
+
return {
|
|
82
|
+
service, action,
|
|
83
|
+
provider_track_id: typeof providerTrackId === "string" && providerTrackId ? providerTrackId : null,
|
|
84
|
+
isrc: typeof isrc === "string" && /^[A-Z]{2}[A-Z0-9]{3}[0-9]{7}$/iu.test(isrc) ? isrc.toUpperCase() : null,
|
|
85
|
+
track_key: key,
|
|
86
|
+
release_key: album,
|
|
87
|
+
title: Array.from(String(title || release).trim()).slice(0, 512).join(""),
|
|
88
|
+
artist_credit: Array.from(credit).slice(0, 512).join(""),
|
|
89
|
+
requested_at: requestedAt,
|
|
90
|
+
mix_slot: key && MIX_SLOTS.includes(mixSlot) ? mixSlot : null,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Marks each stored play Moondog started with its suggestion, and returns the suggestions the
|
|
96
|
+
* listener later played again on their own. Events are never rewritten.
|
|
97
|
+
*/
|
|
98
|
+
export function markMoondogStartedPlays(eventRows, suggestions) {
|
|
99
|
+
const adopted = new Set();
|
|
100
|
+
if (!suggestions.length) return { rows: eventRows, adopted };
|
|
101
|
+
const byIdentity = new Map();
|
|
102
|
+
for (const suggestion of suggestions) {
|
|
103
|
+
const entry = { ...suggestion, requestedMs: Date.parse(suggestion.requested_at) };
|
|
104
|
+
for (const identity of suggestionIdentities(suggestion)) {
|
|
105
|
+
byIdentity.set(identity, [...(byIdentity.get(identity) ?? []), entry]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Plays repeat a much smaller set of tracks, so a stored track (one revision of a track_refs
|
|
109
|
+
// row) is matched once, and only plays of a matched track read their time.
|
|
110
|
+
const byTrack = new Map();
|
|
111
|
+
const trackMatches = (track) => {
|
|
112
|
+
const key = typeof track?.track_ref_id === "string" && Number.isInteger(track.revision)
|
|
113
|
+
? `${track.track_ref_id}\0${track.revision}` : null;
|
|
114
|
+
let matches = key === null ? undefined : byTrack.get(key);
|
|
115
|
+
if (!matches) {
|
|
116
|
+
matches = [...new Set(playedTrackIdentities(track).flatMap((identity) => byIdentity.get(identity) ?? []))];
|
|
117
|
+
if (key !== null) byTrack.set(key, matches);
|
|
118
|
+
}
|
|
119
|
+
return matches;
|
|
120
|
+
};
|
|
121
|
+
const rows = eventRows.map((row) => {
|
|
122
|
+
if (row.event?.extensions?.["spotify.incognito_mode"] === true) return row;
|
|
123
|
+
const matches = trackMatches(row.track);
|
|
124
|
+
if (!matches.length) return row;
|
|
125
|
+
const playedMs = Date.parse(row.event?.occurred_at);
|
|
126
|
+
if (!Number.isFinite(playedMs)) return row;
|
|
127
|
+
const started = matches.find((entry) =>
|
|
128
|
+
playedMs >= entry.requestedMs - SUGGESTION_WINDOW.beforeMs && playedMs <= entry.requestedMs + SUGGESTION_WINDOW.afterMs);
|
|
129
|
+
if (started) return { ...row, moondog_suggestion_id: started.suggestion_id };
|
|
130
|
+
for (const entry of matches) if (playedMs > entry.requestedMs + SUGGESTION_WINDOW.afterMs) adopted.add(entry.suggestion_id);
|
|
131
|
+
return row;
|
|
132
|
+
});
|
|
133
|
+
return { rows, adopted };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The declared mix: where each song sits in the listener's own plays, Moondog's plays left out.
|
|
137
|
+
// At least 3 plays is a song they know: familiar when one falls within 365 days of their latest
|
|
138
|
+
// play, otherwise returning (then none falls in the last 180 days either). One or two plays, or
|
|
139
|
+
// only plays Moondog started, is a song they have heard. With none, it is new from an artist
|
|
140
|
+
// they play, or new to them.
|
|
141
|
+
export const MIX_SLOTS = Object.freeze(["familiar", "returning", "heard", "known_artist", "new"]);
|
|
142
|
+
|
|
143
|
+
/** What the listener's own plays say about songs and artists, or null with no plays at all. */
|
|
144
|
+
export function listeningFamiliarity(catalog, referenceDate) {
|
|
145
|
+
const referenceMs = Date.parse(referenceDate ?? "");
|
|
146
|
+
if (!Number.isFinite(referenceMs) || !catalog?.history_tracks?.length) return null;
|
|
147
|
+
const tracks = new Map();
|
|
148
|
+
for (const row of catalog.history_tracks) {
|
|
149
|
+
const key = trackMatchKey(row.label, row.artist_credit);
|
|
150
|
+
if (!key) continue;
|
|
151
|
+
const known = tracks.get(key) ?? { plays: 0, lastMs: 0 };
|
|
152
|
+
known.plays += row.play_count ?? 0;
|
|
153
|
+
known.lastMs = Math.max(known.lastMs, Date.parse(row.last_played_at) || 0);
|
|
154
|
+
tracks.set(key, known);
|
|
155
|
+
}
|
|
156
|
+
const artists = new Set((catalog.history_artists ?? []).map((row) => normalizedArtistName(row.name)).filter(Boolean));
|
|
157
|
+
const moondogHeard = new Set((catalog.moondog_suggestions ?? []).map((row) => trackMatchKey(row.label, row.artist_credit)).filter(Boolean));
|
|
158
|
+
return { referenceMs, tracks, artists, moondogHeard };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function mixSlot(track, familiarity) {
|
|
162
|
+
if (!familiarity) return "unknown";
|
|
163
|
+
const names = Array.isArray(track?.artists) && track.artists.length ? track.artists : creditNames(track?.artist_credit);
|
|
164
|
+
let plays = 0;
|
|
165
|
+
let lastMs = 0;
|
|
166
|
+
let moondogHeard = false;
|
|
167
|
+
for (const name of names) {
|
|
168
|
+
const key = trackMatchKey(track?.title, name);
|
|
169
|
+
moondogHeard ||= familiarity.moondogHeard?.has(key) === true;
|
|
170
|
+
const known = familiarity.tracks.get(key);
|
|
171
|
+
if (!known) continue;
|
|
172
|
+
plays += known.plays;
|
|
173
|
+
lastMs = Math.max(lastMs, known.lastMs);
|
|
174
|
+
}
|
|
175
|
+
if (plays >= 3) return familiarity.referenceMs - lastMs <= 365 * DAY_MS ? "familiar" : "returning";
|
|
176
|
+
if (plays > 0 || moondogHeard) return "heard";
|
|
177
|
+
return names.some((name) => familiarity.artists.has(normalizedArtistName(name))) ? "known_artist" : "new";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Counts per slot for a mix line, or null when no song could be placed. */
|
|
181
|
+
export function mixCounts(slots) {
|
|
182
|
+
if (!slots.some((slot) => MIX_SLOTS.includes(slot))) return null;
|
|
183
|
+
return Object.fromEntries(MIX_SLOTS.map((slot) => [slot, slots.filter((value) => value === slot).length]));
|
|
184
|
+
}
|
|
@@ -6,17 +6,20 @@ export const PROFILE_SECTIONS = Object.freeze([
|
|
|
6
6
|
"provider_evidence", "apple_tracks", "apple_artists", "apple_genres",
|
|
7
7
|
]);
|
|
8
8
|
|
|
9
|
+
export const TRAJECTORIES = Object.freeze(["rising", "steady", "fading", "returning", "burst", "unknown"]);
|
|
10
|
+
|
|
9
11
|
const normalize = (value) => String(value ?? "").normalize("NFKC").toLocaleLowerCase("und");
|
|
10
12
|
|
|
11
13
|
export function profileExplorationInput(value = {}) {
|
|
12
14
|
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
|
13
|
-
Object.keys(value).some((key) => !["section", "query", "offset", "limit", "sort"].includes(key))) {
|
|
15
|
+
Object.keys(value).some((key) => !["section", "query", "offset", "limit", "sort", "trajectory"].includes(key))) {
|
|
14
16
|
throw new TypeError("Profile exploration arguments are invalid");
|
|
15
17
|
}
|
|
16
|
-
const { section = "overview", query = "", offset = 0, limit = 12, sort = "ranked" } = value;
|
|
18
|
+
const { section = "overview", query = "", offset = 0, limit = 12, sort = "ranked", trajectory } = value;
|
|
17
19
|
if (!PROFILE_SECTIONS.includes(section) || typeof query !== "string" || query.length > 256 ||
|
|
18
20
|
!Number.isSafeInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 20 ||
|
|
19
|
-
!["ranked", "least_played", "recent"].includes(sort)
|
|
21
|
+
!["ranked", "least_played", "recent"].includes(sort) ||
|
|
22
|
+
(trajectory !== undefined && !TRAJECTORIES.includes(trajectory))) {
|
|
20
23
|
throw new TypeError("Profile exploration selection is invalid");
|
|
21
24
|
}
|
|
22
25
|
if (sort !== "ranked" && !["history_tracks", "history_artists", "recent_tracks", "recent_artists", "apple_tracks"].includes(section)) {
|
|
@@ -25,19 +28,22 @@ export function profileExplorationInput(value = {}) {
|
|
|
25
28
|
if (sort === "recent" && section === "apple_tracks") {
|
|
26
29
|
throw new TypeError("Apple library play counts have no event chronology");
|
|
27
30
|
}
|
|
28
|
-
|
|
31
|
+
if (trajectory !== undefined && section !== "history_artists") {
|
|
32
|
+
throw new TypeError("Only history artists have a trajectory");
|
|
33
|
+
}
|
|
34
|
+
return { section, query: query.trim(), offset, limit, sort, ...(trajectory ? { trajectory } : {}) };
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
export function exploreProfileCatalog({ listening, apple, input }) {
|
|
32
|
-
const { section, query, offset, limit, sort } = profileExplorationInput(input);
|
|
38
|
+
const { section, query, offset, limit, sort, trajectory } = profileExplorationInput(input);
|
|
33
39
|
const catalog = { ...listening?.sections, ...apple?.sections };
|
|
34
40
|
const all = catalog[section] ?? [];
|
|
35
41
|
const needle = normalize(query);
|
|
36
|
-
let matches = all.filter((row) => !needle || [
|
|
42
|
+
let matches = all.filter((row) => (!trajectory || row.trajectory === trajectory) && (!needle || [
|
|
37
43
|
row.name, row.label, row.artist_credit, row.release, row.genre, row.year, row.evidence_kind,
|
|
38
44
|
row.source_label, row.period, row.text, row.playlist_name, ...(row.playlist_names ?? []),
|
|
39
45
|
]
|
|
40
|
-
.some((text) => normalize(text).includes(needle)));
|
|
46
|
+
.some((text) => normalize(text).includes(needle))));
|
|
41
47
|
if (sort !== "ranked") {
|
|
42
48
|
matches = matches.map((row, index) => ({ row, index })).sort((left, right) => {
|
|
43
49
|
const order = sort === "least_played"
|
|
@@ -59,7 +65,9 @@ export function exploreProfileCatalog({ listening, apple, input }) {
|
|
|
59
65
|
coverage: { ...listening?.coverage, ...apple?.coverage },
|
|
60
66
|
sources: listening?.sources ?? [],
|
|
61
67
|
context: listening?.context ?? {},
|
|
62
|
-
section_note:
|
|
68
|
+
section_note: trajectory
|
|
69
|
+
? "Artists with this trajectory label, computed from their share of yearly plays across the retained history. It describes attention over time, not liking."
|
|
70
|
+
: section.startsWith("recent_")
|
|
63
71
|
? "Recent rankings use the retained-history reference date and recent_window_days, not today's date."
|
|
64
72
|
: section === "history_releases"
|
|
65
73
|
? "Release depth includes releases meeting release_minimum_distinct_tracks. It does not prove album completion."
|