@audiofool/moondog 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +125 -0
  2. package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
  3. package/contracts/v1/capability-policy.schema.json +572 -0
  4. package/contracts/v1/definitions.schema.json +365 -0
  5. package/contracts/v1/library-track-observation.schema.json +138 -0
  6. package/contracts/v1/listening-event.schema.json +111 -0
  7. package/contracts/v1/profile-evidence.schema.json +237 -0
  8. package/contracts/v1/taste-event.schema.json +258 -0
  9. package/contracts/v1/tool-invocation.schema.json +318 -0
  10. package/contracts/v1/tool-receipt.schema.json +1066 -0
  11. package/contracts/v1/track-ref.schema.json +275 -0
  12. package/contracts/v2/profile-evidence.schema.json +284 -0
  13. package/package.json +105 -0
  14. package/scripts/contract-lib.mjs +361 -0
  15. package/scripts/contract-semantics.mjs +2630 -0
  16. package/scripts/demo-studio.mjs +58 -0
  17. package/scripts/moondog +52 -0
  18. package/scripts/moondog.mjs +954 -0
  19. package/src/core/apple-data-migration.mjs +50 -0
  20. package/src/core/apple-library-source-status.mjs +220 -0
  21. package/src/core/apple-projection-domain-services.mjs +2356 -0
  22. package/src/core/capability-catalog.mjs +577 -0
  23. package/src/core/listening-profile-domain-services.mjs +1010 -0
  24. package/src/core/lyric-library.mjs +121 -0
  25. package/src/core/lyric-profile.mjs +50 -0
  26. package/src/core/lyric-service.mjs +41 -0
  27. package/src/core/moondog-application.mjs +1827 -0
  28. package/src/core/state-directory.mjs +19 -0
  29. package/src/core/synthetic-domain-services.mjs +1485 -0
  30. package/src/core/uuid-v5.mjs +42 -0
  31. package/src/demo/fictional-spotify-history.mjs +400 -0
  32. package/src/demo/moondog-demo.mjs +355 -0
  33. package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
  34. package/src/demo/moondog-tasteprint-demo.mjs +631 -0
  35. package/src/importers/apple-music-library/import-batch.mjs +290 -0
  36. package/src/importers/apple-music-library/index.mjs +76 -0
  37. package/src/importers/apple-music-library/normalize.mjs +1126 -0
  38. package/src/importers/apple-music-library/parse-plist.mjs +357 -0
  39. package/src/importers/apple-music-library/projection-records.mjs +557 -0
  40. package/src/importers/apple-music-library/read-batch.mjs +276 -0
  41. package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
  42. package/src/importers/apple-music-library/stable-ids.mjs +19 -0
  43. package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
  44. package/src/integrations/apple-music/catalog.mjs +770 -0
  45. package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
  46. package/src/integrations/listenbrainz/history-file.mjs +560 -0
  47. package/src/integrations/lyrics/lrclib.mjs +55 -0
  48. package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
  49. package/src/integrations/public-playlists.mjs +104 -0
  50. package/src/integrations/spotify/account-data-history.mjs +545 -0
  51. package/src/integrations/spotify/account-data-profile.mjs +918 -0
  52. package/src/integrations/spotify/authentication.mjs +791 -0
  53. package/src/integrations/spotify/catalog-resolver.mjs +657 -0
  54. package/src/integrations/spotify/connection.mjs +125 -0
  55. package/src/integrations/spotify/credential-store.mjs +26 -0
  56. package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
  57. package/src/integrations/spotify/history-archive.mjs +59 -0
  58. package/src/integrations/spotify/index.mjs +80 -0
  59. package/src/integrations/spotify/recent-activity.mjs +198 -0
  60. package/src/integrations/spotify/resolution-cache.mjs +260 -0
  61. package/src/integrations/spotify/service.mjs +713 -0
  62. package/src/integrations/spotify/settings.mjs +89 -0
  63. package/src/integrations/spotify/web-api-client.mjs +761 -0
  64. package/src/integrations/web/codex-web.mjs +263 -0
  65. package/src/integrations/youtube-music/takeout.mjs +143 -0
  66. package/src/memory/local-memory-store.mjs +1705 -0
  67. package/src/memory/reflection-worker.mjs +87 -0
  68. package/src/profile/apple-library-import.mjs +28 -0
  69. package/src/profile/history-import.mjs +163 -0
  70. package/src/profile/listener-corrections.mjs +217 -0
  71. package/src/profile/listening-history-store.mjs +2055 -0
  72. package/src/profile/listening-profile-projection.mjs +2520 -0
  73. package/src/profile/local-music-data.mjs +812 -0
  74. package/src/profile/music-import-bundle.mjs +91 -0
  75. package/src/profile/music-providers.mjs +25 -0
  76. package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
  77. package/src/profile/spotify-archive-taste.mjs +482 -0
  78. package/src/runtime/pi/agent-runtime.mjs +6444 -0
  79. package/src/runtime/pi/authentication.mjs +104 -0
  80. package/src/runtime/pi/configured-runtime.mjs +98 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
  82. package/src/runtime/pi/model-catalog.mjs +62 -0
  83. package/src/runtime/pi/model-transport.mjs +117 -0
  84. package/src/runtime/pi/persistent-credential-store.mjs +670 -0
  85. package/src/runtime/pi/provider-registry.mjs +74 -0
  86. package/src/runtime/pi/runtime-settings.mjs +81 -0
  87. package/src/surfaces/cli/auth-command.mjs +355 -0
  88. package/src/surfaces/cli/brand-components.mjs +390 -0
  89. package/src/surfaces/cli/brand-theme.mjs +231 -0
  90. package/src/surfaces/cli/catalog-command.mjs +509 -0
  91. package/src/surfaces/cli/command-completions.mjs +113 -0
  92. package/src/surfaces/cli/data-command.mjs +214 -0
  93. package/src/surfaces/cli/format-output.mjs +750 -0
  94. package/src/surfaces/cli/history-import-view.mjs +445 -0
  95. package/src/surfaces/cli/import-guides.mjs +9 -0
  96. package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
  97. package/src/surfaces/cli/listening-menu.mjs +171 -0
  98. package/src/surfaces/cli/profile-command.mjs +290 -0
  99. package/src/surfaces/cli/spotify-command.mjs +849 -0
  100. package/src/surfaces/cli/taste-profile-model.mjs +306 -0
  101. package/src/surfaces/cli/taste-profile-view.mjs +272 -0
  102. package/src/surfaces/cli/terminal-art.mjs +295 -0
  103. package/src/surfaces/cli/tui.mjs +1714 -0
  104. package/src/surfaces/cli/web-command.mjs +52 -0
  105. package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
  106. package/src/surfaces/html/tasteprint.mjs +3575 -0
  107. package/src/surfaces/web/showcase.mjs +272 -0
  108. package/src/surfaces/web/studio-page.mjs +4148 -0
  109. package/src/surfaces/web/studio.mjs +2288 -0
@@ -0,0 +1,2055 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmod, mkdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { resolveMoondogStateDirectory } from "../core/state-directory.mjs";
6
+ import { isUuid } from "../core/uuid-v5.mjs";
7
+ import {
8
+ createListenerCorrection,
9
+ createListenerCorrectionRetraction,
10
+ } from "./listener-corrections.mjs";
11
+ import { projectListeningProfile } from "./listening-profile-projection.mjs";
12
+
13
+ export const LISTENING_HISTORY_SCHEMA_VERSION = 6;
14
+
15
+ export function resolveListeningHistoryPath(environment = process.env) {
16
+ return path.join(resolveMoondogStateDirectory(environment), "listening-history.sqlite");
17
+ }
18
+
19
+ export const defaultListeningHistoryPath = resolveListeningHistoryPath();
20
+
21
+ const schema = `
22
+ PRAGMA foreign_keys = ON;
23
+ PRAGMA trusted_schema = OFF;
24
+
25
+ CREATE TABLE IF NOT EXISTS local_music_subject (
26
+ singleton INTEGER PRIMARY KEY NOT NULL CHECK (singleton = 1),
27
+ subject_id TEXT NOT NULL UNIQUE
28
+ ) STRICT;
29
+
30
+ CREATE TABLE IF NOT EXISTS track_refs (
31
+ track_ref_id TEXT NOT NULL,
32
+ revision INTEGER NOT NULL,
33
+ record_json TEXT NOT NULL,
34
+ PRIMARY KEY (track_ref_id, revision)
35
+ ) STRICT;
36
+
37
+ CREATE TABLE IF NOT EXISTS listening_events (
38
+ listening_event_id TEXT PRIMARY KEY NOT NULL,
39
+ subject_id TEXT NOT NULL,
40
+ track_ref_id TEXT NOT NULL,
41
+ track_ref_revision INTEGER NOT NULL,
42
+ occurred_at TEXT NOT NULL,
43
+ deduplication_key TEXT NOT NULL UNIQUE,
44
+ record_json TEXT NOT NULL,
45
+ FOREIGN KEY (track_ref_id, track_ref_revision)
46
+ REFERENCES track_refs(track_ref_id, revision)
47
+ ) STRICT;
48
+
49
+ CREATE INDEX IF NOT EXISTS listening_events_subject_time
50
+ ON listening_events(subject_id, occurred_at DESC);
51
+
52
+ CREATE TABLE IF NOT EXISTS source_cursors (
53
+ source_key TEXT PRIMARY KEY NOT NULL,
54
+ cursor_after_ms INTEGER NOT NULL,
55
+ updated_at TEXT NOT NULL
56
+ ) STRICT;
57
+
58
+ CREATE TABLE IF NOT EXISTS import_batches (
59
+ import_batch_id TEXT PRIMARY KEY NOT NULL,
60
+ subject_id TEXT NOT NULL,
61
+ source_key TEXT NOT NULL,
62
+ source_format TEXT NOT NULL,
63
+ data_scope TEXT NOT NULL,
64
+ archive_sha256 TEXT NOT NULL,
65
+ archive_size_bytes INTEGER NOT NULL,
66
+ member_names_json TEXT NOT NULL,
67
+ input_records INTEGER NOT NULL,
68
+ earliest_occurred_at TEXT,
69
+ latest_occurred_at TEXT,
70
+ imported_at TEXT NOT NULL,
71
+ inserted_events INTEGER NOT NULL,
72
+ duplicate_events INTEGER NOT NULL,
73
+ inserted_track_refs INTEGER NOT NULL,
74
+ UNIQUE (source_key, subject_id, archive_sha256)
75
+ ) STRICT;
76
+
77
+ CREATE TABLE IF NOT EXISTS listening_event_supersessions (
78
+ superseded_event_id TEXT PRIMARY KEY NOT NULL,
79
+ superseding_event_id TEXT NOT NULL UNIQUE,
80
+ import_batch_id TEXT NOT NULL,
81
+ created_at TEXT NOT NULL,
82
+ FOREIGN KEY (superseded_event_id)
83
+ REFERENCES listening_events(listening_event_id),
84
+ FOREIGN KEY (superseding_event_id)
85
+ REFERENCES listening_events(listening_event_id),
86
+ FOREIGN KEY (import_batch_id)
87
+ REFERENCES import_batches(import_batch_id)
88
+ ) STRICT;
89
+
90
+ CREATE TABLE IF NOT EXISTS listening_event_reconciliation_candidates (
91
+ superseded_event_id TEXT PRIMARY KEY NOT NULL,
92
+ superseding_event_id TEXT NOT NULL UNIQUE,
93
+ import_batch_id TEXT NOT NULL,
94
+ created_at TEXT NOT NULL,
95
+ FOREIGN KEY (superseding_event_id)
96
+ REFERENCES listening_events(listening_event_id),
97
+ FOREIGN KEY (import_batch_id)
98
+ REFERENCES import_batches(import_batch_id)
99
+ ) STRICT;
100
+
101
+ CREATE TABLE IF NOT EXISTS profile_import_batches (
102
+ profile_import_id TEXT PRIMARY KEY NOT NULL,
103
+ subject_id TEXT NOT NULL,
104
+ source_key TEXT NOT NULL,
105
+ source_format TEXT NOT NULL,
106
+ archive_sha256 TEXT NOT NULL,
107
+ archive_size_bytes INTEGER NOT NULL,
108
+ member_names_json TEXT NOT NULL,
109
+ input_records INTEGER NOT NULL,
110
+ imported_at TEXT NOT NULL,
111
+ inserted_evidence INTEGER NOT NULL,
112
+ UNIQUE (source_key, subject_id, archive_sha256)
113
+ ) STRICT;
114
+
115
+ CREATE TABLE IF NOT EXISTS spotify_profile_evidence (
116
+ profile_evidence_id TEXT PRIMARY KEY NOT NULL,
117
+ evidence_key TEXT NOT NULL,
118
+ subject_id TEXT NOT NULL,
119
+ evidence_kind TEXT NOT NULL,
120
+ profile_import_id TEXT NOT NULL,
121
+ observed_at TEXT NOT NULL,
122
+ captured_at TEXT NOT NULL,
123
+ record_json TEXT NOT NULL,
124
+ UNIQUE (profile_import_id, evidence_key),
125
+ FOREIGN KEY (profile_import_id)
126
+ REFERENCES profile_import_batches(profile_import_id)
127
+ ) STRICT;
128
+
129
+ CREATE INDEX IF NOT EXISTS spotify_profile_evidence_subject_kind
130
+ ON spotify_profile_evidence(subject_id, evidence_kind, observed_at DESC);
131
+
132
+ CREATE TABLE IF NOT EXISTS taste_events (
133
+ taste_event_id TEXT PRIMARY KEY NOT NULL,
134
+ subject_id TEXT NOT NULL,
135
+ operation TEXT NOT NULL CHECK (operation IN ('assert', 'retract')),
136
+ target_key TEXT,
137
+ occurred_at TEXT NOT NULL,
138
+ recorded_at TEXT NOT NULL,
139
+ supersedes_taste_event_id TEXT,
140
+ retracts_taste_event_id TEXT,
141
+ record_json TEXT NOT NULL,
142
+ CHECK (
143
+ (operation = 'assert' AND target_key IS NOT NULL AND retracts_taste_event_id IS NULL) OR
144
+ (operation = 'retract' AND target_key IS NULL AND retracts_taste_event_id IS NOT NULL)
145
+ ),
146
+ FOREIGN KEY (subject_id) REFERENCES local_music_subject(subject_id),
147
+ FOREIGN KEY (supersedes_taste_event_id) REFERENCES taste_events(taste_event_id),
148
+ FOREIGN KEY (retracts_taste_event_id) REFERENCES taste_events(taste_event_id)
149
+ ) STRICT;
150
+
151
+ CREATE INDEX IF NOT EXISTS taste_events_subject_time
152
+ ON taste_events(subject_id, recorded_at DESC, taste_event_id DESC);
153
+
154
+ CREATE UNIQUE INDEX IF NOT EXISTS taste_events_one_supersession
155
+ ON taste_events(supersedes_taste_event_id)
156
+ WHERE supersedes_taste_event_id IS NOT NULL;
157
+
158
+ CREATE UNIQUE INDEX IF NOT EXISTS taste_events_one_retraction
159
+ ON taste_events(retracts_taste_event_id)
160
+ WHERE retracts_taste_event_id IS NOT NULL;
161
+ `;
162
+
163
+ function listenerCorrectionError(code, message) {
164
+ const error = new Error(message);
165
+ error.name = "ListenerCorrectionError";
166
+ error.code = code;
167
+ return error;
168
+ }
169
+
170
+ function isPlainObject(value) {
171
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
172
+ const prototype = Object.getPrototypeOf(value);
173
+ return prototype === Object.prototype || prototype === null;
174
+ }
175
+
176
+ function cleanSourceKey(value) {
177
+ if (
178
+ typeof value !== "string" ||
179
+ !/^[a-z][a-z0-9_]*(?:\.[a-z0-9_]+)+$/u.test(value)
180
+ ) {
181
+ throw new TypeError("Listening history source key is invalid");
182
+ }
183
+ return value;
184
+ }
185
+
186
+ function normalizedBundle(value, maximumRecords = 50) {
187
+ if (
188
+ !isPlainObject(value) ||
189
+ !Array.isArray(value.track_refs) ||
190
+ !Array.isArray(value.listening_events) ||
191
+ value.track_refs.length > maximumRecords ||
192
+ value.listening_events.length > maximumRecords
193
+ ) {
194
+ throw new TypeError("Listening history ingestion bundle is invalid");
195
+ }
196
+ return {
197
+ sourceKey: cleanSourceKey(value.source_key),
198
+ cursorAfterMs:
199
+ value.cursor_after_ms === null
200
+ ? null
201
+ : Number.isSafeInteger(value.cursor_after_ms) &&
202
+ value.cursor_after_ms >= 0
203
+ ? value.cursor_after_ms
204
+ : (() => {
205
+ throw new TypeError("Listening history cursor is invalid");
206
+ })(),
207
+ capturedAt: new Date(value.captured_at).toISOString(),
208
+ trackRefs: value.track_refs,
209
+ listeningEvents: value.listening_events,
210
+ };
211
+ }
212
+
213
+ function normalizedUtcTimestamp(value, label, { nullable = false } = {}) {
214
+ if (nullable && value === null) return null;
215
+ if (typeof value !== "string") throw new TypeError(`${label} is invalid`);
216
+ const milliseconds = Date.parse(value);
217
+ if (!Number.isFinite(milliseconds) || new Date(milliseconds).toISOString() !== value) {
218
+ throw new TypeError(`${label} is invalid`);
219
+ }
220
+ return value;
221
+ }
222
+
223
+ function safeProfileAttributes(value) {
224
+ if (!isPlainObject(value) || Object.keys(value).length > 24) {
225
+ throw new TypeError("Spotify profile evidence attributes are invalid");
226
+ }
227
+ for (const [key, entry] of Object.entries(value)) {
228
+ if (!/^[a-z][a-z0-9_]*$/u.test(key)) {
229
+ throw new TypeError("Spotify profile evidence attributes are invalid");
230
+ }
231
+ if (typeof entry === "string") {
232
+ if (
233
+ Array.from(entry).length > 2_000 ||
234
+ /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(entry)
235
+ ) {
236
+ throw new TypeError("Spotify profile evidence attributes are invalid");
237
+ }
238
+ continue;
239
+ }
240
+ if (
241
+ typeof entry === "boolean" ||
242
+ (typeof entry === "number" && Number.isFinite(entry))
243
+ ) {
244
+ continue;
245
+ }
246
+ throw new TypeError("Spotify profile evidence attributes are invalid");
247
+ }
248
+ return value;
249
+ }
250
+
251
+ function normalizedProfileInput(value, batch) {
252
+ const profileImport = value.profile_import;
253
+ const profileEvidence = value.profile_evidence;
254
+ if (profileImport === undefined && profileEvidence === undefined) return null;
255
+ if (
256
+ !isPlainObject(profileImport) ||
257
+ !Array.isArray(profileEvidence) ||
258
+ profileEvidence.length > 100_000 ||
259
+ !isUuid(profileImport.profile_import_id) ||
260
+ !isUuid(profileImport.subject_id) ||
261
+ cleanSourceKey(profileImport.source_key) !== profileImport.source_key ||
262
+ typeof profileImport.source_format !== "string" ||
263
+ !/^[a-z][a-z0-9_]+_v[1-9][0-9]*$/u.test(profileImport.source_format) ||
264
+ profileImport.archive_sha256 !== batch.archiveSha256 ||
265
+ !Number.isSafeInteger(profileImport.archive_size_bytes) ||
266
+ profileImport.archive_size_bytes !== batch.archiveSizeBytes ||
267
+ !Array.isArray(profileImport.member_names) ||
268
+ profileImport.member_names.length < 1 ||
269
+ profileImport.member_names.length > 100 ||
270
+ new Set(profileImport.member_names).size !== profileImport.member_names.length ||
271
+ !Number.isSafeInteger(profileImport.input_records) ||
272
+ profileImport.input_records !== profileEvidence.length
273
+ ) {
274
+ throw new TypeError("Spotify profile import manifest is invalid");
275
+ }
276
+ const subjectId = profileImport.subject_id.toLowerCase();
277
+ const profileImportId = profileImport.profile_import_id.toLowerCase();
278
+ if (subjectId !== batch.subjectId) {
279
+ throw new TypeError("Spotify profile import subject scope is invalid");
280
+ }
281
+ const importedAt = normalizedUtcTimestamp(
282
+ profileImport.imported_at,
283
+ "Spotify profile import timestamp",
284
+ );
285
+ for (const memberName of profileImport.member_names) {
286
+ if (
287
+ typeof memberName !== "string" ||
288
+ memberName.length < 1 ||
289
+ memberName.length > 255 ||
290
+ /[\/\\\u0000-\u001f\u007f]/u.test(memberName)
291
+ ) {
292
+ throw new TypeError("Spotify profile import member name is invalid");
293
+ }
294
+ }
295
+ const allowedDirections = new Set(["supports", "contradicts", "context"]);
296
+ const allowedStrengths = new Set([
297
+ "explicit",
298
+ "curated",
299
+ "behavioral",
300
+ "provider_derived",
301
+ ]);
302
+ const evidenceIds = new Set();
303
+ const evidenceKeys = new Set();
304
+ const records = profileEvidence.map((record) => {
305
+ if (
306
+ !isPlainObject(record) ||
307
+ record.schema_version !== 1 ||
308
+ !isUuid(record.profile_evidence_id) ||
309
+ typeof record.evidence_key !== "string" ||
310
+ !/^[a-f0-9]{64}$/u.test(record.evidence_key) ||
311
+ !isUuid(record.subject_id) ||
312
+ record.subject_id.toLowerCase() !== subjectId ||
313
+ typeof record.evidence_kind !== "string" ||
314
+ !/^[a-z][a-z0-9_]*$/u.test(record.evidence_kind) ||
315
+ !allowedDirections.has(record.direction) ||
316
+ !allowedStrengths.has(record.strength_class) ||
317
+ !isPlainObject(record.entity) ||
318
+ typeof record.entity.entity_type !== "string" ||
319
+ !/^[a-z][a-z0-9_]*$/u.test(record.entity.entity_type) ||
320
+ !isUuid(record.entity.entity_ref_id) ||
321
+ !isPlainObject(record.provenance) ||
322
+ record.provenance.source_kind !== "import" ||
323
+ !(record.provenance.source_system === "spotify_account_data" ||
324
+ (["youtube_music", "qq_music", "netease"].includes(record.provenance.source_system) &&
325
+ profileImport.source_key === `${record.provenance.source_system}.music_import`)) ||
326
+ !profileImport.member_names.includes(record.provenance.source_member) ||
327
+ record.provenance.profile_import_id?.toLowerCase() !== profileImportId ||
328
+ record.provenance.captured_at !== importedAt
329
+ ) {
330
+ throw new TypeError("Spotify profile evidence record is invalid");
331
+ }
332
+ const evidenceId = record.profile_evidence_id.toLowerCase();
333
+ if (evidenceIds.has(evidenceId) || evidenceKeys.has(record.evidence_key)) {
334
+ throw new TypeError("Spotify profile evidence contains duplicate records");
335
+ }
336
+ evidenceIds.add(evidenceId);
337
+ evidenceKeys.add(record.evidence_key);
338
+ for (const field of ["label", "artist_credit", "release"]) {
339
+ if (
340
+ record.entity[field] !== undefined &&
341
+ (typeof record.entity[field] !== "string" ||
342
+ !record.entity[field].trim() ||
343
+ Array.from(record.entity[field]).length > 512)
344
+ ) {
345
+ throw new TypeError("Spotify profile evidence entity is invalid");
346
+ }
347
+ }
348
+ if (
349
+ (record.entity.track_ref_id !== undefined &&
350
+ !isUuid(record.entity.track_ref_id)) ||
351
+ (record.entity.track_ref_revision !== undefined &&
352
+ record.entity.track_ref_revision !== 1) ||
353
+ (record.entity.track_ref_id === undefined) !==
354
+ (record.entity.track_ref_revision === undefined)
355
+ ) {
356
+ throw new TypeError("Spotify profile evidence track reference is invalid");
357
+ }
358
+ const observedAt = normalizedUtcTimestamp(
359
+ record.observed_at,
360
+ "Spotify profile evidence timestamp",
361
+ );
362
+ if (Date.parse(observedAt) > Date.parse(importedAt)) {
363
+ throw new TypeError("Spotify profile evidence timestamp is in the future");
364
+ }
365
+ safeProfileAttributes(record.attributes);
366
+ return record;
367
+ });
368
+ return {
369
+ profileImport: {
370
+ profileImportId,
371
+ subjectId,
372
+ sourceKey: profileImport.source_key,
373
+ sourceFormat: profileImport.source_format,
374
+ archiveSha256: profileImport.archive_sha256,
375
+ archiveSizeBytes: profileImport.archive_size_bytes,
376
+ memberNames: [...profileImport.member_names],
377
+ inputRecords: profileImport.input_records,
378
+ importedAt,
379
+ },
380
+ profileEvidence: records,
381
+ };
382
+ }
383
+
384
+ function normalizedImportBundle(value) {
385
+ const bundle = normalizedBundle(value, 500_000);
386
+ const batch = value.import_batch;
387
+ if (
388
+ !isPlainObject(batch) ||
389
+ !isUuid(batch.import_batch_id) ||
390
+ !isUuid(batch.subject_id) ||
391
+ typeof batch.source_format !== "string" ||
392
+ !/^[a-z][a-z0-9_]+_v[1-9][0-9]*$/u.test(batch.source_format) ||
393
+ typeof batch.data_scope !== "string" ||
394
+ !/^[a-z][a-z0-9_]+$/u.test(batch.data_scope) ||
395
+ typeof batch.archive_sha256 !== "string" ||
396
+ !/^[a-f0-9]{64}$/u.test(batch.archive_sha256) ||
397
+ !Number.isSafeInteger(batch.archive_size_bytes) ||
398
+ batch.archive_size_bytes < 1 ||
399
+ !Array.isArray(batch.member_names) ||
400
+ batch.member_names.length < 1 ||
401
+ batch.member_names.length > 100 ||
402
+ new Set(batch.member_names).size !== batch.member_names.length ||
403
+ !Number.isSafeInteger(batch.input_records) ||
404
+ batch.input_records < 0 ||
405
+ batch.input_records !== bundle.listeningEvents.length
406
+ ) {
407
+ throw new TypeError("Listening history import manifest is invalid");
408
+ }
409
+ for (const memberName of batch.member_names) {
410
+ if (
411
+ typeof memberName !== "string" ||
412
+ memberName.length < 1 ||
413
+ memberName.length > 255 ||
414
+ /[\/\\\u0000-\u001f\u007f]/u.test(memberName)
415
+ ) {
416
+ throw new TypeError("Listening history import member name is invalid");
417
+ }
418
+ }
419
+ const subjectId = batch.subject_id.toLowerCase();
420
+ for (const event of bundle.listeningEvents) {
421
+ if (
422
+ event?.subject_id?.toLowerCase() !== subjectId ||
423
+ event?.provenance?.import_batch_id?.toLowerCase() !==
424
+ batch.import_batch_id.toLowerCase()
425
+ ) {
426
+ throw new TypeError("Listening history import records do not match their manifest");
427
+ }
428
+ }
429
+ const reconciliationCandidates = value.reconciliation_candidates ?? [];
430
+ if (
431
+ !Array.isArray(reconciliationCandidates) ||
432
+ reconciliationCandidates.length > bundle.listeningEvents.length
433
+ ) {
434
+ throw new TypeError("Listening history reconciliation candidates are invalid");
435
+ }
436
+ const eventIds = new Set(
437
+ bundle.listeningEvents.map((event) => event?.listening_event_id?.toLowerCase()),
438
+ );
439
+ const supersedingIds = new Set();
440
+ const supersededIds = new Set();
441
+ for (const candidate of reconciliationCandidates) {
442
+ const supersedingEventId = candidate?.superseding_event_id?.toLowerCase();
443
+ const supersededEventId = candidate?.superseded_event_id?.toLowerCase();
444
+ if (
445
+ !isPlainObject(candidate) ||
446
+ !isUuid(supersedingEventId) ||
447
+ !isUuid(supersededEventId) ||
448
+ supersedingEventId === supersededEventId ||
449
+ !eventIds.has(supersedingEventId) ||
450
+ supersedingIds.has(supersedingEventId) ||
451
+ supersededIds.has(supersededEventId)
452
+ ) {
453
+ throw new TypeError("Listening history reconciliation candidates are invalid");
454
+ }
455
+ supersedingIds.add(supersedingEventId);
456
+ supersededIds.add(supersededEventId);
457
+ }
458
+ const earliestOccurredAt = normalizedUtcTimestamp(
459
+ batch.earliest_occurred_at,
460
+ "Listening history import earliest timestamp",
461
+ { nullable: true },
462
+ );
463
+ const latestOccurredAt = normalizedUtcTimestamp(
464
+ batch.latest_occurred_at,
465
+ "Listening history import latest timestamp",
466
+ { nullable: true },
467
+ );
468
+ const importedAt = normalizedUtcTimestamp(
469
+ batch.imported_at,
470
+ "Listening history import timestamp",
471
+ );
472
+ if (importedAt !== bundle.capturedAt) {
473
+ throw new TypeError("Listening history import timestamp does not match its manifest");
474
+ }
475
+ if (bundle.listeningEvents.length === 0) {
476
+ if (earliestOccurredAt !== null || latestOccurredAt !== null) {
477
+ throw new TypeError("Empty listening history imports cannot have an event range");
478
+ }
479
+ } else {
480
+ const occurredAt = bundle.listeningEvents
481
+ .map((event) => normalizedUtcTimestamp(
482
+ event?.occurred_at,
483
+ "Listening history event timestamp",
484
+ ))
485
+ .sort();
486
+ if (
487
+ earliestOccurredAt !== occurredAt[0] ||
488
+ latestOccurredAt !== occurredAt.at(-1)
489
+ ) {
490
+ throw new TypeError("Listening history import event range is invalid");
491
+ }
492
+ }
493
+ const normalized = {
494
+ ...bundle,
495
+ importBatch: {
496
+ importBatchId: batch.import_batch_id.toLowerCase(),
497
+ subjectId,
498
+ sourceFormat: batch.source_format,
499
+ dataScope: batch.data_scope,
500
+ archiveSha256: batch.archive_sha256,
501
+ archiveSizeBytes: batch.archive_size_bytes,
502
+ memberNames: batch.member_names,
503
+ inputRecords: batch.input_records,
504
+ earliestOccurredAt,
505
+ latestOccurredAt,
506
+ importedAt,
507
+ },
508
+ reconciliationCandidates: reconciliationCandidates.map((candidate) => ({
509
+ supersedingEventId: candidate.superseding_event_id.toLowerCase(),
510
+ supersededEventId: candidate.superseded_event_id.toLowerCase(),
511
+ })),
512
+ };
513
+ const profile = normalizedProfileInput(value, normalized.importBatch);
514
+ return profile ? { ...normalized, ...profile } : normalized;
515
+ }
516
+
517
+ function ingestionStatements(database) {
518
+ return {
519
+ insertTrack: database.prepare(
520
+ `INSERT OR IGNORE INTO track_refs (
521
+ track_ref_id, revision, record_json
522
+ ) VALUES (?, ?, ?)`,
523
+ ),
524
+ insertEvent: database.prepare(
525
+ `INSERT OR IGNORE INTO listening_events (
526
+ listening_event_id, subject_id, track_ref_id, track_ref_revision,
527
+ occurred_at, deduplication_key, record_json
528
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`,
529
+ ),
530
+ upsertCursor: database.prepare(
531
+ `INSERT INTO source_cursors (
532
+ source_key, cursor_after_ms, updated_at
533
+ ) VALUES (?, ?, ?)
534
+ ON CONFLICT(source_key) DO UPDATE SET
535
+ cursor_after_ms = MAX(source_cursors.cursor_after_ms, excluded.cursor_after_ms),
536
+ updated_at = excluded.updated_at`,
537
+ ),
538
+ };
539
+ }
540
+
541
+ function insertBundleRecords(bundle, statements) {
542
+ let insertedTracks = 0;
543
+ let insertedEvents = 0;
544
+ for (const record of bundle.trackRefs) {
545
+ insertedTracks += statements.insertTrack.run(
546
+ record.track_ref_id,
547
+ record.revision,
548
+ JSON.stringify(record),
549
+ ).changes;
550
+ }
551
+ for (const record of bundle.listeningEvents) {
552
+ insertedEvents += statements.insertEvent.run(
553
+ record.listening_event_id,
554
+ record.subject_id,
555
+ record.track_ref_id,
556
+ record.track_ref_revision,
557
+ record.occurred_at,
558
+ record.deduplication_key,
559
+ JSON.stringify(record),
560
+ ).changes;
561
+ }
562
+ if (bundle.cursorAfterMs !== null) {
563
+ statements.upsertCursor.run(
564
+ bundle.sourceKey,
565
+ bundle.cursorAfterMs,
566
+ bundle.capturedAt,
567
+ );
568
+ }
569
+ return { insertedTracks, insertedEvents };
570
+ }
571
+
572
+ export class ListeningHistoryStore {
573
+ #database;
574
+ #closed = false;
575
+
576
+ constructor(database) {
577
+ this.#database = database;
578
+ }
579
+
580
+ sourceCursor(sourceKey) {
581
+ if (this.#closed) throw new Error("Listening history store is closed");
582
+ const row = this.#database
583
+ .prepare(
584
+ "SELECT cursor_after_ms FROM source_cursors WHERE source_key = ?",
585
+ )
586
+ .get(cleanSourceKey(sourceKey));
587
+ return Number.isSafeInteger(row?.cursor_after_ms)
588
+ ? row.cursor_after_ms
589
+ : null;
590
+ }
591
+
592
+ localSubjectId({ preferredSubjectId, create = false } = {}) {
593
+ if (this.#closed) throw new Error("Listening history store is closed");
594
+ if (preferredSubjectId !== undefined && !isUuid(preferredSubjectId)) {
595
+ throw new TypeError("A valid preferred local music subject is required");
596
+ }
597
+ if (typeof create !== "boolean") {
598
+ throw new TypeError("Local music subject creation flag is invalid");
599
+ }
600
+ const preferred = preferredSubjectId?.toLowerCase() ?? null;
601
+ const registered = this.#database
602
+ .prepare(
603
+ "SELECT subject_id FROM local_music_subject WHERE singleton = 1",
604
+ )
605
+ .get()?.subject_id;
606
+ if (registered !== undefined) {
607
+ if (!isUuid(registered)) {
608
+ throw new Error("The local music subject registry is invalid");
609
+ }
610
+ const normalized = registered.toLowerCase();
611
+ if (preferred && preferred !== normalized) {
612
+ throw new Error(
613
+ "The trusted Apple and local listening-history subjects do not match",
614
+ );
615
+ }
616
+ return normalized;
617
+ }
618
+
619
+ const observed = this.#database
620
+ .prepare(
621
+ `SELECT subject_id FROM (
622
+ SELECT subject_id FROM listening_events
623
+ UNION
624
+ SELECT subject_id FROM import_batches
625
+ UNION
626
+ SELECT subject_id FROM profile_import_batches
627
+ UNION
628
+ SELECT subject_id FROM spotify_profile_evidence
629
+ UNION
630
+ SELECT subject_id FROM taste_events
631
+ )
632
+ ORDER BY subject_id`,
633
+ )
634
+ .all()
635
+ .map((row) => row.subject_id?.toLowerCase());
636
+ if (observed.some((subjectId) => !isUuid(subjectId))) {
637
+ throw new Error("Stored listening history contains an invalid subject");
638
+ }
639
+ if (observed.length > 1) {
640
+ throw new Error(
641
+ "Stored listening history contains multiple local music subjects",
642
+ );
643
+ }
644
+ const inherited = observed[0] ?? null;
645
+ if (preferred && inherited && preferred !== inherited) {
646
+ throw new Error(
647
+ "The trusted Apple and local listening-history subjects do not match",
648
+ );
649
+ }
650
+ const resolved = inherited ?? preferred ?? (create ? randomUUID() : null);
651
+ if (!resolved) return null;
652
+ this.#database
653
+ .prepare(
654
+ "INSERT INTO local_music_subject (singleton, subject_id) VALUES (1, ?)",
655
+ )
656
+ .run(resolved);
657
+ return resolved;
658
+ }
659
+
660
+ subjectDataStatus({ subjectId } = {}) {
661
+ if (this.#closed) throw new Error("Listening history store is closed");
662
+ if (!isUuid(subjectId)) {
663
+ throw new TypeError("A valid listening profile subject ID is required");
664
+ }
665
+ const normalized = subjectId.toLowerCase();
666
+ const effectiveListeningEvents = this.#database
667
+ .prepare(
668
+ `SELECT COUNT(*) AS count FROM listening_events e
669
+ WHERE e.subject_id = ?
670
+ AND NOT EXISTS (
671
+ SELECT 1 FROM listening_event_supersessions s
672
+ WHERE s.superseded_event_id = e.listening_event_id
673
+ )`,
674
+ )
675
+ .get(normalized).count;
676
+ const profileEvidence = this.#database
677
+ .prepare(
678
+ `SELECT COUNT(*) AS count FROM spotify_profile_evidence
679
+ WHERE subject_id = ?`,
680
+ )
681
+ .get(normalized).count;
682
+ const activeTasteAssertions = this.#database
683
+ .prepare(
684
+ `SELECT COUNT(*) AS count FROM taste_events a
685
+ WHERE a.subject_id = ?
686
+ AND a.operation = 'assert'
687
+ AND NOT EXISTS (
688
+ SELECT 1 FROM taste_events r
689
+ WHERE r.retracts_taste_event_id = a.taste_event_id
690
+ )
691
+ AND NOT EXISTS (
692
+ SELECT 1 FROM taste_events s
693
+ WHERE s.supersedes_taste_event_id = a.taste_event_id
694
+ )`,
695
+ )
696
+ .get(normalized).count;
697
+ return {
698
+ state:
699
+ effectiveListeningEvents > 0 ||
700
+ profileEvidence > 0 ||
701
+ activeTasteAssertions > 0
702
+ ? "ready"
703
+ : "empty",
704
+ effective_listening_events: effectiveListeningEvents,
705
+ profile_evidence: profileEvidence,
706
+ active_taste_assertions: activeTasteAssertions,
707
+ };
708
+ }
709
+
710
+ recordListenerCorrection({
711
+ subjectId,
712
+ entityType,
713
+ label,
714
+ artistCredit,
715
+ stance,
716
+ note,
717
+ occurredAt = new Date().toISOString(),
718
+ recordedAt = occurredAt,
719
+ correctionId,
720
+ } = {}) {
721
+ if (this.#closed) throw new Error("Listening history store is closed");
722
+ if (!isUuid(subjectId)) {
723
+ throw new TypeError("A valid listener correction subject is required");
724
+ }
725
+ const normalizedSubjectId = subjectId.toLowerCase();
726
+ const initial = createListenerCorrection({
727
+ subjectId: normalizedSubjectId,
728
+ entityType,
729
+ label,
730
+ artistCredit,
731
+ stance,
732
+ note,
733
+ occurredAt,
734
+ recordedAt,
735
+ correctionId,
736
+ });
737
+ this.#database.exec("BEGIN IMMEDIATE");
738
+ try {
739
+ this.localSubjectId({ preferredSubjectId: normalizedSubjectId });
740
+ const previous = this.#database
741
+ .prepare(
742
+ `SELECT a.taste_event_id, a.record_json
743
+ FROM taste_events a
744
+ WHERE a.subject_id = ?
745
+ AND a.operation = 'assert'
746
+ AND a.target_key = ?
747
+ AND NOT EXISTS (
748
+ SELECT 1 FROM taste_events r
749
+ WHERE r.retracts_taste_event_id = a.taste_event_id
750
+ )
751
+ AND NOT EXISTS (
752
+ SELECT 1 FROM taste_events s
753
+ WHERE s.supersedes_taste_event_id = a.taste_event_id
754
+ )
755
+ ORDER BY a.recorded_at DESC, a.taste_event_id DESC
756
+ LIMIT 1`,
757
+ )
758
+ .get(normalizedSubjectId, initial.targetKey);
759
+ const value = previous
760
+ ? createListenerCorrection({
761
+ subjectId: normalizedSubjectId,
762
+ entityType,
763
+ label,
764
+ artistCredit,
765
+ stance,
766
+ note,
767
+ occurredAt,
768
+ recordedAt,
769
+ correctionId: initial.record.taste_event_id,
770
+ supersedesCorrectionId: previous.taste_event_id,
771
+ })
772
+ : initial;
773
+ if (previous) {
774
+ const previousRecord = JSON.parse(previous.record_json);
775
+ if (
776
+ value.record.occurred_at < previousRecord.occurred_at ||
777
+ value.record.recorded_at < previousRecord.recorded_at
778
+ ) {
779
+ throw listenerCorrectionError(
780
+ "listener_correction_time_invalid",
781
+ "A listener correction cannot supersede a later assertion.",
782
+ );
783
+ }
784
+ }
785
+ this.#database
786
+ .prepare(
787
+ `INSERT INTO taste_events (
788
+ taste_event_id, subject_id, operation, target_key, occurred_at,
789
+ recorded_at, supersedes_taste_event_id,
790
+ retracts_taste_event_id, record_json
791
+ ) VALUES (?, ?, 'assert', ?, ?, ?, ?, NULL, ?)`,
792
+ )
793
+ .run(
794
+ value.record.taste_event_id,
795
+ normalizedSubjectId,
796
+ value.targetKey,
797
+ value.record.occurred_at,
798
+ value.record.recorded_at,
799
+ value.record.supersedes_taste_event_id ?? null,
800
+ JSON.stringify(value.record),
801
+ );
802
+ this.#database.exec("COMMIT");
803
+ return {
804
+ state: "active",
805
+ correction_id: value.record.taste_event_id,
806
+ subject_id: normalizedSubjectId,
807
+ entity_type: value.record.target.entity_type,
808
+ label: value.record.target.label,
809
+ ...(value.record.extensions?.["moondog.artist_credit"]
810
+ ? {
811
+ artist_credit:
812
+ value.record.extensions["moondog.artist_credit"],
813
+ }
814
+ : {}),
815
+ stance,
816
+ occurred_at: value.record.occurred_at,
817
+ ...(value.record.note ? { note: value.record.note } : {}),
818
+ ...(previous
819
+ ? { superseded_correction_id: previous.taste_event_id }
820
+ : {}),
821
+ profile_effects: "updated",
822
+ };
823
+ } catch (error) {
824
+ this.#database.exec("ROLLBACK");
825
+ throw error;
826
+ }
827
+ }
828
+
829
+ retractListenerCorrection({
830
+ subjectId,
831
+ correctionId,
832
+ occurredAt = new Date().toISOString(),
833
+ recordedAt = occurredAt,
834
+ retractionId,
835
+ } = {}) {
836
+ if (this.#closed) throw new Error("Listening history store is closed");
837
+ if (!isUuid(subjectId) || !isUuid(correctionId)) {
838
+ throw new TypeError("A valid listener correction and subject are required");
839
+ }
840
+ const normalizedSubjectId = subjectId.toLowerCase();
841
+ const normalizedCorrectionId = correctionId.toLowerCase();
842
+ this.#database.exec("BEGIN IMMEDIATE");
843
+ try {
844
+ const assertion = this.#database
845
+ .prepare(
846
+ `SELECT a.record_json,
847
+ EXISTS (
848
+ SELECT 1 FROM taste_events r
849
+ WHERE r.retracts_taste_event_id = a.taste_event_id
850
+ ) AS retracted,
851
+ EXISTS (
852
+ SELECT 1 FROM taste_events s
853
+ WHERE s.supersedes_taste_event_id = a.taste_event_id
854
+ ) AS superseded
855
+ FROM taste_events a
856
+ WHERE a.subject_id = ?
857
+ AND a.taste_event_id = ?
858
+ AND a.operation = 'assert'`,
859
+ )
860
+ .get(normalizedSubjectId, normalizedCorrectionId);
861
+ if (!assertion) {
862
+ throw listenerCorrectionError(
863
+ "listener_correction_not_found",
864
+ "The listener correction was not found in the trusted subject scope.",
865
+ );
866
+ }
867
+ if (assertion.retracted || assertion.superseded) {
868
+ throw listenerCorrectionError(
869
+ "listener_correction_inactive",
870
+ "Only an active listener correction can be retracted.",
871
+ );
872
+ }
873
+ const retraction = createListenerCorrectionRetraction({
874
+ subjectId: normalizedSubjectId,
875
+ retractsCorrectionId: normalizedCorrectionId,
876
+ occurredAt,
877
+ recordedAt,
878
+ correctionId: retractionId,
879
+ });
880
+ const previous = JSON.parse(assertion.record_json);
881
+ if (
882
+ retraction.occurred_at < previous.occurred_at ||
883
+ retraction.recorded_at < previous.recorded_at
884
+ ) {
885
+ throw listenerCorrectionError(
886
+ "listener_correction_time_invalid",
887
+ "A listener correction cannot be retracted before it was asserted.",
888
+ );
889
+ }
890
+ this.#database
891
+ .prepare(
892
+ `INSERT INTO taste_events (
893
+ taste_event_id, subject_id, operation, target_key, occurred_at,
894
+ recorded_at, supersedes_taste_event_id,
895
+ retracts_taste_event_id, record_json
896
+ ) VALUES (?, ?, 'retract', NULL, ?, ?, NULL, ?, ?)`,
897
+ )
898
+ .run(
899
+ retraction.taste_event_id,
900
+ normalizedSubjectId,
901
+ retraction.occurred_at,
902
+ retraction.recorded_at,
903
+ normalizedCorrectionId,
904
+ JSON.stringify(retraction),
905
+ );
906
+ this.#database.exec("COMMIT");
907
+ return {
908
+ state: "retracted",
909
+ correction_id: normalizedCorrectionId,
910
+ retraction_id: retraction.taste_event_id,
911
+ subject_id: normalizedSubjectId,
912
+ entity_type: previous.target.entity_type,
913
+ label: previous.target.label,
914
+ profile_effects: "updated",
915
+ };
916
+ } catch (error) {
917
+ this.#database.exec("ROLLBACK");
918
+ throw error;
919
+ }
920
+ }
921
+
922
+ listListenerCorrections({
923
+ subjectId,
924
+ includeInactive = false,
925
+ limit = 50,
926
+ } = {}) {
927
+ if (this.#closed) throw new Error("Listening history store is closed");
928
+ if (!isUuid(subjectId)) {
929
+ throw new TypeError("A valid listener correction subject is required");
930
+ }
931
+ if (typeof includeInactive !== "boolean") {
932
+ throw new TypeError("Listener correction history flag is invalid");
933
+ }
934
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
935
+ throw new TypeError("Listener correction limit is invalid");
936
+ }
937
+ const rows = this.#database
938
+ .prepare(
939
+ `SELECT a.record_json,
940
+ CASE
941
+ WHEN EXISTS (
942
+ SELECT 1 FROM taste_events r
943
+ WHERE r.retracts_taste_event_id = a.taste_event_id
944
+ ) THEN 'retracted'
945
+ WHEN EXISTS (
946
+ SELECT 1 FROM taste_events s
947
+ WHERE s.supersedes_taste_event_id = a.taste_event_id
948
+ ) THEN 'superseded'
949
+ ELSE 'active'
950
+ END AS state,
951
+ (
952
+ SELECT r.taste_event_id FROM taste_events r
953
+ WHERE r.retracts_taste_event_id = a.taste_event_id
954
+ ) AS retracted_by,
955
+ (
956
+ SELECT s.taste_event_id FROM taste_events s
957
+ WHERE s.supersedes_taste_event_id = a.taste_event_id
958
+ ) AS superseded_by
959
+ FROM taste_events a
960
+ WHERE a.subject_id = ?
961
+ AND a.operation = 'assert'
962
+ AND (
963
+ ? = 1 OR (
964
+ NOT EXISTS (
965
+ SELECT 1 FROM taste_events active_r
966
+ WHERE active_r.retracts_taste_event_id = a.taste_event_id
967
+ )
968
+ AND NOT EXISTS (
969
+ SELECT 1 FROM taste_events active_s
970
+ WHERE active_s.supersedes_taste_event_id = a.taste_event_id
971
+ )
972
+ )
973
+ )
974
+ ORDER BY a.recorded_at DESC, a.taste_event_id DESC
975
+ LIMIT ?`,
976
+ )
977
+ .all(subjectId.toLowerCase(), includeInactive ? 1 : 0, limit);
978
+ return rows
979
+ .map((row) => {
980
+ const record = JSON.parse(row.record_json);
981
+ return {
982
+ correction_id: record.taste_event_id,
983
+ state: row.state,
984
+ entity_type: record.target.entity_type,
985
+ label: record.target.label,
986
+ ...(record.extensions?.["moondog.artist_credit"]
987
+ ? {
988
+ artist_credit:
989
+ record.extensions["moondog.artist_credit"],
990
+ }
991
+ : {}),
992
+ stance: record.signal_type === "avoidance" ? "avoid" : "like",
993
+ strength: record.strength,
994
+ occurred_at: record.occurred_at,
995
+ ...(record.note ? { note: record.note } : {}),
996
+ ...(record.supersedes_taste_event_id
997
+ ? {
998
+ supersedes_correction_id:
999
+ record.supersedes_taste_event_id,
1000
+ }
1001
+ : {}),
1002
+ ...(row.retracted_by
1003
+ ? { retracted_by: row.retracted_by }
1004
+ : {}),
1005
+ ...(row.superseded_by
1006
+ ? { superseded_by: row.superseded_by }
1007
+ : {}),
1008
+ };
1009
+ });
1010
+ }
1011
+
1012
+ ingest(value) {
1013
+ if (this.#closed) throw new Error("Listening history store is closed");
1014
+ const bundle = normalizedBundle(value);
1015
+ const subjects = new Set(
1016
+ bundle.listeningEvents.map((record) => record.subject_id?.toLowerCase()),
1017
+ );
1018
+ if (subjects.size > 1) {
1019
+ throw new TypeError("Listening history ingestion spans multiple subjects");
1020
+ }
1021
+ const subjectId = [...subjects][0];
1022
+ const statements = ingestionStatements(this.#database);
1023
+
1024
+ this.#database.exec("BEGIN IMMEDIATE");
1025
+ let insertedTracks;
1026
+ let insertedEvents;
1027
+ try {
1028
+ if (subjectId !== undefined) {
1029
+ this.localSubjectId({ preferredSubjectId: subjectId });
1030
+ }
1031
+ ({ insertedTracks, insertedEvents } = insertBundleRecords(
1032
+ bundle,
1033
+ statements,
1034
+ ));
1035
+ this.#database.exec("COMMIT");
1036
+ } catch (error) {
1037
+ this.#database.exec("ROLLBACK");
1038
+ throw error;
1039
+ }
1040
+
1041
+ return {
1042
+ state: "ready",
1043
+ source_key: bundle.sourceKey,
1044
+ fetched_events: bundle.listeningEvents.length,
1045
+ inserted_events: insertedEvents,
1046
+ duplicate_events: bundle.listeningEvents.length - insertedEvents,
1047
+ inserted_track_refs: insertedTracks,
1048
+ cursor_after_ms: this.sourceCursor(bundle.sourceKey),
1049
+ profile_effects: insertedEvents > 0 ? "updated" : "unchanged",
1050
+ };
1051
+ }
1052
+
1053
+ ingestImport(value) {
1054
+ if (this.#closed) throw new Error("Listening history store is closed");
1055
+ const bundle = normalizedImportBundle(value);
1056
+ const batch = bundle.importBatch;
1057
+ const existingBatch = this.#database.prepare(
1058
+ `SELECT import_batch_id FROM import_batches
1059
+ WHERE import_batch_id = ?`,
1060
+ );
1061
+ const insertBatch = this.#database.prepare(
1062
+ `INSERT INTO import_batches (
1063
+ import_batch_id, subject_id, source_key, source_format, data_scope,
1064
+ archive_sha256, archive_size_bytes, member_names_json, input_records,
1065
+ earliest_occurred_at, latest_occurred_at, imported_at,
1066
+ inserted_events, duplicate_events, inserted_track_refs
1067
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1068
+ );
1069
+ const selectBySuperseded = this.#database.prepare(
1070
+ `SELECT superseding_event_id FROM listening_event_supersessions
1071
+ WHERE superseded_event_id = ?`,
1072
+ );
1073
+ const selectBySuperseding = this.#database.prepare(
1074
+ `SELECT superseded_event_id FROM listening_event_supersessions
1075
+ WHERE superseding_event_id = ?`,
1076
+ );
1077
+ const insertSupersession = this.#database.prepare(
1078
+ `INSERT INTO listening_event_supersessions (
1079
+ superseded_event_id, superseding_event_id, import_batch_id, created_at
1080
+ ) VALUES (?, ?, ?, ?)`,
1081
+ );
1082
+ const selectCandidateBySuperseded = this.#database.prepare(
1083
+ `SELECT superseding_event_id FROM listening_event_reconciliation_candidates
1084
+ WHERE superseded_event_id = ?`,
1085
+ );
1086
+ const selectCandidateBySuperseding = this.#database.prepare(
1087
+ `SELECT superseded_event_id FROM listening_event_reconciliation_candidates
1088
+ WHERE superseding_event_id = ?`,
1089
+ );
1090
+ const insertCandidate = this.#database.prepare(
1091
+ `INSERT INTO listening_event_reconciliation_candidates (
1092
+ superseded_event_id, superseding_event_id, import_batch_id, created_at
1093
+ ) VALUES (?, ?, ?, ?)`,
1094
+ );
1095
+ const selectResolvableCandidates = this.#database.prepare(
1096
+ `SELECT
1097
+ candidate.superseded_event_id,
1098
+ candidate.superseding_event_id,
1099
+ candidate.import_batch_id
1100
+ FROM listening_event_reconciliation_candidates candidate
1101
+ JOIN listening_events superseded
1102
+ ON superseded.listening_event_id = candidate.superseded_event_id
1103
+ JOIN listening_events superseding
1104
+ ON superseding.listening_event_id = candidate.superseding_event_id
1105
+ LEFT JOIN listening_event_supersessions resolved
1106
+ ON resolved.superseded_event_id = candidate.superseded_event_id
1107
+ WHERE resolved.superseded_event_id IS NULL
1108
+ ORDER BY candidate.superseded_event_id, candidate.superseding_event_id`,
1109
+ );
1110
+ const selectEventRecord = this.#database.prepare(
1111
+ "SELECT record_json FROM listening_events WHERE listening_event_id = ?",
1112
+ );
1113
+ const updateEventRecord = this.#database.prepare(
1114
+ `UPDATE listening_events SET record_json = ?
1115
+ WHERE listening_event_id = ?`,
1116
+ );
1117
+ const existingProfileBatch = this.#database.prepare(
1118
+ `SELECT profile_import_id FROM profile_import_batches
1119
+ WHERE profile_import_id = ?`,
1120
+ );
1121
+ const insertProfileBatch = this.#database.prepare(
1122
+ `INSERT INTO profile_import_batches (
1123
+ profile_import_id, subject_id, source_key, source_format,
1124
+ archive_sha256, archive_size_bytes, member_names_json, input_records,
1125
+ imported_at, inserted_evidence
1126
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)`,
1127
+ );
1128
+ const insertProfileEvidence = this.#database.prepare(
1129
+ `INSERT OR IGNORE INTO spotify_profile_evidence (
1130
+ profile_evidence_id, evidence_key, subject_id, evidence_kind,
1131
+ profile_import_id, observed_at, captured_at, record_json
1132
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
1133
+ );
1134
+ const updateProfileBatch = this.#database.prepare(
1135
+ `UPDATE profile_import_batches SET inserted_evidence = ?
1136
+ WHERE profile_import_id = ?`,
1137
+ );
1138
+ const statements = ingestionStatements(this.#database);
1139
+
1140
+ this.#database.exec("BEGIN IMMEDIATE");
1141
+ let insertedTracks = 0;
1142
+ let insertedEvents = 0;
1143
+ let supersededEvents = 0;
1144
+ let insertedProfileEvidence = 0;
1145
+ let alreadyImported = false;
1146
+ let profileAlreadyImported = bundle.profileImport === undefined;
1147
+ try {
1148
+ this.localSubjectId({ preferredSubjectId: batch.subjectId });
1149
+ alreadyImported = Boolean(existingBatch.get(batch.importBatchId));
1150
+ if (!alreadyImported) {
1151
+ ({ insertedTracks, insertedEvents } = insertBundleRecords(
1152
+ bundle,
1153
+ statements,
1154
+ ));
1155
+ insertBatch.run(
1156
+ batch.importBatchId,
1157
+ batch.subjectId,
1158
+ bundle.sourceKey,
1159
+ batch.sourceFormat,
1160
+ batch.dataScope,
1161
+ batch.archiveSha256,
1162
+ batch.archiveSizeBytes,
1163
+ JSON.stringify(batch.memberNames),
1164
+ batch.inputRecords,
1165
+ batch.earliestOccurredAt,
1166
+ batch.latestOccurredAt,
1167
+ batch.importedAt,
1168
+ insertedEvents,
1169
+ batch.inputRecords - insertedEvents,
1170
+ insertedTracks,
1171
+ );
1172
+ }
1173
+ for (const candidate of bundle.reconciliationCandidates) {
1174
+ const existingBySuperseded = selectCandidateBySuperseded.get(
1175
+ candidate.supersededEventId,
1176
+ )?.superseding_event_id;
1177
+ const existingBySuperseding = selectCandidateBySuperseding.get(
1178
+ candidate.supersedingEventId,
1179
+ )?.superseded_event_id;
1180
+ const resolvedSuperseding = selectBySuperseded.get(
1181
+ candidate.supersededEventId,
1182
+ )?.superseding_event_id;
1183
+ const resolvedSuperseded = selectBySuperseding.get(
1184
+ candidate.supersedingEventId,
1185
+ )?.superseded_event_id;
1186
+ if (
1187
+ (existingBySuperseded &&
1188
+ existingBySuperseded !== candidate.supersedingEventId) ||
1189
+ (existingBySuperseding &&
1190
+ existingBySuperseding !== candidate.supersededEventId) ||
1191
+ (resolvedSuperseding &&
1192
+ resolvedSuperseding !== candidate.supersedingEventId) ||
1193
+ (resolvedSuperseded &&
1194
+ resolvedSuperseded !== candidate.supersededEventId)
1195
+ ) {
1196
+ throw new Error("Listening history reconciliation conflict");
1197
+ }
1198
+ if (!existingBySuperseded && !existingBySuperseding) {
1199
+ insertCandidate.run(
1200
+ candidate.supersededEventId,
1201
+ candidate.supersedingEventId,
1202
+ batch.importBatchId,
1203
+ batch.importedAt,
1204
+ );
1205
+ }
1206
+ }
1207
+ for (const candidate of selectResolvableCandidates.all()) {
1208
+ const supersededEventId = candidate.superseded_event_id;
1209
+ const supersedingEventId = candidate.superseding_event_id;
1210
+ const existingSuperseding = selectBySuperseded.get(
1211
+ supersededEventId,
1212
+ )?.superseding_event_id;
1213
+ const existingSuperseded = selectBySuperseding.get(
1214
+ supersedingEventId,
1215
+ )?.superseded_event_id;
1216
+ if (
1217
+ (existingSuperseding && existingSuperseding !== supersedingEventId) ||
1218
+ (existingSuperseded && existingSuperseded !== supersededEventId)
1219
+ ) {
1220
+ throw new Error("Listening history reconciliation conflict");
1221
+ }
1222
+ const eventRow = selectEventRecord.get(supersedingEventId);
1223
+ const event = JSON.parse(eventRow.record_json);
1224
+ if (
1225
+ event.supersedes_listening_event_id !== undefined &&
1226
+ event.supersedes_listening_event_id.toLowerCase() !== supersededEventId
1227
+ ) {
1228
+ throw new Error("Listening history reconciliation conflict");
1229
+ }
1230
+ if (event.supersedes_listening_event_id === undefined) {
1231
+ updateEventRecord.run(
1232
+ JSON.stringify({
1233
+ ...event,
1234
+ supersedes_listening_event_id: supersededEventId,
1235
+ }),
1236
+ supersedingEventId,
1237
+ );
1238
+ }
1239
+ if (!existingSuperseding && !existingSuperseded) {
1240
+ supersededEvents += insertSupersession.run(
1241
+ supersededEventId,
1242
+ supersedingEventId,
1243
+ candidate.import_batch_id,
1244
+ batch.importedAt,
1245
+ ).changes;
1246
+ }
1247
+ }
1248
+ if (bundle.profileImport) {
1249
+ const profile = bundle.profileImport;
1250
+ profileAlreadyImported = Boolean(
1251
+ existingProfileBatch.get(profile.profileImportId),
1252
+ );
1253
+ if (!profileAlreadyImported) {
1254
+ if (alreadyImported) {
1255
+ for (const record of bundle.trackRefs) {
1256
+ insertedTracks += statements.insertTrack.run(
1257
+ record.track_ref_id,
1258
+ record.revision,
1259
+ JSON.stringify(record),
1260
+ ).changes;
1261
+ }
1262
+ }
1263
+ insertProfileBatch.run(
1264
+ profile.profileImportId,
1265
+ profile.subjectId,
1266
+ profile.sourceKey,
1267
+ profile.sourceFormat,
1268
+ profile.archiveSha256,
1269
+ profile.archiveSizeBytes,
1270
+ JSON.stringify(profile.memberNames),
1271
+ profile.inputRecords,
1272
+ profile.importedAt,
1273
+ );
1274
+ for (const record of bundle.profileEvidence) {
1275
+ insertedProfileEvidence += insertProfileEvidence.run(
1276
+ record.profile_evidence_id,
1277
+ record.evidence_key,
1278
+ record.subject_id,
1279
+ record.evidence_kind,
1280
+ profile.profileImportId,
1281
+ record.observed_at,
1282
+ record.provenance.captured_at,
1283
+ JSON.stringify(record),
1284
+ ).changes;
1285
+ }
1286
+ if (insertedProfileEvidence !== profile.inputRecords) {
1287
+ throw new Error("Spotify profile evidence import was incomplete");
1288
+ }
1289
+ updateProfileBatch.run(
1290
+ insertedProfileEvidence,
1291
+ profile.profileImportId,
1292
+ );
1293
+ }
1294
+ }
1295
+ this.#database.exec("COMMIT");
1296
+ } catch (error) {
1297
+ this.#database.exec("ROLLBACK");
1298
+ throw error;
1299
+ }
1300
+
1301
+ return {
1302
+ state: "ready",
1303
+ source_key: bundle.sourceKey,
1304
+ source_format: batch.sourceFormat,
1305
+ data_scope: batch.dataScope,
1306
+ import_batch_id: batch.importBatchId,
1307
+ archive_sha256: batch.archiveSha256,
1308
+ input_records: batch.inputRecords,
1309
+ inserted_events: insertedEvents,
1310
+ duplicate_events: batch.inputRecords - insertedEvents,
1311
+ inserted_track_refs: insertedTracks,
1312
+ superseded_events: supersededEvents,
1313
+ effective_event_delta: insertedEvents - supersededEvents,
1314
+ cursor_after_ms: this.sourceCursor(bundle.sourceKey),
1315
+ earliest_occurred_at: batch.earliestOccurredAt,
1316
+ latest_occurred_at: batch.latestOccurredAt,
1317
+ already_imported: alreadyImported,
1318
+ ...(bundle.profileImport
1319
+ ? {
1320
+ profile_input_records: bundle.profileImport.inputRecords,
1321
+ inserted_profile_evidence: insertedProfileEvidence,
1322
+ profile_already_imported: profileAlreadyImported,
1323
+ profile_evidence_by_kind: Object.fromEntries(
1324
+ Object.entries(
1325
+ Object.groupBy(
1326
+ bundle.profileEvidence,
1327
+ (record) => record.evidence_kind,
1328
+ ),
1329
+ )
1330
+ .map(([kind, records]) => [kind, records.length])
1331
+ .sort(([left], [right]) => left.localeCompare(right)),
1332
+ ),
1333
+ }
1334
+ : {}),
1335
+ profile_effects:
1336
+ insertedEvents - supersededEvents !== 0 || insertedProfileEvidence > 0
1337
+ ? "updated"
1338
+ : "unchanged",
1339
+ };
1340
+ }
1341
+
1342
+ recentEvents({ subjectId, limit = 20 } = {}) {
1343
+ if (this.#closed) throw new Error("Listening history store is closed");
1344
+ if (typeof subjectId !== "string" || !subjectId.trim()) {
1345
+ throw new TypeError("A listening history subject ID is required");
1346
+ }
1347
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
1348
+ throw new TypeError("Listening history limit is invalid");
1349
+ }
1350
+ return this.#database
1351
+ .prepare(
1352
+ `SELECT record_json FROM listening_events
1353
+ WHERE subject_id = ?
1354
+ AND NOT EXISTS (
1355
+ SELECT 1 FROM listening_event_supersessions
1356
+ WHERE superseded_event_id = listening_events.listening_event_id
1357
+ )
1358
+ ORDER BY occurred_at DESC, listening_event_id DESC
1359
+ LIMIT ?`,
1360
+ )
1361
+ .all(subjectId.toLowerCase(), limit)
1362
+ .map((row) => JSON.parse(row.record_json));
1363
+ }
1364
+
1365
+ #profileProjection({ subjectId, maxItems }) {
1366
+ if (this.#closed) throw new Error("Listening history store is closed");
1367
+ if (!isUuid(subjectId)) {
1368
+ throw new TypeError("A valid listening profile subject ID is required");
1369
+ }
1370
+ if (!Number.isInteger(maxItems) || maxItems < 1 || maxItems > 50) {
1371
+ throw new TypeError("Listening profile limit is invalid");
1372
+ }
1373
+ const normalizedSubjectId = subjectId.toLowerCase();
1374
+ const linkCandidates = this.#database
1375
+ .prepare(
1376
+ `SELECT
1377
+ superseded.track_ref_id AS source_track_ref_id,
1378
+ superseding.track_ref_id AS target_track_ref_id,
1379
+ superseding.track_ref_revision AS target_track_ref_revision,
1380
+ source_track.record_json AS source_track_json,
1381
+ target_track.record_json AS target_track_json
1382
+ FROM listening_event_supersessions link
1383
+ JOIN listening_events superseded
1384
+ ON superseded.listening_event_id = link.superseded_event_id
1385
+ JOIN listening_events superseding
1386
+ ON superseding.listening_event_id = link.superseding_event_id
1387
+ JOIN track_refs source_track
1388
+ ON source_track.track_ref_id = superseded.track_ref_id
1389
+ AND source_track.revision = superseded.track_ref_revision
1390
+ JOIN track_refs target_track
1391
+ ON target_track.track_ref_id = superseding.track_ref_id
1392
+ AND target_track.revision = superseding.track_ref_revision
1393
+ WHERE superseded.subject_id = ?
1394
+ AND superseding.subject_id = ?
1395
+ ORDER BY
1396
+ superseded.track_ref_id,
1397
+ superseding.track_ref_id,
1398
+ superseding.track_ref_revision`,
1399
+ )
1400
+ .all(normalizedSubjectId, normalizedSubjectId);
1401
+ const groupedLinks = new Map();
1402
+ for (const row of linkCandidates) {
1403
+ const sourceTrack = JSON.parse(row.source_track_json);
1404
+ const targetTrack = JSON.parse(row.target_track_json);
1405
+ const group = groupedLinks.get(row.source_track_ref_id) ?? {
1406
+ source_is_provisional: true,
1407
+ targets: new Map(),
1408
+ };
1409
+ group.source_is_provisional &&=
1410
+ sourceTrack.identity_status !== "resolved";
1411
+ const currentTarget = group.targets.get(row.target_track_ref_id);
1412
+ if (
1413
+ !currentTarget ||
1414
+ row.target_track_ref_revision > currentTarget.revision
1415
+ ) {
1416
+ group.targets.set(row.target_track_ref_id, {
1417
+ revision: row.target_track_ref_revision,
1418
+ resolved: targetTrack.identity_status === "resolved",
1419
+ track: targetTrack,
1420
+ });
1421
+ }
1422
+ groupedLinks.set(row.source_track_ref_id, group);
1423
+ }
1424
+ const exactTrackLinks = new Map();
1425
+ const ambiguousTrackSources = new Set();
1426
+ for (const [sourceTrackRefId, group] of groupedLinks) {
1427
+ if (!group.source_is_provisional) continue;
1428
+ if (group.targets.size > 1) {
1429
+ const resolvedTargets = [...group.targets.values()].filter(
1430
+ (target) => target.resolved,
1431
+ ).length;
1432
+ if (resolvedTargets > 1) {
1433
+ ambiguousTrackSources.add(sourceTrackRefId);
1434
+ }
1435
+ continue;
1436
+ }
1437
+ if (group.targets.size !== 1) continue;
1438
+ const [target] = group.targets.values();
1439
+ if (!target.resolved) continue;
1440
+ exactTrackLinks.set(sourceTrackRefId, target.track);
1441
+ }
1442
+ const eventRows = this.#database
1443
+ .prepare(
1444
+ `SELECT
1445
+ e.track_ref_id,
1446
+ e.record_json AS event_json,
1447
+ t.record_json AS track_json
1448
+ FROM listening_events e
1449
+ JOIN track_refs t
1450
+ ON t.track_ref_id = e.track_ref_id
1451
+ AND t.revision = e.track_ref_revision
1452
+ WHERE e.subject_id = ?
1453
+ AND NOT EXISTS (
1454
+ SELECT 1 FROM listening_event_supersessions s
1455
+ WHERE s.superseded_event_id = e.listening_event_id
1456
+ )
1457
+ ORDER BY e.occurred_at, e.listening_event_id`,
1458
+ )
1459
+ .all(normalizedSubjectId)
1460
+ .map((row) => {
1461
+ const originalTrack = JSON.parse(row.track_json);
1462
+ const linkedTrack = exactTrackLinks.get(row.track_ref_id);
1463
+ return {
1464
+ event: JSON.parse(row.event_json),
1465
+ track: linkedTrack ?? originalTrack,
1466
+ ...(linkedTrack
1467
+ ? { cross_format_source_track_ref_id: row.track_ref_id }
1468
+ : {}),
1469
+ ...(!linkedTrack && ambiguousTrackSources.has(row.track_ref_id)
1470
+ ? { cross_format_ambiguous_track_ref_id: row.track_ref_id }
1471
+ : {}),
1472
+ };
1473
+ });
1474
+ const profileEvidence = this.#database
1475
+ .prepare(
1476
+ `SELECT record_json FROM spotify_profile_evidence
1477
+ WHERE subject_id = ?
1478
+ ORDER BY observed_at, profile_evidence_id`,
1479
+ )
1480
+ .all(normalizedSubjectId)
1481
+ .map((row) => JSON.parse(row.record_json));
1482
+ const tasteEvents = this.#database
1483
+ .prepare(
1484
+ `SELECT record_json FROM taste_events
1485
+ WHERE subject_id = ?
1486
+ ORDER BY recorded_at, taste_event_id`,
1487
+ )
1488
+ .all(normalizedSubjectId)
1489
+ .map((row) => JSON.parse(row.record_json));
1490
+ return projectListeningProfile({
1491
+ subjectId: normalizedSubjectId,
1492
+ eventRows,
1493
+ profileEvidence,
1494
+ tasteEvents,
1495
+ maxItems,
1496
+ });
1497
+ }
1498
+
1499
+ /** Every track the subject has played, with its play count, most played first. */
1500
+ listenedTracks({ subjectId } = {}) {
1501
+ if (this.#closed) throw new Error("Listening history store is closed");
1502
+ if (typeof subjectId !== "string" || !subjectId.trim()) {
1503
+ throw new TypeError("A listening history subject ID is required");
1504
+ }
1505
+ const trackStatement = this.#database.prepare(
1506
+ `SELECT record_json FROM track_refs
1507
+ WHERE track_ref_id = ?
1508
+ ORDER BY revision DESC
1509
+ LIMIT 1`,
1510
+ );
1511
+ return this.#database
1512
+ .prepare(
1513
+ `SELECT track_ref_id, COUNT(*) AS play_count
1514
+ FROM listening_events
1515
+ WHERE subject_id = ?
1516
+ AND NOT EXISTS (
1517
+ SELECT 1 FROM listening_event_supersessions
1518
+ WHERE superseded_event_id = listening_events.listening_event_id
1519
+ )
1520
+ GROUP BY track_ref_id
1521
+ ORDER BY play_count DESC, track_ref_id`,
1522
+ )
1523
+ .all(subjectId.toLowerCase())
1524
+ .flatMap((row) => {
1525
+ const record = trackStatement.get(row.track_ref_id);
1526
+ if (!record) return [];
1527
+ const track = JSON.parse(record.record_json);
1528
+ const artistCredit = track.artist_credits?.[0]?.name;
1529
+ if (typeof track.title !== "string" || typeof artistCredit !== "string") return [];
1530
+ return [{
1531
+ track_ref_id: row.track_ref_id,
1532
+ title: track.title,
1533
+ artist_credit: artistCredit,
1534
+ release: typeof track.release?.title === "string" && track.release.title.trim()
1535
+ ? track.release.title
1536
+ : "Unknown release",
1537
+ play_count: row.play_count,
1538
+ ...(Number.isInteger(track.duration_ms) ? { duration_ms: track.duration_ms } : {}),
1539
+ }];
1540
+ });
1541
+ }
1542
+
1543
+ lyricProfile({ subjectId } = {}) {
1544
+ const value = this.#profileProjection({ subjectId, maxItems: 50 });
1545
+ return structuredClone({ ...value.summary, lyric_exclusions: value.lyricExclusions });
1546
+ }
1547
+
1548
+ profileSummary({ subjectId, maxItems = 10 } = {}) {
1549
+ return structuredClone(
1550
+ this.#profileProjection({ subjectId, maxItems }).summary,
1551
+ );
1552
+ }
1553
+
1554
+ rediscoveryCandidates({ subjectId, limit = 6 } = {}) {
1555
+ if (!Number.isInteger(limit) || limit < 1 || limit > 12) {
1556
+ throw new TypeError("Listening rediscovery limit is invalid");
1557
+ }
1558
+ const summary = this.#profileProjection({ subjectId, maxItems: limit }).summary;
1559
+ const candidates = summary.listening_behavior.rediscovery_tracks ?? [];
1560
+ const trackStatement = this.#database.prepare(
1561
+ `SELECT record_json FROM track_refs
1562
+ WHERE track_ref_id = ?
1563
+ ORDER BY revision DESC
1564
+ LIMIT 1`,
1565
+ );
1566
+ const tracks = candidates.map((candidate) => {
1567
+ const row = trackStatement.get(candidate.track_ref_id);
1568
+ if (!row) {
1569
+ throw new Error("A rediscovery track is missing from the trusted store");
1570
+ }
1571
+ const track = JSON.parse(row.record_json);
1572
+ const artistCredit = track.artist_credits?.[0]?.name;
1573
+ if (
1574
+ typeof track.title !== "string" ||
1575
+ typeof artistCredit !== "string" ||
1576
+ track.track_ref_id !== candidate.track_ref_id
1577
+ ) {
1578
+ throw new Error("A trusted rediscovery track is invalid");
1579
+ }
1580
+ return {
1581
+ track_ref_id: candidate.track_ref_id,
1582
+ title: track.title,
1583
+ artist_credit: artistCredit,
1584
+ release:
1585
+ typeof track.release?.title === "string" && track.release.title.trim()
1586
+ ? track.release.title
1587
+ : "Unknown release",
1588
+ candidate_scope: "private_history",
1589
+ identity_status:
1590
+ track.identity_status === "resolved" ? "resolved" : "provisional",
1591
+ labels: { genres: [] },
1592
+ observation_summary: {
1593
+ preference_signals: [],
1594
+ familiarity: {
1595
+ level:
1596
+ candidate.play_count >= 20
1597
+ ? "high"
1598
+ : candidate.play_count >= 5
1599
+ ? "medium"
1600
+ : "low",
1601
+ basis: "effective_listening_history",
1602
+ play_count: candidate.play_count,
1603
+ },
1604
+ },
1605
+ rediscovery: {
1606
+ listening_minutes: candidate.listening_minutes,
1607
+ engaged_play_count: candidate.engaged_play_count,
1608
+ explicit_skips: candidate.explicit_skips,
1609
+ first_played_at: candidate.first_played_at,
1610
+ last_played_at: candidate.last_played_at,
1611
+ quiet_days: candidate.quiet_days,
1612
+ rediscovery_signal: candidate.rediscovery_signal,
1613
+ peak_year: candidate.peak_year,
1614
+ peak_year_play_count: candidate.peak_year_play_count,
1615
+ peak_year_listening_minutes:
1616
+ candidate.peak_year_listening_minutes,
1617
+ evidence_id: candidate.evidence_id,
1618
+ },
1619
+ ...(Number.isInteger(track.duration_ms)
1620
+ ? { duration_ms: track.duration_ms }
1621
+ : {}),
1622
+ ...(Array.isArray(track.external_refs)
1623
+ ? { external_refs: structuredClone(track.external_refs) }
1624
+ : {}),
1625
+ };
1626
+ });
1627
+ return {
1628
+ reference_date: summary.listening_behavior.context.reference_date,
1629
+ quiet_days: summary.listening_behavior.context.rediscovery_quiet_days,
1630
+ minimum_plays:
1631
+ summary.listening_behavior.context.rediscovery_minimum_plays,
1632
+ minimum_engaged_plays:
1633
+ summary.listening_behavior.context.rediscovery_minimum_engaged_plays,
1634
+ minimum_listening_minutes:
1635
+ summary.listening_behavior.context
1636
+ .rediscovery_minimum_listening_minutes,
1637
+ tracks,
1638
+ };
1639
+ }
1640
+
1641
+ historicalReturnCandidates({ subjectId, limit = 6 } = {}) {
1642
+ if (!Number.isInteger(limit) || limit < 1 || limit > 12) {
1643
+ throw new TypeError("Listening historical-return limit is invalid");
1644
+ }
1645
+ const summary = this.#profileProjection({ subjectId, maxItems: limit }).summary;
1646
+ const candidates =
1647
+ summary.listening_behavior.historical_return_tracks ?? [];
1648
+ const trackStatement = this.#database.prepare(
1649
+ `SELECT record_json FROM track_refs
1650
+ WHERE track_ref_id = ?
1651
+ ORDER BY revision DESC
1652
+ LIMIT 1`,
1653
+ );
1654
+ const tracks = candidates.map((candidate) => {
1655
+ const row = trackStatement.get(candidate.track_ref_id);
1656
+ if (!row) {
1657
+ throw new Error(
1658
+ "A historical-return track is missing from the trusted store",
1659
+ );
1660
+ }
1661
+ const track = JSON.parse(row.record_json);
1662
+ const artistCredit = track.artist_credits?.[0]?.name;
1663
+ if (
1664
+ typeof track.title !== "string" ||
1665
+ typeof artistCredit !== "string" ||
1666
+ track.track_ref_id !== candidate.track_ref_id
1667
+ ) {
1668
+ throw new Error("A trusted historical-return track is invalid");
1669
+ }
1670
+ return {
1671
+ track_ref_id: candidate.track_ref_id,
1672
+ title: track.title,
1673
+ artist_credit: artistCredit,
1674
+ release:
1675
+ typeof track.release?.title === "string" && track.release.title.trim()
1676
+ ? track.release.title
1677
+ : "Unknown release",
1678
+ candidate_scope: "private_history",
1679
+ identity_status:
1680
+ track.identity_status === "resolved" ? "resolved" : "provisional",
1681
+ labels: { genres: [] },
1682
+ observation_summary: {
1683
+ preference_signals: [],
1684
+ familiarity: {
1685
+ level:
1686
+ candidate.play_count >= 20
1687
+ ? "high"
1688
+ : candidate.play_count >= 5
1689
+ ? "medium"
1690
+ : "low",
1691
+ basis: "effective_listening_history",
1692
+ play_count: candidate.play_count,
1693
+ },
1694
+ },
1695
+ historical_return: {
1696
+ listening_minutes: candidate.listening_minutes,
1697
+ engaged_play_count: candidate.engaged_play_count,
1698
+ explicit_skips: candidate.explicit_skips,
1699
+ first_played_at: candidate.first_played_at,
1700
+ last_played_at: candidate.last_played_at,
1701
+ return_count: candidate.return_count,
1702
+ longest_gap_days: candidate.longest_gap_days,
1703
+ latest_return_at: candidate.latest_return_at,
1704
+ latest_return_gap_days: candidate.latest_return_gap_days,
1705
+ historical_return_signal: candidate.historical_return_signal,
1706
+ evidence_id: candidate.evidence_id,
1707
+ },
1708
+ ...(Number.isInteger(track.duration_ms)
1709
+ ? { duration_ms: track.duration_ms }
1710
+ : {}),
1711
+ ...(Array.isArray(track.external_refs)
1712
+ ? { external_refs: structuredClone(track.external_refs) }
1713
+ : {}),
1714
+ };
1715
+ });
1716
+ return {
1717
+ reference_date: summary.listening_behavior.context.reference_date,
1718
+ minimum_gap_days:
1719
+ summary.listening_behavior.context
1720
+ .historical_return_minimum_gap_days,
1721
+ minimum_plays:
1722
+ summary.listening_behavior.context.historical_return_minimum_plays,
1723
+ minimum_engaged_plays:
1724
+ summary.listening_behavior.context
1725
+ .historical_return_minimum_engaged_plays,
1726
+ minimum_listening_minutes:
1727
+ summary.listening_behavior.context
1728
+ .historical_return_minimum_listening_minutes,
1729
+ tracks,
1730
+ };
1731
+ }
1732
+
1733
+ backToBackCandidates({ subjectId, limit = 6 } = {}) {
1734
+ if (!Number.isInteger(limit) || limit < 1 || limit > 12) {
1735
+ throw new TypeError("Listening back-to-back limit is invalid");
1736
+ }
1737
+ const summary = this.#profileProjection({ subjectId, maxItems: limit }).summary;
1738
+ const candidates = summary.listening_behavior.back_to_back_tracks ?? [];
1739
+ const trackStatement = this.#database.prepare(
1740
+ `SELECT record_json FROM track_refs
1741
+ WHERE track_ref_id = ?
1742
+ ORDER BY revision DESC
1743
+ LIMIT 1`,
1744
+ );
1745
+ const tracks = candidates.map((candidate) => {
1746
+ const row = trackStatement.get(candidate.track_ref_id);
1747
+ if (!row) {
1748
+ throw new Error("A back-to-back track is missing from the trusted store");
1749
+ }
1750
+ const track = JSON.parse(row.record_json);
1751
+ const artistCredit = track.artist_credits?.[0]?.name;
1752
+ if (
1753
+ typeof track.title !== "string" ||
1754
+ typeof artistCredit !== "string" ||
1755
+ track.track_ref_id !== candidate.track_ref_id
1756
+ ) {
1757
+ throw new Error("A trusted back-to-back track is invalid");
1758
+ }
1759
+ return {
1760
+ track_ref_id: candidate.track_ref_id,
1761
+ title: track.title,
1762
+ artist_credit: artistCredit,
1763
+ release:
1764
+ typeof track.release?.title === "string" && track.release.title.trim()
1765
+ ? track.release.title
1766
+ : "Unknown release",
1767
+ candidate_scope: "private_history",
1768
+ identity_status:
1769
+ track.identity_status === "resolved" ? "resolved" : "provisional",
1770
+ labels: { genres: [] },
1771
+ observation_summary: {
1772
+ preference_signals: [],
1773
+ familiarity: {
1774
+ level:
1775
+ candidate.play_count >= 20
1776
+ ? "high"
1777
+ : candidate.play_count >= 5
1778
+ ? "medium"
1779
+ : "low",
1780
+ basis: "effective_listening_history",
1781
+ play_count: candidate.play_count,
1782
+ },
1783
+ },
1784
+ back_to_back: {
1785
+ engaged_play_count: candidate.engaged_play_count,
1786
+ explicit_skips: candidate.explicit_skips,
1787
+ burst_count: candidate.burst_count,
1788
+ maximum_consecutive_plays: candidate.maximum_consecutive_plays,
1789
+ plays_in_bursts: candidate.plays_in_bursts,
1790
+ listening_minutes_in_bursts:
1791
+ candidate.listening_minutes_in_bursts,
1792
+ latest_burst_at: candidate.latest_burst_at,
1793
+ sequence_signal: candidate.sequence_signal,
1794
+ evidence_id: candidate.evidence_id,
1795
+ },
1796
+ ...(Number.isInteger(track.duration_ms)
1797
+ ? { duration_ms: track.duration_ms }
1798
+ : {}),
1799
+ ...(Array.isArray(track.external_refs)
1800
+ ? { external_refs: structuredClone(track.external_refs) }
1801
+ : {}),
1802
+ };
1803
+ });
1804
+ return {
1805
+ reference_date: summary.listening_behavior.context.reference_date,
1806
+ minimum_consecutive_plays:
1807
+ summary.listening_behavior.context
1808
+ .back_to_back_minimum_consecutive_plays,
1809
+ minimum_played_seconds:
1810
+ summary.listening_behavior.context
1811
+ .back_to_back_minimum_played_seconds,
1812
+ maximum_gap_minutes:
1813
+ summary.listening_behavior.context
1814
+ .back_to_back_maximum_gap_minutes,
1815
+ tracks,
1816
+ };
1817
+ }
1818
+
1819
+ timeCapsuleCandidates({ subjectId, limit = 6 } = {}) {
1820
+ if (!Number.isInteger(limit) || limit < 2 || limit > 12) {
1821
+ throw new TypeError("Listening time capsule limit is invalid");
1822
+ }
1823
+ const summary = this.#profileProjection({ subjectId, maxItems: limit }).summary;
1824
+ const candidates = summary.listening_behavior.time_capsule_tracks ?? [];
1825
+ const trackStatement = this.#database.prepare(
1826
+ `SELECT record_json FROM track_refs
1827
+ WHERE track_ref_id = ?
1828
+ ORDER BY revision DESC
1829
+ LIMIT 1`,
1830
+ );
1831
+ const tracks = candidates.map((candidate) => {
1832
+ const row = trackStatement.get(candidate.track_ref_id);
1833
+ if (!row) {
1834
+ throw new Error("A time capsule track is missing from the trusted store");
1835
+ }
1836
+ const track = JSON.parse(row.record_json);
1837
+ const artistCredit = track.artist_credits?.[0]?.name;
1838
+ if (
1839
+ typeof track.title !== "string" ||
1840
+ typeof artistCredit !== "string" ||
1841
+ track.track_ref_id !== candidate.track_ref_id
1842
+ ) {
1843
+ throw new Error("A trusted time capsule track is invalid");
1844
+ }
1845
+ return {
1846
+ track_ref_id: candidate.track_ref_id,
1847
+ title: track.title,
1848
+ artist_credit: artistCredit,
1849
+ release:
1850
+ typeof track.release?.title === "string" && track.release.title.trim()
1851
+ ? track.release.title
1852
+ : "Unknown release",
1853
+ candidate_scope: "private_history",
1854
+ identity_status:
1855
+ track.identity_status === "resolved" ? "resolved" : "provisional",
1856
+ labels: { genres: [] },
1857
+ observation_summary: {
1858
+ preference_signals: [],
1859
+ familiarity: {
1860
+ level:
1861
+ candidate.lifetime_play_count >= 20
1862
+ ? "high"
1863
+ : candidate.lifetime_play_count >= 5
1864
+ ? "medium"
1865
+ : "low",
1866
+ basis: "effective_listening_history",
1867
+ play_count: candidate.lifetime_play_count,
1868
+ },
1869
+ },
1870
+ time_capsule: {
1871
+ year: candidate.capsule_year,
1872
+ year_play_count: candidate.year_play_count,
1873
+ year_engaged_play_count: candidate.year_engaged_play_count,
1874
+ year_listening_minutes: candidate.year_listening_minutes,
1875
+ year_explicit_skips: candidate.year_explicit_skips,
1876
+ lifetime_listening_minutes: candidate.lifetime_listening_minutes,
1877
+ representative_signal: candidate.representative_signal,
1878
+ evidence_id: candidate.evidence_id,
1879
+ },
1880
+ ...(Number.isInteger(track.duration_ms)
1881
+ ? { duration_ms: track.duration_ms }
1882
+ : {}),
1883
+ ...(Array.isArray(track.external_refs)
1884
+ ? { external_refs: structuredClone(track.external_refs) }
1885
+ : {}),
1886
+ };
1887
+ });
1888
+ const years = tracks.map((track) => track.time_capsule.year);
1889
+ return {
1890
+ reference_date: summary.listening_behavior.context.reference_date,
1891
+ history_start_year:
1892
+ summary.listening_behavior.history_arc[0]?.year ?? null,
1893
+ history_end_year:
1894
+ summary.listening_behavior.history_arc.at(-1)?.year ?? null,
1895
+ minimum_years:
1896
+ summary.listening_behavior.context.time_capsule_minimum_years,
1897
+ minimum_engaged_plays:
1898
+ summary.listening_behavior.context
1899
+ .time_capsule_minimum_engaged_plays,
1900
+ minimum_listening_minutes:
1901
+ summary.listening_behavior.context
1902
+ .time_capsule_minimum_listening_minutes,
1903
+ represented_years: years,
1904
+ tracks,
1905
+ };
1906
+ }
1907
+
1908
+ explainProfileEvidence({ subjectId, evidenceId } = {}) {
1909
+ if (!isUuid(evidenceId)) {
1910
+ throw new TypeError("Listening profile evidence ID is invalid");
1911
+ }
1912
+ const projected = this.#profileProjection({ subjectId, maxItems: 50 });
1913
+ const value = projected.explanations.get(evidenceId.toLowerCase());
1914
+ return value ? structuredClone(value) : null;
1915
+ }
1916
+
1917
+ status() {
1918
+ if (this.#closed) return { state: "closed" };
1919
+ return {
1920
+ state: "ready",
1921
+ track_refs: this.#database
1922
+ .prepare("SELECT COUNT(*) AS count FROM track_refs")
1923
+ .get().count,
1924
+ listening_events: this.#database
1925
+ .prepare("SELECT COUNT(*) AS count FROM listening_events")
1926
+ .get().count,
1927
+ effective_listening_events: this.#database
1928
+ .prepare(
1929
+ `SELECT COUNT(*) AS count FROM listening_events
1930
+ WHERE NOT EXISTS (
1931
+ SELECT 1 FROM listening_event_supersessions
1932
+ WHERE superseded_event_id = listening_events.listening_event_id
1933
+ )`,
1934
+ )
1935
+ .get().count,
1936
+ superseded_listening_events: this.#database
1937
+ .prepare("SELECT COUNT(*) AS count FROM listening_event_supersessions")
1938
+ .get().count,
1939
+ sources: this.#database
1940
+ .prepare("SELECT COUNT(*) AS count FROM source_cursors")
1941
+ .get().count,
1942
+ import_batches: this.#database
1943
+ .prepare("SELECT COUNT(*) AS count FROM import_batches")
1944
+ .get().count,
1945
+ profile_import_batches: this.#database
1946
+ .prepare("SELECT COUNT(*) AS count FROM profile_import_batches")
1947
+ .get().count,
1948
+ profile_evidence: this.#database
1949
+ .prepare("SELECT COUNT(*) AS count FROM spotify_profile_evidence")
1950
+ .get().count,
1951
+ taste_assertions: this.#database
1952
+ .prepare(
1953
+ "SELECT COUNT(*) AS count FROM taste_events WHERE operation = 'assert'",
1954
+ )
1955
+ .get().count,
1956
+ active_taste_assertions: this.#database
1957
+ .prepare(
1958
+ `SELECT COUNT(*) AS count FROM taste_events a
1959
+ WHERE a.operation = 'assert'
1960
+ AND NOT EXISTS (
1961
+ SELECT 1 FROM taste_events r
1962
+ WHERE r.retracts_taste_event_id = a.taste_event_id
1963
+ )
1964
+ AND NOT EXISTS (
1965
+ SELECT 1 FROM taste_events s
1966
+ WHERE s.supersedes_taste_event_id = a.taste_event_id
1967
+ )`,
1968
+ )
1969
+ .get().count,
1970
+ };
1971
+ }
1972
+
1973
+ close() {
1974
+ if (this.#closed) return;
1975
+ this.#closed = true;
1976
+ this.#database.close();
1977
+ }
1978
+ }
1979
+
1980
+ let databaseConstructorPromise;
1981
+
1982
+ function databaseConstructor() {
1983
+ databaseConstructorPromise ??= import("node:sqlite").then(
1984
+ ({ DatabaseSync }) => DatabaseSync,
1985
+ );
1986
+ return databaseConstructorPromise;
1987
+ }
1988
+
1989
+ async function initializeListeningHistoryDatabase(
1990
+ database,
1991
+ { journalMode, filePath = null },
1992
+ ) {
1993
+ database.exec("PRAGMA busy_timeout = 5000");
1994
+ database.exec(`PRAGMA journal_mode = ${journalMode}`);
1995
+ database.exec("PRAGMA foreign_keys = ON");
1996
+ database.exec("PRAGMA trusted_schema = OFF");
1997
+ const currentVersion = database.prepare("PRAGMA user_version").get()
1998
+ .user_version;
1999
+ if (
2000
+ !Number.isInteger(currentVersion) ||
2001
+ currentVersion < 0 ||
2002
+ currentVersion > LISTENING_HISTORY_SCHEMA_VERSION
2003
+ ) {
2004
+ throw new Error("Listening history database schema is incompatible");
2005
+ }
2006
+ database.exec(schema);
2007
+ if (currentVersion < LISTENING_HISTORY_SCHEMA_VERSION) {
2008
+ database.exec(
2009
+ `PRAGMA user_version = ${LISTENING_HISTORY_SCHEMA_VERSION}`,
2010
+ );
2011
+ }
2012
+ if (filePath && process.platform !== "win32") {
2013
+ await chmod(filePath, 0o600);
2014
+ }
2015
+ }
2016
+
2017
+ export async function openEphemeralListeningHistoryStore() {
2018
+ const DatabaseSync = await databaseConstructor();
2019
+ const database = new DatabaseSync(":memory:");
2020
+ try {
2021
+ await initializeListeningHistoryDatabase(database, {
2022
+ journalMode: "MEMORY",
2023
+ });
2024
+ return new ListeningHistoryStore(database);
2025
+ } catch (error) {
2026
+ database.close();
2027
+ throw error;
2028
+ }
2029
+ }
2030
+
2031
+ export async function openListeningHistoryStore({
2032
+ databasePath = defaultListeningHistoryPath,
2033
+ environment = process.env,
2034
+ } = {}) {
2035
+ const resolvedPath =
2036
+ databasePath === defaultListeningHistoryPath
2037
+ ? resolveListeningHistoryPath(environment)
2038
+ : databasePath;
2039
+ const parent = path.dirname(path.resolve(resolvedPath));
2040
+ await mkdir(parent, { recursive: true, mode: 0o700 });
2041
+ if (process.platform !== "win32") await chmod(parent, 0o700);
2042
+
2043
+ const DatabaseSync = await databaseConstructor();
2044
+ const database = new DatabaseSync(path.resolve(resolvedPath));
2045
+ try {
2046
+ await initializeListeningHistoryDatabase(database, {
2047
+ journalMode: "WAL",
2048
+ filePath: path.resolve(resolvedPath),
2049
+ });
2050
+ return new ListeningHistoryStore(database);
2051
+ } catch (error) {
2052
+ database.close();
2053
+ throw error;
2054
+ }
2055
+ }