@audiofool/moondog 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +125 -0
  2. package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
  3. package/contracts/v1/capability-policy.schema.json +572 -0
  4. package/contracts/v1/definitions.schema.json +365 -0
  5. package/contracts/v1/library-track-observation.schema.json +138 -0
  6. package/contracts/v1/listening-event.schema.json +111 -0
  7. package/contracts/v1/profile-evidence.schema.json +237 -0
  8. package/contracts/v1/taste-event.schema.json +258 -0
  9. package/contracts/v1/tool-invocation.schema.json +318 -0
  10. package/contracts/v1/tool-receipt.schema.json +1066 -0
  11. package/contracts/v1/track-ref.schema.json +275 -0
  12. package/contracts/v2/profile-evidence.schema.json +284 -0
  13. package/package.json +105 -0
  14. package/scripts/contract-lib.mjs +361 -0
  15. package/scripts/contract-semantics.mjs +2630 -0
  16. package/scripts/demo-studio.mjs +58 -0
  17. package/scripts/moondog +52 -0
  18. package/scripts/moondog.mjs +954 -0
  19. package/src/core/apple-data-migration.mjs +50 -0
  20. package/src/core/apple-library-source-status.mjs +220 -0
  21. package/src/core/apple-projection-domain-services.mjs +2356 -0
  22. package/src/core/capability-catalog.mjs +577 -0
  23. package/src/core/listening-profile-domain-services.mjs +1010 -0
  24. package/src/core/lyric-library.mjs +121 -0
  25. package/src/core/lyric-profile.mjs +50 -0
  26. package/src/core/lyric-service.mjs +41 -0
  27. package/src/core/moondog-application.mjs +1827 -0
  28. package/src/core/state-directory.mjs +19 -0
  29. package/src/core/synthetic-domain-services.mjs +1485 -0
  30. package/src/core/uuid-v5.mjs +42 -0
  31. package/src/demo/fictional-spotify-history.mjs +400 -0
  32. package/src/demo/moondog-demo.mjs +355 -0
  33. package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
  34. package/src/demo/moondog-tasteprint-demo.mjs +631 -0
  35. package/src/importers/apple-music-library/import-batch.mjs +290 -0
  36. package/src/importers/apple-music-library/index.mjs +76 -0
  37. package/src/importers/apple-music-library/normalize.mjs +1126 -0
  38. package/src/importers/apple-music-library/parse-plist.mjs +357 -0
  39. package/src/importers/apple-music-library/projection-records.mjs +557 -0
  40. package/src/importers/apple-music-library/read-batch.mjs +276 -0
  41. package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
  42. package/src/importers/apple-music-library/stable-ids.mjs +19 -0
  43. package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
  44. package/src/integrations/apple-music/catalog.mjs +770 -0
  45. package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
  46. package/src/integrations/listenbrainz/history-file.mjs +560 -0
  47. package/src/integrations/lyrics/lrclib.mjs +55 -0
  48. package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
  49. package/src/integrations/public-playlists.mjs +104 -0
  50. package/src/integrations/spotify/account-data-history.mjs +545 -0
  51. package/src/integrations/spotify/account-data-profile.mjs +918 -0
  52. package/src/integrations/spotify/authentication.mjs +791 -0
  53. package/src/integrations/spotify/catalog-resolver.mjs +657 -0
  54. package/src/integrations/spotify/connection.mjs +125 -0
  55. package/src/integrations/spotify/credential-store.mjs +26 -0
  56. package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
  57. package/src/integrations/spotify/history-archive.mjs +59 -0
  58. package/src/integrations/spotify/index.mjs +80 -0
  59. package/src/integrations/spotify/recent-activity.mjs +198 -0
  60. package/src/integrations/spotify/resolution-cache.mjs +260 -0
  61. package/src/integrations/spotify/service.mjs +713 -0
  62. package/src/integrations/spotify/settings.mjs +89 -0
  63. package/src/integrations/spotify/web-api-client.mjs +761 -0
  64. package/src/integrations/web/codex-web.mjs +263 -0
  65. package/src/integrations/youtube-music/takeout.mjs +143 -0
  66. package/src/memory/local-memory-store.mjs +1705 -0
  67. package/src/memory/reflection-worker.mjs +87 -0
  68. package/src/profile/apple-library-import.mjs +28 -0
  69. package/src/profile/history-import.mjs +163 -0
  70. package/src/profile/listener-corrections.mjs +217 -0
  71. package/src/profile/listening-history-store.mjs +2055 -0
  72. package/src/profile/listening-profile-projection.mjs +2520 -0
  73. package/src/profile/local-music-data.mjs +812 -0
  74. package/src/profile/music-import-bundle.mjs +91 -0
  75. package/src/profile/music-providers.mjs +25 -0
  76. package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
  77. package/src/profile/spotify-archive-taste.mjs +482 -0
  78. package/src/runtime/pi/agent-runtime.mjs +6444 -0
  79. package/src/runtime/pi/authentication.mjs +104 -0
  80. package/src/runtime/pi/configured-runtime.mjs +98 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
  82. package/src/runtime/pi/model-catalog.mjs +62 -0
  83. package/src/runtime/pi/model-transport.mjs +117 -0
  84. package/src/runtime/pi/persistent-credential-store.mjs +670 -0
  85. package/src/runtime/pi/provider-registry.mjs +74 -0
  86. package/src/runtime/pi/runtime-settings.mjs +81 -0
  87. package/src/surfaces/cli/auth-command.mjs +355 -0
  88. package/src/surfaces/cli/brand-components.mjs +390 -0
  89. package/src/surfaces/cli/brand-theme.mjs +231 -0
  90. package/src/surfaces/cli/catalog-command.mjs +509 -0
  91. package/src/surfaces/cli/command-completions.mjs +113 -0
  92. package/src/surfaces/cli/data-command.mjs +214 -0
  93. package/src/surfaces/cli/format-output.mjs +750 -0
  94. package/src/surfaces/cli/history-import-view.mjs +445 -0
  95. package/src/surfaces/cli/import-guides.mjs +9 -0
  96. package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
  97. package/src/surfaces/cli/listening-menu.mjs +171 -0
  98. package/src/surfaces/cli/profile-command.mjs +290 -0
  99. package/src/surfaces/cli/spotify-command.mjs +849 -0
  100. package/src/surfaces/cli/taste-profile-model.mjs +306 -0
  101. package/src/surfaces/cli/taste-profile-view.mjs +272 -0
  102. package/src/surfaces/cli/terminal-art.mjs +295 -0
  103. package/src/surfaces/cli/tui.mjs +1714 -0
  104. package/src/surfaces/cli/web-command.mjs +52 -0
  105. package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
  106. package/src/surfaces/html/tasteprint.mjs +3575 -0
  107. package/src/surfaces/web/showcase.mjs +272 -0
  108. package/src/surfaces/web/studio-page.mjs +4148 -0
  109. package/src/surfaces/web/studio.mjs +2288 -0
@@ -0,0 +1,560 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { open } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { TextDecoder } from "node:util";
6
+
7
+ import { isUuid, uuidV5 } from "../../core/uuid-v5.mjs";
8
+
9
+ export const LISTENBRAINZ_HISTORY_SOURCE = "listenbrainz.listen_history";
10
+ export const LISTENBRAINZ_HISTORY_FORMAT = "listenbrainz_listen_json_v1";
11
+ export const LISTENBRAINZ_HISTORY_SCOPE = "saved_listen_json_selection";
12
+ export const LISTENBRAINZ_MUSICBRAINZ_TRACK_NAMESPACE =
13
+ "355c0040-e3f4-47df-b33b-7f573ddb5134";
14
+ export const LISTENBRAINZ_MSID_TRACK_NAMESPACE =
15
+ "d8168717-9ed6-43e7-90ed-3288f28ab9db";
16
+ export const LISTENBRAINZ_LABEL_TRACK_NAMESPACE =
17
+ "355d7588-b2c9-4b6e-86c6-d7858918a9a3";
18
+ export const LISTENBRAINZ_EVENT_NAMESPACE =
19
+ "22d72263-f00c-4bca-bdc3-b5a14631b738";
20
+ export const LISTENBRAINZ_BATCH_NAMESPACE =
21
+ "88e59cf7-fdaa-4a70-b6a1-f7ac469cc99d";
22
+
23
+ export const LISTENBRAINZ_HISTORY_LIMITS = Object.freeze({
24
+ maximumFileBytes: 256 * 1024 * 1024,
25
+ maximumRecords: 500_000,
26
+ maximumDurationMs: 24 * 24 * 60 * 60 * 1_000,
27
+ });
28
+
29
+ const minimumListenedAt = 1_033_410_600;
30
+ const sha256Pattern = /^[a-f0-9]{64}$/u;
31
+ const unsafeTextPattern =
32
+ /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu;
33
+ const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
34
+
35
+ export class ListenBrainzHistoryError extends Error {
36
+ constructor(message, code = "listenbrainz_history_invalid") {
37
+ super(message);
38
+ this.name = "ListenBrainzHistoryError";
39
+ this.code = code;
40
+ }
41
+ }
42
+
43
+ function fail(message, code) {
44
+ throw new ListenBrainzHistoryError(message, code);
45
+ }
46
+
47
+ function isPlainObject(value) {
48
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
49
+ const prototype = Object.getPrototypeOf(value);
50
+ return prototype === Object.prototype || prototype === null;
51
+ }
52
+
53
+ function lexicalCompare(left, right) {
54
+ return left < right ? -1 : left > right ? 1 : 0;
55
+ }
56
+
57
+ function normalizedTimestamp(value, label) {
58
+ if (typeof value !== "string") fail(`${label} is invalid.`);
59
+ const milliseconds = Date.parse(value);
60
+ if (!Number.isFinite(milliseconds)) fail(`${label} is invalid.`);
61
+ const normalized = new Date(milliseconds).toISOString();
62
+ if (normalized !== value) fail(`${label} must be a normalized UTC timestamp.`);
63
+ return normalized;
64
+ }
65
+
66
+ function cleanText(value, maximum, label, index) {
67
+ if (typeof value !== "string") {
68
+ fail(`ListenBrainz record ${index + 1} has an invalid ${label}.`);
69
+ }
70
+ const cleaned = value
71
+ .normalize("NFC")
72
+ .replace(unsafeTextPattern, " ")
73
+ .replace(/\s+/gu, " ")
74
+ .trim();
75
+ if (!cleaned || Array.from(cleaned).length > maximum) {
76
+ fail(`ListenBrainz record ${index + 1} has an invalid ${label}.`);
77
+ }
78
+ return cleaned;
79
+ }
80
+
81
+ function optionalText(value, maximum, label, index) {
82
+ if (value === null || value === undefined) return null;
83
+ return cleanText(value, maximum, label, index);
84
+ }
85
+
86
+ function normalizedIdentityText(value) {
87
+ return value.normalize("NFKC").toLocaleLowerCase("und");
88
+ }
89
+
90
+ function hashParts(...parts) {
91
+ const hash = createHash("sha256");
92
+ for (const part of parts) hash.update(String(part)).update("\0");
93
+ return hash.digest("hex");
94
+ }
95
+
96
+ function clippedText(value, maximum = 512) {
97
+ return Array.from(value).slice(0, maximum).join("");
98
+ }
99
+
100
+ function listenedAtTimestamp(value, capturedMilliseconds, index) {
101
+ if (
102
+ !Number.isSafeInteger(value) ||
103
+ value < minimumListenedAt ||
104
+ value > Math.floor(capturedMilliseconds / 1_000)
105
+ ) {
106
+ fail(`ListenBrainz record ${index + 1} has an invalid listened_at.`);
107
+ }
108
+ return new Date(value * 1_000).toISOString();
109
+ }
110
+
111
+ function millisecondsValue(value, label, index) {
112
+ if (
113
+ !Number.isSafeInteger(value) ||
114
+ value < 0 ||
115
+ value > LISTENBRAINZ_HISTORY_LIMITS.maximumDurationMs
116
+ ) {
117
+ fail(`ListenBrainz record ${index + 1} has an invalid ${label}.`);
118
+ }
119
+ return value;
120
+ }
121
+
122
+ function secondsValue(value, label, index) {
123
+ if (
124
+ !Number.isSafeInteger(value) ||
125
+ value < 0 ||
126
+ value * 1_000 > LISTENBRAINZ_HISTORY_LIMITS.maximumDurationMs
127
+ ) {
128
+ fail(`ListenBrainz record ${index + 1} has an invalid ${label}.`);
129
+ }
130
+ const milliseconds = Math.round(value * 1_000);
131
+ if (!Number.isSafeInteger(milliseconds)) {
132
+ fail(`ListenBrainz record ${index + 1} has an invalid ${label}.`);
133
+ }
134
+ return milliseconds;
135
+ }
136
+
137
+ function durationFields(value, index) {
138
+ if (value === null || value === undefined) return {};
139
+ if (!isPlainObject(value)) {
140
+ fail(`ListenBrainz record ${index + 1} has invalid additional_info.`);
141
+ }
142
+ const durationMs =
143
+ value.duration_ms === undefined
144
+ ? null
145
+ : millisecondsValue(value.duration_ms, "duration_ms", index);
146
+ const legacyDurationMs =
147
+ value.duration === undefined
148
+ ? null
149
+ : secondsValue(value.duration, "duration", index);
150
+ if (
151
+ durationMs !== null &&
152
+ legacyDurationMs !== null
153
+ ) {
154
+ fail(`ListenBrainz record ${index + 1} supplies both duration_ms and duration.`);
155
+ }
156
+ const playedMs =
157
+ value.duration_played === undefined
158
+ ? null
159
+ : secondsValue(value.duration_played, "duration_played", index);
160
+ return {
161
+ ...(durationMs !== null || legacyDurationMs !== null
162
+ ? { durationMs: durationMs ?? legacyDurationMs }
163
+ : {}),
164
+ ...(playedMs !== null ? { playedMs } : {}),
165
+ };
166
+ }
167
+
168
+ function optionalUuid(value, label, index) {
169
+ if (value === null || value === undefined) return null;
170
+ if (!isUuid(value)) {
171
+ fail(`ListenBrainz record ${index + 1} has an invalid ${label}.`);
172
+ }
173
+ return value.toLowerCase();
174
+ }
175
+
176
+ function serverRecordingMbid(value, index) {
177
+ if (value === null || value === undefined) return null;
178
+ if (!isPlainObject(value) || !isUuid(value.recording_mbid)) {
179
+ fail(`ListenBrainz record ${index + 1} has an invalid mbid_mapping.`);
180
+ }
181
+ return value.recording_mbid.toLowerCase();
182
+ }
183
+
184
+ function extractedListens(document) {
185
+ if (!isPlainObject(document)) {
186
+ fail("ListenBrainz JSON must be an official response or submission object.");
187
+ }
188
+ if (document.listen_type !== undefined) {
189
+ if (
190
+ Object.keys(document).some(
191
+ (key) => key !== "listen_type" && key !== "payload",
192
+ )
193
+ ) {
194
+ fail("ListenBrainz submission JSON has unsupported top-level fields.");
195
+ }
196
+ if (document.listen_type === "playing_now") {
197
+ fail("ListenBrainz playing_now JSON is not persistent listening history.");
198
+ }
199
+ if (!new Set(["single", "import"]).has(document.listen_type)) {
200
+ fail("ListenBrainz submission JSON has an unsupported listen_type.");
201
+ }
202
+ if (
203
+ !Array.isArray(document.payload) ||
204
+ document.payload.length < 1 ||
205
+ (document.listen_type === "single" && document.payload.length !== 1)
206
+ ) {
207
+ fail("ListenBrainz submission JSON has an invalid payload.");
208
+ }
209
+ return { records: document.payload, allowServerMappings: false };
210
+ }
211
+ const payload = document.payload;
212
+ if (isPlainObject(payload) && payload.playing_now === true) {
213
+ fail("ListenBrainz playing_now JSON is not persistent listening history.");
214
+ }
215
+ if (
216
+ !isPlainObject(payload) ||
217
+ !Array.isArray(payload.listens) ||
218
+ !Number.isSafeInteger(payload.count) ||
219
+ payload.count < 0 ||
220
+ payload.count !== payload.listens.length ||
221
+ typeof payload.user_id !== "string" ||
222
+ !payload.user_id.trim()
223
+ ) {
224
+ fail("ListenBrainz API response JSON has an invalid payload.");
225
+ }
226
+ return { records: payload.listens, allowServerMappings: true };
227
+ }
228
+
229
+ function normalizedRows(records, capturedAt, { allowServerMappings }) {
230
+ if (!Array.isArray(records)) fail("ListenBrainz listen records are invalid.");
231
+ if (records.length > LISTENBRAINZ_HISTORY_LIMITS.maximumRecords) {
232
+ fail("ListenBrainz JSON contains too many records.");
233
+ }
234
+ const capturedMilliseconds = Date.parse(capturedAt);
235
+ const rows = records.map((record, index) => {
236
+ if (!isPlainObject(record) || !isPlainObject(record.track_metadata)) {
237
+ fail(`ListenBrainz record ${index + 1} is invalid.`);
238
+ }
239
+ if (
240
+ !allowServerMappings &&
241
+ Object.keys(record).some(
242
+ (key) => key !== "listened_at" && key !== "track_metadata",
243
+ )
244
+ ) {
245
+ fail(
246
+ `ListenBrainz submission record ${index + 1} has unsupported top-level fields.`,
247
+ );
248
+ }
249
+ const metadata = record.track_metadata;
250
+ if (!allowServerMappings && metadata.mbid_mapping !== undefined) {
251
+ fail(
252
+ `ListenBrainz record ${index + 1} includes read-only mbid_mapping in submission JSON.`,
253
+ );
254
+ }
255
+ const title = cleanText(metadata.track_name, 512, "track_name", index);
256
+ const artist = cleanText(metadata.artist_name, 512, "artist_name", index);
257
+ const release = optionalText(
258
+ metadata.release_name,
259
+ 512,
260
+ "release_name",
261
+ index,
262
+ );
263
+ const occurredAt = listenedAtTimestamp(
264
+ record.listened_at,
265
+ capturedMilliseconds,
266
+ index,
267
+ );
268
+ const recordingMsid = optionalUuid(
269
+ record.recording_msid,
270
+ "recording_msid",
271
+ index,
272
+ );
273
+ const recordingMbid = serverRecordingMbid(metadata.mbid_mapping, index);
274
+ const durations = durationFields(metadata.additional_info, index);
275
+ const labelFingerprint = hashParts(
276
+ "listenbrainz-track-label-v1",
277
+ normalizedIdentityText(artist),
278
+ normalizedIdentityText(title),
279
+ normalizedIdentityText(release ?? ""),
280
+ );
281
+ const identityKey = recordingMbid
282
+ ? `musicbrainz:${recordingMbid}`
283
+ : recordingMsid
284
+ ? `listenbrainz-msid:${recordingMsid}`
285
+ : `label:${labelFingerprint}`;
286
+ const tuple = [
287
+ occurredAt,
288
+ identityKey,
289
+ durations.playedMs === undefined ? "unknown" : durations.playedMs,
290
+ ].join("\0");
291
+ return {
292
+ title,
293
+ artist,
294
+ release,
295
+ occurredAt,
296
+ recordingMsid,
297
+ recordingMbid,
298
+ labelFingerprint,
299
+ identityKey,
300
+ tuple,
301
+ ...durations,
302
+ };
303
+ });
304
+ return rows.sort((left, right) => lexicalCompare(left.tuple, right.tuple));
305
+ }
306
+
307
+ function trackIdentity(row) {
308
+ if (row.recordingMbid) {
309
+ return {
310
+ trackRefId: uuidV5(
311
+ row.recordingMbid,
312
+ LISTENBRAINZ_MUSICBRAINZ_TRACK_NAMESPACE,
313
+ ),
314
+ identityStatus: "resolved",
315
+ externalRefs: [
316
+ {
317
+ system: "musicbrainz",
318
+ entity_type: "musicbrainz.recording",
319
+ external_id: row.recordingMbid,
320
+ },
321
+ ...(row.recordingMsid
322
+ ? [
323
+ {
324
+ system: "listenbrainz",
325
+ entity_type: "listenbrainz.recording_msid",
326
+ external_id: row.recordingMsid,
327
+ },
328
+ ]
329
+ : []),
330
+ ],
331
+ };
332
+ }
333
+ if (row.recordingMsid) {
334
+ return {
335
+ trackRefId: uuidV5(
336
+ row.recordingMsid,
337
+ LISTENBRAINZ_MSID_TRACK_NAMESPACE,
338
+ ),
339
+ identityStatus: "provisional",
340
+ externalRefs: [
341
+ {
342
+ system: "listenbrainz",
343
+ entity_type: "listenbrainz.recording_msid",
344
+ external_id: row.recordingMsid,
345
+ },
346
+ ],
347
+ };
348
+ }
349
+ return {
350
+ trackRefId: uuidV5(
351
+ row.labelFingerprint,
352
+ LISTENBRAINZ_LABEL_TRACK_NAMESPACE,
353
+ ),
354
+ identityStatus: "provisional",
355
+ externalRefs: [
356
+ {
357
+ system: "listenbrainz-import",
358
+ entity_type: "listenbrainz.track_label",
359
+ external_id: row.labelFingerprint,
360
+ },
361
+ ],
362
+ };
363
+ }
364
+
365
+ export function projectListenBrainzHistory({
366
+ subjectId,
367
+ document,
368
+ fileSha256,
369
+ fileSizeBytes,
370
+ memberName,
371
+ capturedAt = new Date().toISOString(),
372
+ } = {}) {
373
+ if (!isUuid(subjectId)) fail("A trusted subject ID is required.");
374
+ const normalizedSubjectId = subjectId.toLowerCase();
375
+ const normalizedCapturedAt = normalizedTimestamp(
376
+ capturedAt,
377
+ "ListenBrainz import timestamp",
378
+ );
379
+ if (typeof fileSha256 !== "string" || !sha256Pattern.test(fileSha256)) {
380
+ fail("ListenBrainz file SHA-256 is invalid.");
381
+ }
382
+ if (
383
+ !Number.isSafeInteger(fileSizeBytes) ||
384
+ fileSizeBytes < 1 ||
385
+ fileSizeBytes > LISTENBRAINZ_HISTORY_LIMITS.maximumFileBytes
386
+ ) {
387
+ fail("ListenBrainz JSON file size is invalid.");
388
+ }
389
+ if (
390
+ typeof memberName !== "string" ||
391
+ !memberName ||
392
+ memberName.length > 255 ||
393
+ path.basename(memberName) !== memberName ||
394
+ /[\\\u0000-\u001f\u007f]/u.test(memberName) ||
395
+ path.extname(memberName).toLowerCase() !== ".json"
396
+ ) {
397
+ fail("ListenBrainz JSON file name is invalid.");
398
+ }
399
+ const source = extractedListens(document);
400
+ const rows = normalizedRows(source.records, normalizedCapturedAt, source);
401
+ const importBatchId = uuidV5(
402
+ `${normalizedSubjectId}\0${fileSha256}`,
403
+ LISTENBRAINZ_BATCH_NAMESPACE,
404
+ );
405
+ const trackRefs = new Map();
406
+ const listeningEvents = [];
407
+ let previousTuple = null;
408
+ let duplicateOrdinal = 0;
409
+
410
+ for (const row of rows) {
411
+ if (row.tuple === previousTuple) duplicateOrdinal += 1;
412
+ else duplicateOrdinal = 1;
413
+ previousTuple = row.tuple;
414
+
415
+ const identity = trackIdentity(row);
416
+ if (!trackRefs.has(identity.trackRefId)) {
417
+ const trackRef = {
418
+ schema_version: 1,
419
+ track_ref_id: identity.trackRefId,
420
+ revision: 1,
421
+ identity_status: identity.identityStatus,
422
+ display_label: clippedText(`${row.title} - ${row.artist}`),
423
+ title: row.title,
424
+ artist_credits: [{ name: row.artist, role: "unknown" }],
425
+ external_refs: identity.externalRefs,
426
+ created_at: row.occurredAt,
427
+ ...(identity.identityStatus === "resolved"
428
+ ? { resolved_at: row.occurredAt }
429
+ : {}),
430
+ ...(row.release ? { release: { title: row.release } } : {}),
431
+ ...(row.durationMs !== undefined
432
+ ? { duration_ms: row.durationMs }
433
+ : {}),
434
+ };
435
+ trackRefs.set(identity.trackRefId, trackRef);
436
+ }
437
+
438
+ const eventFingerprint = hashParts(
439
+ "listenbrainz-listen-v1",
440
+ normalizedSubjectId,
441
+ row.tuple,
442
+ duplicateOrdinal,
443
+ );
444
+ listeningEvents.push({
445
+ schema_version: 1,
446
+ listening_event_id: uuidV5(
447
+ eventFingerprint,
448
+ LISTENBRAINZ_EVENT_NAMESPACE,
449
+ ),
450
+ subject_id: normalizedSubjectId,
451
+ track_ref_id: identity.trackRefId,
452
+ track_ref_revision: 1,
453
+ event_type: "play_observed",
454
+ occurred_at: row.occurredAt,
455
+ ingested_at: normalizedCapturedAt,
456
+ interaction_mode: "unknown",
457
+ ...(row.playedMs !== undefined ? { played_ms: row.playedMs } : {}),
458
+ context: { context_type: "unknown" },
459
+ provenance: {
460
+ source_kind: "import",
461
+ captured_at: normalizedCapturedAt,
462
+ external_ref: {
463
+ system: "listenbrainz",
464
+ entity_type: "listenbrainz.listen",
465
+ external_id: eventFingerprint,
466
+ },
467
+ import_batch_id: importBatchId,
468
+ },
469
+ deduplication_key: eventFingerprint,
470
+ });
471
+ }
472
+
473
+ const earliestOccurredAt = rows[0]?.occurredAt ?? null;
474
+ const latestOccurredAt = rows.at(-1)?.occurredAt ?? null;
475
+ return {
476
+ source_key: LISTENBRAINZ_HISTORY_SOURCE,
477
+ captured_at: normalizedCapturedAt,
478
+ cursor_after_ms:
479
+ latestOccurredAt === null ? null : Date.parse(latestOccurredAt),
480
+ track_refs: [...trackRefs.values()],
481
+ listening_events: listeningEvents,
482
+ import_batch: {
483
+ import_batch_id: importBatchId,
484
+ subject_id: normalizedSubjectId,
485
+ source_format: LISTENBRAINZ_HISTORY_FORMAT,
486
+ data_scope: LISTENBRAINZ_HISTORY_SCOPE,
487
+ archive_sha256: fileSha256,
488
+ archive_size_bytes: fileSizeBytes,
489
+ member_names: [memberName],
490
+ input_records: rows.length,
491
+ earliest_occurred_at: earliestOccurredAt,
492
+ latest_occurred_at: latestOccurredAt,
493
+ imported_at: normalizedCapturedAt,
494
+ },
495
+ };
496
+ }
497
+
498
+ export async function readListenBrainzHistoryFile({
499
+ filePath,
500
+ subjectId,
501
+ capturedAt = new Date().toISOString(),
502
+ } = {}) {
503
+ if (typeof filePath !== "string" || !filePath.trim()) {
504
+ fail("A ListenBrainz history JSON path is required.");
505
+ }
506
+ const resolvedPath = path.resolve(filePath);
507
+ if (path.extname(resolvedPath).toLowerCase() !== ".json") {
508
+ fail("ListenBrainz history must be supplied as a JSON file.");
509
+ }
510
+ let handle;
511
+ try {
512
+ handle = await open(resolvedPath, constants.O_RDONLY | constants.O_NOFOLLOW);
513
+ } catch {
514
+ fail(
515
+ "The ListenBrainz history JSON could not be opened safely.",
516
+ "listenbrainz_history_unreadable",
517
+ );
518
+ }
519
+ let bytes;
520
+ try {
521
+ const file = await handle.stat();
522
+ if (
523
+ !file.isFile() ||
524
+ file.size < 1 ||
525
+ file.size > LISTENBRAINZ_HISTORY_LIMITS.maximumFileBytes
526
+ ) {
527
+ fail("The ListenBrainz history JSON is not a supported regular file.");
528
+ }
529
+ bytes = await handle.readFile();
530
+ if (
531
+ bytes.length < 1 ||
532
+ bytes.length > LISTENBRAINZ_HISTORY_LIMITS.maximumFileBytes
533
+ ) {
534
+ fail("The ListenBrainz history JSON is not a supported regular file.");
535
+ }
536
+ } finally {
537
+ await handle.close();
538
+ }
539
+
540
+ let jsonText;
541
+ try {
542
+ jsonText = utf8Decoder.decode(bytes).replace(/^\uFEFF/u, "");
543
+ } catch {
544
+ fail("The ListenBrainz history file is not valid UTF-8 JSON.");
545
+ }
546
+ let document;
547
+ try {
548
+ document = JSON.parse(jsonText);
549
+ } catch {
550
+ fail("The ListenBrainz history file is invalid JSON.");
551
+ }
552
+ return projectListenBrainzHistory({
553
+ subjectId,
554
+ document,
555
+ fileSha256: createHash("sha256").update(bytes).digest("hex"),
556
+ fileSizeBytes: bytes.length,
557
+ memberName: path.basename(resolvedPath),
558
+ capturedAt,
559
+ });
560
+ }
@@ -0,0 +1,55 @@
1
+ import { lyricIdentity } from "../../core/lyric-profile.mjs";
2
+
3
+ const origin = "https://lrclib.net";
4
+ const userAgent = "Moondog/0.1.0 (https://github.com/Audiofool934/moondog)";
5
+
6
+ export class LyricsProviderError extends Error {
7
+ constructor(message, retryAfterMs = 60_000) {
8
+ super(message);
9
+ this.name = "LyricsProviderError";
10
+ this.retryAfterMs = retryAfterMs;
11
+ }
12
+ }
13
+
14
+ export function createLrclibProvider({ fetchImpl = fetch } = {}) {
15
+ return {
16
+ async lookup(track, { signal } = {}) {
17
+ const url = new URL("/api/get", origin);
18
+ url.searchParams.set("track_name", track.title);
19
+ url.searchParams.set("artist_name", track.artist);
20
+ if (track.album) url.searchParams.set("album_name", track.album);
21
+ if (track.durationSeconds) url.searchParams.set("duration", String(track.durationSeconds));
22
+ const response = await fetchImpl(url, {
23
+ headers: { "User-Agent": userAgent, Accept: "application/json" },
24
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(8_000)]) : AbortSignal.timeout(8_000),
25
+ redirect: "error",
26
+ });
27
+ if (response.status === 404) return { state: "not_found" };
28
+ if (response.status === 429) {
29
+ const retry = response.headers.get("retry-after");
30
+ const delay = /^\d+$/u.test(retry ?? "") ? Number(retry) * 1_000 : Date.parse(retry) - Date.now();
31
+ throw new LyricsProviderError("LRCLIB is rate limited.", Math.max(60_000, Number.isFinite(delay) ? delay : 0));
32
+ }
33
+ if (!response.ok) throw new LyricsProviderError(`LRCLIB returned HTTP ${response.status}.`);
34
+ const body = await response.text();
35
+ if (body.length > 512_000) throw new LyricsProviderError("Lyrics response is too large.");
36
+ const value = JSON.parse(body);
37
+ if (!Number.isSafeInteger(value.id) || value.id < 1 ||
38
+ lyricIdentity(value.trackName) !== lyricIdentity(track.title) ||
39
+ lyricIdentity(value.artistName) !== lyricIdentity(track.artist) ||
40
+ (track.album && lyricIdentity(value.albumName) !== lyricIdentity(track.album)) ||
41
+ (track.durationSeconds && (!Number.isFinite(value.duration) || Math.abs(value.duration - track.durationSeconds) > 2))) {
42
+ return { state: "mismatch" };
43
+ }
44
+ const plainLyrics = typeof value.plainLyrics === "string" ? value.plainLyrics : null;
45
+ const syncedLyrics = typeof value.syncedLyrics === "string" ? value.syncedLyrics : null;
46
+ return {
47
+ state: value.instrumental ? "instrumental" : plainLyrics || syncedLyrics ? "ready" : "not_found",
48
+ provider: "lrclib", providerId: String(value.id), sourceUrl: `${origin}/api/get/${value.id}`,
49
+ title: value.trackName, artist: value.artistName, album: value.albumName ?? null,
50
+ durationSeconds: Number.isFinite(value.duration) ? value.duration : null,
51
+ plainLyrics, syncedLyrics,
52
+ };
53
+ },
54
+ };
55
+ }