@audiofool/moondog 0.1.0 → 0.2.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +156 -68
  3. package/contracts/v1/definitions.schema.json +1 -169
  4. package/package.json +7 -23
  5. package/scripts/contract-lib.mjs +0 -7
  6. package/scripts/contract-semantics.mjs +0 -1904
  7. package/scripts/moondog.mjs +285 -74
  8. package/src/core/apple-library-source-status.mjs +0 -16
  9. package/src/core/apple-projection-domain-services.mjs +253 -53
  10. package/src/core/application-update.mjs +320 -0
  11. package/src/core/capability-catalog.mjs +290 -55
  12. package/src/core/listener-avoids.mjs +101 -0
  13. package/src/core/listening-now.mjs +108 -0
  14. package/src/core/listening-profile-domain-services.mjs +189 -41
  15. package/src/core/listening-upkeep.mjs +31 -0
  16. package/src/core/moondog-application.mjs +2115 -107
  17. package/src/core/playback-preferences.mjs +97 -0
  18. package/src/core/spotify-listening-intent.mjs +124 -0
  19. package/src/core/synthetic-domain-services.mjs +24 -3
  20. package/src/core/track-title.mjs +107 -0
  21. package/src/demo/moondog-demo.mjs +4 -22
  22. package/src/i18n/index.mjs +285 -0
  23. package/src/i18n/messages/de.mjs +135 -0
  24. package/src/i18n/messages/en.mjs +136 -0
  25. package/src/i18n/messages/es.mjs +135 -0
  26. package/src/i18n/messages/fr.mjs +135 -0
  27. package/src/i18n/messages/it.mjs +135 -0
  28. package/src/i18n/messages/ja.mjs +135 -0
  29. package/src/i18n/messages/ko.mjs +135 -0
  30. package/src/i18n/messages/pt.mjs +135 -0
  31. package/src/i18n/messages/zh-Hant.mjs +135 -0
  32. package/src/i18n/messages/zh.mjs +135 -0
  33. package/src/i18n/preferences.mjs +34 -0
  34. package/src/i18n/screen/es.mjs +967 -0
  35. package/src/i18n/screen/ja.mjs +967 -0
  36. package/src/i18n/screen/pt.mjs +967 -0
  37. package/src/i18n/screen/zh.mjs +967 -0
  38. package/src/importers/apple-music-library/index.mjs +0 -16
  39. package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
  40. package/src/integrations/apple-music/catalog.mjs +166 -14
  41. package/src/integrations/apple-music/music-app.mjs +232 -0
  42. package/src/integrations/apple-music/storefront.mjs +28 -0
  43. package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
  44. package/src/integrations/lastfm/history.mjs +221 -0
  45. package/src/integrations/lastfm/settings.mjs +75 -0
  46. package/src/integrations/netease/ncm-cli-player.mjs +328 -0
  47. package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
  48. package/src/integrations/public-playlists.mjs +2 -1
  49. package/src/integrations/spotify/account-data-profile.mjs +14 -6
  50. package/src/integrations/spotify/authentication.mjs +149 -15
  51. package/src/integrations/spotify/catalog-resolver.mjs +9 -6
  52. package/src/integrations/spotify/connection.mjs +2 -1
  53. package/src/integrations/spotify/credential-store.mjs +4 -0
  54. package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
  55. package/src/integrations/spotify/history-archive.mjs +1 -1
  56. package/src/integrations/spotify/library-snapshot.mjs +227 -0
  57. package/src/integrations/spotify/recent-activity.mjs +2 -0
  58. package/src/integrations/spotify/service.mjs +873 -69
  59. package/src/integrations/spotify/web-api-client.mjs +410 -127
  60. package/src/integrations/web/brave-web.mjs +45 -0
  61. package/src/integrations/web/codex-web.mjs +15 -82
  62. package/src/integrations/web/deepseek-web.mjs +81 -0
  63. package/src/integrations/web/web-research.mjs +119 -0
  64. package/src/integrations/web/web-result.mjs +215 -0
  65. package/src/integrations/youtube-music/takeout.mjs +3 -1
  66. package/src/memory/local-memory-store.mjs +542 -57
  67. package/src/profile/history-import.mjs +42 -6
  68. package/src/profile/listener-corrections.mjs +22 -0
  69. package/src/profile/listener-profile-build.mjs +461 -0
  70. package/src/profile/listener-profile-store.mjs +130 -0
  71. package/src/profile/listener-profile-verification.mjs +68 -0
  72. package/src/profile/listener-profile-worker.mjs +179 -0
  73. package/src/profile/listening-history-store.mjs +640 -17
  74. package/src/profile/listening-profile-projection.mjs +454 -86
  75. package/src/profile/local-music-data.mjs +1 -1
  76. package/src/profile/moondog-suggestions.mjs +184 -0
  77. package/src/profile/music-providers.mjs +11 -7
  78. package/src/profile/profile-exploration.mjs +92 -0
  79. package/src/profile/recorded-measures.mjs +24 -0
  80. package/src/profile/spotify-archive-taste.mjs +0 -4
  81. package/src/runtime/pi/agent-runtime.mjs +2536 -692
  82. package/src/runtime/pi/authentication.mjs +34 -1
  83. package/src/runtime/pi/configured-runtime.mjs +22 -6
  84. package/src/runtime/pi/conversation-name.mjs +53 -0
  85. package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
  86. package/src/runtime/pi/model-catalog.mjs +28 -5
  87. package/src/runtime/pi/model-settings.mjs +202 -0
  88. package/src/runtime/pi/model-transport.mjs +10 -0
  89. package/src/runtime/pi/persistent-credential-store.mjs +67 -31
  90. package/src/runtime/pi/profile-build-runtime.mjs +161 -0
  91. package/src/runtime/pi/public-model-catalog.mjs +288 -0
  92. package/src/runtime/pi/runtime-settings.mjs +10 -2
  93. package/src/runtime/pi/skills/listener-profile/SKILL.md +48 -0
  94. package/src/runtime/pi/tool-trace.mjs +37 -0
  95. package/src/surfaces/cli/auth-command.mjs +12 -4
  96. package/src/surfaces/cli/brand-components.mjs +310 -132
  97. package/src/surfaces/cli/brand-theme.mjs +51 -28
  98. package/src/surfaces/cli/command-completions.mjs +58 -42
  99. package/src/surfaces/cli/format-output.mjs +199 -388
  100. package/src/surfaces/cli/history-import-view.mjs +292 -139
  101. package/src/surfaces/cli/import-guides.mjs +2 -0
  102. package/src/surfaces/cli/listener-profile-command.mjs +92 -0
  103. package/src/surfaces/cli/listening-menu.mjs +7 -4
  104. package/src/surfaces/cli/profile-command.mjs +30 -21
  105. package/src/surfaces/cli/seasons-chart.mjs +87 -0
  106. package/src/surfaces/cli/spotify-command.mjs +54 -6
  107. package/src/surfaces/cli/taste-profile-model.mjs +78 -79
  108. package/src/surfaces/cli/taste-profile-view.mjs +33 -26
  109. package/src/surfaces/cli/terminal-art.mjs +140 -35
  110. package/src/surfaces/cli/tui.mjs +1066 -357
  111. package/src/surfaces/cli/update-command.mjs +32 -0
  112. package/src/surfaces/cli/web-command.mjs +53 -14
  113. package/src/surfaces/html/tasteprint.mjs +43 -70
  114. package/contracts/v1/capability-policy.schema.json +0 -572
  115. package/contracts/v1/profile-evidence.schema.json +0 -237
  116. package/contracts/v1/tool-invocation.schema.json +0 -318
  117. package/contracts/v1/tool-receipt.schema.json +0 -1066
  118. package/scripts/demo-studio.mjs +0 -58
  119. package/src/core/apple-data-migration.mjs +0 -50
  120. package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
  121. package/src/demo/moondog-tasteprint-demo.mjs +0 -631
  122. package/src/integrations/spotify/index.mjs +0 -80
  123. package/src/surfaces/web/showcase.mjs +0 -272
  124. package/src/surfaces/web/studio-page.mjs +0 -4148
  125. package/src/surfaces/web/studio.mjs +0 -2288
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { chmod, mkdir } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
@@ -9,8 +9,23 @@ import {
9
9
  createListenerCorrectionRetraction,
10
10
  } from "./listener-corrections.mjs";
11
11
  import { projectListeningProfile } from "./listening-profile-projection.mjs";
12
+ import {
13
+ listeningFamiliarity, markMoondogStartedPlays, mixSlot, suggestionEntry, suggestionIdentities, SUGGESTION_WINDOW,
14
+ } from "./moondog-suggestions.mjs";
15
+ import { ListenerProfileStore, listenerProfileSchema } from "./listener-profile-store.mjs";
16
+
17
+ export const LISTENING_HISTORY_SCHEMA_VERSION = 7;
12
18
 
13
- export const LISTENING_HISTORY_SCHEMA_VERSION = 6;
19
+ // The tables a cached projection, build input, Avoid gate or status reads, apart from the
20
+ // suggestions ledger (listeningDataVersion). Rows are only added to the first ones: the one
21
+ // update to a stored play, which records the play that supersedes it, is made with its row in
22
+ // listening_event_supersessions. Imports update their batch rows, so those are read in full.
23
+ // A new table those caches read, or a new update or delete, belongs here.
24
+ const APPENDED_EVIDENCE_TABLES = Object.freeze([
25
+ "track_refs", "listening_events", "listening_event_supersessions", "listening_event_mirrors",
26
+ "spotify_profile_evidence", "taste_events",
27
+ ]);
28
+ const UPDATED_EVIDENCE_TABLES = Object.freeze(["import_batches", "profile_import_batches"]);
14
29
 
15
30
  export function resolveListeningHistoryPath(environment = process.env) {
16
31
  return path.join(resolveMoondogStateDirectory(environment), "listening-history.sqlite");
@@ -48,6 +63,8 @@ const schema = `
48
63
 
49
64
  CREATE INDEX IF NOT EXISTS listening_events_subject_time
50
65
  ON listening_events(subject_id, occurred_at DESC);
66
+ CREATE INDEX IF NOT EXISTS listening_events_subject_track
67
+ ON listening_events(subject_id, track_ref_id, track_ref_revision);
51
68
 
52
69
  CREATE TABLE IF NOT EXISTS source_cursors (
53
70
  source_key TEXT PRIMARY KEY NOT NULL,
@@ -55,6 +72,18 @@ const schema = `
55
72
  updated_at TEXT NOT NULL
56
73
  ) STRICT;
57
74
 
75
+ -- Spotify keeps only a listener's last 50 plays. When a sync finds the window full,
76
+ -- plays between the last stored one and the oldest returned one may be gone for good.
77
+ CREATE TABLE IF NOT EXISTS listening_coverage_gaps (
78
+ subject_id TEXT NOT NULL,
79
+ source_key TEXT NOT NULL,
80
+ gap_start TEXT NOT NULL,
81
+ gap_end TEXT NOT NULL,
82
+ detected_at TEXT NOT NULL,
83
+ PRIMARY KEY (subject_id, gap_start, gap_end),
84
+ CHECK (gap_start < gap_end)
85
+ ) STRICT;
86
+
58
87
  CREATE TABLE IF NOT EXISTS import_batches (
59
88
  import_batch_id TEXT PRIMARY KEY NOT NULL,
60
89
  subject_id TEXT NOT NULL,
@@ -98,6 +127,18 @@ const schema = `
98
127
  REFERENCES import_batches(import_batch_id)
99
128
  ) STRICT;
100
129
 
130
+ -- A Last.fm scrobble that repeats a play another service recorded stays stored,
131
+ -- linked to that play, and is left out of every count.
132
+ CREATE TABLE IF NOT EXISTS listening_event_mirrors (
133
+ mirror_event_id TEXT PRIMARY KEY NOT NULL,
134
+ original_event_id TEXT NOT NULL UNIQUE,
135
+ created_at TEXT NOT NULL,
136
+ FOREIGN KEY (mirror_event_id)
137
+ REFERENCES listening_events(listening_event_id),
138
+ FOREIGN KEY (original_event_id)
139
+ REFERENCES listening_events(listening_event_id)
140
+ ) STRICT;
141
+
101
142
  CREATE TABLE IF NOT EXISTS profile_import_batches (
102
143
  profile_import_id TEXT PRIMARY KEY NOT NULL,
103
144
  subject_id TEXT NOT NULL,
@@ -158,6 +199,28 @@ const schema = `
158
199
  CREATE UNIQUE INDEX IF NOT EXISTS taste_events_one_retraction
159
200
  ON taste_events(retracts_taste_event_id)
160
201
  WHERE retracts_taste_event_id IS NOT NULL;
202
+
203
+ -- What Moondog itself queued or played, written from the host's accepted receipts. Plays
204
+ -- that match one are left out of the listener's own attention when the profile is read
205
+ -- (src/profile/moondog-suggestions.mjs). Provider track identities never leave the host.
206
+ CREATE TABLE IF NOT EXISTS moondog_suggestions (
207
+ suggestion_id TEXT PRIMARY KEY NOT NULL,
208
+ service TEXT NOT NULL,
209
+ action TEXT NOT NULL,
210
+ provider_track_id TEXT,
211
+ isrc TEXT,
212
+ track_key TEXT,
213
+ release_key TEXT,
214
+ title TEXT NOT NULL,
215
+ artist_credit TEXT NOT NULL,
216
+ requested_at TEXT NOT NULL,
217
+ mix_slot TEXT
218
+ ) STRICT;
219
+
220
+ CREATE INDEX IF NOT EXISTS moondog_suggestions_time
221
+ ON moondog_suggestions(requested_at);
222
+
223
+ ${listenerProfileSchema}
161
224
  `;
162
225
 
163
226
  function listenerCorrectionError(code, message) {
@@ -207,6 +270,7 @@ function normalizedBundle(value, maximumRecords = 50) {
207
270
  capturedAt: new Date(value.captured_at).toISOString(),
208
271
  trackRefs: value.track_refs,
209
272
  listeningEvents: value.listening_events,
273
+ windowFull: value.window_full === true,
210
274
  };
211
275
  }
212
276
 
@@ -321,7 +385,8 @@ function normalizedProfileInput(value, batch) {
321
385
  !isPlainObject(record.provenance) ||
322
386
  record.provenance.source_kind !== "import" ||
323
387
  !(record.provenance.source_system === "spotify_account_data" ||
324
- (["youtube_music", "qq_music", "netease"].includes(record.provenance.source_system) &&
388
+ (record.provenance.source_system === "spotify_web_api" && profileImport.source_key === "spotify.web_api_library") ||
389
+ (["youtube_music", "qq_music", "netease", "lastfm"].includes(record.provenance.source_system) &&
325
390
  profileImport.source_key === `${record.provenance.source_system}.music_import`)) ||
326
391
  !profileImport.member_names.includes(record.provenance.source_member) ||
327
392
  record.provenance.profile_import_id?.toLowerCase() !== profileImportId ||
@@ -535,9 +600,45 @@ function ingestionStatements(database) {
535
600
  cursor_after_ms = MAX(source_cursors.cursor_after_ms, excluded.cursor_after_ms),
536
601
  updated_at = excluded.updated_at`,
537
602
  ),
603
+ hasEvent: database.prepare(
604
+ "SELECT 1 FROM listening_events WHERE deduplication_key = ?",
605
+ ),
606
+ touchCursor: database.prepare(
607
+ "UPDATE source_cursors SET updated_at = MAX(updated_at, ?) WHERE source_key = ?",
608
+ ),
538
609
  };
539
610
  }
540
611
 
612
+ // Records the span between the newest stored play and `before` as possibly missed.
613
+ function recordCoverageGap(database, { subjectId, sourceKey, before, detectedAt }) {
614
+ const after = database.prepare(
615
+ "SELECT MAX(occurred_at) AS occurred_at FROM listening_events WHERE subject_id = ? AND occurred_at < ?",
616
+ ).get(subjectId, before)?.occurred_at;
617
+ // A history that starts with this read has nothing to have missed.
618
+ if (!after) return;
619
+ database.prepare(
620
+ `INSERT OR IGNORE INTO listening_coverage_gaps (subject_id, source_key, gap_start, gap_end, detected_at)
621
+ VALUES (?, ?, ?, ?, ?)`,
622
+ ).run(subjectId, sourceKey, after, before, detectedAt);
623
+ }
624
+
625
+ // Recent syncs from before gaps were recorded: a run that stored a full window of 50
626
+ // new plays at once may have missed plays before its oldest one.
627
+ function recordEarlierRecentSyncGaps(database) {
628
+ const runs = database.prepare(
629
+ `SELECT subject_id, json_extract(record_json, '$.ingested_at') AS ingested_at,
630
+ MIN(occurred_at) AS earliest, COUNT(*) AS plays
631
+ FROM listening_events
632
+ WHERE json_extract(record_json, '$.provenance.external_ref.entity_type') = 'spotify.play_history'
633
+ GROUP BY subject_id, ingested_at HAVING plays >= 50`,
634
+ ).all();
635
+ for (const run of runs) {
636
+ recordCoverageGap(database, {
637
+ subjectId: run.subject_id, sourceKey: "spotify.recently_played", before: run.earliest, detectedAt: run.ingested_at,
638
+ });
639
+ }
640
+ }
641
+
541
642
  function insertBundleRecords(bundle, statements) {
542
643
  let insertedTracks = 0;
543
644
  let insertedEvents = 0;
@@ -569,9 +670,180 @@ function insertBundleRecords(bundle, statements) {
569
670
  return { insertedTracks, insertedEvents };
570
671
  }
571
672
 
673
+ // Where stored plays came from, in words the agent can repeat. Others keep their kind.
674
+ const COVERAGE_SOURCE_LABELS = {
675
+ "spotify.play_history": "Spotify recent plays, read while Moondog runs",
676
+ "spotify.extended_history.play": "Spotify Extended Streaming History file",
677
+ "spotify.account_data.play_history": "Spotify Account Data file",
678
+ "listenbrainz.listen": "ListenBrainz export file",
679
+ };
680
+
681
+ const SCROBBLE_SOURCE_KEY = "lastfm.music_import";
682
+ // Scrobbles record when a song started. Spotify records when a play ended, so its start is
683
+ // the end minus the listening time; Account Data times are rounded to the minute.
684
+ const MIRROR_TOLERANCE_MS = 150_000;
685
+ const DAY_MS = 86_400_000;
686
+
687
+ // A local calendar date, such as 2026-10-25.
688
+ function localDate(date) {
689
+ const pad = (value) => String(value).padStart(2, "0");
690
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
691
+ }
692
+
693
+ const foldLabel = (value) => String(value ?? "").normalize("NFKC").toLocaleLowerCase("und")
694
+ .replace(/[^\p{L}\p{N}]+/gu, " ").trim();
695
+ const lexicalCompare = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
696
+
697
+ /**
698
+ * Many Last.fm scrobbles repeat plays that Spotify or ListenBrainz recorded too. Each scrobble
699
+ * that matches another service's play (same artist and title, starting within a few minutes)
700
+ * is linked to it once, whichever was imported first, so the play counts once.
701
+ */
702
+ function reconcileScrobbleMirrors(database, subjectId, earliest, latest, createdAt) {
703
+ if (!subjectId || !earliest || !latest) return 0;
704
+ const scrobbled = database.prepare(
705
+ "SELECT 1 FROM import_batches WHERE subject_id = ? AND source_key = ? LIMIT 1",
706
+ ).get(subjectId, SCROBBLE_SOURCE_KEY);
707
+ if (!scrobbled) return 0;
708
+ const rows = database.prepare(
709
+ `SELECT e.listening_event_id AS id, e.occurred_at AS at,
710
+ e.record_json AS event_json, t.record_json AS track_json
711
+ FROM listening_events e
712
+ JOIN track_refs t
713
+ ON t.track_ref_id = e.track_ref_id AND t.revision = e.track_ref_revision
714
+ WHERE e.subject_id = ? AND e.occurred_at BETWEEN ? AND ?
715
+ AND NOT EXISTS (
716
+ SELECT 1 FROM listening_event_supersessions s
717
+ WHERE s.superseded_event_id = e.listening_event_id
718
+ )
719
+ AND NOT EXISTS (
720
+ SELECT 1 FROM listening_event_mirrors m
721
+ WHERE m.mirror_event_id = e.listening_event_id
722
+ OR m.original_event_id = e.listening_event_id
723
+ )`,
724
+ ).all(
725
+ subjectId,
726
+ new Date(Date.parse(earliest) - DAY_MS).toISOString(),
727
+ new Date(Date.parse(latest) + DAY_MS).toISOString(),
728
+ );
729
+ const scrobbles = new Map();
730
+ const plays = [];
731
+ for (const row of rows) {
732
+ const event = JSON.parse(row.event_json);
733
+ const track = JSON.parse(row.track_json);
734
+ const artist = foldLabel(track.artist_credits?.[0]?.name);
735
+ const title = foldLabel(track.title);
736
+ if (!artist || !title) continue;
737
+ const key = `${artist}\u0000${title}`;
738
+ const at = Date.parse(row.at);
739
+ const system = String(event.provenance?.external_ref?.system ?? "");
740
+ if (system === "lastfm") {
741
+ if (!scrobbles.has(key)) scrobbles.set(key, []);
742
+ scrobbles.get(key).push({ id: row.id, start: at });
743
+ continue;
744
+ }
745
+ const length = Number.isSafeInteger(event.played_ms) ? event.played_ms
746
+ : Number.isSafeInteger(track.duration_ms) ? track.duration_ms : null;
747
+ // A Spotify play of unknown length can't be placed precisely enough to match.
748
+ if (system.startsWith("spotify") && length === null) continue;
749
+ plays.push({ id: row.id, key, start: system.startsWith("spotify") ? at - length : at });
750
+ }
751
+ const pairs = [];
752
+ for (const list of scrobbles.values()) list.sort((left, right) => left.start - right.start);
753
+ for (const play of plays) {
754
+ const list = scrobbles.get(play.key);
755
+ if (!list) continue;
756
+ let low = 0;
757
+ let high = list.length;
758
+ while (low < high) {
759
+ const middle = (low + high) >> 1;
760
+ if (list[middle].start < play.start - MIRROR_TOLERANCE_MS) low = middle + 1;
761
+ else high = middle;
762
+ }
763
+ for (let index = low; index < list.length && list[index].start <= play.start + MIRROR_TOLERANCE_MS; index += 1) {
764
+ pairs.push({ gap: Math.abs(list[index].start - play.start), play: play.id, scrobble: list[index].id });
765
+ }
766
+ }
767
+ // Closest pairs first, so each play and each scrobble is used once.
768
+ pairs.sort((left, right) => left.gap - right.gap ||
769
+ lexicalCompare(left.scrobble, right.scrobble) || lexicalCompare(left.play, right.play));
770
+ const insert = database.prepare(
771
+ `INSERT INTO listening_event_mirrors (mirror_event_id, original_event_id, created_at)
772
+ VALUES (?, ?, ?)`,
773
+ );
774
+ const usedPlays = new Set();
775
+ const usedScrobbles = new Set();
776
+ let linked = 0;
777
+ for (const pair of pairs) {
778
+ if (usedPlays.has(pair.play) || usedScrobbles.has(pair.scrobble)) continue;
779
+ usedPlays.add(pair.play);
780
+ usedScrobbles.add(pair.scrobble);
781
+ linked += insert.run(pair.scrobble, pair.play, createdAt).changes;
782
+ }
783
+ return linked;
784
+ }
785
+
786
+ function preserveExtendedHistoryMatches(bundle, retainedMatch) {
787
+ const candidates = bundle.reconciliationCandidates;
788
+ if (bundle.importBatch.sourceFormat !== "spotify_extended_streaming_history_music_v1") return candidates;
789
+ const retained = new Map(candidates.map((candidate) => [
790
+ candidate.supersedingEventId, retainedMatch(candidate.supersedingEventId),
791
+ ]));
792
+ if (!candidates.some((candidate) => retained.get(candidate.supersedingEventId) &&
793
+ retained.get(candidate.supersedingEventId) !== candidate.supersededEventId)) return candidates;
794
+
795
+ // Account Data cannot distinguish equal-duration plays of the same title and
796
+ // artist within one minute. Added video plays can reorder those ordinals.
797
+ // A narrower export may omit an established counterpart's ordinal entirely.
798
+ // Keep those pairs too; only new plays use this archive's remaining candidates.
799
+ // The store's conflict checks still reject a new play claiming an absent play's
800
+ // retained counterpart, so an incomplete export cannot invent another match.
801
+ const events = new Map(bundle.listeningEvents.map((event) => [event.listening_event_id, event]));
802
+ const tracks = new Map(bundle.trackRefs.map((track) => [track.track_ref_id, track]));
803
+ const groups = new Map();
804
+ for (const candidate of candidates) {
805
+ const event = events.get(candidate.supersedingEventId);
806
+ const track = tracks.get(event.track_ref_id);
807
+ const key = JSON.stringify([
808
+ event.occurred_at.slice(0, 16), event.played_ms,
809
+ track.title.normalize("NFKC").toLowerCase(),
810
+ track.artist_credits[0].name.normalize("NFKC").toLowerCase(),
811
+ ]);
812
+ const group = groups.get(key) ?? [];
813
+ group.push(candidate);
814
+ groups.set(key, group);
815
+ }
816
+ const matches = new Map();
817
+ for (const group of groups.values()) {
818
+ const available = new Set(group.map((candidate) => candidate.supersededEventId));
819
+ for (const candidate of group) {
820
+ const previous = retained.get(candidate.supersedingEventId);
821
+ if (!previous) continue;
822
+ available.delete(previous);
823
+ matches.set(candidate.supersedingEventId, previous);
824
+ }
825
+ const remaining = available.values();
826
+ for (const candidate of group) {
827
+ if (matches.has(candidate.supersedingEventId)) continue;
828
+ const selected = available.has(candidate.supersededEventId)
829
+ ? candidate.supersededEventId : remaining.next().value;
830
+ matches.set(candidate.supersedingEventId, selected);
831
+ available.delete(selected);
832
+ }
833
+ }
834
+ return candidates.map((candidate) => ({
835
+ ...candidate, supersededEventId: matches.get(candidate.supersedingEventId),
836
+ }));
837
+ }
838
+
572
839
  export class ListeningHistoryStore {
573
840
  #database;
574
841
  #closed = false;
842
+ #snapshot = null;
843
+ #projection = null;
844
+ #coverageSources = null;
845
+ #evidence = null;
846
+ #ledgerChanges = 0;
575
847
 
576
848
  constructor(database) {
577
849
  this.#database = database;
@@ -670,6 +942,10 @@ export class ListeningHistoryStore {
670
942
  AND NOT EXISTS (
671
943
  SELECT 1 FROM listening_event_supersessions s
672
944
  WHERE s.superseded_event_id = e.listening_event_id
945
+ )
946
+ AND NOT EXISTS (
947
+ SELECT 1 FROM listening_event_mirrors m
948
+ WHERE m.mirror_event_id = e.listening_event_id
673
949
  )`,
674
950
  )
675
951
  .get(normalized).count;
@@ -1009,6 +1285,22 @@ export class ListeningHistoryStore {
1009
1285
  });
1010
1286
  }
1011
1287
 
1288
+ // How many choices are in force, beyond the ones a list shows.
1289
+ countActiveListenerCorrections({ subjectId } = {}) {
1290
+ if (this.#closed) throw new Error("Listening history store is closed");
1291
+ if (!isUuid(subjectId)) {
1292
+ throw new TypeError("A valid listener correction subject is required");
1293
+ }
1294
+ return this.#database
1295
+ .prepare(
1296
+ `SELECT COUNT(*) AS count FROM taste_events a
1297
+ WHERE a.subject_id = ? AND a.operation = 'assert'
1298
+ AND NOT EXISTS (SELECT 1 FROM taste_events r WHERE r.retracts_taste_event_id = a.taste_event_id)
1299
+ AND NOT EXISTS (SELECT 1 FROM taste_events s WHERE s.supersedes_taste_event_id = a.taste_event_id)`,
1300
+ )
1301
+ .get(subjectId.toLowerCase()).count;
1302
+ }
1303
+
1012
1304
  ingest(value) {
1013
1305
  if (this.#closed) throw new Error("Listening history store is closed");
1014
1306
  const bundle = normalizedBundle(value);
@@ -1024,14 +1316,24 @@ export class ListeningHistoryStore {
1024
1316
  this.#database.exec("BEGIN IMMEDIATE");
1025
1317
  let insertedTracks;
1026
1318
  let insertedEvents;
1319
+ let mirroredEvents = 0;
1027
1320
  try {
1028
1321
  if (subjectId !== undefined) {
1029
1322
  this.localSubjectId({ preferredSubjectId: subjectId });
1030
1323
  }
1324
+ const times = bundle.listeningEvents.map((record) => record.occurred_at).sort();
1325
+ // A read that overlaps a stored play joins up with it.
1326
+ const overlaps = bundle.listeningEvents.some((record) => statements.hasEvent.get(record.deduplication_key));
1327
+ if (bundle.windowFull && subjectId !== undefined && !overlaps) {
1328
+ recordCoverageGap(this.#database, { subjectId, sourceKey: bundle.sourceKey, before: times[0], detectedAt: bundle.capturedAt });
1329
+ }
1031
1330
  ({ insertedTracks, insertedEvents } = insertBundleRecords(
1032
1331
  bundle,
1033
1332
  statements,
1034
1333
  ));
1334
+ // A read that finds nothing new still moves the source's checked time.
1335
+ statements.touchCursor.run(bundle.capturedAt, bundle.sourceKey);
1336
+ mirroredEvents = reconcileScrobbleMirrors(this.#database, subjectId, times[0], times.at(-1), bundle.capturedAt);
1035
1337
  this.#database.exec("COMMIT");
1036
1338
  } catch (error) {
1037
1339
  this.#database.exec("ROLLBACK");
@@ -1045,6 +1347,7 @@ export class ListeningHistoryStore {
1045
1347
  inserted_events: insertedEvents,
1046
1348
  duplicate_events: bundle.listeningEvents.length - insertedEvents,
1047
1349
  inserted_track_refs: insertedTracks,
1350
+ ...(mirroredEvents ? { mirrored_events: mirroredEvents } : {}),
1048
1351
  cursor_after_ms: this.sourceCursor(bundle.sourceKey),
1049
1352
  profile_effects: insertedEvents > 0 ? "updated" : "unchanged",
1050
1353
  };
@@ -1055,7 +1358,7 @@ export class ListeningHistoryStore {
1055
1358
  const bundle = normalizedImportBundle(value);
1056
1359
  const batch = bundle.importBatch;
1057
1360
  const existingBatch = this.#database.prepare(
1058
- `SELECT import_batch_id FROM import_batches
1361
+ `SELECT * FROM import_batches
1059
1362
  WHERE import_batch_id = ?`,
1060
1363
  );
1061
1364
  const insertBatch = this.#database.prepare(
@@ -1141,12 +1444,14 @@ export class ListeningHistoryStore {
1141
1444
  let insertedTracks = 0;
1142
1445
  let insertedEvents = 0;
1143
1446
  let supersededEvents = 0;
1447
+ let mirroredEvents = 0;
1144
1448
  let insertedProfileEvidence = 0;
1145
1449
  let alreadyImported = false;
1146
1450
  let profileAlreadyImported = bundle.profileImport === undefined;
1147
1451
  try {
1148
1452
  this.localSubjectId({ preferredSubjectId: batch.subjectId });
1149
- alreadyImported = Boolean(existingBatch.get(batch.importBatchId));
1453
+ const previousBatch = existingBatch.get(batch.importBatchId);
1454
+ alreadyImported = Boolean(previousBatch);
1150
1455
  if (!alreadyImported) {
1151
1456
  ({ insertedTracks, insertedEvents } = insertBundleRecords(
1152
1457
  bundle,
@@ -1169,8 +1474,38 @@ export class ListeningHistoryStore {
1169
1474
  batch.inputRecords - insertedEvents,
1170
1475
  insertedTracks,
1171
1476
  );
1477
+ } else {
1478
+ // Earlier readers skipped video members, even when they contained music.
1479
+ // Extend that same archive's receipt without replacing its existing rows.
1480
+ const previousMembers = JSON.parse(previousBatch.member_names_json);
1481
+ const addedMembers = batch.memberNames.filter((name) => !previousMembers.includes(name));
1482
+ const expandsExtendedArchive = batch.sourceFormat === "spotify_extended_streaming_history_music_v1" &&
1483
+ previousBatch.subject_id === batch.subjectId && previousBatch.source_key === bundle.sourceKey &&
1484
+ previousBatch.source_format === batch.sourceFormat && previousBatch.data_scope === batch.dataScope &&
1485
+ previousBatch.archive_sha256 === batch.archiveSha256 && previousBatch.archive_size_bytes === batch.archiveSizeBytes &&
1486
+ previousMembers.every((name) => batch.memberNames.includes(name)) && addedMembers.length > 0 &&
1487
+ addedMembers.every((name) => /^Streaming_History_Video_\d{4}(?:_\d+)?\.json$/u.test(name));
1488
+ if (expandsExtendedArchive) {
1489
+ const incomingIds = new Set(bundle.listeningEvents.map((event) => event.listening_event_id));
1490
+ const previousEvents = this.#database.prepare(`SELECT listening_event_id FROM listening_events
1491
+ WHERE subject_id = ? AND json_extract(record_json, '$.provenance.import_batch_id') = ?`)
1492
+ .all(batch.subjectId, batch.importBatchId);
1493
+ if (batch.inputRecords < previousBatch.input_records || previousEvents.some((event) => !incomingIds.has(event.listening_event_id))) {
1494
+ throw new Error("Extended archive expansion must preserve every previously imported event");
1495
+ }
1496
+ ({ insertedTracks, insertedEvents } = insertBundleRecords(bundle, statements));
1497
+ this.#database.prepare(`UPDATE import_batches SET member_names_json = ?, input_records = ?,
1498
+ earliest_occurred_at = ?, latest_occurred_at = ?,
1499
+ inserted_events = inserted_events + ?, duplicate_events = ? - inserted_events - ?,
1500
+ inserted_track_refs = inserted_track_refs + ? WHERE import_batch_id = ?`)
1501
+ .run(JSON.stringify(batch.memberNames), batch.inputRecords, batch.earliestOccurredAt, batch.latestOccurredAt,
1502
+ insertedEvents, batch.inputRecords, insertedEvents, insertedTracks, batch.importBatchId);
1503
+ }
1172
1504
  }
1173
- for (const candidate of bundle.reconciliationCandidates) {
1505
+ const reconciliationCandidates = preserveExtendedHistoryMatches(bundle, (eventId) =>
1506
+ selectCandidateBySuperseding.get(eventId)?.superseded_event_id ??
1507
+ selectBySuperseding.get(eventId)?.superseded_event_id);
1508
+ for (const candidate of reconciliationCandidates) {
1174
1509
  const existingBySuperseded = selectCandidateBySuperseded.get(
1175
1510
  candidate.supersededEventId,
1176
1511
  )?.superseding_event_id;
@@ -1292,6 +1627,9 @@ export class ListeningHistoryStore {
1292
1627
  );
1293
1628
  }
1294
1629
  }
1630
+ mirroredEvents = reconcileScrobbleMirrors(
1631
+ this.#database, batch.subjectId, batch.earliestOccurredAt, batch.latestOccurredAt, batch.importedAt,
1632
+ );
1295
1633
  this.#database.exec("COMMIT");
1296
1634
  } catch (error) {
1297
1635
  this.#database.exec("ROLLBACK");
@@ -1310,7 +1648,8 @@ export class ListeningHistoryStore {
1310
1648
  duplicate_events: batch.inputRecords - insertedEvents,
1311
1649
  inserted_track_refs: insertedTracks,
1312
1650
  superseded_events: supersededEvents,
1313
- effective_event_delta: insertedEvents - supersededEvents,
1651
+ ...(mirroredEvents ? { mirrored_events: mirroredEvents } : {}),
1652
+ effective_event_delta: insertedEvents - supersededEvents - mirroredEvents,
1314
1653
  cursor_after_ms: this.sourceCursor(bundle.sourceKey),
1315
1654
  earliest_occurred_at: batch.earliestOccurredAt,
1316
1655
  latest_occurred_at: batch.latestOccurredAt,
@@ -1333,7 +1672,7 @@ export class ListeningHistoryStore {
1333
1672
  }
1334
1673
  : {}),
1335
1674
  profile_effects:
1336
- insertedEvents - supersededEvents !== 0 || insertedProfileEvidence > 0
1675
+ insertedEvents > 0 || supersededEvents > 0 || insertedProfileEvidence > 0
1337
1676
  ? "updated"
1338
1677
  : "unchanged",
1339
1678
  };
@@ -1355,6 +1694,10 @@ export class ListeningHistoryStore {
1355
1694
  SELECT 1 FROM listening_event_supersessions
1356
1695
  WHERE superseded_event_id = listening_events.listening_event_id
1357
1696
  )
1697
+ AND NOT EXISTS (
1698
+ SELECT 1 FROM listening_event_mirrors m
1699
+ WHERE m.mirror_event_id = listening_events.listening_event_id
1700
+ )
1358
1701
  ORDER BY occurred_at DESC, listening_event_id DESC
1359
1702
  LIMIT ?`,
1360
1703
  )
@@ -1454,6 +1797,10 @@ export class ListeningHistoryStore {
1454
1797
  SELECT 1 FROM listening_event_supersessions s
1455
1798
  WHERE s.superseded_event_id = e.listening_event_id
1456
1799
  )
1800
+ AND NOT EXISTS (
1801
+ SELECT 1 FROM listening_event_mirrors m
1802
+ WHERE m.mirror_event_id = e.listening_event_id
1803
+ )
1457
1804
  ORDER BY e.occurred_at, e.listening_event_id`,
1458
1805
  )
1459
1806
  .all(normalizedSubjectId)
@@ -1487,15 +1834,103 @@ export class ListeningHistoryStore {
1487
1834
  )
1488
1835
  .all(normalizedSubjectId)
1489
1836
  .map((row) => JSON.parse(row.record_json));
1490
- return projectListeningProfile({
1491
- subjectId: normalizedSubjectId,
1492
- eventRows,
1493
- profileEvidence,
1494
- tasteEvents,
1495
- maxItems,
1837
+ const { rows, adopted } = markMoondogStartedPlays(eventRows, this.#suggestionsForMatching());
1838
+ return {
1839
+ ...projectListeningProfile({
1840
+ subjectId: normalizedSubjectId,
1841
+ eventRows: rows,
1842
+ profileEvidence,
1843
+ tasteEvents,
1844
+ maxItems,
1845
+ adoptedSuggestionIds: adopted,
1846
+ }),
1847
+ adoptedSuggestionIds: adopted,
1848
+ };
1849
+ }
1850
+
1851
+ #suggestionsForMatching() {
1852
+ return this.#database.prepare(`SELECT suggestion_id, provider_track_id, isrc, track_key, release_key, title, artist_credit,
1853
+ requested_at FROM moondog_suggestions ORDER BY requested_at, suggestion_id`).all();
1854
+ }
1855
+
1856
+ /** Records what Moondog itself queued or played. Only accepted host writes belong here. */
1857
+ recordMoondogSuggestions({ entries } = {}) {
1858
+ if (this.#closed) throw new Error("Listening history store is closed");
1859
+ if (!Array.isArray(entries) || entries.length > 50) throw new TypeError("Moondog suggestions are invalid");
1860
+ const insert = this.#database.prepare(`INSERT INTO moondog_suggestions
1861
+ (suggestion_id, service, action, provider_track_id, isrc, track_key, release_key, title, artist_credit, requested_at, mix_slot)
1862
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
1863
+ let recorded = 0;
1864
+ this.#database.exec("BEGIN IMMEDIATE");
1865
+ try {
1866
+ for (const value of entries) {
1867
+ const entry = suggestionEntry(value);
1868
+ if (!entry || !/^[a-z][a-z0-9_.]{1,63}$/u.test(entry.action ?? "") || !Number.isFinite(Date.parse(entry.requested_at))) continue;
1869
+ insert.run(randomUUID(), entry.service, entry.action, entry.provider_track_id, entry.isrc, entry.track_key, entry.release_key,
1870
+ entry.title, entry.artist_credit, new Date(entry.requested_at).toISOString(), entry.mix_slot);
1871
+ recorded += 1;
1872
+ }
1873
+ this.#database.exec("COMMIT");
1874
+ } catch (error) {
1875
+ this.#database.exec("ROLLBACK");
1876
+ throw error;
1877
+ }
1878
+ this.#ledgerChanges += recorded;
1879
+ return { recorded };
1880
+ }
1881
+
1882
+ /**
1883
+ * Moondog's earlier suggestions of each track, matched as stored plays are: the same provider
1884
+ * track, ISRC or song. Adopted means the listener later played it again on their own.
1885
+ */
1886
+ moondogSuggestionHistory({ subjectId, tracks } = {}) {
1887
+ if (!Array.isArray(tracks)) throw new TypeError("Suggestion history tracks are invalid");
1888
+ const suggestions = this.#suggestionsForMatching();
1889
+ if (!suggestions.length) return tracks.map(() => null);
1890
+ const { adopted } = this.#listeningSnapshot(subjectId);
1891
+ const byIdentity = new Map();
1892
+ for (const suggestion of suggestions) {
1893
+ for (const identity of suggestionIdentities(suggestion)) {
1894
+ byIdentity.set(identity, [...(byIdentity.get(identity) ?? []), suggestion]);
1895
+ }
1896
+ }
1897
+ return tracks.map((track) => {
1898
+ const entry = suggestionEntry({ service: "spotify", action: "lookup", ...track });
1899
+ const matches = new Set(entry ? suggestionIdentities(entry).flatMap((identity) => byIdentity.get(identity) ?? []) : []);
1900
+ if (!matches.size) return null;
1901
+ const last = [...matches].reduce((latest, item) => item.requested_at > latest ? item.requested_at : latest, "");
1902
+ return { times: matches.size, last_suggested_at: last.slice(0, 10),
1903
+ adopted: [...matches].some((item) => adopted.has(item.suggestion_id)) };
1496
1904
  });
1497
1905
  }
1498
1906
 
1907
+ /** Where each track sits in the listener's own plays (MIX_SLOTS in moondog-suggestions.mjs). */
1908
+ listeningMix({ subjectId, tracks } = {}) {
1909
+ if (!Array.isArray(tracks)) throw new TypeError("Listening mix tracks are invalid");
1910
+ const { familiarity } = this.#listeningSnapshot(subjectId);
1911
+ return tracks.map((track) => mixSlot(track, familiarity));
1912
+ }
1913
+
1914
+ // One projection with a single item per list serves the status, the Avoids, the catalog, the
1915
+ // mix and the suggestion history until the listening data changes. Callers copy what they return.
1916
+ #sharedProjection(subjectId) {
1917
+ const version = `${subjectId}:${this.listeningDataVersion()}`;
1918
+ if (this.#projection?.version !== version) {
1919
+ this.#projection = { version, value: this.#profileProjection({ subjectId, maxItems: 1 }) };
1920
+ }
1921
+ return this.#projection.value;
1922
+ }
1923
+
1924
+ #listeningSnapshot(subjectId) {
1925
+ const version = `${subjectId}:${this.listeningDataVersion()}`;
1926
+ if (this.#snapshot?.version !== version) {
1927
+ const projected = this.#sharedProjection(subjectId);
1928
+ this.#snapshot = { version, adopted: projected.adoptedSuggestionIds,
1929
+ familiarity: listeningFamiliarity(projected.catalog, projected.summary.listening_behavior.context.reference_date) };
1930
+ }
1931
+ return this.#snapshot;
1932
+ }
1933
+
1499
1934
  /** Every track the subject has played, with its play count, most played first. */
1500
1935
  listenedTracks({ subjectId } = {}) {
1501
1936
  if (this.#closed) throw new Error("Listening history store is closed");
@@ -1517,6 +1952,10 @@ export class ListeningHistoryStore {
1517
1952
  SELECT 1 FROM listening_event_supersessions
1518
1953
  WHERE superseded_event_id = listening_events.listening_event_id
1519
1954
  )
1955
+ AND NOT EXISTS (
1956
+ SELECT 1 FROM listening_event_mirrors m
1957
+ WHERE m.mirror_event_id = listening_events.listening_event_id
1958
+ )
1520
1959
  GROUP BY track_ref_id
1521
1960
  ORDER BY play_count DESC, track_ref_id`,
1522
1961
  )
@@ -1545,10 +1984,186 @@ export class ListeningHistoryStore {
1545
1984
  return structuredClone({ ...value.summary, lyric_exclusions: value.lyricExclusions });
1546
1985
  }
1547
1986
 
1987
+ activeAvoidances({ subjectId } = {}) {
1988
+ // Host-side filtering must include corrections outside the bounded display.
1989
+ return structuredClone(this.#sharedProjection(subjectId).lyricExclusions);
1990
+ }
1991
+
1548
1992
  profileSummary({ subjectId, maxItems = 10 } = {}) {
1549
- return structuredClone(
1550
- this.#profileProjection({ subjectId, maxItems }).summary,
1551
- );
1993
+ return structuredClone((maxItems === 1 ? this.#sharedProjection(subjectId)
1994
+ : this.#profileProjection({ subjectId, maxItems })).summary);
1995
+ }
1996
+
1997
+ profileCatalog({ subjectId } = {}) {
1998
+ const projected = this.#sharedProjection(subjectId);
1999
+ const normalizedSubjectId = subjectId.toLowerCase();
2000
+ const imports = this.#database.prepare(`SELECT source_format AS format, data_scope AS scope,
2001
+ SUM(input_records) AS input_records, MIN(earliest_occurred_at) AS earliest,
2002
+ MAX(latest_occurred_at) AS latest
2003
+ FROM import_batches WHERE subject_id = ? GROUP BY source_format, data_scope ORDER BY source_format`)
2004
+ .all(normalizedSubjectId);
2005
+ const stored = this.#database.prepare("SELECT COUNT(*) AS count FROM listening_events WHERE subject_id = ?")
2006
+ .get(normalizedSubjectId).count;
2007
+ return structuredClone({
2008
+ coverage: { ...projected.summary.coverage,
2009
+ stored_listening_events: stored,
2010
+ superseded_listening_events: stored - projected.summary.coverage.effective_listening_events },
2011
+ sources: imports,
2012
+ context: {
2013
+ reference_date: projected.summary.listening_behavior.context.reference_date,
2014
+ recent_window_days: projected.summary.listening_behavior.context.recent_window_days,
2015
+ incognito_events_excluded: projected.summary.listening_behavior.context.incognito_events_excluded,
2016
+ ...(projected.summary.listening_behavior.context.moondog_started_events_excluded
2017
+ ? { moondog_started_events_excluded: projected.summary.listening_behavior.context.moondog_started_events_excluded } : {}),
2018
+ release_minimum_distinct_tracks: projected.summary.listening_behavior.context.release_minimum_distinct_tracks,
2019
+ },
2020
+ sections: projected.catalog,
2021
+ });
2022
+ }
2023
+
2024
+ // What the listener played lately: stored plays on today and the `days` - 1 local days before
2025
+ // it, by artist. It reads the clock, not the newest play, so a history that stopped shows as
2026
+ // quiet. Whole local days keep the window, and so the model's context, the same all day.
2027
+ recentAttention({ subjectId, days = 28, now = Date.now(), limit = 10 } = {}) {
2028
+ if (this.#closed) throw new Error("Listening history store is closed");
2029
+ if (!isUuid(subjectId)) throw new TypeError("A valid profile subject is required");
2030
+ const today = new Date(now);
2031
+ const start = new Date(today);
2032
+ start.setHours(0, 0, 0, 0);
2033
+ start.setDate(start.getDate() - (days - 1));
2034
+ const from = start.toISOString();
2035
+ const effective = `e.subject_id = ? AND e.occurred_at >= ?
2036
+ AND NOT EXISTS (SELECT 1 FROM listening_event_supersessions s WHERE s.superseded_event_id = e.listening_event_id)
2037
+ AND NOT EXISTS (SELECT 1 FROM listening_event_mirrors m WHERE m.mirror_event_id = e.listening_event_id)`;
2038
+ const subject = subjectId.toLowerCase();
2039
+ // Plays Moondog started are not the listener's own attention.
2040
+ const suggestions = this.#suggestionsForMatching()
2041
+ .filter((suggestion) => Date.parse(suggestion.requested_at) + SUGGESTION_WINDOW.afterMs >= Date.parse(from));
2042
+ const started = suggestions.length === 0 ? [] : markMoondogStartedPlays(this.#database.prepare(`SELECT
2043
+ e.listening_event_id, e.record_json AS event_json, t.record_json AS track_json FROM listening_events e
2044
+ JOIN track_refs t ON t.track_ref_id = e.track_ref_id AND t.revision = e.track_ref_revision WHERE ${effective}`)
2045
+ .all(subject, from).map((row) => ({ id: row.listening_event_id, event: JSON.parse(row.event_json), track: JSON.parse(row.track_json) })),
2046
+ suggestions).rows.filter((row) => row.moondog_suggestion_id).map((row) => row.id);
2047
+ const own = `${effective} AND e.listening_event_id NOT IN (SELECT value FROM json_each(?))`;
2048
+ const plays = this.#database.prepare(`SELECT COUNT(*) AS plays FROM listening_events e WHERE ${own}`)
2049
+ .get(subject, from, JSON.stringify(started)).plays;
2050
+ const artists = this.#database.prepare(`SELECT json_extract(a.value, '$.name') AS name, COUNT(*) AS plays,
2051
+ MAX(e.occurred_at) AS last_played_at
2052
+ FROM listening_events e
2053
+ JOIN track_refs t ON t.track_ref_id = e.track_ref_id AND t.revision = e.track_ref_revision
2054
+ JOIN json_each(t.record_json, '$.artist_credits') a
2055
+ WHERE ${own} AND name IS NOT NULL
2056
+ GROUP BY name ORDER BY plays DESC, last_played_at DESC LIMIT ?`).all(subject, from, JSON.stringify(started), limit);
2057
+ return structuredClone({ from_date: localDate(start), through_date: localDate(today), plays,
2058
+ ...(started.length ? { moondog_started_plays: started.length } : {}), top_artists: artists });
2059
+ }
2060
+
2061
+ // Which spans of time the stored plays cover, and where plays may be missing.
2062
+ listeningCoverage({ subjectId } = {}) {
2063
+ if (this.#closed) throw new Error("Listening history store is closed");
2064
+ if (!isUuid(subjectId)) throw new TypeError("A valid profile subject is required");
2065
+ const subject = subjectId.toLowerCase();
2066
+ // Reading every play is the slow part, so it is kept until the listening data changes.
2067
+ const version = `${subject}:${this.listeningDataVersion()}`;
2068
+ if (this.#coverageSources?.version !== version) {
2069
+ this.#coverageSources = { version, sources: this.#database.prepare(`SELECT
2070
+ json_extract(record_json, '$.provenance.external_ref.entity_type') AS kind,
2071
+ COUNT(*) AS plays, MIN(occurred_at) AS first_played_at, MAX(occurred_at) AS last_played_at
2072
+ FROM listening_events WHERE subject_id = ? GROUP BY kind ORDER BY first_played_at`).all(subject) };
2073
+ }
2074
+ const { sources } = this.#coverageSources;
2075
+ // A history file imported later can fill a span that a sync missed.
2076
+ const filled = this.#database.prepare(`SELECT earliest_occurred_at AS earliest, latest_occurred_at AS latest
2077
+ FROM import_batches WHERE subject_id = ? AND earliest_occurred_at IS NOT NULL AND latest_occurred_at IS NOT NULL`).all(subject);
2078
+ const gaps = this.#database.prepare(`SELECT gap_start AS "from", gap_end AS "to"
2079
+ FROM listening_coverage_gaps WHERE subject_id = ? ORDER BY gap_start`).all(subject)
2080
+ .flatMap((gap) => filled.reduce((spans, { earliest, latest }) => spans.flatMap((span) =>
2081
+ latest <= span.from || earliest >= span.to ? [span] : [
2082
+ ...(earliest > span.from ? [{ from: span.from, to: earliest }] : []),
2083
+ ...(latest < span.to ? [{ from: latest, to: span.to }] : []),
2084
+ ]), [{ ...gap }]));
2085
+ const checked = this.#database.prepare("SELECT updated_at FROM source_cursors WHERE source_key = 'spotify.recently_played'").get();
2086
+ return structuredClone({
2087
+ sources: sources.map(({ kind, ...source }) => ({ source: COVERAGE_SOURCE_LABELS[kind] ?? kind, ...source })),
2088
+ gaps: gaps.slice(-10),
2089
+ ...(checked ? { recent_plays_checked_at: checked.updated_at } : {}),
2090
+ });
2091
+ }
2092
+
2093
+ listenerProfileStorage({ subjectId } = {}) {
2094
+ if (this.#closed) throw new Error("Listening history store is closed");
2095
+ if (!isUuid(subjectId)) throw new TypeError("A valid profile subject is required");
2096
+ return new ListenerProfileStore(this.#database, subjectId.toLowerCase());
2097
+ }
2098
+
2099
+ profileDataVersion() {
2100
+ if (this.#closed) throw new Error("Listening history store is closed");
2101
+ // data_version observes other connections; total_changes observes our own.
2102
+ // This is a connection-local cache key, never a persisted evidence digest.
2103
+ return `${this.#database.prepare("PRAGMA data_version").get().data_version}:${
2104
+ this.#database.prepare("SELECT total_changes() AS changes").get().changes}`;
2105
+ }
2106
+
2107
+ // A version of the listening evidence that no suggestions ledger write changes, from this
2108
+ // connection or another, such as the other surface's. A new suggestion changes no projection
2109
+ // until its plays arrive, so caches of the projection, the Avoids, the status and the build
2110
+ // input key on this and are not rebuilt after every queue. This connection's own writes count
2111
+ // by total_changes. Another connection's commit moves data_version whatever it wrote, so then
2112
+ // the evidence tables themselves say whether the evidence changed.
2113
+ listeningDataVersion() {
2114
+ if (this.#closed) throw new Error("Listening history store is closed");
2115
+ const dataVersion = this.#database.prepare("PRAGMA data_version").get().data_version;
2116
+ const ownChanges = this.#database.prepare("SELECT total_changes() AS changes").get().changes - this.#ledgerChanges;
2117
+ if (this.#evidence?.dataVersion !== dataVersion || this.#evidence.ownChanges !== ownChanges) {
2118
+ this.#evidence = { dataVersion, ownChanges, fingerprint: this.#evidenceFingerprint() };
2119
+ }
2120
+ return this.#evidence.fingerprint;
2121
+ }
2122
+
2123
+ #evidenceFingerprint() {
2124
+ const digest = createHash("sha256");
2125
+ for (const table of APPENDED_EVIDENCE_TABLES) {
2126
+ digest.update(JSON.stringify(this.#database.prepare(`SELECT COUNT(*) AS rows, MAX(rowid) AS last FROM ${table}`).get()));
2127
+ }
2128
+ for (const table of UPDATED_EVIDENCE_TABLES) {
2129
+ for (const row of this.#database.prepare(`SELECT * FROM ${table} ORDER BY rowid`).iterate()) digest.update(JSON.stringify(row));
2130
+ }
2131
+ return digest.digest("hex");
2132
+ }
2133
+
2134
+ profileEvidenceRevision({ subjectId } = {}) {
2135
+ if (this.#closed) throw new Error("Listening history store is closed");
2136
+ if (!isUuid(subjectId)) throw new TypeError("A valid profile subject is required");
2137
+ const digest = createHash("sha256");
2138
+ // Build artifacts never feed back into source evidence. Hash locally; raw
2139
+ // events, source identifiers and private import metadata do not go to Pi.
2140
+ const queries = [
2141
+ "SELECT record_json FROM listening_events WHERE subject_id = ? ORDER BY listening_event_id",
2142
+ "SELECT record_json FROM spotify_profile_evidence WHERE subject_id = ? ORDER BY profile_evidence_id",
2143
+ "SELECT record_json FROM taste_events WHERE subject_id = ? ORDER BY taste_event_id",
2144
+ "SELECT * FROM import_batches WHERE subject_id = ? ORDER BY import_batch_id",
2145
+ "SELECT * FROM profile_import_batches WHERE subject_id = ? ORDER BY profile_import_id",
2146
+ `SELECT t.record_json FROM track_refs t WHERE EXISTS (SELECT 1 FROM listening_events e
2147
+ WHERE e.subject_id = ? AND e.track_ref_id = t.track_ref_id AND e.track_ref_revision = t.revision)
2148
+ ORDER BY t.track_ref_id, t.revision`,
2149
+ ];
2150
+ for (const query of queries) {
2151
+ digest.update(query).update("\0");
2152
+ for (const row of this.#database.prepare(query).iterate(subjectId.toLowerCase())) {
2153
+ digest.update(JSON.stringify(row)).update("\0");
2154
+ }
2155
+ }
2156
+ // Moondog's suggestions decide which plays count as the listener's own. A play can match a
2157
+ // suggestion made up to SUGGESTION_WINDOW.beforeMs after it, for clock skew; one made later
2158
+ // than that after the latest stored play matches nothing yet, so it leaves the reading current.
2159
+ const latest = this.#database.prepare("SELECT MAX(occurred_at) AS latest FROM listening_events WHERE subject_id = ?")
2160
+ .get(subjectId.toLowerCase()).latest;
2161
+ const latestMs = Date.parse(latest ?? "");
2162
+ const suggestions = Number.isFinite(latestMs) ? this.#database.prepare(`SELECT suggestion_id, provider_track_id, isrc, track_key,
2163
+ release_key, title, artist_credit, requested_at FROM moondog_suggestions WHERE requested_at <= ? ORDER BY suggestion_id`)
2164
+ .all(new Date(latestMs + SUGGESTION_WINDOW.beforeMs).toISOString()) : [];
2165
+ if (suggestions.length) digest.update("moondog_suggestions\0").update(JSON.stringify(suggestions));
2166
+ return digest.digest("hex");
1552
2167
  }
1553
2168
 
1554
2169
  rediscoveryCandidates({ subjectId, limit = 6 } = {}) {
@@ -1930,6 +2545,10 @@ export class ListeningHistoryStore {
1930
2545
  WHERE NOT EXISTS (
1931
2546
  SELECT 1 FROM listening_event_supersessions
1932
2547
  WHERE superseded_event_id = listening_events.listening_event_id
2548
+ )
2549
+ AND NOT EXISTS (
2550
+ SELECT 1 FROM listening_event_mirrors m
2551
+ WHERE m.mirror_event_id = listening_events.listening_event_id
1933
2552
  )`,
1934
2553
  )
1935
2554
  .get().count,
@@ -2003,7 +2622,11 @@ async function initializeListeningHistoryDatabase(
2003
2622
  ) {
2004
2623
  throw new Error("Listening history database schema is incompatible");
2005
2624
  }
2625
+ const recordsGaps = database.prepare(
2626
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'listening_coverage_gaps'",
2627
+ ).get();
2006
2628
  database.exec(schema);
2629
+ if (!recordsGaps) recordEarlierRecentSyncGaps(database);
2007
2630
  if (currentVersion < LISTENING_HISTORY_SCHEMA_VERSION) {
2008
2631
  database.exec(
2009
2632
  `PRAGMA user_version = ${LISTENING_HISTORY_SCHEMA_VERSION}`,