@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,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
|
+
}
|