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