@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.
- package/README.md +125 -0
- package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
- package/contracts/v1/capability-policy.schema.json +572 -0
- package/contracts/v1/definitions.schema.json +365 -0
- package/contracts/v1/library-track-observation.schema.json +138 -0
- package/contracts/v1/listening-event.schema.json +111 -0
- package/contracts/v1/profile-evidence.schema.json +237 -0
- package/contracts/v1/taste-event.schema.json +258 -0
- package/contracts/v1/tool-invocation.schema.json +318 -0
- package/contracts/v1/tool-receipt.schema.json +1066 -0
- package/contracts/v1/track-ref.schema.json +275 -0
- package/contracts/v2/profile-evidence.schema.json +284 -0
- package/package.json +105 -0
- package/scripts/contract-lib.mjs +361 -0
- package/scripts/contract-semantics.mjs +2630 -0
- package/scripts/demo-studio.mjs +58 -0
- package/scripts/moondog +52 -0
- package/scripts/moondog.mjs +954 -0
- package/src/core/apple-data-migration.mjs +50 -0
- package/src/core/apple-library-source-status.mjs +220 -0
- package/src/core/apple-projection-domain-services.mjs +2356 -0
- package/src/core/capability-catalog.mjs +577 -0
- package/src/core/listening-profile-domain-services.mjs +1010 -0
- package/src/core/lyric-library.mjs +121 -0
- package/src/core/lyric-profile.mjs +50 -0
- package/src/core/lyric-service.mjs +41 -0
- package/src/core/moondog-application.mjs +1827 -0
- package/src/core/state-directory.mjs +19 -0
- package/src/core/synthetic-domain-services.mjs +1485 -0
- package/src/core/uuid-v5.mjs +42 -0
- package/src/demo/fictional-spotify-history.mjs +400 -0
- package/src/demo/moondog-demo.mjs +355 -0
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
- package/src/demo/moondog-tasteprint-demo.mjs +631 -0
- package/src/importers/apple-music-library/import-batch.mjs +290 -0
- package/src/importers/apple-music-library/index.mjs +76 -0
- package/src/importers/apple-music-library/normalize.mjs +1126 -0
- package/src/importers/apple-music-library/parse-plist.mjs +357 -0
- package/src/importers/apple-music-library/projection-records.mjs +557 -0
- package/src/importers/apple-music-library/read-batch.mjs +276 -0
- package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
- package/src/importers/apple-music-library/stable-ids.mjs +19 -0
- package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
- package/src/integrations/apple-music/catalog.mjs +770 -0
- package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
- package/src/integrations/listenbrainz/history-file.mjs +560 -0
- package/src/integrations/lyrics/lrclib.mjs +55 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
- package/src/integrations/public-playlists.mjs +104 -0
- package/src/integrations/spotify/account-data-history.mjs +545 -0
- package/src/integrations/spotify/account-data-profile.mjs +918 -0
- package/src/integrations/spotify/authentication.mjs +791 -0
- package/src/integrations/spotify/catalog-resolver.mjs +657 -0
- package/src/integrations/spotify/connection.mjs +125 -0
- package/src/integrations/spotify/credential-store.mjs +26 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
- package/src/integrations/spotify/history-archive.mjs +59 -0
- package/src/integrations/spotify/index.mjs +80 -0
- package/src/integrations/spotify/recent-activity.mjs +198 -0
- package/src/integrations/spotify/resolution-cache.mjs +260 -0
- package/src/integrations/spotify/service.mjs +713 -0
- package/src/integrations/spotify/settings.mjs +89 -0
- package/src/integrations/spotify/web-api-client.mjs +761 -0
- package/src/integrations/web/codex-web.mjs +263 -0
- package/src/integrations/youtube-music/takeout.mjs +143 -0
- package/src/memory/local-memory-store.mjs +1705 -0
- package/src/memory/reflection-worker.mjs +87 -0
- package/src/profile/apple-library-import.mjs +28 -0
- package/src/profile/history-import.mjs +163 -0
- package/src/profile/listener-corrections.mjs +217 -0
- package/src/profile/listening-history-store.mjs +2055 -0
- package/src/profile/listening-profile-projection.mjs +2520 -0
- package/src/profile/local-music-data.mjs +812 -0
- package/src/profile/music-import-bundle.mjs +91 -0
- package/src/profile/music-providers.mjs +25 -0
- package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
- package/src/profile/spotify-archive-taste.mjs +482 -0
- package/src/runtime/pi/agent-runtime.mjs +6444 -0
- package/src/runtime/pi/authentication.mjs +104 -0
- package/src/runtime/pi/configured-runtime.mjs +98 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
- package/src/runtime/pi/model-catalog.mjs +62 -0
- package/src/runtime/pi/model-transport.mjs +117 -0
- package/src/runtime/pi/persistent-credential-store.mjs +670 -0
- package/src/runtime/pi/provider-registry.mjs +74 -0
- package/src/runtime/pi/runtime-settings.mjs +81 -0
- package/src/surfaces/cli/auth-command.mjs +355 -0
- package/src/surfaces/cli/brand-components.mjs +390 -0
- package/src/surfaces/cli/brand-theme.mjs +231 -0
- package/src/surfaces/cli/catalog-command.mjs +509 -0
- package/src/surfaces/cli/command-completions.mjs +113 -0
- package/src/surfaces/cli/data-command.mjs +214 -0
- package/src/surfaces/cli/format-output.mjs +750 -0
- package/src/surfaces/cli/history-import-view.mjs +445 -0
- package/src/surfaces/cli/import-guides.mjs +9 -0
- package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
- package/src/surfaces/cli/listening-menu.mjs +171 -0
- package/src/surfaces/cli/profile-command.mjs +290 -0
- package/src/surfaces/cli/spotify-command.mjs +849 -0
- package/src/surfaces/cli/taste-profile-model.mjs +306 -0
- package/src/surfaces/cli/taste-profile-view.mjs +272 -0
- package/src/surfaces/cli/terminal-art.mjs +295 -0
- package/src/surfaces/cli/tui.mjs +1714 -0
- package/src/surfaces/cli/web-command.mjs +52 -0
- package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
- package/src/surfaces/html/tasteprint.mjs +3575 -0
- package/src/surfaces/web/showcase.mjs +272 -0
- package/src/surfaces/web/studio-page.mjs +4148 -0
- 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
|
+
}
|