@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,1307 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import {
3
+ chmod,
4
+ lstat,
5
+ mkdir,
6
+ open,
7
+ realpath,
8
+ rename,
9
+ unlink,
10
+ } from "node:fs/promises";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ import {
15
+ canonicalizeJson,
16
+ sha256Hex,
17
+ } from "../../../scripts/contract-semantics.mjs";
18
+ import { resolveMoondogStateDirectory } from "../../core/state-directory.mjs";
19
+ import { AppleMusicImportError } from "./parse-plist.mjs";
20
+ import { listAppleMusicImportBatches } from "./read-batch.mjs";
21
+ import { promoteAppleMusicImportBatches } from "./projection-records.mjs";
22
+ import { isUuid } from "./stable-ids.mjs";
23
+
24
+ export const APPLE_SQLITE_PROJECTION_VERSION = 1;
25
+ export const APPLE_LIBRARY_SEARCH_HARD_LIMIT = 25;
26
+ export const APPLE_LIBRARY_SEARCH_HARD_OFFSET = 10_000;
27
+ export const APPLE_PROFILE_SUMMARY_HARD_LIMIT = 50;
28
+ // Earlier versions kept the projection inside the source checkout.
29
+ export const legacyAppleMusicProjectionPath = fileURLToPath(
30
+ new URL(
31
+ "../../../data/private/apple-music-library/projection.sqlite",
32
+ import.meta.url,
33
+ ),
34
+ );
35
+
36
+ export function defaultAppleMusicProjectionPathFor(environment = process.env) {
37
+ return path.join(resolveMoondogStateDirectory(environment), "apple-music-library", "projection.sqlite");
38
+ }
39
+
40
+ export const defaultAppleMusicProjectionPath = defaultAppleMusicProjectionPathFor();
41
+
42
+ const projectionSchema = `
43
+ PRAGMA foreign_keys = ON;
44
+ PRAGMA journal_mode = DELETE;
45
+ PRAGMA trusted_schema = OFF;
46
+ PRAGMA user_version = ${APPLE_SQLITE_PROJECTION_VERSION};
47
+
48
+ CREATE TABLE projection_meta (
49
+ key TEXT PRIMARY KEY NOT NULL,
50
+ value TEXT NOT NULL
51
+ ) STRICT;
52
+
53
+ CREATE TABLE track_refs (
54
+ subject_id TEXT NOT NULL,
55
+ track_ref_id TEXT NOT NULL,
56
+ revision INTEGER NOT NULL CHECK (revision >= 1),
57
+ is_current INTEGER NOT NULL CHECK (is_current IN (0, 1)),
58
+ metadata_digest TEXT NOT NULL,
59
+ record_json TEXT NOT NULL,
60
+ PRIMARY KEY (subject_id, track_ref_id, revision)
61
+ ) STRICT;
62
+
63
+ CREATE UNIQUE INDEX track_refs_one_current
64
+ ON track_refs(subject_id, track_ref_id)
65
+ WHERE is_current = 1;
66
+
67
+ CREATE TABLE external_anchors (
68
+ subject_id TEXT NOT NULL,
69
+ system TEXT NOT NULL,
70
+ entity_type TEXT NOT NULL,
71
+ external_id TEXT NOT NULL,
72
+ track_ref_id TEXT NOT NULL,
73
+ track_ref_revision INTEGER NOT NULL,
74
+ PRIMARY KEY (subject_id, system, entity_type, external_id),
75
+ FOREIGN KEY (subject_id, track_ref_id, track_ref_revision)
76
+ REFERENCES track_refs(subject_id, track_ref_id, revision)
77
+ ) STRICT;
78
+
79
+ CREATE TABLE observations (
80
+ subject_id TEXT NOT NULL,
81
+ observation_id TEXT NOT NULL,
82
+ track_ref_id TEXT NOT NULL,
83
+ track_ref_revision INTEGER NOT NULL,
84
+ observed_at TEXT NOT NULL,
85
+ source_batch_ref TEXT NOT NULL,
86
+ source_record_digest TEXT NOT NULL,
87
+ play_count INTEGER,
88
+ loved INTEGER,
89
+ favorited INTEGER,
90
+ rating_value INTEGER,
91
+ rating_computed INTEGER,
92
+ record_json TEXT NOT NULL,
93
+ PRIMARY KEY (subject_id, observation_id),
94
+ UNIQUE (subject_id, source_batch_ref, source_record_digest),
95
+ FOREIGN KEY (subject_id, track_ref_id, track_ref_revision)
96
+ REFERENCES track_refs(subject_id, track_ref_id, revision)
97
+ ) STRICT;
98
+
99
+ CREATE TABLE profile_evidence (
100
+ subject_id TEXT NOT NULL,
101
+ evidence_id TEXT NOT NULL,
102
+ claim_dimension TEXT NOT NULL,
103
+ direction TEXT NOT NULL,
104
+ strength REAL NOT NULL,
105
+ confidence REAL NOT NULL,
106
+ basis_observation_id TEXT NOT NULL,
107
+ track_ref_id TEXT NOT NULL,
108
+ track_ref_revision INTEGER NOT NULL,
109
+ created_at TEXT NOT NULL,
110
+ record_json TEXT NOT NULL,
111
+ PRIMARY KEY (subject_id, evidence_id),
112
+ FOREIGN KEY (subject_id, basis_observation_id)
113
+ REFERENCES observations(subject_id, observation_id),
114
+ FOREIGN KEY (subject_id, track_ref_id, track_ref_revision)
115
+ REFERENCES track_refs(subject_id, track_ref_id, revision)
116
+ ) STRICT;
117
+
118
+ CREATE TABLE profile_summary_items (
119
+ subject_id TEXT NOT NULL,
120
+ evidence_id TEXT NOT NULL,
121
+ dimension TEXT NOT NULL,
122
+ direction TEXT NOT NULL,
123
+ strength REAL NOT NULL,
124
+ item_json TEXT NOT NULL,
125
+ PRIMARY KEY (subject_id, evidence_id),
126
+ FOREIGN KEY (subject_id, evidence_id)
127
+ REFERENCES profile_evidence(subject_id, evidence_id)
128
+ ) STRICT;
129
+
130
+ CREATE TABLE library_search (
131
+ rowid INTEGER PRIMARY KEY,
132
+ subject_id TEXT NOT NULL,
133
+ track_ref_id TEXT NOT NULL,
134
+ track_ref_revision INTEGER NOT NULL,
135
+ title TEXT NOT NULL,
136
+ title_norm TEXT NOT NULL,
137
+ artist_credit TEXT NOT NULL,
138
+ artist_credit_norm TEXT NOT NULL,
139
+ release_title TEXT NOT NULL,
140
+ release_title_norm TEXT NOT NULL,
141
+ composer TEXT NOT NULL,
142
+ composer_norm TEXT NOT NULL,
143
+ genre_label TEXT NOT NULL,
144
+ genre_label_norm TEXT NOT NULL,
145
+ duration_ms INTEGER,
146
+ play_count INTEGER,
147
+ loved INTEGER,
148
+ favorited INTEGER,
149
+ rating_value INTEGER,
150
+ rating_computed INTEGER,
151
+ UNIQUE (subject_id, track_ref_id),
152
+ FOREIGN KEY (subject_id, track_ref_id, track_ref_revision)
153
+ REFERENCES track_refs(subject_id, track_ref_id, revision)
154
+ ) STRICT;
155
+
156
+ CREATE VIRTUAL TABLE library_fts USING fts5(
157
+ title,
158
+ artist_credit,
159
+ release_title,
160
+ composer,
161
+ genre_label,
162
+ content = 'library_search',
163
+ content_rowid = 'rowid',
164
+ tokenize = 'unicode61 remove_diacritics 2'
165
+ );
166
+ `;
167
+
168
+ let databaseConstructorPromise;
169
+
170
+ async function databaseConstructor() {
171
+ databaseConstructorPromise ??= import("node:sqlite").then(
172
+ ({ DatabaseSync }) => DatabaseSync,
173
+ );
174
+ return databaseConstructorPromise;
175
+ }
176
+
177
+ function fail(code, message, options) {
178
+ throw new AppleMusicImportError(code, message, options);
179
+ }
180
+
181
+ function compareText(left, right) {
182
+ return left < right ? -1 : left > right ? 1 : 0;
183
+ }
184
+
185
+ function isPathInside(parent, candidate) {
186
+ const relative = path.relative(parent, candidate);
187
+ return (
188
+ relative !== "" &&
189
+ relative !== ".." &&
190
+ !relative.startsWith(`..${path.sep}`) &&
191
+ !path.isAbsolute(relative)
192
+ );
193
+ }
194
+
195
+ async function pathMetadata(target) {
196
+ try {
197
+ return await lstat(target);
198
+ } catch (error) {
199
+ if (error?.code === "ENOENT") return null;
200
+ throw error;
201
+ }
202
+ }
203
+
204
+ async function preparePrivateDatabasePath(databasePath, boundaryRoot) {
205
+ if (
206
+ typeof databasePath !== "string" ||
207
+ databasePath.trim() === "" ||
208
+ typeof boundaryRoot !== "string" ||
209
+ boundaryRoot.trim() === ""
210
+ ) {
211
+ throw new TypeError("Projection database path and boundary are required");
212
+ }
213
+ const lexicalBoundary = path.resolve(boundaryRoot);
214
+ const lexicalDatabase = path.resolve(databasePath);
215
+ if (!isPathInside(lexicalBoundary, lexicalDatabase)) {
216
+ fail("projection_boundary_invalid", "Projection database is outside its boundary");
217
+ }
218
+
219
+ const boundaryMetadata = await pathMetadata(lexicalBoundary);
220
+ if (
221
+ !boundaryMetadata?.isDirectory() ||
222
+ boundaryMetadata.isSymbolicLink()
223
+ ) {
224
+ fail("projection_boundary_invalid", "Projection boundary is invalid");
225
+ }
226
+ const canonicalBoundary = await realpath(lexicalBoundary);
227
+ const parent = path.dirname(lexicalDatabase);
228
+ const segments = path
229
+ .relative(lexicalBoundary, parent)
230
+ .split(path.sep)
231
+ .filter(Boolean);
232
+ let current = lexicalBoundary;
233
+ for (const segment of segments) {
234
+ current = path.join(current, segment);
235
+ let metadata = await pathMetadata(current);
236
+ let created = false;
237
+ if (!metadata) {
238
+ try {
239
+ await mkdir(current, { mode: 0o700 });
240
+ created = true;
241
+ } catch (error) {
242
+ if (error?.code !== "EEXIST") throw error;
243
+ }
244
+ metadata = await pathMetadata(current);
245
+ }
246
+ if (!metadata?.isDirectory() || metadata.isSymbolicLink()) {
247
+ fail("projection_path_invalid", "Projection path contains an unsafe entry");
248
+ }
249
+ const canonicalCurrent = await realpath(current);
250
+ if (!isPathInside(canonicalBoundary, canonicalCurrent)) {
251
+ fail("projection_boundary_invalid", "Projection path escaped its boundary");
252
+ }
253
+ if (created || current === parent) await chmod(current, 0o700);
254
+ }
255
+
256
+ const existing = await pathMetadata(lexicalDatabase);
257
+ if (existing?.isSymbolicLink() || (existing && !existing.isFile())) {
258
+ fail("projection_path_invalid", "Projection database path is unsafe");
259
+ }
260
+ return lexicalDatabase;
261
+ }
262
+
263
+ async function removeTemporaryDatabase(databasePath) {
264
+ for (const suffix of ["", "-journal", "-wal", "-shm"]) {
265
+ try {
266
+ await unlink(`${databasePath}${suffix}`);
267
+ } catch (error) {
268
+ if (error?.code !== "ENOENT") throw error;
269
+ }
270
+ }
271
+ }
272
+
273
+ function normalizedText(value) {
274
+ return (value ?? "").normalize("NFKC").trim().toLowerCase();
275
+ }
276
+
277
+ function artistCredit(trackRef) {
278
+ return (trackRef.artist_credits ?? [])
279
+ .map((credit) => credit.name)
280
+ .filter(Boolean)
281
+ .join("; ");
282
+ }
283
+
284
+ function currentObservationByTrack(records) {
285
+ const latestBatchRef = records.inputs.at(-1).import_batch_id;
286
+ return new Map(
287
+ records.observations
288
+ .filter((observation) => observation.source_batch_ref === latestBatchRef)
289
+ .map((observation) => [observation.track_ref_id, observation]),
290
+ );
291
+ }
292
+
293
+ function profileSummaryItem(evidence) {
294
+ const entity = evidence.claim.value.entity_ref;
295
+ return {
296
+ evidence_id: evidence.profile_evidence_id,
297
+ dimension: evidence.claim.dimension,
298
+ direction: evidence.direction,
299
+ strength: evidence.strength,
300
+ confidence: evidence.confidence,
301
+ track_ref: {
302
+ track_ref_id: entity.entity_id,
303
+ revision: entity.entity_revision,
304
+ },
305
+ label: entity.label,
306
+ observed_at: evidence.created_at,
307
+ };
308
+ }
309
+
310
+ function logicalPayloadFromDatabase(database) {
311
+ database.exec(
312
+ "CREATE VIRTUAL TABLE IF NOT EXISTS temp.moondog_fts_vocab USING fts5vocab(main, library_fts, instance)",
313
+ );
314
+ const subjectId = JSON.parse(
315
+ database.prepare("SELECT value FROM projection_meta WHERE key = ?").get(
316
+ "projection.subject_id",
317
+ ).value,
318
+ );
319
+ const inputs = JSON.parse(
320
+ database.prepare("SELECT value FROM projection_meta WHERE key = ?").get(
321
+ "projection.inputs",
322
+ ).value,
323
+ );
324
+ const trackRefs = database
325
+ .prepare(
326
+ "SELECT record_json FROM track_refs ORDER BY track_ref_id, revision",
327
+ )
328
+ .all()
329
+ .map((row) => JSON.parse(row.record_json));
330
+ const currentTrackRefs = database
331
+ .prepare(
332
+ "SELECT track_ref_id, revision FROM track_refs WHERE is_current = 1 ORDER BY track_ref_id",
333
+ )
334
+ .all();
335
+ const observations = database
336
+ .prepare(
337
+ "SELECT record_json FROM observations ORDER BY observation_id",
338
+ )
339
+ .all()
340
+ .map((row) => JSON.parse(row.record_json));
341
+ const profileEvidence = database
342
+ .prepare(
343
+ "SELECT record_json FROM profile_evidence ORDER BY evidence_id",
344
+ )
345
+ .all()
346
+ .map((row) => JSON.parse(row.record_json));
347
+ const profileSummaryItems = database
348
+ .prepare(
349
+ "SELECT item_json FROM profile_summary_items ORDER BY evidence_id",
350
+ )
351
+ .all()
352
+ .map((row) => JSON.parse(row.item_json));
353
+ const externalAnchors = database
354
+ .prepare(`
355
+ SELECT
356
+ subject_id, system, entity_type, external_id,
357
+ track_ref_id, track_ref_revision
358
+ FROM external_anchors
359
+ ORDER BY subject_id, system, entity_type, external_id
360
+ `)
361
+ .all();
362
+ const librarySearch = database
363
+ .prepare(`
364
+ SELECT
365
+ rowid, subject_id, track_ref_id, track_ref_revision,
366
+ title, title_norm, artist_credit, artist_credit_norm,
367
+ release_title, release_title_norm, composer, composer_norm,
368
+ genre_label, genre_label_norm, duration_ms,
369
+ play_count, loved, favorited, rating_value, rating_computed
370
+ FROM library_search
371
+ ORDER BY rowid
372
+ `)
373
+ .all();
374
+ const libraryFtsSchema = database
375
+ .prepare("SELECT sql FROM sqlite_schema WHERE type = ? AND name = ?")
376
+ .get("table", "library_fts")?.sql;
377
+ const libraryFtsVocabulary = database
378
+ .prepare(`
379
+ SELECT term, doc, col, offset
380
+ FROM temp.moondog_fts_vocab
381
+ ORDER BY term, doc, col, offset
382
+ `)
383
+ .all();
384
+ if (typeof libraryFtsSchema !== "string") {
385
+ fail("projection_schema_incompatible", "Projection search schema is missing");
386
+ }
387
+ return {
388
+ kind: "moondog.apple_sqlite_logical_projection",
389
+ version: APPLE_SQLITE_PROJECTION_VERSION,
390
+ subject_id: subjectId,
391
+ inputs,
392
+ counts: JSON.parse(
393
+ database.prepare("SELECT value FROM projection_meta WHERE key = ?").get(
394
+ "projection.counts",
395
+ ).value,
396
+ ),
397
+ source_timestamp: JSON.parse(
398
+ database.prepare("SELECT value FROM projection_meta WHERE key = ?").get(
399
+ "projection.source_timestamp",
400
+ ).value,
401
+ ),
402
+ track_refs: trackRefs,
403
+ current_track_refs: currentTrackRefs,
404
+ external_anchors: externalAnchors,
405
+ observations,
406
+ profile_evidence: profileEvidence,
407
+ profile_summary_items: profileSummaryItems,
408
+ library_search: librarySearch,
409
+ library_fts_schema: libraryFtsSchema,
410
+ library_fts_vocabulary: libraryFtsVocabulary,
411
+ };
412
+ }
413
+
414
+ export function computeAppleSqliteLogicalDigest(database) {
415
+ return sha256Hex(logicalPayloadFromDatabase(database));
416
+ }
417
+
418
+ function insertProjectionRecords(database, records) {
419
+ const insertMeta = database.prepare(
420
+ "INSERT INTO projection_meta(key, value) VALUES (?, ?)",
421
+ );
422
+ insertMeta.run("projection.subject_id", canonicalizeJson(records.subject_id));
423
+ insertMeta.run("projection.inputs", canonicalizeJson(records.inputs));
424
+ insertMeta.run("projection.counts", canonicalizeJson(records.counts));
425
+ insertMeta.run(
426
+ "projection.source_timestamp",
427
+ canonicalizeJson(records.inputs.at(-1).captured_at),
428
+ );
429
+
430
+ const currentRevisionByTrack = new Map(
431
+ records.currentTrackRefs.map((trackRef) => [
432
+ trackRef.track_ref_id,
433
+ trackRef.revision,
434
+ ]),
435
+ );
436
+ const insertTrackRef = database.prepare(`
437
+ INSERT INTO track_refs(
438
+ subject_id, track_ref_id, revision, is_current, metadata_digest, record_json
439
+ ) VALUES (?, ?, ?, ?, ?, ?)
440
+ `);
441
+ const insertExternalAnchor = database.prepare(`
442
+ INSERT INTO external_anchors(
443
+ subject_id, system, entity_type, external_id,
444
+ track_ref_id, track_ref_revision
445
+ ) VALUES (?, ?, ?, ?, ?, ?)
446
+ `);
447
+ for (const trackRef of records.trackRefs) {
448
+ insertTrackRef.run(
449
+ records.subject_id,
450
+ trackRef.track_ref_id,
451
+ trackRef.revision,
452
+ currentRevisionByTrack.get(trackRef.track_ref_id) === trackRef.revision ? 1 : 0,
453
+ sha256Hex({
454
+ kind: "moondog.sqlite_track_ref_record",
455
+ version: 1,
456
+ track_ref: trackRef,
457
+ }),
458
+ canonicalizeJson(trackRef),
459
+ );
460
+ if (trackRef.revision === 1) {
461
+ for (const externalRef of trackRef.external_refs) {
462
+ insertExternalAnchor.run(
463
+ records.subject_id,
464
+ externalRef.system,
465
+ externalRef.entity_type,
466
+ externalRef.external_id,
467
+ trackRef.track_ref_id,
468
+ trackRef.revision,
469
+ );
470
+ }
471
+ }
472
+ }
473
+
474
+ const insertObservation = database.prepare(`
475
+ INSERT INTO observations(
476
+ subject_id, observation_id, track_ref_id, track_ref_revision,
477
+ observed_at, source_batch_ref, source_record_digest,
478
+ play_count, loved, favorited, rating_value, rating_computed, record_json
479
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
480
+ `);
481
+ for (const observation of records.observations) {
482
+ insertObservation.run(
483
+ records.subject_id,
484
+ observation.library_track_observation_id,
485
+ observation.track_ref_id,
486
+ observation.track_ref_revision,
487
+ observation.observed_at,
488
+ observation.source_batch_ref,
489
+ observation.source_record_digest,
490
+ observation.aggregate_state.play_count ?? null,
491
+ observation.aggregate_state.loved === undefined
492
+ ? null
493
+ : Number(observation.aggregate_state.loved),
494
+ observation.aggregate_state.favorited === undefined
495
+ ? null
496
+ : Number(observation.aggregate_state.favorited),
497
+ observation.aggregate_state.rating?.value ?? null,
498
+ observation.aggregate_state.rating?.computed === undefined
499
+ ? null
500
+ : Number(observation.aggregate_state.rating.computed),
501
+ canonicalizeJson(observation),
502
+ );
503
+ }
504
+
505
+ const insertEvidence = database.prepare(`
506
+ INSERT INTO profile_evidence(
507
+ subject_id, evidence_id, claim_dimension, direction, strength,
508
+ confidence, basis_observation_id, track_ref_id, track_ref_revision,
509
+ created_at, record_json
510
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
511
+ `);
512
+ const insertSummaryItem = database.prepare(`
513
+ INSERT INTO profile_summary_items(
514
+ subject_id, evidence_id, dimension, direction, strength, item_json
515
+ ) VALUES (?, ?, ?, ?, ?, ?)
516
+ `);
517
+ for (const evidence of records.profileEvidence) {
518
+ const entity = evidence.claim.value.entity_ref;
519
+ const basisObservationId = evidence.basis_refs[0].record_id;
520
+ insertEvidence.run(
521
+ records.subject_id,
522
+ evidence.profile_evidence_id,
523
+ evidence.claim.dimension,
524
+ evidence.direction,
525
+ evidence.strength,
526
+ evidence.confidence,
527
+ basisObservationId,
528
+ entity.entity_id,
529
+ entity.entity_revision,
530
+ evidence.created_at,
531
+ canonicalizeJson(evidence),
532
+ );
533
+ const item = profileSummaryItem(evidence);
534
+ insertSummaryItem.run(
535
+ records.subject_id,
536
+ evidence.profile_evidence_id,
537
+ evidence.claim.dimension,
538
+ evidence.direction,
539
+ evidence.strength,
540
+ canonicalizeJson(item),
541
+ );
542
+ }
543
+
544
+ const observationByTrack = currentObservationByTrack(records);
545
+ const insertSearchRow = database.prepare(`
546
+ INSERT INTO library_search(
547
+ rowid, subject_id, track_ref_id, track_ref_revision,
548
+ title, title_norm, artist_credit, artist_credit_norm,
549
+ release_title, release_title_norm, composer, composer_norm,
550
+ genre_label, genre_label_norm, duration_ms,
551
+ play_count, loved, favorited, rating_value, rating_computed
552
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
553
+ `);
554
+ records.currentTrackRefs.forEach((trackRef, index) => {
555
+ const observation = observationByTrack.get(trackRef.track_ref_id);
556
+ const title = trackRef.title ?? trackRef.display_label;
557
+ const artist = artistCredit(trackRef);
558
+ const releaseTitle = trackRef.release?.title ?? "";
559
+ const composer = trackRef.extensions?.["apple_music.composer"] ?? "";
560
+ const genre = trackRef.extensions?.["apple_music.genre"] ?? "";
561
+ insertSearchRow.run(
562
+ index + 1,
563
+ records.subject_id,
564
+ trackRef.track_ref_id,
565
+ trackRef.revision,
566
+ title,
567
+ normalizedText(title),
568
+ artist,
569
+ normalizedText(artist),
570
+ releaseTitle,
571
+ normalizedText(releaseTitle),
572
+ composer,
573
+ normalizedText(composer),
574
+ genre,
575
+ normalizedText(genre),
576
+ trackRef.duration_ms ?? null,
577
+ observation?.aggregate_state.play_count ?? null,
578
+ observation?.aggregate_state.loved === undefined
579
+ ? null
580
+ : Number(observation.aggregate_state.loved),
581
+ observation?.aggregate_state.favorited === undefined
582
+ ? null
583
+ : Number(observation.aggregate_state.favorited),
584
+ observation?.aggregate_state.rating?.value ?? null,
585
+ observation?.aggregate_state.rating?.computed === undefined
586
+ ? null
587
+ : Number(observation.aggregate_state.rating.computed),
588
+ );
589
+ });
590
+ database.exec("INSERT INTO library_fts(library_fts) VALUES ('rebuild')");
591
+ }
592
+
593
+ function assertDatabaseIntegrity(database) {
594
+ const foreignKeyFailures = database.prepare("PRAGMA foreign_key_check").all();
595
+ if (foreignKeyFailures.length > 0) {
596
+ fail("projection_foreign_key_invalid", "Projection foreign keys are invalid");
597
+ }
598
+ const integrity = database.prepare("PRAGMA integrity_check").get();
599
+ if (integrity.integrity_check !== "ok") {
600
+ fail("projection_integrity_invalid", "Projection database is not valid");
601
+ }
602
+ }
603
+
604
+ export async function rebuildAppleMusicSqliteProjection({
605
+ importsRoot,
606
+ databasePath,
607
+ boundaryRoot,
608
+ subjectId,
609
+ }) {
610
+ if (subjectId !== undefined && !isUuid(subjectId)) {
611
+ throw new TypeError("Apple projection requires a valid subject UUID");
612
+ }
613
+ const resolvedDatabasePath = await preparePrivateDatabasePath(
614
+ databasePath,
615
+ boundaryRoot,
616
+ );
617
+ const batches = await listAppleMusicImportBatches(importsRoot, { subjectId });
618
+ if (batches.length === 0) {
619
+ fail("projection_input_missing", "No Apple import batch exists for the subject");
620
+ }
621
+ const records = await promoteAppleMusicImportBatches(batches, { subjectId });
622
+ const temporaryPath = path.join(
623
+ path.dirname(resolvedDatabasePath),
624
+ `.${path.basename(resolvedDatabasePath)}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`,
625
+ );
626
+
627
+ let database;
628
+ let logicalDigest;
629
+ try {
630
+ const privateFile = await open(temporaryPath, "wx", 0o600);
631
+ await privateFile.close();
632
+ await chmod(temporaryPath, 0o600);
633
+ const DatabaseSync = await databaseConstructor();
634
+ database = new DatabaseSync(temporaryPath);
635
+ database.exec("PRAGMA temp_store = MEMORY");
636
+ database.exec(projectionSchema);
637
+ database.exec("BEGIN IMMEDIATE");
638
+ try {
639
+ insertProjectionRecords(database, records);
640
+ logicalDigest = computeAppleSqliteLogicalDigest(database);
641
+ database
642
+ .prepare("INSERT INTO projection_meta(key, value) VALUES (?, ?)")
643
+ .run("projection.logical_digest", canonicalizeJson(logicalDigest));
644
+ assertDatabaseIntegrity(database);
645
+ database.exec("COMMIT");
646
+ if (computeAppleSqliteLogicalDigest(database) !== logicalDigest) {
647
+ fail("projection_digest_mismatch", "Projection logical digest is unstable");
648
+ }
649
+ } catch (cause) {
650
+ try {
651
+ database.exec("ROLLBACK");
652
+ } catch {
653
+ // The original failure remains authoritative.
654
+ }
655
+ throw cause;
656
+ }
657
+ database.close();
658
+ database = null;
659
+ await chmod(temporaryPath, 0o600);
660
+ await rename(temporaryPath, resolvedDatabasePath);
661
+ } catch (cause) {
662
+ try {
663
+ database?.close();
664
+ } catch {
665
+ // Cleanup continues without replacing the original projection.
666
+ }
667
+ await removeTemporaryDatabase(temporaryPath);
668
+ if (cause instanceof AppleMusicImportError) throw cause;
669
+ fail("projection_rebuild_failed", "Apple projection rebuild failed", {
670
+ cause,
671
+ });
672
+ }
673
+
674
+ return {
675
+ status: "rebuilt",
676
+ logical_digest: logicalDigest,
677
+ counts: records.counts,
678
+ source_timestamp: records.inputs.at(-1).captured_at,
679
+ };
680
+ }
681
+
682
+ function boundedLimit(value, fallback, hardLimit, field) {
683
+ const limit = value ?? fallback;
684
+ if (!Number.isInteger(limit) || limit < 1 || limit > hardLimit) {
685
+ throw new RangeError(`${field} must be between 1 and ${hardLimit}`);
686
+ }
687
+ return limit;
688
+ }
689
+
690
+ function boundedOffset(value, hardLimit, field) {
691
+ const offset = value ?? 0;
692
+ if (!Number.isInteger(offset) || offset < 0 || offset > hardLimit) {
693
+ throw new RangeError(`${field} must be between 0 and ${hardLimit}`);
694
+ }
695
+ return offset;
696
+ }
697
+
698
+ function escapeLike(value) {
699
+ return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
700
+ }
701
+
702
+ function literalFtsQuery(query) {
703
+ return query
704
+ .split(/\s+/u)
705
+ .filter(Boolean)
706
+ .map((token) => `"${token.replaceAll('"', '""')}"*`)
707
+ .join(" AND ");
708
+ }
709
+
710
+ function usesLikeFallback(query) {
711
+ return (
712
+ Array.from(query).length < 3 ||
713
+ /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(
714
+ query,
715
+ )
716
+ );
717
+ }
718
+
719
+ function normalizeSearchFilters(filters = {}) {
720
+ if (!filters || typeof filters !== "object" || Array.isArray(filters)) {
721
+ throw new TypeError("Library search filters must be an object");
722
+ }
723
+ const allowed = new Set([
724
+ "genre",
725
+ "loved",
726
+ "favorited",
727
+ "minimumPlayCount",
728
+ "artists",
729
+ "genres",
730
+ "familiarity",
731
+ "preferenceSignals",
732
+ ]);
733
+ if (Object.keys(filters).some((key) => !allowed.has(key))) {
734
+ throw new TypeError("Library search contains an unsupported filter");
735
+ }
736
+ const normalized = {};
737
+ if (filters.genre !== undefined) {
738
+ if (typeof filters.genre !== "string" || filters.genre.trim() === "") {
739
+ throw new TypeError("Library search genre filter must be non-empty text");
740
+ }
741
+ normalized.genre = normalizedText(filters.genre);
742
+ }
743
+ for (const field of ["loved", "favorited"]) {
744
+ if (filters[field] !== undefined) {
745
+ if (typeof filters[field] !== "boolean") {
746
+ throw new TypeError(`Library search ${field} filter must be boolean`);
747
+ }
748
+ normalized[field] = Number(filters[field]);
749
+ }
750
+ }
751
+ if (filters.minimumPlayCount !== undefined) {
752
+ if (
753
+ !Number.isSafeInteger(filters.minimumPlayCount) ||
754
+ filters.minimumPlayCount < 1
755
+ ) {
756
+ throw new TypeError("minimumPlayCount must be a positive integer");
757
+ }
758
+ normalized.minimumPlayCount = filters.minimumPlayCount;
759
+ }
760
+ const normalizedArray = (field, allowedValues) => {
761
+ const value = filters[field];
762
+ if (value === undefined) return [];
763
+ if (!Array.isArray(value) || value.length > 4) {
764
+ throw new TypeError(`Library search ${field} filter must be an array`);
765
+ }
766
+ const items = value.map((item) => {
767
+ if (typeof item !== "string" || item.trim() === "") {
768
+ throw new TypeError(`Library search ${field} filter must contain text`);
769
+ }
770
+ const itemValue = normalizedText(item);
771
+ if (allowedValues && !allowedValues.has(itemValue)) {
772
+ throw new TypeError(`Library search ${field} filter is invalid`);
773
+ }
774
+ return itemValue;
775
+ });
776
+ return [...new Set(items)];
777
+ };
778
+ normalized.artists = normalizedArray("artists");
779
+ normalized.genres = normalizedArray("genres");
780
+ normalized.familiarity = normalizedArray(
781
+ "familiarity",
782
+ new Set(["low", "medium", "high", "unknown"]),
783
+ );
784
+ normalized.preferenceSignals = normalizedArray(
785
+ "preferenceSignals",
786
+ new Set(["loved", "favorited", "rated"]),
787
+ );
788
+ return normalized;
789
+ }
790
+
791
+ function appendSearchFilters(where, parameters, filters) {
792
+ if (filters.genre !== undefined) {
793
+ where.push("s.genre_label_norm = ?");
794
+ parameters.push(filters.genre);
795
+ }
796
+ if (filters.loved !== undefined) {
797
+ where.push("s.loved = ?");
798
+ parameters.push(filters.loved);
799
+ }
800
+ if (filters.favorited !== undefined) {
801
+ where.push("s.favorited = ?");
802
+ parameters.push(filters.favorited);
803
+ }
804
+ if (filters.minimumPlayCount !== undefined) {
805
+ where.push("s.play_count >= ?");
806
+ parameters.push(filters.minimumPlayCount);
807
+ }
808
+ if (filters.artists.length > 0) {
809
+ where.push(
810
+ `(${filters.artists
811
+ .map(() => "s.artist_credit_norm LIKE ? ESCAPE '\\'")
812
+ .join(" OR ")})`,
813
+ );
814
+ parameters.push(
815
+ ...filters.artists.map((artist) => `%${escapeLike(artist)}%`),
816
+ );
817
+ }
818
+ if (filters.genres.length > 0) {
819
+ where.push(
820
+ `(${filters.genres.map(() => "s.genre_label_norm = ?").join(" OR ")})`,
821
+ );
822
+ parameters.push(...filters.genres);
823
+ }
824
+ if (filters.familiarity.length > 0) {
825
+ const clauses = filters.familiarity.map((level) => {
826
+ switch (level) {
827
+ case "low":
828
+ return "(s.play_count IS NOT NULL AND s.play_count < 5)";
829
+ case "medium":
830
+ return "(s.play_count >= 5 AND s.play_count < 20)";
831
+ case "high":
832
+ return "s.play_count >= 20";
833
+ case "unknown":
834
+ return "s.play_count IS NULL";
835
+ default:
836
+ throw new TypeError("Library search familiarity filter is invalid");
837
+ }
838
+ });
839
+ where.push(`(${clauses.join(" OR ")})`);
840
+ }
841
+ for (const signal of filters.preferenceSignals) {
842
+ switch (signal) {
843
+ case "loved":
844
+ where.push("s.loved = 1");
845
+ break;
846
+ case "favorited":
847
+ where.push("s.favorited = 1");
848
+ break;
849
+ case "rated":
850
+ where.push(
851
+ "s.rating_value >= 60 AND COALESCE(s.rating_computed, 0) = 0",
852
+ );
853
+ break;
854
+ default:
855
+ throw new TypeError("Library search preference filter is invalid");
856
+ }
857
+ }
858
+ }
859
+
860
+ function compactSearchResult(row) {
861
+ const labels = {};
862
+ if (row.genre_label) labels.provider_genre = row.genre_label;
863
+ if (row.composer) labels.composer = row.composer;
864
+ const observationSummary = {};
865
+ if (row.play_count !== null) observationSummary.play_count = row.play_count;
866
+ if (row.loved !== null) observationSummary.loved = Boolean(row.loved);
867
+ if (row.favorited !== null) {
868
+ observationSummary.favorited = Boolean(row.favorited);
869
+ }
870
+ if (row.rating_value !== null) {
871
+ observationSummary.rating = {
872
+ value: row.rating_value,
873
+ ...(row.rating_computed === null
874
+ ? {}
875
+ : { computed: Boolean(row.rating_computed) }),
876
+ };
877
+ }
878
+ return {
879
+ track_ref: {
880
+ track_ref_id: row.track_ref_id,
881
+ revision: row.track_ref_revision,
882
+ },
883
+ title: row.title,
884
+ artist_credit: row.artist_credit || null,
885
+ release: row.release_title || null,
886
+ duration_ms: row.duration_ms,
887
+ labels,
888
+ observation_summary: observationSummary,
889
+ };
890
+ }
891
+
892
+ function readMeta(database, key) {
893
+ const row = database
894
+ .prepare("SELECT value FROM projection_meta WHERE key = ?")
895
+ .get(key);
896
+ if (!row) {
897
+ fail("projection_metadata_missing", "Projection metadata is incomplete");
898
+ }
899
+ return JSON.parse(row.value);
900
+ }
901
+
902
+ class AppleMusicSqliteProjection {
903
+ #database;
904
+ #subjectId;
905
+ #closed = false;
906
+
907
+ constructor(database, subjectId) {
908
+ this.#database = database;
909
+ this.#subjectId = subjectId;
910
+ }
911
+
912
+ #assertOpen() {
913
+ if (this.#closed) {
914
+ throw new Error("Apple projection is closed");
915
+ }
916
+ }
917
+
918
+ logicalDigest() {
919
+ this.#assertOpen();
920
+ return readMeta(this.#database, "projection.logical_digest");
921
+ }
922
+
923
+ searchLibrary({ query, limit, offset, filters } = {}) {
924
+ this.#assertOpen();
925
+ if (typeof query !== "string") {
926
+ throw new TypeError("Library search query must be text");
927
+ }
928
+ const normalizedQuery = normalizedText(query);
929
+ if (Array.from(normalizedQuery).length > 256) {
930
+ throw new RangeError("Library search query is too long");
931
+ }
932
+ const bounded = boundedLimit(
933
+ limit,
934
+ 10,
935
+ APPLE_LIBRARY_SEARCH_HARD_LIMIT,
936
+ "Library search limit",
937
+ );
938
+ const boundedSearchOffset = boundedOffset(
939
+ offset,
940
+ APPLE_LIBRARY_SEARCH_HARD_OFFSET,
941
+ "Library search offset",
942
+ );
943
+ const normalizedFilters = normalizeSearchFilters(filters);
944
+ const selectColumns = `
945
+ s.track_ref_id, s.track_ref_revision, s.title, s.artist_credit,
946
+ s.release_title, s.composer, s.genre_label, s.duration_ms,
947
+ s.play_count, s.loved, s.favorited, s.rating_value, s.rating_computed
948
+ `;
949
+ let sql;
950
+ let parameters;
951
+
952
+ if (normalizedQuery === "") {
953
+ const where = ["s.subject_id = ?"];
954
+ parameters = [this.#subjectId];
955
+ appendSearchFilters(where, parameters, normalizedFilters);
956
+ sql = `
957
+ SELECT ${selectColumns}
958
+ FROM library_search s
959
+ WHERE ${where.join(" AND ")}
960
+ ORDER BY s.title_norm, s.artist_credit_norm, s.track_ref_id
961
+ LIMIT ? OFFSET ?
962
+ `;
963
+ parameters.push(bounded, boundedSearchOffset);
964
+ } else if (usesLikeFallback(normalizedQuery)) {
965
+ const pattern = `%${escapeLike(normalizedQuery)}%`;
966
+ const where = [
967
+ "s.subject_id = ?",
968
+ `(s.title_norm LIKE ? ESCAPE '\\'
969
+ OR s.artist_credit_norm LIKE ? ESCAPE '\\'
970
+ OR s.release_title_norm LIKE ? ESCAPE '\\'
971
+ OR s.composer_norm LIKE ? ESCAPE '\\'
972
+ OR s.genre_label_norm LIKE ? ESCAPE '\\')`,
973
+ ];
974
+ parameters = [
975
+ this.#subjectId,
976
+ pattern,
977
+ pattern,
978
+ pattern,
979
+ pattern,
980
+ pattern,
981
+ ];
982
+ appendSearchFilters(where, parameters, normalizedFilters);
983
+ sql = `
984
+ SELECT ${selectColumns}
985
+ FROM library_search s
986
+ WHERE ${where.join(" AND ")}
987
+ ORDER BY
988
+ CASE
989
+ WHEN s.title_norm = ? THEN 0
990
+ WHEN s.artist_credit_norm = ? THEN 1
991
+ ELSE 2
992
+ END,
993
+ s.title_norm,
994
+ s.track_ref_id
995
+ LIMIT ? OFFSET ?
996
+ `;
997
+ parameters.push(
998
+ normalizedQuery,
999
+ normalizedQuery,
1000
+ bounded,
1001
+ boundedSearchOffset,
1002
+ );
1003
+ } else {
1004
+ const where = ["library_fts MATCH ?", "s.subject_id = ?"];
1005
+ parameters = [literalFtsQuery(normalizedQuery), this.#subjectId];
1006
+ appendSearchFilters(where, parameters, normalizedFilters);
1007
+ sql = `
1008
+ SELECT ${selectColumns},
1009
+ bm25(library_fts, 8.0, 4.0, 2.0, 1.0, 1.0) AS lexical_rank
1010
+ FROM library_fts
1011
+ JOIN library_search s ON s.rowid = library_fts.rowid
1012
+ WHERE ${where.join(" AND ")}
1013
+ ORDER BY
1014
+ CASE
1015
+ WHEN s.title_norm = ? THEN 0
1016
+ WHEN s.artist_credit_norm = ? THEN 1
1017
+ ELSE 2
1018
+ END,
1019
+ lexical_rank,
1020
+ s.track_ref_id
1021
+ LIMIT ? OFFSET ?
1022
+ `;
1023
+ parameters.push(
1024
+ normalizedQuery,
1025
+ normalizedQuery,
1026
+ bounded,
1027
+ boundedSearchOffset,
1028
+ );
1029
+ }
1030
+
1031
+ return {
1032
+ schema_version: "library-search-result/1",
1033
+ query: query.trim(),
1034
+ limit: bounded,
1035
+ offset: boundedSearchOffset,
1036
+ tracks: this.#database.prepare(sql).all(...parameters).map(compactSearchResult),
1037
+ };
1038
+ }
1039
+
1040
+ resolveTrackLabel({ label, artistCredit } = {}) {
1041
+ this.#assertOpen();
1042
+ if (typeof label !== "string" || typeof artistCredit !== "string") return null;
1043
+ const canonical = this.#database.prepare(`
1044
+ SELECT title, artist_credit
1045
+ FROM library_search
1046
+ WHERE subject_id = ? AND title_norm = ? AND artist_credit_norm = ?
1047
+ LIMIT 1
1048
+ `).get(this.#subjectId, normalizedText(label), normalizedText(artistCredit));
1049
+ if (canonical) return { ...canonical, match: "canonical_title" };
1050
+ const aliases = this.#database.prepare(`
1051
+ SELECT MIN(s.title) AS title, MIN(s.artist_credit) AS artist_credit
1052
+ FROM profile_summary_items i
1053
+ JOIN profile_evidence e
1054
+ ON e.subject_id = i.subject_id AND e.evidence_id = i.evidence_id
1055
+ JOIN library_search s
1056
+ ON s.subject_id = e.subject_id AND s.track_ref_id = e.track_ref_id
1057
+ WHERE i.subject_id = ? AND s.artist_credit_norm = ?
1058
+ AND json_extract(i.item_json, '$.label') = ?
1059
+ GROUP BY s.title_norm, s.artist_credit_norm
1060
+ LIMIT 2
1061
+ `).all(this.#subjectId, normalizedText(artistCredit), label);
1062
+ return aliases.length === 1
1063
+ ? { ...aliases[0], match: "legacy_display_label" }
1064
+ : null;
1065
+ }
1066
+
1067
+ getProfileSummary({ maxItems } = {}) {
1068
+ this.#assertOpen();
1069
+ const bounded = boundedLimit(
1070
+ maxItems,
1071
+ 20,
1072
+ APPLE_PROFILE_SUMMARY_HARD_LIMIT,
1073
+ "Profile summary maxItems",
1074
+ );
1075
+ const items = this.#database
1076
+ .prepare(`
1077
+ WITH ranked AS (
1078
+ SELECT
1079
+ i.item_json,
1080
+ i.dimension,
1081
+ i.strength,
1082
+ i.evidence_id,
1083
+ s.title,
1084
+ s.artist_credit,
1085
+ s.genre_label,
1086
+ s.composer,
1087
+ s.play_count,
1088
+ s.loved,
1089
+ s.favorited,
1090
+ s.rating_value,
1091
+ s.rating_computed,
1092
+ ROW_NUMBER() OVER (
1093
+ PARTITION BY i.dimension
1094
+ ORDER BY i.strength DESC, i.evidence_id
1095
+ ) AS dimension_rank
1096
+ FROM profile_summary_items i
1097
+ JOIN profile_evidence e
1098
+ ON e.subject_id = i.subject_id AND e.evidence_id = i.evidence_id
1099
+ JOIN library_search s
1100
+ ON s.subject_id = e.subject_id AND s.track_ref_id = e.track_ref_id
1101
+ WHERE i.subject_id = ?
1102
+ )
1103
+ SELECT *
1104
+ FROM ranked
1105
+ WHERE dimension_rank <= ?
1106
+ ORDER BY
1107
+ CASE dimension
1108
+ WHEN 'taste.track_preference' THEN 0
1109
+ WHEN 'taste.track_familiarity' THEN 1
1110
+ ELSE 2
1111
+ END,
1112
+ strength DESC,
1113
+ evidence_id
1114
+ `)
1115
+ .all(this.#subjectId, bounded)
1116
+ .map((row) => {
1117
+ const item = JSON.parse(row.item_json);
1118
+ item.label = row.title;
1119
+ item.artist_credit = row.artist_credit || null;
1120
+ item.labels = {};
1121
+ if (row.genre_label) item.labels.provider_genre = row.genre_label;
1122
+ if (row.composer) item.labels.composer = row.composer;
1123
+ item.observation_summary = {};
1124
+ if (row.play_count !== null) {
1125
+ item.observation_summary.play_count = row.play_count;
1126
+ }
1127
+ if (row.loved !== null) {
1128
+ item.observation_summary.loved = Boolean(row.loved);
1129
+ }
1130
+ if (row.favorited !== null) {
1131
+ item.observation_summary.favorited = Boolean(row.favorited);
1132
+ }
1133
+ if (row.rating_value !== null) {
1134
+ item.observation_summary.rating = {
1135
+ value: row.rating_value,
1136
+ ...(row.rating_computed === null
1137
+ ? {}
1138
+ : { computed: Boolean(row.rating_computed) }),
1139
+ };
1140
+ }
1141
+ return item;
1142
+ });
1143
+ const counts = readMeta(this.#database, "projection.counts");
1144
+ const signalCoverage = this.#database
1145
+ .prepare(`
1146
+ SELECT
1147
+ SUM(CASE WHEN loved = 1 OR favorited = 1 THEN 1 ELSE 0 END)
1148
+ AS loved_or_favorited,
1149
+ SUM(CASE WHEN play_count IS NOT NULL THEN 1 ELSE 0 END)
1150
+ AS aggregate_play_count,
1151
+ SUM(
1152
+ CASE
1153
+ WHEN rating_value IS NOT NULL AND COALESCE(rating_computed, 0) = 0
1154
+ THEN 1
1155
+ ELSE 0
1156
+ END
1157
+ ) AS non_computed_rating
1158
+ FROM library_search
1159
+ WHERE subject_id = ?
1160
+ `)
1161
+ .get(this.#subjectId);
1162
+ return {
1163
+ schema_version: "profile-projection/0",
1164
+ preference: items.filter(
1165
+ (item) => item.dimension === "taste.track_preference",
1166
+ ),
1167
+ familiarity: items.filter(
1168
+ (item) => item.dimension === "taste.track_familiarity",
1169
+ ),
1170
+ coverage: {
1171
+ current_tracks: counts.current_tracks,
1172
+ observations: counts.observations,
1173
+ evidence_records: counts.profile_evidence,
1174
+ unresolved_identities: counts.unresolved_identities,
1175
+ loved_or_favorited: signalCoverage.loved_or_favorited,
1176
+ aggregate_play_count: signalCoverage.aggregate_play_count,
1177
+ non_computed_rating: signalCoverage.non_computed_rating,
1178
+ source_timestamp: readMeta(
1179
+ this.#database,
1180
+ "projection.source_timestamp",
1181
+ ),
1182
+ },
1183
+ limitations: [
1184
+ "Play Count supports familiarity, not liking.",
1185
+ "Loved, Favorited, and ratings are current provider snapshot states without action timestamps.",
1186
+ "Name-only artist and provider genre labels are not resolved entities.",
1187
+ ],
1188
+ };
1189
+ }
1190
+
1191
+ explainProfileEvidence({ evidenceId } = {}) {
1192
+ this.#assertOpen();
1193
+ if (!isUuid(evidenceId)) {
1194
+ throw new TypeError("Profile evidence ID must be a UUID");
1195
+ }
1196
+ const row = this.#database
1197
+ .prepare(`
1198
+ SELECT e.record_json AS evidence_json, o.record_json AS observation_json
1199
+ FROM profile_evidence e
1200
+ JOIN observations o
1201
+ ON o.subject_id = e.subject_id
1202
+ AND o.observation_id = e.basis_observation_id
1203
+ WHERE e.subject_id = ? AND e.evidence_id = ?
1204
+ `)
1205
+ .get(this.#subjectId, evidenceId);
1206
+ if (!row) return null;
1207
+ const evidence = JSON.parse(row.evidence_json);
1208
+ const observation = JSON.parse(row.observation_json);
1209
+ const aggregate = observation.aggregate_state;
1210
+ const basisSummary = { observed_at: observation.observed_at };
1211
+ for (const field of ["play_count", "loved", "favorited", "rating"]) {
1212
+ if (aggregate[field] !== undefined) {
1213
+ basisSummary[field] = structuredClone(aggregate[field]);
1214
+ }
1215
+ }
1216
+ return {
1217
+ schema_version: "profile-evidence-explanation/1",
1218
+ evidence_id: evidence.profile_evidence_id,
1219
+ claim: evidence.claim,
1220
+ direction: evidence.direction,
1221
+ strength: evidence.strength,
1222
+ confidence: evidence.confidence,
1223
+ basis_summary: basisSummary,
1224
+ derivation: evidence.derivation,
1225
+ limitation:
1226
+ evidence.claim.dimension === "taste.track_familiarity"
1227
+ ? "This evidence supports familiarity, not liking."
1228
+ : "This evidence reflects current provider state, not a timestamped preference action or permanent preference.",
1229
+ };
1230
+ }
1231
+
1232
+ close() {
1233
+ if (this.#closed) return;
1234
+ this.#closed = true;
1235
+ this.#database.close();
1236
+ }
1237
+ }
1238
+
1239
+ export async function openAppleMusicSqliteProjection({
1240
+ databasePath,
1241
+ subjectId,
1242
+ expectedInputs,
1243
+ verifyDigest = true,
1244
+ }) {
1245
+ if (!isUuid(subjectId)) {
1246
+ throw new TypeError("Apple projection requires a valid subject UUID");
1247
+ }
1248
+ const normalizedSubjectId = subjectId.toLowerCase();
1249
+ const metadata = await pathMetadata(databasePath);
1250
+ if (!metadata?.isFile() || metadata.isSymbolicLink()) {
1251
+ fail("projection_database_invalid", "Projection database file is invalid");
1252
+ }
1253
+ if (process.platform !== "win32" && (metadata.mode & 0o077) !== 0) {
1254
+ fail("projection_permissions_invalid", "Projection database is not private");
1255
+ }
1256
+
1257
+ let database;
1258
+ try {
1259
+ const DatabaseSync = await databaseConstructor();
1260
+ database = new DatabaseSync(databasePath, { readOnly: true });
1261
+ database.exec("PRAGMA temp_store = MEMORY");
1262
+ const userVersion = database.prepare("PRAGMA user_version").get().user_version;
1263
+ if (userVersion !== APPLE_SQLITE_PROJECTION_VERSION) {
1264
+ fail(
1265
+ "projection_schema_incompatible",
1266
+ "Projection schema is incompatible and must be rebuilt",
1267
+ );
1268
+ }
1269
+ assertDatabaseIntegrity(database);
1270
+ if (readMeta(database, "projection.subject_id") !== normalizedSubjectId) {
1271
+ fail("projection_subject_mismatch", "Projection belongs to another subject");
1272
+ }
1273
+ if (
1274
+ expectedInputs !== undefined &&
1275
+ canonicalizeJson(readMeta(database, "projection.inputs")) !==
1276
+ canonicalizeJson(expectedInputs)
1277
+ ) {
1278
+ fail(
1279
+ "projection_input_mismatch",
1280
+ "Projection inputs changed and must be rebuilt",
1281
+ );
1282
+ }
1283
+ if (
1284
+ verifyDigest &&
1285
+ readMeta(database, "projection.logical_digest") !==
1286
+ computeAppleSqliteLogicalDigest(database)
1287
+ ) {
1288
+ fail(
1289
+ "projection_digest_mismatch",
1290
+ "Projection logical digest does not match and must be rebuilt",
1291
+ );
1292
+ }
1293
+ return new AppleMusicSqliteProjection(database, normalizedSubjectId);
1294
+ } catch (cause) {
1295
+ try {
1296
+ database?.close();
1297
+ } catch {
1298
+ // The original validation failure remains authoritative.
1299
+ }
1300
+ if (cause instanceof AppleMusicImportError) throw cause;
1301
+ fail(
1302
+ "projection_database_invalid",
1303
+ "Projection database is invalid and must be rebuilt",
1304
+ { cause },
1305
+ );
1306
+ }
1307
+ }