@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.
Files changed (39) hide show
  1. package/README.md +5 -2
  2. package/package.json +1 -1
  3. package/src/core/apple-projection-domain-services.mjs +95 -27
  4. package/src/core/capability-catalog.mjs +12 -0
  5. package/src/core/listener-avoids.mjs +101 -0
  6. package/src/core/listening-profile-domain-services.mjs +44 -8
  7. package/src/core/moondog-application.mjs +238 -34
  8. package/src/core/spotify-listening-intent.mjs +5 -2
  9. package/src/core/track-title.mjs +107 -0
  10. package/src/i18n/messages/de.mjs +7 -0
  11. package/src/i18n/messages/en.mjs +7 -0
  12. package/src/i18n/messages/es.mjs +7 -0
  13. package/src/i18n/messages/fr.mjs +7 -0
  14. package/src/i18n/messages/it.mjs +7 -0
  15. package/src/i18n/messages/ja.mjs +7 -0
  16. package/src/i18n/messages/ko.mjs +7 -0
  17. package/src/i18n/messages/pt.mjs +7 -0
  18. package/src/i18n/messages/zh-Hant.mjs +7 -0
  19. package/src/i18n/messages/zh.mjs +7 -0
  20. package/src/i18n/screen/es.mjs +1 -0
  21. package/src/i18n/screen/ja.mjs +1 -0
  22. package/src/i18n/screen/pt.mjs +1 -0
  23. package/src/i18n/screen/zh.mjs +1 -0
  24. package/src/integrations/apple-music/catalog.mjs +52 -9
  25. package/src/integrations/netease/ncm-cli-player.mjs +10 -3
  26. package/src/integrations/spotify/catalog-resolver.mjs +1 -2
  27. package/src/integrations/spotify/web-api-client.mjs +3 -0
  28. package/src/memory/local-memory-store.mjs +73 -16
  29. package/src/profile/listener-corrections.mjs +22 -0
  30. package/src/profile/listener-profile-build.mjs +92 -9
  31. package/src/profile/listener-profile-worker.mjs +7 -0
  32. package/src/profile/listening-history-store.mjs +231 -24
  33. package/src/profile/listening-profile-projection.mjs +212 -10
  34. package/src/profile/moondog-suggestions.mjs +184 -0
  35. package/src/profile/profile-exploration.mjs +16 -8
  36. package/src/runtime/pi/agent-runtime.mjs +363 -118
  37. package/src/runtime/pi/profile-build-runtime.mjs +2 -1
  38. package/src/runtime/pi/skills/listener-profile/SKILL.md +2 -0
  39. 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 avoids = combinedAvoids(direct.avoids, evidence.avoids);
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 avoids = combinedAvoids(direct.avoids, evidence.avoids);
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
- !avoids.some((item) => assertionMatchesTrack(item, track)),
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 avoids = combinedAvoids(direct.avoids, evidence.avoids);
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
- !avoids.some((item) => assertionMatchesTrack(item, track)),
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 avoids = combinedAvoids(direct.avoids, evidence.avoids);
2367
+ const avoided = historyAvoidCheck(direct, evidence);
2204
2368
  const byYear = new Map();
2205
2369
  for (const track of eventAnalysis.lifetime.tracks.values()) {
2206
- if (avoids.some((item) => assertionMatchesTrack(item, track))) continue;
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
- return { section, query: query.trim(), offset, limit, sort };
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: section.startsWith("recent_")
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."