@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,806 @@
1
+ import { uuidV5 } from "../../core/uuid-v5.mjs";
2
+
3
+ export const LISTENBRAINZ_RECORDING_TRACK_NAMESPACE =
4
+ "1ea3cad6-a0a2-58a5-a584-64953336f69f";
5
+
6
+ export const OPEN_MUSIC_SIMILARITY_LIMITS = Object.freeze({
7
+ artistNameLengthMax: 512,
8
+ outputTracksMax: 12,
9
+ wikidataSearchResultsMax: 8,
10
+ listenBrainzRecordingsMax: 24,
11
+ cacheTtlMs: 10 * 60 * 1_000,
12
+ identityCacheTtlMs: 60 * 60 * 1_000,
13
+ requestTimeoutMs: 8_000,
14
+ });
15
+
16
+ const wikidataOrigin = "https://www.wikidata.org";
17
+ const listenBrainzOrigin = "https://api.listenbrainz.org";
18
+ const defaultUserAgent = "Moondog/0.1 (https://ai.ruc.edu.cn/)";
19
+ const uuidPattern =
20
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
21
+ const modes = new Set(["easy", "medium", "hard"]);
22
+ const popularityRanges = Object.freeze({
23
+ easy: Object.freeze({ begin: 80, end: 100 }),
24
+ medium: Object.freeze({ begin: 65, end: 100 }),
25
+ hard: Object.freeze({ begin: 20, end: 70 }),
26
+ });
27
+
28
+ export class OpenMusicSimilarityError extends Error {
29
+ constructor(code, message, provider) {
30
+ super(message);
31
+ this.name = "OpenMusicSimilarityError";
32
+ this.code = code;
33
+ this.provider = provider;
34
+ }
35
+ }
36
+
37
+ function fail(code, message, provider) {
38
+ throw new OpenMusicSimilarityError(code, message, provider);
39
+ }
40
+
41
+ function isPlainObject(value) {
42
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
43
+ const prototype = Object.getPrototypeOf(value);
44
+ return prototype === Object.prototype || prototype === null;
45
+ }
46
+
47
+ function cleanText(value, maximum) {
48
+ if (typeof value !== "string") return null;
49
+ const cleaned = value
50
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "")
51
+ .replace(/[\u202a-\u202e\u2066-\u2069]/gu, "")
52
+ .replace(/\s+/gu, " ")
53
+ .trim();
54
+ if (!cleaned) return null;
55
+ return Array.from(cleaned).slice(0, maximum).join("");
56
+ }
57
+
58
+ function requiredArtistName(value) {
59
+ if (
60
+ typeof value !== "string" ||
61
+ Array.from(value).length < 1 ||
62
+ Array.from(value).length > OPEN_MUSIC_SIMILARITY_LIMITS.artistNameLengthMax
63
+ ) {
64
+ fail(
65
+ "open_music_artist_invalid",
66
+ "The trusted seed artist name is invalid.",
67
+ "open_music_similarity",
68
+ );
69
+ }
70
+ const cleaned = cleanText(value, OPEN_MUSIC_SIMILARITY_LIMITS.artistNameLengthMax);
71
+ if (!cleaned) {
72
+ fail(
73
+ "open_music_artist_invalid",
74
+ "The trusted seed artist name is invalid.",
75
+ "open_music_similarity",
76
+ );
77
+ }
78
+ return cleaned.replace(/\s+(?:feat\.?|featuring|ft\.?)\s+.+$/iu, "").trim();
79
+ }
80
+
81
+ function normalizeForMatch(value) {
82
+ return String(value)
83
+ .normalize("NFKC")
84
+ .toLocaleLowerCase("und")
85
+ .replace(/["'‘’“”`]/gu, "")
86
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
87
+ .trim();
88
+ }
89
+
90
+ function languageForArtist(value) {
91
+ if (/\p{Script=Hiragana}|\p{Script=Katakana}/u.test(value)) return "ja";
92
+ if (/\p{Script=Hangul}/u.test(value)) return "ko";
93
+ if (/\p{Script=Han}/u.test(value)) return "zh";
94
+ if (/\p{Script=Cyrillic}/u.test(value)) return "ru";
95
+ return "en";
96
+ }
97
+
98
+ function safeUuid(value) {
99
+ return typeof value === "string" && uuidPattern.test(value)
100
+ ? value.toLowerCase()
101
+ : null;
102
+ }
103
+
104
+ function safeAppleMusicArtistId(value) {
105
+ return typeof value === "string" && /^[1-9]\d{0,19}$/u.test(value)
106
+ ? value
107
+ : null;
108
+ }
109
+
110
+ function safeNonnegativeInteger(value) {
111
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
112
+ }
113
+
114
+ function headerInteger(response, name) {
115
+ const raw = response.headers?.get?.(name);
116
+ if (typeof raw !== "string" || !/^\d+$/u.test(raw.trim())) return null;
117
+ return Number.parseInt(raw, 10);
118
+ }
119
+
120
+ async function responseJson(response, provider, codePrefix) {
121
+ if (!response || typeof response.ok !== "boolean") {
122
+ fail(
123
+ `${codePrefix}_response_invalid`,
124
+ `${provider} returned an invalid response.`,
125
+ provider,
126
+ );
127
+ }
128
+ if (!response.ok) {
129
+ const status = Number.isInteger(response.status) ? response.status : 0;
130
+ const retryAfter =
131
+ headerInteger(response, "retry-after") ??
132
+ headerInteger(response, "x-ratelimit-reset-in");
133
+ if (status === 429) {
134
+ const suffix = retryAfter === null ? "" : ` Retry after ${retryAfter} seconds.`;
135
+ fail(
136
+ `${codePrefix}_rate_limited`,
137
+ `${provider} rate limit reached.${suffix}`,
138
+ provider,
139
+ );
140
+ }
141
+ fail(
142
+ `${codePrefix}_http_error`,
143
+ `${provider} returned HTTP ${status}.`,
144
+ provider,
145
+ );
146
+ }
147
+ try {
148
+ return await response.json();
149
+ } catch {
150
+ fail(
151
+ `${codePrefix}_response_invalid`,
152
+ `${provider} returned invalid JSON.`,
153
+ provider,
154
+ );
155
+ }
156
+ }
157
+
158
+ async function cached(cache, key, ttlMs, now, loader) {
159
+ const current = now();
160
+ if (!Number.isFinite(current)) {
161
+ fail(
162
+ "open_music_clock_invalid",
163
+ "The open music service clock is invalid.",
164
+ "open_music_similarity",
165
+ );
166
+ }
167
+ const entry = cache.get(key);
168
+ if (entry && entry.expiresAt > current) return structuredClone(entry.value);
169
+ const value = await loader();
170
+ cache.set(key, { expiresAt: current + ttlMs, value: structuredClone(value) });
171
+ return structuredClone(value);
172
+ }
173
+
174
+ function wikidataMbidClaims(entity) {
175
+ const claims = Array.isArray(entity?.claims?.P434) ? entity.claims.P434 : [];
176
+ return [
177
+ ...new Set(
178
+ claims
179
+ .map((claim) => safeUuid(claim?.mainsnak?.datavalue?.value))
180
+ .filter(Boolean),
181
+ ),
182
+ ].sort((left, right) => left.localeCompare(right, "en"));
183
+ }
184
+
185
+ function wikidataAppleMusicArtistClaims(entity) {
186
+ const claims = Array.isArray(entity?.claims?.P2850)
187
+ ? entity.claims.P2850
188
+ : [];
189
+ return [
190
+ ...new Set(
191
+ claims
192
+ .map((claim) =>
193
+ safeAppleMusicArtistId(claim?.mainsnak?.datavalue?.value),
194
+ )
195
+ .filter(Boolean),
196
+ ),
197
+ ].sort((left, right) => left.localeCompare(right, "en", { numeric: true }));
198
+ }
199
+
200
+ function entityText(entity, field, language, fallback) {
201
+ return (
202
+ cleanText(entity?.[field]?.[language]?.value, 512) ??
203
+ cleanText(entity?.[field]?.en?.value, 512) ??
204
+ cleanText(fallback, 512)
205
+ );
206
+ }
207
+
208
+ export function createWikidataArtistResolver({
209
+ fetchImpl = globalThis.fetch,
210
+ now = Date.now,
211
+ cacheTtlMs = OPEN_MUSIC_SIMILARITY_LIMITS.identityCacheTtlMs,
212
+ userAgent = defaultUserAgent,
213
+ } = {}) {
214
+ if (typeof fetchImpl !== "function") {
215
+ throw new TypeError("A fetch implementation is required.");
216
+ }
217
+ if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0) {
218
+ throw new TypeError("The Wikidata identity cache TTL is invalid.");
219
+ }
220
+ const agent = cleanText(userAgent, 512);
221
+ if (!agent) throw new TypeError("A descriptive Wikidata user agent is required.");
222
+ const cache = new Map();
223
+
224
+ async function request(parameters) {
225
+ const url = new URL("/w/api.php", wikidataOrigin);
226
+ for (const [key, value] of Object.entries(parameters)) {
227
+ url.searchParams.set(key, String(value));
228
+ }
229
+ try {
230
+ const response = await fetchImpl(url, {
231
+ headers: {
232
+ accept: "application/json",
233
+ "user-agent": agent,
234
+ },
235
+ signal: AbortSignal.timeout(
236
+ OPEN_MUSIC_SIMILARITY_LIMITS.requestTimeoutMs,
237
+ ),
238
+ });
239
+ return await responseJson(response, "Wikidata", "wikidata");
240
+ } catch (error) {
241
+ if (error instanceof OpenMusicSimilarityError) throw error;
242
+ fail(
243
+ "wikidata_request_failed",
244
+ "Wikidata could not be reached.",
245
+ "wikidata",
246
+ );
247
+ }
248
+ }
249
+
250
+ return Object.freeze({
251
+ async resolveArtist(artistName) {
252
+ const artist = requiredArtistName(artistName);
253
+ const normalized = normalizeForMatch(artist);
254
+ const language = languageForArtist(artist);
255
+ const key = `${language}|${normalized}`;
256
+ return cached(cache, key, cacheTtlMs, now, async () => {
257
+ const searchPayload = await request({
258
+ action: "wbsearchentities",
259
+ search: artist,
260
+ language,
261
+ uselang: language,
262
+ type: "item",
263
+ limit: OPEN_MUSIC_SIMILARITY_LIMITS.wikidataSearchResultsMax,
264
+ format: "json",
265
+ });
266
+ const search = Array.isArray(searchPayload?.search)
267
+ ? searchPayload.search
268
+ : [];
269
+ const exact = search.filter((entry) => {
270
+ const matched = cleanText(entry?.match?.text, 512);
271
+ const label = cleanText(entry?.label, 512);
272
+ return [matched, label]
273
+ .filter(Boolean)
274
+ .some((value) => normalizeForMatch(value) === normalized);
275
+ });
276
+ const ids = [
277
+ ...new Set(
278
+ exact
279
+ .map((entry) =>
280
+ typeof entry?.id === "string" && /^Q\d+$/u.test(entry.id)
281
+ ? entry.id
282
+ : null,
283
+ )
284
+ .filter(Boolean),
285
+ ),
286
+ ].sort((left, right) =>
287
+ left.localeCompare(right, "en", { numeric: true }),
288
+ );
289
+ if (ids.length === 0) {
290
+ return {
291
+ state: "not_found",
292
+ artist_name: artist,
293
+ language,
294
+ candidates: [],
295
+ };
296
+ }
297
+ const entityPayload = await request({
298
+ action: "wbgetentities",
299
+ ids: ids.join("|"),
300
+ props: "claims|labels|descriptions",
301
+ languages: language === "en" ? "en" : `${language}|en`,
302
+ format: "json",
303
+ });
304
+ const entities = isPlainObject(entityPayload?.entities)
305
+ ? entityPayload.entities
306
+ : {};
307
+ const candidates = ids
308
+ .map((id) => {
309
+ const entity = entities[id];
310
+ const artistMbids = wikidataMbidClaims(entity);
311
+ if (artistMbids.length === 0) return null;
312
+ const searchEntry = exact.find((entry) => entry.id === id);
313
+ const appleMusicArtistIds =
314
+ wikidataAppleMusicArtistClaims(entity);
315
+ return {
316
+ wikidata_id: id,
317
+ name: entityText(entity, "labels", language, searchEntry?.label),
318
+ description: entityText(
319
+ entity,
320
+ "descriptions",
321
+ language,
322
+ searchEntry?.description,
323
+ ),
324
+ artist_mbids: artistMbids,
325
+ apple_music_artist_ids: appleMusicArtistIds,
326
+ };
327
+ })
328
+ .filter(Boolean);
329
+ const artistMbids = [
330
+ ...new Set(candidates.flatMap((candidate) => candidate.artist_mbids)),
331
+ ];
332
+ if (artistMbids.length !== 1) {
333
+ return {
334
+ state: artistMbids.length === 0 ? "not_found" : "ambiguous",
335
+ artist_name: artist,
336
+ language,
337
+ candidates,
338
+ };
339
+ }
340
+ const matchedCandidates = candidates.filter((candidate) =>
341
+ candidate.artist_mbids.includes(artistMbids[0]),
342
+ );
343
+ const appleMusicArtistIds = [
344
+ ...new Set(
345
+ matchedCandidates.flatMap(
346
+ (candidate) => candidate.apple_music_artist_ids,
347
+ ),
348
+ ),
349
+ ].sort((left, right) =>
350
+ left.localeCompare(right, "en", { numeric: true }),
351
+ );
352
+ return {
353
+ state: "resolved",
354
+ artist_name: artist,
355
+ language,
356
+ artist_mbid: artistMbids[0],
357
+ wikidata_ids: matchedCandidates.map((candidate) => candidate.wikidata_id),
358
+ canonical_name: matchedCandidates[0]?.name ?? artist,
359
+ apple_music_artist_ids: appleMusicArtistIds,
360
+ candidates: matchedCandidates,
361
+ };
362
+ });
363
+ },
364
+ });
365
+ }
366
+
367
+ function parseRadioPayload(payload) {
368
+ if (!isPlainObject(payload)) {
369
+ fail(
370
+ "listenbrainz_radio_response_invalid",
371
+ "ListenBrainz returned an invalid artist radio response.",
372
+ "listenbrainz",
373
+ );
374
+ }
375
+ const groups = [];
376
+ for (const [artistMbidValue, values] of Object.entries(payload)) {
377
+ const artistMbid = safeUuid(artistMbidValue);
378
+ if (!artistMbid || !Array.isArray(values)) continue;
379
+ const recordings = values
380
+ .map((entry) => {
381
+ const recordingMbid = safeUuid(entry?.recording_mbid);
382
+ const similarArtistMbid = safeUuid(entry?.similar_artist_mbid);
383
+ const similarArtistName = cleanText(entry?.similar_artist_name, 512);
384
+ const totalListenCount = safeNonnegativeInteger(entry?.total_listen_count);
385
+ if (
386
+ !recordingMbid ||
387
+ similarArtistMbid !== artistMbid ||
388
+ !similarArtistName ||
389
+ totalListenCount === null
390
+ ) {
391
+ return null;
392
+ }
393
+ return {
394
+ recording_mbid: recordingMbid,
395
+ similar_artist_mbid: similarArtistMbid,
396
+ similar_artist_name: similarArtistName,
397
+ total_listen_count: totalListenCount,
398
+ };
399
+ })
400
+ .filter(Boolean)
401
+ .sort((left, right) => right.total_listen_count - left.total_listen_count);
402
+ if (recordings.length > 0) groups.push({ artist_mbid: artistMbid, recordings });
403
+ }
404
+ return groups;
405
+ }
406
+
407
+ function parseRecordingMetadata(payload, recordingMbids) {
408
+ if (!isPlainObject(payload)) {
409
+ fail(
410
+ "listenbrainz_metadata_response_invalid",
411
+ "ListenBrainz returned invalid recording metadata.",
412
+ "listenbrainz",
413
+ );
414
+ }
415
+ const result = new Map();
416
+ for (const recordingMbid of recordingMbids) {
417
+ const value = payload[recordingMbid];
418
+ const title = cleanText(value?.recording?.name, 512);
419
+ const artistCredit = cleanText(value?.artist?.name, 512);
420
+ const release = cleanText(value?.release?.name, 512);
421
+ const durationMs = safeNonnegativeInteger(value?.recording?.length);
422
+ if (!title || !artistCredit || !release) continue;
423
+ result.set(recordingMbid, {
424
+ title,
425
+ artist_credit: artistCredit,
426
+ release,
427
+ ...(durationMs !== null && durationMs <= 86_400_000
428
+ ? { duration_ms: durationMs }
429
+ : {}),
430
+ });
431
+ }
432
+ return result;
433
+ }
434
+
435
+ export function createListenBrainzArtistRadioClient({
436
+ fetchImpl = globalThis.fetch,
437
+ now = Date.now,
438
+ cacheTtlMs = OPEN_MUSIC_SIMILARITY_LIMITS.cacheTtlMs,
439
+ userAgent = defaultUserAgent,
440
+ } = {}) {
441
+ if (typeof fetchImpl !== "function") {
442
+ throw new TypeError("A fetch implementation is required.");
443
+ }
444
+ if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0) {
445
+ throw new TypeError("The ListenBrainz cache TTL is invalid.");
446
+ }
447
+ const agent = cleanText(userAgent, 512);
448
+ if (!agent) throw new TypeError("A descriptive ListenBrainz user agent is required.");
449
+ const cache = new Map();
450
+ let rateLimitedUntil = 0;
451
+
452
+ async function request(pathname, parameters) {
453
+ const current = now();
454
+ if (!Number.isFinite(current)) {
455
+ fail(
456
+ "open_music_clock_invalid",
457
+ "The open music service clock is invalid.",
458
+ "open_music_similarity",
459
+ );
460
+ }
461
+ if (current < rateLimitedUntil) {
462
+ const seconds = Math.max(1, Math.ceil((rateLimitedUntil - current) / 1_000));
463
+ fail(
464
+ "listenbrainz_rate_limited",
465
+ `ListenBrainz rate limit reached. Retry after ${seconds} seconds.`,
466
+ "listenbrainz",
467
+ );
468
+ }
469
+ const url = new URL(pathname, listenBrainzOrigin);
470
+ for (const [key, value] of Object.entries(parameters)) {
471
+ url.searchParams.set(key, String(value));
472
+ }
473
+ try {
474
+ const response = await fetchImpl(url, {
475
+ headers: {
476
+ accept: "application/json",
477
+ "user-agent": agent,
478
+ },
479
+ signal: AbortSignal.timeout(
480
+ OPEN_MUSIC_SIMILARITY_LIMITS.requestTimeoutMs,
481
+ ),
482
+ });
483
+ const resetIn = headerInteger(response, "x-ratelimit-reset-in");
484
+ const retryAfter = headerInteger(response, "retry-after") ?? resetIn;
485
+ const remaining = headerInteger(response, "x-ratelimit-remaining");
486
+ if (remaining === 0 && resetIn !== null) {
487
+ rateLimitedUntil = current + resetIn * 1_000;
488
+ }
489
+ if (response.status === 429) {
490
+ rateLimitedUntil = current + (retryAfter ?? 1) * 1_000;
491
+ }
492
+ return await responseJson(response, "ListenBrainz", "listenbrainz");
493
+ } catch (error) {
494
+ if (error instanceof OpenMusicSimilarityError) {
495
+ if (error.code === "listenbrainz_rate_limited") {
496
+ rateLimitedUntil = Math.max(rateLimitedUntil, current + 1_000);
497
+ }
498
+ throw error;
499
+ }
500
+ fail(
501
+ "listenbrainz_request_failed",
502
+ "ListenBrainz could not be reached.",
503
+ "listenbrainz",
504
+ );
505
+ }
506
+ }
507
+
508
+ return Object.freeze({
509
+ async artistRadio({
510
+ artistMbid,
511
+ mode,
512
+ maxSimilarArtists,
513
+ maxRecordingsPerArtist,
514
+ popBegin,
515
+ popEnd,
516
+ }) {
517
+ const cleanedMbid = safeUuid(artistMbid);
518
+ if (!cleanedMbid) {
519
+ fail(
520
+ "listenbrainz_artist_mbid_invalid",
521
+ "The ListenBrainz seed artist identity is invalid.",
522
+ "listenbrainz",
523
+ );
524
+ }
525
+ const key = [
526
+ "radio",
527
+ cleanedMbid,
528
+ mode,
529
+ maxSimilarArtists,
530
+ maxRecordingsPerArtist,
531
+ popBegin,
532
+ popEnd,
533
+ ].join("|");
534
+ return cached(cache, key, cacheTtlMs, now, async () => {
535
+ const payload = await request(`/1/lb-radio/artist/${cleanedMbid}`, {
536
+ mode,
537
+ max_similar_artists: maxSimilarArtists,
538
+ max_recordings_per_artist: maxRecordingsPerArtist,
539
+ pop_begin: popBegin,
540
+ pop_end: popEnd,
541
+ });
542
+ return parseRadioPayload(payload);
543
+ });
544
+ },
545
+
546
+ async recordingMetadata(recordingMbids) {
547
+ if (
548
+ !Array.isArray(recordingMbids) ||
549
+ recordingMbids.length < 1 ||
550
+ recordingMbids.length > OPEN_MUSIC_SIMILARITY_LIMITS.listenBrainzRecordingsMax
551
+ ) {
552
+ fail(
553
+ "listenbrainz_recording_mbids_invalid",
554
+ "The ListenBrainz recording identities are invalid.",
555
+ "listenbrainz",
556
+ );
557
+ }
558
+ const cleaned = [...new Set(recordingMbids.map(safeUuid))];
559
+ if (cleaned.includes(null) || cleaned.length !== recordingMbids.length) {
560
+ fail(
561
+ "listenbrainz_recording_mbids_invalid",
562
+ "The ListenBrainz recording identities are invalid.",
563
+ "listenbrainz",
564
+ );
565
+ }
566
+ const key = `metadata|${cleaned.join(",")}`;
567
+ return cached(cache, key, cacheTtlMs, now, async () => {
568
+ const payload = await request("/1/metadata/recording/", {
569
+ recording_mbids: cleaned.join(","),
570
+ inc: "artist release",
571
+ });
572
+ return [...parseRecordingMetadata(payload, cleaned).entries()];
573
+ }).then((entries) => new Map(entries));
574
+ },
575
+ });
576
+ }
577
+
578
+ function roundRobinRecordings(groups, limit) {
579
+ const selected = [];
580
+ const seen = new Set();
581
+ const maximumLength = Math.max(0, ...groups.map((group) => group.recordings.length));
582
+ for (let index = 0; index < maximumLength && selected.length < limit; index += 1) {
583
+ for (const group of groups) {
584
+ const recording = group.recordings[index];
585
+ if (!recording || seen.has(recording.recording_mbid)) continue;
586
+ seen.add(recording.recording_mbid);
587
+ selected.push(recording);
588
+ if (selected.length >= limit) break;
589
+ }
590
+ }
591
+ return selected;
592
+ }
593
+
594
+ function recordingVariantRisk(details) {
595
+ const title = normalizeForMatch(details.title);
596
+ const release = normalizeForMatch(details.release);
597
+ let risk = 0;
598
+ if (/\b(?:interview|commentary|applause|spoken word|talks?)\b/u.test(title)) {
599
+ risk += 100;
600
+ }
601
+ if (/^(?:intro|outro|interlude)(?:\b|$)/u.test(title)) risk += 20;
602
+ if (/\b(?:alternate take|alternative take|outtake|rehearsal|session)\b/u.test(title)) {
603
+ risk += 8;
604
+ }
605
+ if (/^\d{4}\s+\d{2}\s+\d{2}\b/u.test(release)) risk += 12;
606
+ if (
607
+ /\b(?:bootleg|live at|live in|live from|copyright extension|unplugged)\b/u.test(
608
+ release,
609
+ )
610
+ ) {
611
+ risk += 10;
612
+ }
613
+ if (/\b(?:mx|matrix)\s*(?:co)?\s*\d/u.test(title)) risk += 8;
614
+ if (
615
+ /\b(?:a cappella|acappella|demo|karaoke|radio edit|club mix|live|remix|mix|version)\b/u.test(
616
+ title,
617
+ )
618
+ ) {
619
+ risk += 4;
620
+ }
621
+ if (/\b(?:remix|mixes|live)\b/u.test(release)) risk += 2;
622
+ return risk;
623
+ }
624
+
625
+ function sourceMetadata(now, mode, range, seedArtist) {
626
+ const current = now();
627
+ if (!Number.isFinite(current)) {
628
+ fail(
629
+ "open_music_clock_invalid",
630
+ "The open music service clock is invalid.",
631
+ "open_music_similarity",
632
+ );
633
+ }
634
+ return {
635
+ provider: "listenbrainz",
636
+ catalog: "lb_radio_artist+metadata_recording",
637
+ identity_provider: "wikidata",
638
+ retrieved_at: new Date(current).toISOString(),
639
+ license:
640
+ "CC0 inputs: ListenBrainz public listen-derived data, Wikidata structured identity data, and MusicBrainz core artist, recording, and release metadata. Tags and search indexes were not requested.",
641
+ recommendation_basis: "listenbrainz_collaborative_artist_similarity",
642
+ mode,
643
+ popularity_range: { begin: range.begin, end: range.end },
644
+ seed_artist: seedArtist,
645
+ coverage:
646
+ "Listening-derived artist adjacency and recording popularity from ListenBrainz. This is not audio similarity, proof of personal fit, proof of novelty, or complete catalog coverage.",
647
+ };
648
+ }
649
+
650
+ export function createOpenMusicSimilarity({
651
+ identityResolver = createWikidataArtistResolver(),
652
+ radioClient = createListenBrainzArtistRadioClient(),
653
+ now = Date.now,
654
+ } = {}) {
655
+ if (!identityResolver || typeof identityResolver.resolveArtist !== "function") {
656
+ throw new TypeError("A Wikidata artist identity resolver is required.");
657
+ }
658
+ if (
659
+ !radioClient ||
660
+ typeof radioClient.artistRadio !== "function" ||
661
+ typeof radioClient.recordingMetadata !== "function"
662
+ ) {
663
+ throw new TypeError("A ListenBrainz artist radio client is required.");
664
+ }
665
+
666
+ return Object.freeze({
667
+ async discoverSimilarTracks({ artistName, mode = "medium", limit = 8 } = {}) {
668
+ const artist = requiredArtistName(artistName);
669
+ if (!modes.has(mode)) {
670
+ fail(
671
+ "open_music_mode_invalid",
672
+ "The artist similarity mode is invalid.",
673
+ "open_music_similarity",
674
+ );
675
+ }
676
+ if (
677
+ !Number.isInteger(limit) ||
678
+ limit < 1 ||
679
+ limit > OPEN_MUSIC_SIMILARITY_LIMITS.outputTracksMax
680
+ ) {
681
+ fail(
682
+ "open_music_limit_invalid",
683
+ "The artist similarity result limit is invalid.",
684
+ "open_music_similarity",
685
+ );
686
+ }
687
+ const range = popularityRanges[mode];
688
+ const source = sourceMetadata(now, mode, range, artist);
689
+ const identity = await identityResolver.resolveArtist(artist);
690
+ if (identity.state !== "resolved") {
691
+ return {
692
+ state:
693
+ identity.state === "ambiguous"
694
+ ? "seed_identity_ambiguous"
695
+ : "seed_identity_not_found",
696
+ source,
697
+ seed: {
698
+ artist_name: artist,
699
+ identity_resolution: identity.state,
700
+ candidate_count: identity.candidates?.length ?? 0,
701
+ },
702
+ result_count: 0,
703
+ tracks: [],
704
+ };
705
+ }
706
+ const groups = await radioClient.artistRadio({
707
+ artistMbid: identity.artist_mbid,
708
+ mode,
709
+ maxSimilarArtists: Math.min(8, Math.max(4, Math.ceil(limit / 2) + 2)),
710
+ maxRecordingsPerArtist: 5,
711
+ popBegin: range.begin,
712
+ popEnd: range.end,
713
+ });
714
+ const adjacentGroups = groups.filter(
715
+ (group) => group.artist_mbid !== identity.artist_mbid,
716
+ );
717
+ const recordings = adjacentGroups
718
+ .flatMap((group) => group.recordings);
719
+ if (recordings.length === 0) {
720
+ return {
721
+ state: "not_found",
722
+ source,
723
+ seed: {
724
+ artist_name: artist,
725
+ canonical_artist_name: identity.canonical_name,
726
+ identity_resolution: "wikidata_exact_label_or_alias",
727
+ },
728
+ result_count: 0,
729
+ tracks: [],
730
+ };
731
+ }
732
+ const metadata = new Map();
733
+ for (
734
+ let offset = 0;
735
+ offset < recordings.length;
736
+ offset += OPEN_MUSIC_SIMILARITY_LIMITS.listenBrainzRecordingsMax
737
+ ) {
738
+ const chunk = recordings.slice(
739
+ offset,
740
+ offset + OPEN_MUSIC_SIMILARITY_LIMITS.listenBrainzRecordingsMax,
741
+ );
742
+ const chunkMetadata = await radioClient.recordingMetadata(
743
+ chunk.map((recording) => recording.recording_mbid),
744
+ );
745
+ for (const [recordingMbid, details] of chunkMetadata) {
746
+ metadata.set(recordingMbid, details);
747
+ }
748
+ }
749
+ const rankedGroups = adjacentGroups
750
+ .map((group) => ({
751
+ ...group,
752
+ recordings: group.recordings
753
+ .map((recording) => {
754
+ const details = metadata.get(recording.recording_mbid);
755
+ return details
756
+ ? {
757
+ ...recording,
758
+ details,
759
+ variant_risk: recordingVariantRisk(details),
760
+ }
761
+ : null;
762
+ })
763
+ .filter(Boolean)
764
+ .sort((left, right) => {
765
+ if (left.variant_risk !== right.variant_risk) {
766
+ return left.variant_risk - right.variant_risk;
767
+ }
768
+ return right.total_listen_count - left.total_listen_count;
769
+ }),
770
+ }))
771
+ .filter((group) => group.recordings.length > 0);
772
+ const selected = roundRobinRecordings(rankedGroups, limit);
773
+ const tracks = [];
774
+ for (const recording of selected) {
775
+ const details = recording.details;
776
+ tracks.push({
777
+ track_ref_id: uuidV5(
778
+ recording.recording_mbid,
779
+ LISTENBRAINZ_RECORDING_TRACK_NAMESPACE,
780
+ ),
781
+ ...details,
782
+ candidate_scope: "external_catalog",
783
+ catalog_provider: "listenbrainz",
784
+ discovery_basis: {
785
+ kind: "listenbrainz_collaborative_artist_similarity",
786
+ seed_artist: identity.canonical_name,
787
+ adjacent_artist: recording.similar_artist_name,
788
+ mode,
789
+ },
790
+ });
791
+ if (tracks.length >= limit) break;
792
+ }
793
+ return {
794
+ state: tracks.length > 0 ? "resolved" : "not_found",
795
+ source,
796
+ seed: {
797
+ artist_name: artist,
798
+ canonical_artist_name: identity.canonical_name,
799
+ identity_resolution: "wikidata_exact_label_or_alias",
800
+ },
801
+ result_count: tracks.length,
802
+ tracks,
803
+ };
804
+ },
805
+ });
806
+ }