@audiofool/moondog 0.1.0 → 0.2.0-beta.2

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 (121) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +153 -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 +168 -36
  10. package/src/core/application-update.mjs +320 -0
  11. package/src/core/capability-catalog.mjs +278 -55
  12. package/src/core/listening-now.mjs +108 -0
  13. package/src/core/listening-profile-domain-services.mjs +153 -41
  14. package/src/core/listening-upkeep.mjs +31 -0
  15. package/src/core/moondog-application.mjs +1908 -104
  16. package/src/core/playback-preferences.mjs +97 -0
  17. package/src/core/spotify-listening-intent.mjs +121 -0
  18. package/src/core/synthetic-domain-services.mjs +24 -3
  19. package/src/demo/moondog-demo.mjs +4 -22
  20. package/src/i18n/index.mjs +285 -0
  21. package/src/i18n/messages/de.mjs +128 -0
  22. package/src/i18n/messages/en.mjs +129 -0
  23. package/src/i18n/messages/es.mjs +128 -0
  24. package/src/i18n/messages/fr.mjs +128 -0
  25. package/src/i18n/messages/it.mjs +128 -0
  26. package/src/i18n/messages/ja.mjs +128 -0
  27. package/src/i18n/messages/ko.mjs +128 -0
  28. package/src/i18n/messages/pt.mjs +128 -0
  29. package/src/i18n/messages/zh-Hant.mjs +128 -0
  30. package/src/i18n/messages/zh.mjs +128 -0
  31. package/src/i18n/preferences.mjs +34 -0
  32. package/src/i18n/screen/es.mjs +966 -0
  33. package/src/i18n/screen/ja.mjs +966 -0
  34. package/src/i18n/screen/pt.mjs +966 -0
  35. package/src/i18n/screen/zh.mjs +966 -0
  36. package/src/importers/apple-music-library/index.mjs +0 -16
  37. package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
  38. package/src/integrations/apple-music/catalog.mjs +123 -14
  39. package/src/integrations/apple-music/music-app.mjs +232 -0
  40. package/src/integrations/apple-music/storefront.mjs +28 -0
  41. package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
  42. package/src/integrations/lastfm/history.mjs +221 -0
  43. package/src/integrations/lastfm/settings.mjs +75 -0
  44. package/src/integrations/netease/ncm-cli-player.mjs +321 -0
  45. package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
  46. package/src/integrations/public-playlists.mjs +2 -1
  47. package/src/integrations/spotify/account-data-profile.mjs +14 -6
  48. package/src/integrations/spotify/authentication.mjs +149 -15
  49. package/src/integrations/spotify/catalog-resolver.mjs +8 -4
  50. package/src/integrations/spotify/connection.mjs +2 -1
  51. package/src/integrations/spotify/credential-store.mjs +4 -0
  52. package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
  53. package/src/integrations/spotify/history-archive.mjs +1 -1
  54. package/src/integrations/spotify/library-snapshot.mjs +227 -0
  55. package/src/integrations/spotify/recent-activity.mjs +2 -0
  56. package/src/integrations/spotify/service.mjs +873 -69
  57. package/src/integrations/spotify/web-api-client.mjs +407 -127
  58. package/src/integrations/web/brave-web.mjs +45 -0
  59. package/src/integrations/web/codex-web.mjs +15 -82
  60. package/src/integrations/web/deepseek-web.mjs +81 -0
  61. package/src/integrations/web/web-research.mjs +119 -0
  62. package/src/integrations/web/web-result.mjs +215 -0
  63. package/src/integrations/youtube-music/takeout.mjs +3 -1
  64. package/src/memory/local-memory-store.mjs +469 -41
  65. package/src/profile/history-import.mjs +42 -6
  66. package/src/profile/listener-profile-build.mjs +378 -0
  67. package/src/profile/listener-profile-store.mjs +130 -0
  68. package/src/profile/listener-profile-verification.mjs +68 -0
  69. package/src/profile/listener-profile-worker.mjs +172 -0
  70. package/src/profile/listening-history-store.mjs +424 -8
  71. package/src/profile/listening-profile-projection.mjs +242 -76
  72. package/src/profile/local-music-data.mjs +1 -1
  73. package/src/profile/music-providers.mjs +11 -7
  74. package/src/profile/profile-exploration.mjs +84 -0
  75. package/src/profile/recorded-measures.mjs +24 -0
  76. package/src/profile/spotify-archive-taste.mjs +0 -4
  77. package/src/runtime/pi/agent-runtime.mjs +2204 -605
  78. package/src/runtime/pi/authentication.mjs +34 -1
  79. package/src/runtime/pi/configured-runtime.mjs +22 -6
  80. package/src/runtime/pi/conversation-name.mjs +53 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
  82. package/src/runtime/pi/model-catalog.mjs +28 -5
  83. package/src/runtime/pi/model-settings.mjs +202 -0
  84. package/src/runtime/pi/model-transport.mjs +10 -0
  85. package/src/runtime/pi/persistent-credential-store.mjs +67 -31
  86. package/src/runtime/pi/profile-build-runtime.mjs +160 -0
  87. package/src/runtime/pi/public-model-catalog.mjs +288 -0
  88. package/src/runtime/pi/runtime-settings.mjs +10 -2
  89. package/src/runtime/pi/skills/listener-profile/SKILL.md +46 -0
  90. package/src/runtime/pi/tool-trace.mjs +37 -0
  91. package/src/surfaces/cli/auth-command.mjs +12 -4
  92. package/src/surfaces/cli/brand-components.mjs +310 -132
  93. package/src/surfaces/cli/brand-theme.mjs +51 -28
  94. package/src/surfaces/cli/command-completions.mjs +58 -42
  95. package/src/surfaces/cli/format-output.mjs +199 -388
  96. package/src/surfaces/cli/history-import-view.mjs +292 -139
  97. package/src/surfaces/cli/import-guides.mjs +2 -0
  98. package/src/surfaces/cli/listener-profile-command.mjs +92 -0
  99. package/src/surfaces/cli/listening-menu.mjs +7 -4
  100. package/src/surfaces/cli/profile-command.mjs +30 -21
  101. package/src/surfaces/cli/seasons-chart.mjs +87 -0
  102. package/src/surfaces/cli/spotify-command.mjs +54 -6
  103. package/src/surfaces/cli/taste-profile-model.mjs +78 -79
  104. package/src/surfaces/cli/taste-profile-view.mjs +33 -26
  105. package/src/surfaces/cli/terminal-art.mjs +140 -35
  106. package/src/surfaces/cli/tui.mjs +1066 -357
  107. package/src/surfaces/cli/update-command.mjs +32 -0
  108. package/src/surfaces/cli/web-command.mjs +53 -14
  109. package/src/surfaces/html/tasteprint.mjs +43 -70
  110. package/contracts/v1/capability-policy.schema.json +0 -572
  111. package/contracts/v1/profile-evidence.schema.json +0 -237
  112. package/contracts/v1/tool-invocation.schema.json +0 -318
  113. package/contracts/v1/tool-receipt.schema.json +0 -1066
  114. package/scripts/demo-studio.mjs +0 -58
  115. package/src/core/apple-data-migration.mjs +0 -50
  116. package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
  117. package/src/demo/moondog-tasteprint-demo.mjs +0 -631
  118. package/src/integrations/spotify/index.mjs +0 -80
  119. package/src/surfaces/web/showcase.mjs +0 -272
  120. package/src/surfaces/web/studio-page.mjs +0 -4148
  121. 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,9 @@ import {
9
9
  createListenerCorrectionRetraction,
10
10
  } from "./listener-corrections.mjs";
11
11
  import { projectListeningProfile } from "./listening-profile-projection.mjs";
12
+ import { ListenerProfileStore, listenerProfileSchema } from "./listener-profile-store.mjs";
12
13
 
13
- export const LISTENING_HISTORY_SCHEMA_VERSION = 6;
14
+ export const LISTENING_HISTORY_SCHEMA_VERSION = 7;
14
15
 
15
16
  export function resolveListeningHistoryPath(environment = process.env) {
16
17
  return path.join(resolveMoondogStateDirectory(environment), "listening-history.sqlite");
@@ -48,6 +49,8 @@ const schema = `
48
49
 
49
50
  CREATE INDEX IF NOT EXISTS listening_events_subject_time
50
51
  ON listening_events(subject_id, occurred_at DESC);
52
+ CREATE INDEX IF NOT EXISTS listening_events_subject_track
53
+ ON listening_events(subject_id, track_ref_id, track_ref_revision);
51
54
 
52
55
  CREATE TABLE IF NOT EXISTS source_cursors (
53
56
  source_key TEXT PRIMARY KEY NOT NULL,
@@ -55,6 +58,18 @@ const schema = `
55
58
  updated_at TEXT NOT NULL
56
59
  ) STRICT;
57
60
 
61
+ -- Spotify keeps only a listener's last 50 plays. When a sync finds the window full,
62
+ -- plays between the last stored one and the oldest returned one may be gone for good.
63
+ CREATE TABLE IF NOT EXISTS listening_coverage_gaps (
64
+ subject_id TEXT NOT NULL,
65
+ source_key TEXT NOT NULL,
66
+ gap_start TEXT NOT NULL,
67
+ gap_end TEXT NOT NULL,
68
+ detected_at TEXT NOT NULL,
69
+ PRIMARY KEY (subject_id, gap_start, gap_end),
70
+ CHECK (gap_start < gap_end)
71
+ ) STRICT;
72
+
58
73
  CREATE TABLE IF NOT EXISTS import_batches (
59
74
  import_batch_id TEXT PRIMARY KEY NOT NULL,
60
75
  subject_id TEXT NOT NULL,
@@ -98,6 +113,18 @@ const schema = `
98
113
  REFERENCES import_batches(import_batch_id)
99
114
  ) STRICT;
100
115
 
116
+ -- A Last.fm scrobble that repeats a play another service recorded stays stored,
117
+ -- linked to that play, and is left out of every count.
118
+ CREATE TABLE IF NOT EXISTS listening_event_mirrors (
119
+ mirror_event_id TEXT PRIMARY KEY NOT NULL,
120
+ original_event_id TEXT NOT NULL UNIQUE,
121
+ created_at TEXT NOT NULL,
122
+ FOREIGN KEY (mirror_event_id)
123
+ REFERENCES listening_events(listening_event_id),
124
+ FOREIGN KEY (original_event_id)
125
+ REFERENCES listening_events(listening_event_id)
126
+ ) STRICT;
127
+
101
128
  CREATE TABLE IF NOT EXISTS profile_import_batches (
102
129
  profile_import_id TEXT PRIMARY KEY NOT NULL,
103
130
  subject_id TEXT NOT NULL,
@@ -158,6 +185,8 @@ const schema = `
158
185
  CREATE UNIQUE INDEX IF NOT EXISTS taste_events_one_retraction
159
186
  ON taste_events(retracts_taste_event_id)
160
187
  WHERE retracts_taste_event_id IS NOT NULL;
188
+
189
+ ${listenerProfileSchema}
161
190
  `;
162
191
 
163
192
  function listenerCorrectionError(code, message) {
@@ -207,6 +236,7 @@ function normalizedBundle(value, maximumRecords = 50) {
207
236
  capturedAt: new Date(value.captured_at).toISOString(),
208
237
  trackRefs: value.track_refs,
209
238
  listeningEvents: value.listening_events,
239
+ windowFull: value.window_full === true,
210
240
  };
211
241
  }
212
242
 
@@ -321,7 +351,8 @@ function normalizedProfileInput(value, batch) {
321
351
  !isPlainObject(record.provenance) ||
322
352
  record.provenance.source_kind !== "import" ||
323
353
  !(record.provenance.source_system === "spotify_account_data" ||
324
- (["youtube_music", "qq_music", "netease"].includes(record.provenance.source_system) &&
354
+ (record.provenance.source_system === "spotify_web_api" && profileImport.source_key === "spotify.web_api_library") ||
355
+ (["youtube_music", "qq_music", "netease", "lastfm"].includes(record.provenance.source_system) &&
325
356
  profileImport.source_key === `${record.provenance.source_system}.music_import`)) ||
326
357
  !profileImport.member_names.includes(record.provenance.source_member) ||
327
358
  record.provenance.profile_import_id?.toLowerCase() !== profileImportId ||
@@ -535,9 +566,45 @@ function ingestionStatements(database) {
535
566
  cursor_after_ms = MAX(source_cursors.cursor_after_ms, excluded.cursor_after_ms),
536
567
  updated_at = excluded.updated_at`,
537
568
  ),
569
+ hasEvent: database.prepare(
570
+ "SELECT 1 FROM listening_events WHERE deduplication_key = ?",
571
+ ),
572
+ touchCursor: database.prepare(
573
+ "UPDATE source_cursors SET updated_at = MAX(updated_at, ?) WHERE source_key = ?",
574
+ ),
538
575
  };
539
576
  }
540
577
 
578
+ // Records the span between the newest stored play and `before` as possibly missed.
579
+ function recordCoverageGap(database, { subjectId, sourceKey, before, detectedAt }) {
580
+ const after = database.prepare(
581
+ "SELECT MAX(occurred_at) AS occurred_at FROM listening_events WHERE subject_id = ? AND occurred_at < ?",
582
+ ).get(subjectId, before)?.occurred_at;
583
+ // A history that starts with this read has nothing to have missed.
584
+ if (!after) return;
585
+ database.prepare(
586
+ `INSERT OR IGNORE INTO listening_coverage_gaps (subject_id, source_key, gap_start, gap_end, detected_at)
587
+ VALUES (?, ?, ?, ?, ?)`,
588
+ ).run(subjectId, sourceKey, after, before, detectedAt);
589
+ }
590
+
591
+ // Recent syncs from before gaps were recorded: a run that stored a full window of 50
592
+ // new plays at once may have missed plays before its oldest one.
593
+ function recordEarlierRecentSyncGaps(database) {
594
+ const runs = database.prepare(
595
+ `SELECT subject_id, json_extract(record_json, '$.ingested_at') AS ingested_at,
596
+ MIN(occurred_at) AS earliest, COUNT(*) AS plays
597
+ FROM listening_events
598
+ WHERE json_extract(record_json, '$.provenance.external_ref.entity_type') = 'spotify.play_history'
599
+ GROUP BY subject_id, ingested_at HAVING plays >= 50`,
600
+ ).all();
601
+ for (const run of runs) {
602
+ recordCoverageGap(database, {
603
+ subjectId: run.subject_id, sourceKey: "spotify.recently_played", before: run.earliest, detectedAt: run.ingested_at,
604
+ });
605
+ }
606
+ }
607
+
541
608
  function insertBundleRecords(bundle, statements) {
542
609
  let insertedTracks = 0;
543
610
  let insertedEvents = 0;
@@ -569,6 +636,165 @@ function insertBundleRecords(bundle, statements) {
569
636
  return { insertedTracks, insertedEvents };
570
637
  }
571
638
 
639
+ // Where stored plays came from, in words the agent can repeat. Others keep their kind.
640
+ const COVERAGE_SOURCE_LABELS = {
641
+ "spotify.play_history": "Spotify recent plays, read while Moondog runs",
642
+ "spotify.extended_history.play": "Spotify Extended Streaming History file",
643
+ "spotify.account_data.play_history": "Spotify Account Data file",
644
+ "listenbrainz.listen": "ListenBrainz export file",
645
+ };
646
+
647
+ const SCROBBLE_SOURCE_KEY = "lastfm.music_import";
648
+ // Scrobbles record when a song started. Spotify records when a play ended, so its start is
649
+ // the end minus the listening time; Account Data times are rounded to the minute.
650
+ const MIRROR_TOLERANCE_MS = 150_000;
651
+ const DAY_MS = 86_400_000;
652
+ const foldLabel = (value) => String(value ?? "").normalize("NFKC").toLocaleLowerCase("und")
653
+ .replace(/[^\p{L}\p{N}]+/gu, " ").trim();
654
+ const lexicalCompare = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
655
+
656
+ /**
657
+ * Many Last.fm scrobbles repeat plays that Spotify or ListenBrainz recorded too. Each scrobble
658
+ * that matches another service's play (same artist and title, starting within a few minutes)
659
+ * is linked to it once, whichever was imported first, so the play counts once.
660
+ */
661
+ function reconcileScrobbleMirrors(database, subjectId, earliest, latest, createdAt) {
662
+ if (!subjectId || !earliest || !latest) return 0;
663
+ const scrobbled = database.prepare(
664
+ "SELECT 1 FROM import_batches WHERE subject_id = ? AND source_key = ? LIMIT 1",
665
+ ).get(subjectId, SCROBBLE_SOURCE_KEY);
666
+ if (!scrobbled) return 0;
667
+ const rows = database.prepare(
668
+ `SELECT e.listening_event_id AS id, e.occurred_at AS at,
669
+ e.record_json AS event_json, t.record_json AS track_json
670
+ FROM listening_events e
671
+ JOIN track_refs t
672
+ ON t.track_ref_id = e.track_ref_id AND t.revision = e.track_ref_revision
673
+ WHERE e.subject_id = ? AND e.occurred_at BETWEEN ? AND ?
674
+ AND NOT EXISTS (
675
+ SELECT 1 FROM listening_event_supersessions s
676
+ WHERE s.superseded_event_id = e.listening_event_id
677
+ )
678
+ AND NOT EXISTS (
679
+ SELECT 1 FROM listening_event_mirrors m
680
+ WHERE m.mirror_event_id = e.listening_event_id
681
+ OR m.original_event_id = e.listening_event_id
682
+ )`,
683
+ ).all(
684
+ subjectId,
685
+ new Date(Date.parse(earliest) - DAY_MS).toISOString(),
686
+ new Date(Date.parse(latest) + DAY_MS).toISOString(),
687
+ );
688
+ const scrobbles = new Map();
689
+ const plays = [];
690
+ for (const row of rows) {
691
+ const event = JSON.parse(row.event_json);
692
+ const track = JSON.parse(row.track_json);
693
+ const artist = foldLabel(track.artist_credits?.[0]?.name);
694
+ const title = foldLabel(track.title);
695
+ if (!artist || !title) continue;
696
+ const key = `${artist}\u0000${title}`;
697
+ const at = Date.parse(row.at);
698
+ const system = String(event.provenance?.external_ref?.system ?? "");
699
+ if (system === "lastfm") {
700
+ if (!scrobbles.has(key)) scrobbles.set(key, []);
701
+ scrobbles.get(key).push({ id: row.id, start: at });
702
+ continue;
703
+ }
704
+ const length = Number.isSafeInteger(event.played_ms) ? event.played_ms
705
+ : Number.isSafeInteger(track.duration_ms) ? track.duration_ms : null;
706
+ // A Spotify play of unknown length can't be placed precisely enough to match.
707
+ if (system.startsWith("spotify") && length === null) continue;
708
+ plays.push({ id: row.id, key, start: system.startsWith("spotify") ? at - length : at });
709
+ }
710
+ const pairs = [];
711
+ for (const list of scrobbles.values()) list.sort((left, right) => left.start - right.start);
712
+ for (const play of plays) {
713
+ const list = scrobbles.get(play.key);
714
+ if (!list) continue;
715
+ let low = 0;
716
+ let high = list.length;
717
+ while (low < high) {
718
+ const middle = (low + high) >> 1;
719
+ if (list[middle].start < play.start - MIRROR_TOLERANCE_MS) low = middle + 1;
720
+ else high = middle;
721
+ }
722
+ for (let index = low; index < list.length && list[index].start <= play.start + MIRROR_TOLERANCE_MS; index += 1) {
723
+ pairs.push({ gap: Math.abs(list[index].start - play.start), play: play.id, scrobble: list[index].id });
724
+ }
725
+ }
726
+ // Closest pairs first, so each play and each scrobble is used once.
727
+ pairs.sort((left, right) => left.gap - right.gap ||
728
+ lexicalCompare(left.scrobble, right.scrobble) || lexicalCompare(left.play, right.play));
729
+ const insert = database.prepare(
730
+ `INSERT INTO listening_event_mirrors (mirror_event_id, original_event_id, created_at)
731
+ VALUES (?, ?, ?)`,
732
+ );
733
+ const usedPlays = new Set();
734
+ const usedScrobbles = new Set();
735
+ let linked = 0;
736
+ for (const pair of pairs) {
737
+ if (usedPlays.has(pair.play) || usedScrobbles.has(pair.scrobble)) continue;
738
+ usedPlays.add(pair.play);
739
+ usedScrobbles.add(pair.scrobble);
740
+ linked += insert.run(pair.scrobble, pair.play, createdAt).changes;
741
+ }
742
+ return linked;
743
+ }
744
+
745
+ function preserveExtendedHistoryMatches(bundle, retainedMatch) {
746
+ const candidates = bundle.reconciliationCandidates;
747
+ if (bundle.importBatch.sourceFormat !== "spotify_extended_streaming_history_music_v1") return candidates;
748
+ const retained = new Map(candidates.map((candidate) => [
749
+ candidate.supersedingEventId, retainedMatch(candidate.supersedingEventId),
750
+ ]));
751
+ if (!candidates.some((candidate) => retained.get(candidate.supersedingEventId) &&
752
+ retained.get(candidate.supersedingEventId) !== candidate.supersededEventId)) return candidates;
753
+
754
+ // Account Data cannot distinguish equal-duration plays of the same title and
755
+ // artist within one minute. Added video plays can reorder those ordinals.
756
+ // A narrower export may omit an established counterpart's ordinal entirely.
757
+ // Keep those pairs too; only new plays use this archive's remaining candidates.
758
+ // The store's conflict checks still reject a new play claiming an absent play's
759
+ // retained counterpart, so an incomplete export cannot invent another match.
760
+ const events = new Map(bundle.listeningEvents.map((event) => [event.listening_event_id, event]));
761
+ const tracks = new Map(bundle.trackRefs.map((track) => [track.track_ref_id, track]));
762
+ const groups = new Map();
763
+ for (const candidate of candidates) {
764
+ const event = events.get(candidate.supersedingEventId);
765
+ const track = tracks.get(event.track_ref_id);
766
+ const key = JSON.stringify([
767
+ event.occurred_at.slice(0, 16), event.played_ms,
768
+ track.title.normalize("NFKC").toLowerCase(),
769
+ track.artist_credits[0].name.normalize("NFKC").toLowerCase(),
770
+ ]);
771
+ const group = groups.get(key) ?? [];
772
+ group.push(candidate);
773
+ groups.set(key, group);
774
+ }
775
+ const matches = new Map();
776
+ for (const group of groups.values()) {
777
+ const available = new Set(group.map((candidate) => candidate.supersededEventId));
778
+ for (const candidate of group) {
779
+ const previous = retained.get(candidate.supersedingEventId);
780
+ if (!previous) continue;
781
+ available.delete(previous);
782
+ matches.set(candidate.supersedingEventId, previous);
783
+ }
784
+ const remaining = available.values();
785
+ for (const candidate of group) {
786
+ if (matches.has(candidate.supersedingEventId)) continue;
787
+ const selected = available.has(candidate.supersededEventId)
788
+ ? candidate.supersededEventId : remaining.next().value;
789
+ matches.set(candidate.supersedingEventId, selected);
790
+ available.delete(selected);
791
+ }
792
+ }
793
+ return candidates.map((candidate) => ({
794
+ ...candidate, supersededEventId: matches.get(candidate.supersedingEventId),
795
+ }));
796
+ }
797
+
572
798
  export class ListeningHistoryStore {
573
799
  #database;
574
800
  #closed = false;
@@ -670,6 +896,10 @@ export class ListeningHistoryStore {
670
896
  AND NOT EXISTS (
671
897
  SELECT 1 FROM listening_event_supersessions s
672
898
  WHERE s.superseded_event_id = e.listening_event_id
899
+ )
900
+ AND NOT EXISTS (
901
+ SELECT 1 FROM listening_event_mirrors m
902
+ WHERE m.mirror_event_id = e.listening_event_id
673
903
  )`,
674
904
  )
675
905
  .get(normalized).count;
@@ -1024,14 +1254,24 @@ export class ListeningHistoryStore {
1024
1254
  this.#database.exec("BEGIN IMMEDIATE");
1025
1255
  let insertedTracks;
1026
1256
  let insertedEvents;
1257
+ let mirroredEvents = 0;
1027
1258
  try {
1028
1259
  if (subjectId !== undefined) {
1029
1260
  this.localSubjectId({ preferredSubjectId: subjectId });
1030
1261
  }
1262
+ const times = bundle.listeningEvents.map((record) => record.occurred_at).sort();
1263
+ // A read that overlaps a stored play joins up with it.
1264
+ const overlaps = bundle.listeningEvents.some((record) => statements.hasEvent.get(record.deduplication_key));
1265
+ if (bundle.windowFull && subjectId !== undefined && !overlaps) {
1266
+ recordCoverageGap(this.#database, { subjectId, sourceKey: bundle.sourceKey, before: times[0], detectedAt: bundle.capturedAt });
1267
+ }
1031
1268
  ({ insertedTracks, insertedEvents } = insertBundleRecords(
1032
1269
  bundle,
1033
1270
  statements,
1034
1271
  ));
1272
+ // A read that finds nothing new still moves the source's checked time.
1273
+ statements.touchCursor.run(bundle.capturedAt, bundle.sourceKey);
1274
+ mirroredEvents = reconcileScrobbleMirrors(this.#database, subjectId, times[0], times.at(-1), bundle.capturedAt);
1035
1275
  this.#database.exec("COMMIT");
1036
1276
  } catch (error) {
1037
1277
  this.#database.exec("ROLLBACK");
@@ -1045,6 +1285,7 @@ export class ListeningHistoryStore {
1045
1285
  inserted_events: insertedEvents,
1046
1286
  duplicate_events: bundle.listeningEvents.length - insertedEvents,
1047
1287
  inserted_track_refs: insertedTracks,
1288
+ ...(mirroredEvents ? { mirrored_events: mirroredEvents } : {}),
1048
1289
  cursor_after_ms: this.sourceCursor(bundle.sourceKey),
1049
1290
  profile_effects: insertedEvents > 0 ? "updated" : "unchanged",
1050
1291
  };
@@ -1055,7 +1296,7 @@ export class ListeningHistoryStore {
1055
1296
  const bundle = normalizedImportBundle(value);
1056
1297
  const batch = bundle.importBatch;
1057
1298
  const existingBatch = this.#database.prepare(
1058
- `SELECT import_batch_id FROM import_batches
1299
+ `SELECT * FROM import_batches
1059
1300
  WHERE import_batch_id = ?`,
1060
1301
  );
1061
1302
  const insertBatch = this.#database.prepare(
@@ -1141,12 +1382,14 @@ export class ListeningHistoryStore {
1141
1382
  let insertedTracks = 0;
1142
1383
  let insertedEvents = 0;
1143
1384
  let supersededEvents = 0;
1385
+ let mirroredEvents = 0;
1144
1386
  let insertedProfileEvidence = 0;
1145
1387
  let alreadyImported = false;
1146
1388
  let profileAlreadyImported = bundle.profileImport === undefined;
1147
1389
  try {
1148
1390
  this.localSubjectId({ preferredSubjectId: batch.subjectId });
1149
- alreadyImported = Boolean(existingBatch.get(batch.importBatchId));
1391
+ const previousBatch = existingBatch.get(batch.importBatchId);
1392
+ alreadyImported = Boolean(previousBatch);
1150
1393
  if (!alreadyImported) {
1151
1394
  ({ insertedTracks, insertedEvents } = insertBundleRecords(
1152
1395
  bundle,
@@ -1169,8 +1412,38 @@ export class ListeningHistoryStore {
1169
1412
  batch.inputRecords - insertedEvents,
1170
1413
  insertedTracks,
1171
1414
  );
1415
+ } else {
1416
+ // Earlier readers skipped video members, even when they contained music.
1417
+ // Extend that same archive's receipt without replacing its existing rows.
1418
+ const previousMembers = JSON.parse(previousBatch.member_names_json);
1419
+ const addedMembers = batch.memberNames.filter((name) => !previousMembers.includes(name));
1420
+ const expandsExtendedArchive = batch.sourceFormat === "spotify_extended_streaming_history_music_v1" &&
1421
+ previousBatch.subject_id === batch.subjectId && previousBatch.source_key === bundle.sourceKey &&
1422
+ previousBatch.source_format === batch.sourceFormat && previousBatch.data_scope === batch.dataScope &&
1423
+ previousBatch.archive_sha256 === batch.archiveSha256 && previousBatch.archive_size_bytes === batch.archiveSizeBytes &&
1424
+ previousMembers.every((name) => batch.memberNames.includes(name)) && addedMembers.length > 0 &&
1425
+ addedMembers.every((name) => /^Streaming_History_Video_\d{4}(?:_\d+)?\.json$/u.test(name));
1426
+ if (expandsExtendedArchive) {
1427
+ const incomingIds = new Set(bundle.listeningEvents.map((event) => event.listening_event_id));
1428
+ const previousEvents = this.#database.prepare(`SELECT listening_event_id FROM listening_events
1429
+ WHERE subject_id = ? AND json_extract(record_json, '$.provenance.import_batch_id') = ?`)
1430
+ .all(batch.subjectId, batch.importBatchId);
1431
+ if (batch.inputRecords < previousBatch.input_records || previousEvents.some((event) => !incomingIds.has(event.listening_event_id))) {
1432
+ throw new Error("Extended archive expansion must preserve every previously imported event");
1433
+ }
1434
+ ({ insertedTracks, insertedEvents } = insertBundleRecords(bundle, statements));
1435
+ this.#database.prepare(`UPDATE import_batches SET member_names_json = ?, input_records = ?,
1436
+ earliest_occurred_at = ?, latest_occurred_at = ?,
1437
+ inserted_events = inserted_events + ?, duplicate_events = ? - inserted_events - ?,
1438
+ inserted_track_refs = inserted_track_refs + ? WHERE import_batch_id = ?`)
1439
+ .run(JSON.stringify(batch.memberNames), batch.inputRecords, batch.earliestOccurredAt, batch.latestOccurredAt,
1440
+ insertedEvents, batch.inputRecords, insertedEvents, insertedTracks, batch.importBatchId);
1441
+ }
1172
1442
  }
1173
- for (const candidate of bundle.reconciliationCandidates) {
1443
+ const reconciliationCandidates = preserveExtendedHistoryMatches(bundle, (eventId) =>
1444
+ selectCandidateBySuperseding.get(eventId)?.superseded_event_id ??
1445
+ selectBySuperseding.get(eventId)?.superseded_event_id);
1446
+ for (const candidate of reconciliationCandidates) {
1174
1447
  const existingBySuperseded = selectCandidateBySuperseded.get(
1175
1448
  candidate.supersededEventId,
1176
1449
  )?.superseding_event_id;
@@ -1292,6 +1565,9 @@ export class ListeningHistoryStore {
1292
1565
  );
1293
1566
  }
1294
1567
  }
1568
+ mirroredEvents = reconcileScrobbleMirrors(
1569
+ this.#database, batch.subjectId, batch.earliestOccurredAt, batch.latestOccurredAt, batch.importedAt,
1570
+ );
1295
1571
  this.#database.exec("COMMIT");
1296
1572
  } catch (error) {
1297
1573
  this.#database.exec("ROLLBACK");
@@ -1310,7 +1586,8 @@ export class ListeningHistoryStore {
1310
1586
  duplicate_events: batch.inputRecords - insertedEvents,
1311
1587
  inserted_track_refs: insertedTracks,
1312
1588
  superseded_events: supersededEvents,
1313
- effective_event_delta: insertedEvents - supersededEvents,
1589
+ ...(mirroredEvents ? { mirrored_events: mirroredEvents } : {}),
1590
+ effective_event_delta: insertedEvents - supersededEvents - mirroredEvents,
1314
1591
  cursor_after_ms: this.sourceCursor(bundle.sourceKey),
1315
1592
  earliest_occurred_at: batch.earliestOccurredAt,
1316
1593
  latest_occurred_at: batch.latestOccurredAt,
@@ -1333,7 +1610,7 @@ export class ListeningHistoryStore {
1333
1610
  }
1334
1611
  : {}),
1335
1612
  profile_effects:
1336
- insertedEvents - supersededEvents !== 0 || insertedProfileEvidence > 0
1613
+ insertedEvents > 0 || supersededEvents > 0 || insertedProfileEvidence > 0
1337
1614
  ? "updated"
1338
1615
  : "unchanged",
1339
1616
  };
@@ -1355,6 +1632,10 @@ export class ListeningHistoryStore {
1355
1632
  SELECT 1 FROM listening_event_supersessions
1356
1633
  WHERE superseded_event_id = listening_events.listening_event_id
1357
1634
  )
1635
+ AND NOT EXISTS (
1636
+ SELECT 1 FROM listening_event_mirrors m
1637
+ WHERE m.mirror_event_id = listening_events.listening_event_id
1638
+ )
1358
1639
  ORDER BY occurred_at DESC, listening_event_id DESC
1359
1640
  LIMIT ?`,
1360
1641
  )
@@ -1454,6 +1735,10 @@ export class ListeningHistoryStore {
1454
1735
  SELECT 1 FROM listening_event_supersessions s
1455
1736
  WHERE s.superseded_event_id = e.listening_event_id
1456
1737
  )
1738
+ AND NOT EXISTS (
1739
+ SELECT 1 FROM listening_event_mirrors m
1740
+ WHERE m.mirror_event_id = e.listening_event_id
1741
+ )
1457
1742
  ORDER BY e.occurred_at, e.listening_event_id`,
1458
1743
  )
1459
1744
  .all(normalizedSubjectId)
@@ -1517,6 +1802,10 @@ export class ListeningHistoryStore {
1517
1802
  SELECT 1 FROM listening_event_supersessions
1518
1803
  WHERE superseded_event_id = listening_events.listening_event_id
1519
1804
  )
1805
+ AND NOT EXISTS (
1806
+ SELECT 1 FROM listening_event_mirrors m
1807
+ WHERE m.mirror_event_id = listening_events.listening_event_id
1808
+ )
1520
1809
  GROUP BY track_ref_id
1521
1810
  ORDER BY play_count DESC, track_ref_id`,
1522
1811
  )
@@ -1545,12 +1834,131 @@ export class ListeningHistoryStore {
1545
1834
  return structuredClone({ ...value.summary, lyric_exclusions: value.lyricExclusions });
1546
1835
  }
1547
1836
 
1837
+ activeAvoidances({ subjectId } = {}) {
1838
+ // Host-side filtering must include corrections outside the bounded display.
1839
+ return structuredClone(
1840
+ this.#profileProjection({ subjectId, maxItems: 1 }).lyricExclusions,
1841
+ );
1842
+ }
1843
+
1548
1844
  profileSummary({ subjectId, maxItems = 10 } = {}) {
1549
1845
  return structuredClone(
1550
1846
  this.#profileProjection({ subjectId, maxItems }).summary,
1551
1847
  );
1552
1848
  }
1553
1849
 
1850
+ profileCatalog({ subjectId } = {}) {
1851
+ const projected = this.#profileProjection({ subjectId, maxItems: 1 });
1852
+ const normalizedSubjectId = subjectId.toLowerCase();
1853
+ const imports = this.#database.prepare(`SELECT source_format AS format, data_scope AS scope,
1854
+ SUM(input_records) AS input_records, MIN(earliest_occurred_at) AS earliest,
1855
+ MAX(latest_occurred_at) AS latest
1856
+ FROM import_batches WHERE subject_id = ? GROUP BY source_format, data_scope ORDER BY source_format`)
1857
+ .all(normalizedSubjectId);
1858
+ const stored = this.#database.prepare("SELECT COUNT(*) AS count FROM listening_events WHERE subject_id = ?")
1859
+ .get(normalizedSubjectId).count;
1860
+ return structuredClone({
1861
+ coverage: { ...projected.summary.coverage,
1862
+ stored_listening_events: stored,
1863
+ superseded_listening_events: stored - projected.summary.coverage.effective_listening_events },
1864
+ sources: imports,
1865
+ context: {
1866
+ reference_date: projected.summary.listening_behavior.context.reference_date,
1867
+ recent_window_days: projected.summary.listening_behavior.context.recent_window_days,
1868
+ incognito_events_excluded: projected.summary.listening_behavior.context.incognito_events_excluded,
1869
+ release_minimum_distinct_tracks: projected.summary.listening_behavior.context.release_minimum_distinct_tracks,
1870
+ },
1871
+ sections: projected.catalog,
1872
+ });
1873
+ }
1874
+
1875
+ // What the listener played lately: stored plays in the last `days` up to `now`, by artist.
1876
+ // It reads the clock, not the newest play, so a history that stopped shows as quiet.
1877
+ recentAttention({ subjectId, days = 28, now = Date.now(), limit = 10 } = {}) {
1878
+ if (this.#closed) throw new Error("Listening history store is closed");
1879
+ if (!isUuid(subjectId)) throw new TypeError("A valid profile subject is required");
1880
+ const from = new Date(now - days * DAY_MS).toISOString();
1881
+ const effective = `e.subject_id = ? AND e.occurred_at >= ?
1882
+ AND NOT EXISTS (SELECT 1 FROM listening_event_supersessions s WHERE s.superseded_event_id = e.listening_event_id)
1883
+ AND NOT EXISTS (SELECT 1 FROM listening_event_mirrors m WHERE m.mirror_event_id = e.listening_event_id)`;
1884
+ const subject = subjectId.toLowerCase();
1885
+ const plays = this.#database.prepare(`SELECT COUNT(*) AS plays FROM listening_events e WHERE ${effective}`)
1886
+ .get(subject, from).plays;
1887
+ const artists = this.#database.prepare(`SELECT json_extract(a.value, '$.name') AS name, COUNT(*) AS plays,
1888
+ MAX(e.occurred_at) AS last_played_at
1889
+ FROM listening_events e
1890
+ JOIN track_refs t ON t.track_ref_id = e.track_ref_id AND t.revision = e.track_ref_revision
1891
+ JOIN json_each(t.record_json, '$.artist_credits') a
1892
+ WHERE ${effective} AND name IS NOT NULL
1893
+ GROUP BY name ORDER BY plays DESC, last_played_at DESC LIMIT ?`).all(subject, from, limit);
1894
+ return structuredClone({ from, to: new Date(now).toISOString(), plays, top_artists: artists });
1895
+ }
1896
+
1897
+ // Which spans of time the stored plays cover, and where plays may be missing.
1898
+ listeningCoverage({ subjectId } = {}) {
1899
+ if (this.#closed) throw new Error("Listening history store is closed");
1900
+ if (!isUuid(subjectId)) throw new TypeError("A valid profile subject is required");
1901
+ const subject = subjectId.toLowerCase();
1902
+ const sources = this.#database.prepare(`SELECT json_extract(record_json, '$.provenance.external_ref.entity_type') AS kind,
1903
+ COUNT(*) AS plays, MIN(occurred_at) AS first_played_at, MAX(occurred_at) AS last_played_at
1904
+ FROM listening_events WHERE subject_id = ? GROUP BY kind ORDER BY first_played_at`).all(subject);
1905
+ // A history file imported later can fill a span that a sync missed.
1906
+ const filled = this.#database.prepare(`SELECT earliest_occurred_at AS earliest, latest_occurred_at AS latest
1907
+ FROM import_batches WHERE subject_id = ? AND earliest_occurred_at IS NOT NULL AND latest_occurred_at IS NOT NULL`).all(subject);
1908
+ const gaps = this.#database.prepare(`SELECT gap_start AS "from", gap_end AS "to"
1909
+ FROM listening_coverage_gaps WHERE subject_id = ? ORDER BY gap_start`).all(subject)
1910
+ .flatMap((gap) => filled.reduce((spans, { earliest, latest }) => spans.flatMap((span) =>
1911
+ latest <= span.from || earliest >= span.to ? [span] : [
1912
+ ...(earliest > span.from ? [{ from: span.from, to: earliest }] : []),
1913
+ ...(latest < span.to ? [{ from: latest, to: span.to }] : []),
1914
+ ]), [{ ...gap }]));
1915
+ const checked = this.#database.prepare("SELECT updated_at FROM source_cursors WHERE source_key = 'spotify.recently_played'").get();
1916
+ return structuredClone({
1917
+ sources: sources.map(({ kind, ...source }) => ({ source: COVERAGE_SOURCE_LABELS[kind] ?? kind, ...source })),
1918
+ gaps: gaps.slice(-10),
1919
+ ...(checked ? { recent_plays_checked_at: checked.updated_at } : {}),
1920
+ });
1921
+ }
1922
+
1923
+ listenerProfileStorage({ subjectId } = {}) {
1924
+ if (this.#closed) throw new Error("Listening history store is closed");
1925
+ if (!isUuid(subjectId)) throw new TypeError("A valid profile subject is required");
1926
+ return new ListenerProfileStore(this.#database, subjectId.toLowerCase());
1927
+ }
1928
+
1929
+ profileDataVersion() {
1930
+ if (this.#closed) throw new Error("Listening history store is closed");
1931
+ // data_version observes other connections; total_changes observes our own.
1932
+ // This is a connection-local cache key, never a persisted evidence digest.
1933
+ return `${this.#database.prepare("PRAGMA data_version").get().data_version}:${
1934
+ this.#database.prepare("SELECT total_changes() AS changes").get().changes}`;
1935
+ }
1936
+
1937
+ profileEvidenceRevision({ subjectId } = {}) {
1938
+ if (this.#closed) throw new Error("Listening history store is closed");
1939
+ if (!isUuid(subjectId)) throw new TypeError("A valid profile subject is required");
1940
+ const digest = createHash("sha256");
1941
+ // Build artifacts never feed back into source evidence. Hash locally; raw
1942
+ // events, source identifiers and private import metadata do not go to Pi.
1943
+ const queries = [
1944
+ "SELECT record_json FROM listening_events WHERE subject_id = ? ORDER BY listening_event_id",
1945
+ "SELECT record_json FROM spotify_profile_evidence WHERE subject_id = ? ORDER BY profile_evidence_id",
1946
+ "SELECT record_json FROM taste_events WHERE subject_id = ? ORDER BY taste_event_id",
1947
+ "SELECT * FROM import_batches WHERE subject_id = ? ORDER BY import_batch_id",
1948
+ "SELECT * FROM profile_import_batches WHERE subject_id = ? ORDER BY profile_import_id",
1949
+ `SELECT t.record_json FROM track_refs t WHERE EXISTS (SELECT 1 FROM listening_events e
1950
+ WHERE e.subject_id = ? AND e.track_ref_id = t.track_ref_id AND e.track_ref_revision = t.revision)
1951
+ ORDER BY t.track_ref_id, t.revision`,
1952
+ ];
1953
+ for (const query of queries) {
1954
+ digest.update(query).update("\0");
1955
+ for (const row of this.#database.prepare(query).iterate(subjectId.toLowerCase())) {
1956
+ digest.update(JSON.stringify(row)).update("\0");
1957
+ }
1958
+ }
1959
+ return digest.digest("hex");
1960
+ }
1961
+
1554
1962
  rediscoveryCandidates({ subjectId, limit = 6 } = {}) {
1555
1963
  if (!Number.isInteger(limit) || limit < 1 || limit > 12) {
1556
1964
  throw new TypeError("Listening rediscovery limit is invalid");
@@ -1930,6 +2338,10 @@ export class ListeningHistoryStore {
1930
2338
  WHERE NOT EXISTS (
1931
2339
  SELECT 1 FROM listening_event_supersessions
1932
2340
  WHERE superseded_event_id = listening_events.listening_event_id
2341
+ )
2342
+ AND NOT EXISTS (
2343
+ SELECT 1 FROM listening_event_mirrors m
2344
+ WHERE m.mirror_event_id = listening_events.listening_event_id
1933
2345
  )`,
1934
2346
  )
1935
2347
  .get().count,
@@ -2003,7 +2415,11 @@ async function initializeListeningHistoryDatabase(
2003
2415
  ) {
2004
2416
  throw new Error("Listening history database schema is incompatible");
2005
2417
  }
2418
+ const recordsGaps = database.prepare(
2419
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'listening_coverage_gaps'",
2420
+ ).get();
2006
2421
  database.exec(schema);
2422
+ if (!recordsGaps) recordEarlierRecentSyncGaps(database);
2007
2423
  if (currentVersion < LISTENING_HISTORY_SCHEMA_VERSION) {
2008
2424
  database.exec(
2009
2425
  `PRAGMA user_version = ${LISTENING_HISTORY_SCHEMA_VERSION}`,