@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,770 @@
|
|
|
1
|
+
import { uuidV5 } from "../../core/uuid-v5.mjs";
|
|
2
|
+
|
|
3
|
+
export const APPLE_MUSIC_CATALOG_TRACK_NAMESPACE =
|
|
4
|
+
"2837a09e-e5f0-5aa5-b271-bb99d8b0de20";
|
|
5
|
+
|
|
6
|
+
export const APPLE_MUSIC_CATALOG_LIMITS = Object.freeze({
|
|
7
|
+
artistNameLengthMax: 256,
|
|
8
|
+
releaseHintLengthMax: 512,
|
|
9
|
+
discoveryQueryLengthMax: 256,
|
|
10
|
+
discoveryQueriesMax: 3,
|
|
11
|
+
trackSearchResultsMax: 12,
|
|
12
|
+
outputTracksMax: 12,
|
|
13
|
+
artistSearchResultsMax: 10,
|
|
14
|
+
exactArtistCandidatesMax: 4,
|
|
15
|
+
releasesPerArtistMax: 50,
|
|
16
|
+
outputReleasesMax: 8,
|
|
17
|
+
ambiguousCandidateReleasesMax: 3,
|
|
18
|
+
cacheTtlMs: 10 * 60 * 1_000,
|
|
19
|
+
requestTimeoutMs: 8_000,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const appleCatalogOrigin = "https://itunes.apple.com";
|
|
23
|
+
const releaseSuffixPattern = /\s*-\s*(Single|EP)\s*$/iu;
|
|
24
|
+
|
|
25
|
+
export class AppleMusicCatalogError extends Error {
|
|
26
|
+
constructor(code, message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "AppleMusicCatalogError";
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.provider = "apple_music";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fail(code, message) {
|
|
35
|
+
throw new AppleMusicCatalogError(code, message);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isPlainObject(value) {
|
|
39
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
40
|
+
const prototype = Object.getPrototypeOf(value);
|
|
41
|
+
return prototype === Object.prototype || prototype === null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function cleanInputText(value, maximum, code, label) {
|
|
45
|
+
if (
|
|
46
|
+
typeof value !== "string" ||
|
|
47
|
+
Array.from(value).length < 1 ||
|
|
48
|
+
Array.from(value).length > maximum
|
|
49
|
+
) {
|
|
50
|
+
fail(code, `The ${label} is invalid.`);
|
|
51
|
+
}
|
|
52
|
+
const cleaned = value
|
|
53
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "")
|
|
54
|
+
.replace(/[\u202a-\u202e\u2066-\u2069]/gu, "")
|
|
55
|
+
.replace(/\s+/gu, " ")
|
|
56
|
+
.trim();
|
|
57
|
+
if (!cleaned) fail(code, `The ${label} is invalid.`);
|
|
58
|
+
return cleaned;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function safeText(value, maximum) {
|
|
62
|
+
if (typeof value !== "string") return null;
|
|
63
|
+
const cleaned = value
|
|
64
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "")
|
|
65
|
+
.replace(/[\u202a-\u202e\u2066-\u2069]/gu, "")
|
|
66
|
+
.replace(/\s+/gu, " ")
|
|
67
|
+
.trim();
|
|
68
|
+
if (!cleaned) return null;
|
|
69
|
+
return Array.from(cleaned).slice(0, maximum).join("");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeForMatch(value) {
|
|
73
|
+
return String(value)
|
|
74
|
+
.normalize("NFKC")
|
|
75
|
+
.toLocaleLowerCase("und")
|
|
76
|
+
.replace(releaseSuffixPattern, "")
|
|
77
|
+
.replace(/["'‘’“”`]/gu, "")
|
|
78
|
+
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
|
79
|
+
.trim();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function safeCatalogId(value) {
|
|
83
|
+
if (!Number.isSafeInteger(value) || value < 1) return null;
|
|
84
|
+
return String(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function cleanArtistCatalogId(value) {
|
|
88
|
+
const catalogId =
|
|
89
|
+
typeof value === "string" && /^\d{1,20}$/u.test(value)
|
|
90
|
+
? value
|
|
91
|
+
: null;
|
|
92
|
+
if (!catalogId) {
|
|
93
|
+
fail(
|
|
94
|
+
"apple_music_catalog_artist_id_invalid",
|
|
95
|
+
"The Apple Music artist identity is invalid.",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return catalogId;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function safeAppleMusicUrl(value) {
|
|
102
|
+
const cleaned = safeText(value, 2_048);
|
|
103
|
+
if (!cleaned) return null;
|
|
104
|
+
try {
|
|
105
|
+
const url = new URL(cleaned);
|
|
106
|
+
if (
|
|
107
|
+
url.protocol !== "https:" ||
|
|
108
|
+
!new Set(["music.apple.com", "itunes.apple.com"]).has(url.hostname)
|
|
109
|
+
) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return url.toString();
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function releaseDate(value) {
|
|
119
|
+
if (typeof value !== "string") return null;
|
|
120
|
+
const matched = /^(\d{4}-\d{2}-\d{2})T/u.exec(value);
|
|
121
|
+
if (!matched || !Number.isFinite(Date.parse(value))) return null;
|
|
122
|
+
return matched[1];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function releaseIdentity(collectionName) {
|
|
126
|
+
const matched = collectionName.match(releaseSuffixPattern);
|
|
127
|
+
const releaseType = matched
|
|
128
|
+
? matched[1].toLocaleLowerCase("en-US")
|
|
129
|
+
: "album";
|
|
130
|
+
const title = collectionName.replace(releaseSuffixPattern, "").trim();
|
|
131
|
+
return {
|
|
132
|
+
title: title || collectionName,
|
|
133
|
+
release_type: releaseType,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizeArtist(raw) {
|
|
138
|
+
if (!isPlainObject(raw) || raw.wrapperType !== "artist") return null;
|
|
139
|
+
const catalogId = safeCatalogId(raw.artistId);
|
|
140
|
+
const name = safeText(raw.artistName, 256);
|
|
141
|
+
if (!catalogId || !name) return null;
|
|
142
|
+
const artist = {
|
|
143
|
+
catalog_id: catalogId,
|
|
144
|
+
name,
|
|
145
|
+
};
|
|
146
|
+
const primaryGenre = safeText(raw.primaryGenreName, 128);
|
|
147
|
+
const catalogUrl = safeAppleMusicUrl(raw.artistLinkUrl);
|
|
148
|
+
if (primaryGenre) artist.primary_genre = primaryGenre;
|
|
149
|
+
if (catalogUrl) artist.catalog_url = catalogUrl;
|
|
150
|
+
return artist;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function normalizeRelease(raw, expectedArtistId) {
|
|
154
|
+
if (
|
|
155
|
+
!isPlainObject(raw) ||
|
|
156
|
+
raw.wrapperType !== "collection" ||
|
|
157
|
+
safeCatalogId(raw.artistId) !== expectedArtistId
|
|
158
|
+
) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const catalogId = safeCatalogId(raw.collectionId);
|
|
162
|
+
const artistName = safeText(raw.artistName, 256);
|
|
163
|
+
const collectionName = safeText(raw.collectionName, 512);
|
|
164
|
+
const date = releaseDate(raw.releaseDate);
|
|
165
|
+
if (!catalogId || !artistName || !collectionName || !date) return null;
|
|
166
|
+
const release = {
|
|
167
|
+
catalog_id: catalogId,
|
|
168
|
+
artist_name: artistName,
|
|
169
|
+
collection_name: collectionName,
|
|
170
|
+
...releaseIdentity(collectionName),
|
|
171
|
+
release_date: date,
|
|
172
|
+
};
|
|
173
|
+
if (Number.isSafeInteger(raw.trackCount) && raw.trackCount >= 0) {
|
|
174
|
+
release.track_count = raw.trackCount;
|
|
175
|
+
}
|
|
176
|
+
const primaryGenre = safeText(raw.primaryGenreName, 128);
|
|
177
|
+
const catalogUrl = safeAppleMusicUrl(raw.collectionViewUrl);
|
|
178
|
+
if (primaryGenre) release.primary_genre = primaryGenre;
|
|
179
|
+
if (catalogUrl) release.catalog_url = catalogUrl;
|
|
180
|
+
return release;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function normalizeTrack(raw) {
|
|
184
|
+
if (
|
|
185
|
+
!isPlainObject(raw) ||
|
|
186
|
+
raw.wrapperType !== "track" ||
|
|
187
|
+
raw.kind !== "song"
|
|
188
|
+
) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
const catalogId = safeCatalogId(raw.trackId);
|
|
192
|
+
const title = safeText(raw.trackName, 512);
|
|
193
|
+
const artistCredit = safeText(raw.artistName, 512);
|
|
194
|
+
const release = safeText(raw.collectionName, 512);
|
|
195
|
+
if (!catalogId || !title || !artistCredit || !release) return null;
|
|
196
|
+
const track = {
|
|
197
|
+
track_ref_id: uuidV5(catalogId, APPLE_MUSIC_CATALOG_TRACK_NAMESPACE),
|
|
198
|
+
title,
|
|
199
|
+
artist_credit: artistCredit,
|
|
200
|
+
release,
|
|
201
|
+
candidate_scope: "external_catalog",
|
|
202
|
+
catalog_provider: "apple_music",
|
|
203
|
+
};
|
|
204
|
+
if (
|
|
205
|
+
Number.isSafeInteger(raw.trackTimeMillis) &&
|
|
206
|
+
raw.trackTimeMillis >= 0 &&
|
|
207
|
+
raw.trackTimeMillis <= 86_400_000
|
|
208
|
+
) {
|
|
209
|
+
track.duration_ms = raw.trackTimeMillis;
|
|
210
|
+
}
|
|
211
|
+
const primaryGenre = safeText(raw.primaryGenreName, 128);
|
|
212
|
+
const catalogUrl = safeAppleMusicUrl(raw.trackViewUrl);
|
|
213
|
+
const date = releaseDate(raw.releaseDate);
|
|
214
|
+
if (primaryGenre) track.primary_genre = primaryGenre;
|
|
215
|
+
if (catalogUrl) track.catalog_url = catalogUrl;
|
|
216
|
+
if (date) track.release_date = date;
|
|
217
|
+
return track;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function deduplicateAndSortReleases(releases) {
|
|
221
|
+
const seen = new Set();
|
|
222
|
+
const unique = [];
|
|
223
|
+
for (const release of releases) {
|
|
224
|
+
const key = [
|
|
225
|
+
normalizeForMatch(release.title),
|
|
226
|
+
release.release_type,
|
|
227
|
+
release.release_date,
|
|
228
|
+
].join("|");
|
|
229
|
+
if (seen.has(key)) continue;
|
|
230
|
+
seen.add(key);
|
|
231
|
+
unique.push(release);
|
|
232
|
+
}
|
|
233
|
+
return unique.sort((left, right) => {
|
|
234
|
+
const dateOrder = right.release_date.localeCompare(left.release_date);
|
|
235
|
+
if (dateOrder !== 0) return dateOrder;
|
|
236
|
+
return left.title.localeCompare(right.title, "und");
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function sourceMetadata(now) {
|
|
241
|
+
const current = now();
|
|
242
|
+
if (!Number.isFinite(current)) {
|
|
243
|
+
fail("apple_music_catalog_clock_invalid", "The catalog clock is invalid.");
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
provider: "apple_music",
|
|
247
|
+
catalog: "itunes_search_api",
|
|
248
|
+
storefront: "US",
|
|
249
|
+
retrieved_at: new Date(current).toISOString(),
|
|
250
|
+
coverage:
|
|
251
|
+
"Apple Music US storefront catalog only. This is not a claim about every music platform.",
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function releasedAndUpcoming(releases, retrievedAt) {
|
|
256
|
+
const observedDate = retrievedAt.slice(0, 10);
|
|
257
|
+
return {
|
|
258
|
+
released: releases.filter(
|
|
259
|
+
(release) => release.release_date <= observedDate,
|
|
260
|
+
),
|
|
261
|
+
upcoming: releases.filter(
|
|
262
|
+
(release) => release.release_date > observedDate,
|
|
263
|
+
),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function interleaveTrackResults(resultSets, queries, limit) {
|
|
268
|
+
const selected = [];
|
|
269
|
+
const selectedById = new Map();
|
|
270
|
+
const selectedReleaseKeys = new Set();
|
|
271
|
+
const selectedArtistCounts = new Map();
|
|
272
|
+
const maximumLength = Math.max(0, ...resultSets.map((results) => results.length));
|
|
273
|
+
for (let index = 0; index < maximumLength && selected.length < limit; index += 1) {
|
|
274
|
+
for (let queryIndex = 0; queryIndex < resultSets.length; queryIndex += 1) {
|
|
275
|
+
const track = resultSets[queryIndex][index];
|
|
276
|
+
if (!track) continue;
|
|
277
|
+
const existing = selectedById.get(track.track_ref_id);
|
|
278
|
+
if (existing) {
|
|
279
|
+
if (!existing.matched_queries.includes(queries[queryIndex])) {
|
|
280
|
+
existing.matched_queries.push(queries[queryIndex]);
|
|
281
|
+
}
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const artistKey = normalizeForMatch(track.artist_credit);
|
|
285
|
+
const releaseKey = `${artistKey}|${normalizeForMatch(track.release)}`;
|
|
286
|
+
if (
|
|
287
|
+
selectedReleaseKeys.has(releaseKey) ||
|
|
288
|
+
(selectedArtistCounts.get(artistKey) ?? 0) >= 2
|
|
289
|
+
) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const result = {
|
|
293
|
+
...track,
|
|
294
|
+
matched_queries: [queries[queryIndex]],
|
|
295
|
+
};
|
|
296
|
+
selected.push(result);
|
|
297
|
+
selectedById.set(track.track_ref_id, result);
|
|
298
|
+
selectedReleaseKeys.add(releaseKey);
|
|
299
|
+
selectedArtistCounts.set(
|
|
300
|
+
artistKey,
|
|
301
|
+
(selectedArtistCounts.get(artistKey) ?? 0) + 1,
|
|
302
|
+
);
|
|
303
|
+
if (selected.length >= limit) break;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return selected;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function cachedRequest(cache, key, ttlMs, now, loader) {
|
|
310
|
+
const current = now();
|
|
311
|
+
const existing = cache.get(key);
|
|
312
|
+
if (existing && existing.expires_at_ms > current) return existing.promise;
|
|
313
|
+
const entry = {
|
|
314
|
+
expires_at_ms: current + ttlMs,
|
|
315
|
+
promise: Promise.resolve().then(loader),
|
|
316
|
+
};
|
|
317
|
+
cache.set(key, entry);
|
|
318
|
+
entry.promise.catch(() => {
|
|
319
|
+
if (cache.get(key) === entry) cache.delete(key);
|
|
320
|
+
});
|
|
321
|
+
return entry.promise;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function parseJsonResponse(response) {
|
|
325
|
+
if (!response || response.ok !== true) {
|
|
326
|
+
const status = Number.isInteger(response?.status) ? response.status : 0;
|
|
327
|
+
fail(
|
|
328
|
+
"apple_music_catalog_http_error",
|
|
329
|
+
status > 0
|
|
330
|
+
? `Apple Music catalog returned HTTP ${status}.`
|
|
331
|
+
: "Apple Music catalog request failed.",
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
let payload;
|
|
335
|
+
try {
|
|
336
|
+
payload = await response.json();
|
|
337
|
+
} catch {
|
|
338
|
+
fail(
|
|
339
|
+
"apple_music_catalog_response_invalid",
|
|
340
|
+
"Apple Music catalog returned invalid JSON.",
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
if (!isPlainObject(payload) || !Array.isArray(payload.results)) {
|
|
344
|
+
fail(
|
|
345
|
+
"apple_music_catalog_response_invalid",
|
|
346
|
+
"Apple Music catalog returned an invalid response.",
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
return payload.results;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function createAppleMusicCatalogClient({
|
|
353
|
+
fetchImpl = globalThis.fetch,
|
|
354
|
+
now = Date.now,
|
|
355
|
+
cacheTtlMs = APPLE_MUSIC_CATALOG_LIMITS.cacheTtlMs,
|
|
356
|
+
} = {}) {
|
|
357
|
+
if (typeof fetchImpl !== "function") {
|
|
358
|
+
throw new TypeError("A fetch implementation is required.");
|
|
359
|
+
}
|
|
360
|
+
if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0) {
|
|
361
|
+
throw new TypeError("The Apple Music catalog cache TTL is invalid.");
|
|
362
|
+
}
|
|
363
|
+
const cache = new Map();
|
|
364
|
+
|
|
365
|
+
async function request(pathname, parameters) {
|
|
366
|
+
const url = new URL(pathname, appleCatalogOrigin);
|
|
367
|
+
for (const [key, value] of Object.entries(parameters)) {
|
|
368
|
+
if (value !== undefined) url.searchParams.set(key, String(value));
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
const response = await fetchImpl(url, {
|
|
372
|
+
headers: { accept: "application/json" },
|
|
373
|
+
signal: AbortSignal.timeout(
|
|
374
|
+
APPLE_MUSIC_CATALOG_LIMITS.requestTimeoutMs,
|
|
375
|
+
),
|
|
376
|
+
});
|
|
377
|
+
return await parseJsonResponse(response);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
if (error instanceof AppleMusicCatalogError) throw error;
|
|
380
|
+
fail(
|
|
381
|
+
"apple_music_catalog_request_failed",
|
|
382
|
+
"Apple Music catalog could not be reached.",
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function artistCatalog(artistId) {
|
|
388
|
+
const catalogId = cleanArtistCatalogId(artistId);
|
|
389
|
+
const key = `artist-catalog|US|${catalogId}`;
|
|
390
|
+
return cachedRequest(cache, key, cacheTtlMs, now, async () => {
|
|
391
|
+
const results = await request("/lookup", {
|
|
392
|
+
id: catalogId,
|
|
393
|
+
country: "us",
|
|
394
|
+
entity: "album",
|
|
395
|
+
limit: APPLE_MUSIC_CATALOG_LIMITS.releasesPerArtistMax,
|
|
396
|
+
sort: "recent",
|
|
397
|
+
});
|
|
398
|
+
const artist = results
|
|
399
|
+
.map(normalizeArtist)
|
|
400
|
+
.find((candidate) => candidate?.catalog_id === catalogId) ?? null;
|
|
401
|
+
const releases = deduplicateAndSortReleases(
|
|
402
|
+
results
|
|
403
|
+
.map((result) => normalizeRelease(result, catalogId))
|
|
404
|
+
.filter(Boolean),
|
|
405
|
+
);
|
|
406
|
+
return { artist, releases };
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return Object.freeze({
|
|
411
|
+
async searchTracks(query) {
|
|
412
|
+
const cleaned = cleanInputText(
|
|
413
|
+
query,
|
|
414
|
+
APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax,
|
|
415
|
+
"apple_music_catalog_query_invalid",
|
|
416
|
+
"catalog query",
|
|
417
|
+
);
|
|
418
|
+
const key = `tracks|US|${normalizeForMatch(cleaned)}`;
|
|
419
|
+
return cachedRequest(cache, key, cacheTtlMs, now, async () => {
|
|
420
|
+
const results = await request("/search", {
|
|
421
|
+
term: cleaned,
|
|
422
|
+
country: "us",
|
|
423
|
+
media: "music",
|
|
424
|
+
entity: "song",
|
|
425
|
+
limit: APPLE_MUSIC_CATALOG_LIMITS.trackSearchResultsMax,
|
|
426
|
+
explicit: "No",
|
|
427
|
+
});
|
|
428
|
+
return results.map(normalizeTrack).filter(Boolean);
|
|
429
|
+
});
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
async searchArtists(artistName) {
|
|
433
|
+
const cleaned = cleanInputText(
|
|
434
|
+
artistName,
|
|
435
|
+
APPLE_MUSIC_CATALOG_LIMITS.artistNameLengthMax,
|
|
436
|
+
"apple_music_catalog_artist_invalid",
|
|
437
|
+
"artist name",
|
|
438
|
+
);
|
|
439
|
+
const key = `artists|US|${normalizeForMatch(cleaned)}`;
|
|
440
|
+
return cachedRequest(cache, key, cacheTtlMs, now, async () => {
|
|
441
|
+
const results = await request("/search", {
|
|
442
|
+
term: cleaned,
|
|
443
|
+
country: "us",
|
|
444
|
+
media: "music",
|
|
445
|
+
entity: "musicArtist",
|
|
446
|
+
attribute: "artistTerm",
|
|
447
|
+
limit: APPLE_MUSIC_CATALOG_LIMITS.artistSearchResultsMax,
|
|
448
|
+
explicit: "No",
|
|
449
|
+
});
|
|
450
|
+
return results.map(normalizeArtist).filter(Boolean);
|
|
451
|
+
});
|
|
452
|
+
},
|
|
453
|
+
|
|
454
|
+
async artistIdentity(artistId) {
|
|
455
|
+
return (await artistCatalog(artistId)).artist;
|
|
456
|
+
},
|
|
457
|
+
|
|
458
|
+
async artistReleases(artistId) {
|
|
459
|
+
return (await artistCatalog(artistId)).releases;
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function candidateSummary(artist, releases = []) {
|
|
465
|
+
return {
|
|
466
|
+
artist,
|
|
467
|
+
recent_releases: releases.slice(
|
|
468
|
+
0,
|
|
469
|
+
APPLE_MUSIC_CATALOG_LIMITS.ambiguousCandidateReleasesMax,
|
|
470
|
+
),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function createAppleMusicCatalog({
|
|
475
|
+
client = createAppleMusicCatalogClient(),
|
|
476
|
+
now = Date.now,
|
|
477
|
+
} = {}) {
|
|
478
|
+
if (
|
|
479
|
+
!client ||
|
|
480
|
+
typeof client.searchTracks !== "function" ||
|
|
481
|
+
typeof client.searchArtists !== "function" ||
|
|
482
|
+
typeof client.artistReleases !== "function"
|
|
483
|
+
) {
|
|
484
|
+
throw new TypeError("The Apple Music catalog client is invalid.");
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return Object.freeze({
|
|
488
|
+
async searchTracks({ queries, limit = 8 } = {}) {
|
|
489
|
+
if (
|
|
490
|
+
!Array.isArray(queries) ||
|
|
491
|
+
queries.length < 1 ||
|
|
492
|
+
queries.length > APPLE_MUSIC_CATALOG_LIMITS.discoveryQueriesMax
|
|
493
|
+
) {
|
|
494
|
+
fail(
|
|
495
|
+
"apple_music_catalog_queries_invalid",
|
|
496
|
+
"The catalog discovery queries are invalid.",
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
const cleanedQueries = queries.map((query) =>
|
|
500
|
+
cleanInputText(
|
|
501
|
+
query,
|
|
502
|
+
APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax,
|
|
503
|
+
"apple_music_catalog_query_invalid",
|
|
504
|
+
"catalog query",
|
|
505
|
+
),
|
|
506
|
+
);
|
|
507
|
+
if (new Set(cleanedQueries.map(normalizeForMatch)).size !== cleanedQueries.length) {
|
|
508
|
+
fail(
|
|
509
|
+
"apple_music_catalog_queries_invalid",
|
|
510
|
+
"The catalog discovery queries must be distinct.",
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
if (
|
|
514
|
+
!Number.isInteger(limit) ||
|
|
515
|
+
limit < 1 ||
|
|
516
|
+
limit > APPLE_MUSIC_CATALOG_LIMITS.outputTracksMax
|
|
517
|
+
) {
|
|
518
|
+
fail(
|
|
519
|
+
"apple_music_catalog_limit_invalid",
|
|
520
|
+
"The track result limit is invalid.",
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const resultSets = [];
|
|
525
|
+
for (const query of cleanedQueries) {
|
|
526
|
+
resultSets.push(await client.searchTracks(query));
|
|
527
|
+
}
|
|
528
|
+
const source = {
|
|
529
|
+
...sourceMetadata(now),
|
|
530
|
+
coverage:
|
|
531
|
+
"Keyword relevance from the Apple Music US storefront. Results are external catalog candidates, not proof of personal fit, novelty, or cross-platform availability.",
|
|
532
|
+
};
|
|
533
|
+
const tracks = interleaveTrackResults(
|
|
534
|
+
resultSets,
|
|
535
|
+
cleanedQueries,
|
|
536
|
+
limit,
|
|
537
|
+
);
|
|
538
|
+
return {
|
|
539
|
+
state: tracks.length > 0 ? "resolved" : "not_found",
|
|
540
|
+
source,
|
|
541
|
+
queries: cleanedQueries,
|
|
542
|
+
result_count: tracks.length,
|
|
543
|
+
tracks,
|
|
544
|
+
};
|
|
545
|
+
},
|
|
546
|
+
|
|
547
|
+
async findArtistReleasesByCatalogId({
|
|
548
|
+
artistCatalogId,
|
|
549
|
+
limit = 6,
|
|
550
|
+
} = {}) {
|
|
551
|
+
const catalogId = cleanArtistCatalogId(artistCatalogId);
|
|
552
|
+
if (
|
|
553
|
+
!Number.isInteger(limit) ||
|
|
554
|
+
limit < 1 ||
|
|
555
|
+
limit > APPLE_MUSIC_CATALOG_LIMITS.outputReleasesMax
|
|
556
|
+
) {
|
|
557
|
+
fail(
|
|
558
|
+
"apple_music_catalog_limit_invalid",
|
|
559
|
+
"The release result limit is invalid.",
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
if (typeof client.artistIdentity !== "function") {
|
|
563
|
+
fail(
|
|
564
|
+
"apple_music_catalog_artist_identity_unavailable",
|
|
565
|
+
"Explicit Apple Music artist lookup is unavailable.",
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
const source = sourceMetadata(now);
|
|
569
|
+
const artist = await client.artistIdentity(catalogId);
|
|
570
|
+
if (!artist) {
|
|
571
|
+
return {
|
|
572
|
+
state: "not_found",
|
|
573
|
+
source,
|
|
574
|
+
query: { artist_catalog_id: catalogId },
|
|
575
|
+
candidates: [],
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
const releases = await client.artistReleases(catalogId);
|
|
579
|
+
const split = releasedAndUpcoming(releases, source.retrieved_at);
|
|
580
|
+
return {
|
|
581
|
+
state: "resolved",
|
|
582
|
+
source,
|
|
583
|
+
query: { artist_catalog_id: catalogId },
|
|
584
|
+
artist,
|
|
585
|
+
selection_basis: "explicit_artist_catalog_identity",
|
|
586
|
+
releases: split.released.slice(0, limit),
|
|
587
|
+
latest_released_single:
|
|
588
|
+
split.released.find(
|
|
589
|
+
(release) => release.release_type === "single",
|
|
590
|
+
) ?? null,
|
|
591
|
+
upcoming_releases: split.upcoming.slice(0, limit),
|
|
592
|
+
candidates: [],
|
|
593
|
+
};
|
|
594
|
+
},
|
|
595
|
+
|
|
596
|
+
async findArtistReleases({
|
|
597
|
+
artistName,
|
|
598
|
+
artistCatalogId,
|
|
599
|
+
knownRelease,
|
|
600
|
+
limit = 6,
|
|
601
|
+
} = {}) {
|
|
602
|
+
const artist = cleanInputText(
|
|
603
|
+
artistName,
|
|
604
|
+
APPLE_MUSIC_CATALOG_LIMITS.artistNameLengthMax,
|
|
605
|
+
"apple_music_catalog_artist_invalid",
|
|
606
|
+
"artist name",
|
|
607
|
+
);
|
|
608
|
+
const releaseHint =
|
|
609
|
+
knownRelease === undefined
|
|
610
|
+
? null
|
|
611
|
+
: cleanInputText(
|
|
612
|
+
knownRelease,
|
|
613
|
+
APPLE_MUSIC_CATALOG_LIMITS.releaseHintLengthMax,
|
|
614
|
+
"apple_music_catalog_release_hint_invalid",
|
|
615
|
+
"known release",
|
|
616
|
+
);
|
|
617
|
+
const explicitCatalogId =
|
|
618
|
+
artistCatalogId === undefined
|
|
619
|
+
? null
|
|
620
|
+
: cleanArtistCatalogId(artistCatalogId);
|
|
621
|
+
if (releaseHint && explicitCatalogId) {
|
|
622
|
+
fail(
|
|
623
|
+
"apple_music_catalog_identity_hints_conflict",
|
|
624
|
+
"Use either a known release or an explicit artist identity, not both.",
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
if (
|
|
628
|
+
!Number.isInteger(limit) ||
|
|
629
|
+
limit < 1 ||
|
|
630
|
+
limit > APPLE_MUSIC_CATALOG_LIMITS.outputReleasesMax
|
|
631
|
+
) {
|
|
632
|
+
fail(
|
|
633
|
+
"apple_music_catalog_limit_invalid",
|
|
634
|
+
"The release result limit is invalid.",
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const source = sourceMetadata(now);
|
|
639
|
+
const normalizedArtist = normalizeForMatch(artist);
|
|
640
|
+
let selected = null;
|
|
641
|
+
let selectedReleases = [];
|
|
642
|
+
let selectionBasis = null;
|
|
643
|
+
const releasesByArtist = new Map();
|
|
644
|
+
let exactCandidates = [];
|
|
645
|
+
|
|
646
|
+
if (explicitCatalogId) {
|
|
647
|
+
if (typeof client.artistIdentity !== "function") {
|
|
648
|
+
fail(
|
|
649
|
+
"apple_music_catalog_artist_identity_unavailable",
|
|
650
|
+
"Explicit Apple Music artist lookup is unavailable.",
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
selected = await client.artistIdentity(explicitCatalogId);
|
|
654
|
+
if (!selected) {
|
|
655
|
+
return {
|
|
656
|
+
state: "not_found",
|
|
657
|
+
source,
|
|
658
|
+
query: {
|
|
659
|
+
artist_name: artist,
|
|
660
|
+
artist_catalog_id: explicitCatalogId,
|
|
661
|
+
},
|
|
662
|
+
candidates: [],
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
if (normalizeForMatch(selected.name) !== normalizedArtist) {
|
|
666
|
+
return {
|
|
667
|
+
state: "artist_identity_mismatch",
|
|
668
|
+
source,
|
|
669
|
+
query: {
|
|
670
|
+
artist_name: artist,
|
|
671
|
+
artist_catalog_id: explicitCatalogId,
|
|
672
|
+
},
|
|
673
|
+
artist: selected,
|
|
674
|
+
candidates: [],
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
selectedReleases = await client.artistReleases(selected.catalog_id);
|
|
678
|
+
selectionBasis = "explicit_artist_catalog_page";
|
|
679
|
+
} else {
|
|
680
|
+
const searched = await client.searchArtists(artist);
|
|
681
|
+
exactCandidates = searched
|
|
682
|
+
.filter(
|
|
683
|
+
(candidate) =>
|
|
684
|
+
normalizeForMatch(candidate.name) === normalizedArtist,
|
|
685
|
+
)
|
|
686
|
+
.slice(0, APPLE_MUSIC_CATALOG_LIMITS.exactArtistCandidatesMax);
|
|
687
|
+
|
|
688
|
+
if (exactCandidates.length === 0) {
|
|
689
|
+
return {
|
|
690
|
+
state: "not_found",
|
|
691
|
+
source,
|
|
692
|
+
query: { artist_name: artist },
|
|
693
|
+
candidates: [],
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (exactCandidates.length === 1) {
|
|
698
|
+
selected = exactCandidates[0];
|
|
699
|
+
selectedReleases = await client.artistReleases(selected.catalog_id);
|
|
700
|
+
selectionBasis = "unique_exact_artist_name";
|
|
701
|
+
} else if (releaseHint) {
|
|
702
|
+
const normalizedHint = normalizeForMatch(releaseHint);
|
|
703
|
+
const matching = [];
|
|
704
|
+
for (const candidate of exactCandidates) {
|
|
705
|
+
const releases = await client.artistReleases(candidate.catalog_id);
|
|
706
|
+
releasesByArtist.set(candidate.catalog_id, releases);
|
|
707
|
+
if (
|
|
708
|
+
releases.some(
|
|
709
|
+
(release) => normalizeForMatch(release.title) === normalizedHint,
|
|
710
|
+
)
|
|
711
|
+
) {
|
|
712
|
+
matching.push(candidate);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
if (matching.length === 1) {
|
|
716
|
+
selected = matching[0];
|
|
717
|
+
selectedReleases =
|
|
718
|
+
releasesByArtist.get(selected.catalog_id) ?? [];
|
|
719
|
+
selectionBasis = "exact_artist_name_and_known_release";
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (!selected) {
|
|
725
|
+
return {
|
|
726
|
+
state: "ambiguous_artist",
|
|
727
|
+
source,
|
|
728
|
+
query: {
|
|
729
|
+
artist_name: artist,
|
|
730
|
+
...(releaseHint ? { known_release: releaseHint } : {}),
|
|
731
|
+
...(explicitCatalogId
|
|
732
|
+
? { artist_catalog_id: explicitCatalogId }
|
|
733
|
+
: {}),
|
|
734
|
+
},
|
|
735
|
+
candidates: exactCandidates.map((candidate) =>
|
|
736
|
+
candidateSummary(
|
|
737
|
+
candidate,
|
|
738
|
+
releasesByArtist.get(candidate.catalog_id) ?? [],
|
|
739
|
+
),
|
|
740
|
+
),
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const split = releasedAndUpcoming(
|
|
745
|
+
selectedReleases,
|
|
746
|
+
source.retrieved_at,
|
|
747
|
+
);
|
|
748
|
+
return {
|
|
749
|
+
state: "resolved",
|
|
750
|
+
source,
|
|
751
|
+
query: {
|
|
752
|
+
artist_name: artist,
|
|
753
|
+
...(releaseHint ? { known_release: releaseHint } : {}),
|
|
754
|
+
...(explicitCatalogId
|
|
755
|
+
? { artist_catalog_id: explicitCatalogId }
|
|
756
|
+
: {}),
|
|
757
|
+
},
|
|
758
|
+
artist: selected,
|
|
759
|
+
selection_basis: selectionBasis,
|
|
760
|
+
releases: split.released.slice(0, limit),
|
|
761
|
+
latest_released_single:
|
|
762
|
+
split.released.find(
|
|
763
|
+
(release) => release.release_type === "single",
|
|
764
|
+
) ?? null,
|
|
765
|
+
upcoming_releases: split.upcoming.slice(0, limit),
|
|
766
|
+
candidates: [],
|
|
767
|
+
};
|
|
768
|
+
},
|
|
769
|
+
});
|
|
770
|
+
}
|