@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,657 @@
|
|
|
1
|
+
import { spotifyResolutionCacheKey } from "./resolution-cache.mjs";
|
|
2
|
+
|
|
3
|
+
export const SPOTIFY_CATALOG_RESOLVER_LIMITS = Object.freeze({
|
|
4
|
+
tracksPerCallMax: 12,
|
|
5
|
+
trackRefIdLengthMax: 128,
|
|
6
|
+
titleLengthMax: 512,
|
|
7
|
+
artistCreditLengthMax: 512,
|
|
8
|
+
releaseLengthMax: 512,
|
|
9
|
+
searchResultsMax: 10,
|
|
10
|
+
durationToleranceMs: 3_000,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const artistSeparators =
|
|
14
|
+
/\s*(?:,|;|&|\/|(?:featuring|feat\.?|ft\.?|with)(?=\s|$)|、|和|与)\s*/iu;
|
|
15
|
+
const bracketedDescriptorPattern = /[((【\[]([^))】\]]+)[))】\]]/gu;
|
|
16
|
+
const trailingDescriptorPattern = /^(.*)\s+(?:-|–)\s+(.+)$/u;
|
|
17
|
+
const materialRecordingTags = new Set([
|
|
18
|
+
"acoustic",
|
|
19
|
+
"clean",
|
|
20
|
+
"demo",
|
|
21
|
+
"edit",
|
|
22
|
+
"extended",
|
|
23
|
+
"instrumental",
|
|
24
|
+
"karaoke",
|
|
25
|
+
"live",
|
|
26
|
+
"mono",
|
|
27
|
+
"radio_edit",
|
|
28
|
+
"rerecorded",
|
|
29
|
+
"remix",
|
|
30
|
+
"session",
|
|
31
|
+
"single_edit",
|
|
32
|
+
"slowed",
|
|
33
|
+
"sped_up",
|
|
34
|
+
"stereo",
|
|
35
|
+
"version",
|
|
36
|
+
]);
|
|
37
|
+
const spotifyTrackIdPattern = /^[A-Za-z0-9]{22}$/u;
|
|
38
|
+
|
|
39
|
+
export class SpotifyCatalogResolverError extends Error {
|
|
40
|
+
constructor(code, message) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "SpotifyCatalogResolverError";
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.provider = "spotify";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function fail(code, message) {
|
|
49
|
+
throw new SpotifyCatalogResolverError(code, message);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isPlainObject(value) {
|
|
53
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
54
|
+
const prototype = Object.getPrototypeOf(value);
|
|
55
|
+
return prototype === Object.prototype || prototype === null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function cleanInputText(value, maximum, code, field) {
|
|
59
|
+
if (
|
|
60
|
+
typeof value !== "string" ||
|
|
61
|
+
Array.from(value).length < 1 ||
|
|
62
|
+
Array.from(value).length > maximum
|
|
63
|
+
) {
|
|
64
|
+
fail(code, `The track ${field} is invalid.`);
|
|
65
|
+
}
|
|
66
|
+
const cleaned = value
|
|
67
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "")
|
|
68
|
+
.replace(/[\u202a-\u202e\u2066-\u2069]/gu, "")
|
|
69
|
+
.replace(/\s+/gu, " ")
|
|
70
|
+
.trim();
|
|
71
|
+
if (!cleaned) fail(code, `The track ${field} is invalid.`);
|
|
72
|
+
return cleaned;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function trustedSpotifyTrackId(track) {
|
|
76
|
+
if (track.external_refs === undefined) return undefined;
|
|
77
|
+
if (track.candidate_scope !== "private_history" || !Array.isArray(track.external_refs)) {
|
|
78
|
+
fail("invalid_tracks", "A trusted Spotify track reference is invalid.");
|
|
79
|
+
}
|
|
80
|
+
const ids = [];
|
|
81
|
+
for (const raw of track.external_refs) {
|
|
82
|
+
if (!isPlainObject(raw) || raw.system !== "spotify") continue;
|
|
83
|
+
if (
|
|
84
|
+
raw.entity_type !== "spotify.track" ||
|
|
85
|
+
typeof raw.external_id !== "string" ||
|
|
86
|
+
!spotifyTrackIdPattern.test(raw.external_id)
|
|
87
|
+
) {
|
|
88
|
+
fail("invalid_tracks", "A trusted Spotify track reference is invalid.");
|
|
89
|
+
}
|
|
90
|
+
if (!ids.includes(raw.external_id)) ids.push(raw.external_id);
|
|
91
|
+
}
|
|
92
|
+
if (ids.length > 1) {
|
|
93
|
+
fail("invalid_tracks", "A trusted Spotify track reference is ambiguous.");
|
|
94
|
+
}
|
|
95
|
+
return ids[0];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function primaryArtistName(artistCredit) {
|
|
99
|
+
const segments = artistCredit
|
|
100
|
+
.split(artistSeparators)
|
|
101
|
+
.map((segment) => segment.trim())
|
|
102
|
+
.filter(Boolean);
|
|
103
|
+
return segments[0] ?? artistCredit.trim();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function normalizeForMatch(value) {
|
|
107
|
+
return String(value)
|
|
108
|
+
.normalize("NFKC")
|
|
109
|
+
.toLocaleLowerCase("und")
|
|
110
|
+
.replace(/["'‘’“”`]/gu, "")
|
|
111
|
+
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
|
112
|
+
.trim();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function descriptorDetails(value) {
|
|
116
|
+
const raw = String(value).trim();
|
|
117
|
+
const normalized = normalizeForMatch(raw);
|
|
118
|
+
const tags = new Set();
|
|
119
|
+
const collaborators = [];
|
|
120
|
+
const remasterYears = new Set();
|
|
121
|
+
let recognized = false;
|
|
122
|
+
|
|
123
|
+
const feature = /^(?:feat(?:uring)?\.?|ft\.?)\s+(.+)$/iu.exec(raw);
|
|
124
|
+
if (feature) {
|
|
125
|
+
recognized = true;
|
|
126
|
+
collaborators.push(feature[1]);
|
|
127
|
+
}
|
|
128
|
+
if (/\b(?:re ?master(?:ed|ing)?|remaster(?:ed|ing)?)\b/u.test(normalized)) {
|
|
129
|
+
recognized = true;
|
|
130
|
+
tags.add("remaster");
|
|
131
|
+
for (const matched of normalized.matchAll(/\b(?:19|20)\d{2}\b/gu)) {
|
|
132
|
+
remasterYears.add(matched[0]);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (
|
|
136
|
+
normalized === "live" ||
|
|
137
|
+
/^(?:live\b|recorded live\b|in concert\b|concert version\b)/u.test(normalized)
|
|
138
|
+
) {
|
|
139
|
+
recognized = true;
|
|
140
|
+
tags.add("live");
|
|
141
|
+
}
|
|
142
|
+
if (/\bradio (?:edit|version|mix)\b/u.test(normalized)) {
|
|
143
|
+
recognized = true;
|
|
144
|
+
tags.add("radio_edit");
|
|
145
|
+
} else if (/\bsingle (?:edit|version|mix)\b/u.test(normalized)) {
|
|
146
|
+
recognized = true;
|
|
147
|
+
tags.add("single_edit");
|
|
148
|
+
} else if (/\bextended (?:edit|version|mix)\b/u.test(normalized)) {
|
|
149
|
+
recognized = true;
|
|
150
|
+
tags.add("extended");
|
|
151
|
+
} else if (/\bedit\b/u.test(normalized)) {
|
|
152
|
+
recognized = true;
|
|
153
|
+
tags.add("edit");
|
|
154
|
+
}
|
|
155
|
+
if (/\bremix(?:ed)?\b/u.test(normalized)) {
|
|
156
|
+
recognized = true;
|
|
157
|
+
tags.add("remix");
|
|
158
|
+
}
|
|
159
|
+
if (/\bacoustic\b/u.test(normalized)) {
|
|
160
|
+
recognized = true;
|
|
161
|
+
tags.add("acoustic");
|
|
162
|
+
}
|
|
163
|
+
if (/\bdemo\b/u.test(normalized)) {
|
|
164
|
+
recognized = true;
|
|
165
|
+
tags.add("demo");
|
|
166
|
+
}
|
|
167
|
+
if (/\binstrumental\b/u.test(normalized)) {
|
|
168
|
+
recognized = true;
|
|
169
|
+
tags.add("instrumental");
|
|
170
|
+
}
|
|
171
|
+
if (/\bkaraoke\b/u.test(normalized)) {
|
|
172
|
+
recognized = true;
|
|
173
|
+
tags.add("karaoke");
|
|
174
|
+
}
|
|
175
|
+
if (/\bmono(?:phonic)?\b/u.test(normalized)) {
|
|
176
|
+
recognized = true;
|
|
177
|
+
tags.add("mono");
|
|
178
|
+
}
|
|
179
|
+
if (/\bstereo(?:phonic)?\b/u.test(normalized)) {
|
|
180
|
+
recognized = true;
|
|
181
|
+
tags.add("stereo");
|
|
182
|
+
}
|
|
183
|
+
if (/\b(?:bbc |studio )?session\b/u.test(normalized)) {
|
|
184
|
+
recognized = true;
|
|
185
|
+
tags.add("session");
|
|
186
|
+
}
|
|
187
|
+
if (/\bre ?record(?:ed|ing)?\b/u.test(normalized)) {
|
|
188
|
+
recognized = true;
|
|
189
|
+
tags.add("rerecorded");
|
|
190
|
+
}
|
|
191
|
+
if (/\bsped up\b/u.test(normalized)) {
|
|
192
|
+
recognized = true;
|
|
193
|
+
tags.add("sped_up");
|
|
194
|
+
}
|
|
195
|
+
if (/\b(?:slowed|slow version)\b/u.test(normalized)) {
|
|
196
|
+
recognized = true;
|
|
197
|
+
tags.add("slowed");
|
|
198
|
+
}
|
|
199
|
+
if (/\bclean(?: version)?\b/u.test(normalized)) {
|
|
200
|
+
recognized = true;
|
|
201
|
+
tags.add("clean");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const neutralVersion = /^(?:album|lp|original) (?:mix|version)$/u.test(normalized);
|
|
205
|
+
const neutralEdition =
|
|
206
|
+
/^(?:(?:deluxe|expanded|anniversary) edition|bonus track)$/u.test(normalized);
|
|
207
|
+
if (neutralVersion || neutralEdition) recognized = true;
|
|
208
|
+
if (
|
|
209
|
+
!neutralVersion &&
|
|
210
|
+
tags.size === 0 &&
|
|
211
|
+
/\bversion\b/u.test(normalized)
|
|
212
|
+
) {
|
|
213
|
+
recognized = true;
|
|
214
|
+
tags.add("version");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { recognized, tags, collaborators, remasterYears };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function mergeDescriptor(target, details) {
|
|
221
|
+
for (const tag of details.tags) target.tags.add(tag);
|
|
222
|
+
for (const collaborator of details.collaborators) {
|
|
223
|
+
target.collaborators.push(collaborator);
|
|
224
|
+
}
|
|
225
|
+
for (const year of details.remasterYears) target.remasterYears.add(year);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function analyzeComparableTitle(value) {
|
|
229
|
+
const target = {
|
|
230
|
+
tags: new Set(),
|
|
231
|
+
collaborators: [],
|
|
232
|
+
remasterYears: new Set(),
|
|
233
|
+
};
|
|
234
|
+
const original = String(value);
|
|
235
|
+
let core = original.replace(
|
|
236
|
+
bracketedDescriptorPattern,
|
|
237
|
+
(whole, descriptor) => {
|
|
238
|
+
const details = descriptorDetails(descriptor);
|
|
239
|
+
if (!details.recognized) return whole;
|
|
240
|
+
mergeDescriptor(target, details);
|
|
241
|
+
return " ";
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
while (true) {
|
|
245
|
+
const matched = trailingDescriptorPattern.exec(core);
|
|
246
|
+
if (!matched) break;
|
|
247
|
+
const details = descriptorDetails(matched[2]);
|
|
248
|
+
if (!details.recognized) break;
|
|
249
|
+
mergeDescriptor(target, details);
|
|
250
|
+
core = matched[1];
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
...target,
|
|
254
|
+
core: normalizeForMatch(core),
|
|
255
|
+
normalized: normalizeForMatch(original),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function coreComparableTitle(title) {
|
|
260
|
+
return analyzeComparableTitle(title).core;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function searchTerm(value) {
|
|
264
|
+
return value.replace(/["'‘’“”`]/gu, "").replace(/\s+/gu, " ").trim();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function fieldedSearchQuery(track) {
|
|
268
|
+
const artist = searchTerm(primaryArtistName(track.artist_credit));
|
|
269
|
+
return `track:"${searchTerm(track.title)}"${artist ? ` artist:"${artist}"` : ""}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function plainSearchQuery(track) {
|
|
273
|
+
const artist = searchTerm(primaryArtistName(track.artist_credit));
|
|
274
|
+
return [searchTerm(track.title), artist].filter(Boolean).join(" ");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function albumMatches(release, album) {
|
|
278
|
+
if (typeof album !== "string" || typeof release !== "string") return false;
|
|
279
|
+
const left = coreComparableTitle(release);
|
|
280
|
+
const right = coreComparableTitle(album);
|
|
281
|
+
return left.length > 0 && left === right;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function durationMatches(expectedMs, actualMs) {
|
|
285
|
+
if (
|
|
286
|
+
!Number.isInteger(expectedMs) ||
|
|
287
|
+
!Number.isInteger(actualMs) ||
|
|
288
|
+
expectedMs < 0 ||
|
|
289
|
+
actualMs < 0
|
|
290
|
+
) {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
return Math.abs(expectedMs - actualMs) <= SPOTIFY_CATALOG_RESOLVER_LIMITS.durationToleranceMs;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function artistNameCompatible(left, right) {
|
|
297
|
+
if (!left || !right) return false;
|
|
298
|
+
return left === right || left.includes(right) || right.includes(left);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function artistParticipants(artistCredits, titleCollaborators = []) {
|
|
302
|
+
const credits = Array.isArray(artistCredits) ? artistCredits : [artistCredits];
|
|
303
|
+
const participants = [];
|
|
304
|
+
for (const credit of [...credits, ...titleCollaborators]) {
|
|
305
|
+
if (typeof credit !== "string") continue;
|
|
306
|
+
for (const segment of credit.split(artistSeparators)) {
|
|
307
|
+
const normalized = normalizeForMatch(segment);
|
|
308
|
+
if (normalized && !participants.includes(normalized)) {
|
|
309
|
+
participants.push(normalized);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return participants;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function artistsCompatible(track, candidate, trackTitle, candidateTitle) {
|
|
317
|
+
const sourcePrimary = normalizeForMatch(primaryArtistName(track.artist_credit));
|
|
318
|
+
const candidatePrimary = normalizeForMatch(candidate.artists?.[0] ?? "");
|
|
319
|
+
if (!artistNameCompatible(sourcePrimary, candidatePrimary)) return false;
|
|
320
|
+
|
|
321
|
+
const source = artistParticipants(
|
|
322
|
+
track.artist_credit,
|
|
323
|
+
trackTitle.collaborators,
|
|
324
|
+
);
|
|
325
|
+
const available = artistParticipants(
|
|
326
|
+
candidate.artists,
|
|
327
|
+
candidateTitle.collaborators,
|
|
328
|
+
);
|
|
329
|
+
if (source.length !== available.length) return false;
|
|
330
|
+
for (const participant of source) {
|
|
331
|
+
let index = available.indexOf(participant);
|
|
332
|
+
if (index < 0) {
|
|
333
|
+
index = available.findIndex((candidateArtist) =>
|
|
334
|
+
artistNameCompatible(participant, candidateArtist),
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
if (index < 0) return false;
|
|
338
|
+
available.splice(index, 1);
|
|
339
|
+
}
|
|
340
|
+
return available.length === 0;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function setEquals(left, right) {
|
|
344
|
+
if (left.size !== right.size) return false;
|
|
345
|
+
return [...left].every((value) => right.has(value));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function recordingIdentity(title, release) {
|
|
349
|
+
const titleDetails = analyzeComparableTitle(title);
|
|
350
|
+
const releaseDetails = analyzeComparableTitle(release ?? "");
|
|
351
|
+
const tags = new Set([...titleDetails.tags, ...releaseDetails.tags]);
|
|
352
|
+
const materialTags = new Set(
|
|
353
|
+
[...tags].filter((tag) => materialRecordingTags.has(tag)),
|
|
354
|
+
);
|
|
355
|
+
return {
|
|
356
|
+
title: titleDetails,
|
|
357
|
+
materialTags,
|
|
358
|
+
remastered: tags.has("remaster"),
|
|
359
|
+
remasterYears: new Set([
|
|
360
|
+
...titleDetails.remasterYears,
|
|
361
|
+
...releaseDetails.remasterYears,
|
|
362
|
+
]),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function remasterYearsConflict(left, right) {
|
|
367
|
+
if (left.size === 0 || right.size === 0) return false;
|
|
368
|
+
return ![...left].some((year) => right.has(year));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function recordingCompatibility(source, candidate, albumMatch, durationMatch) {
|
|
372
|
+
if (!setEquals(source.materialTags, candidate.materialTags)) return null;
|
|
373
|
+
if (
|
|
374
|
+
source.remastered &&
|
|
375
|
+
candidate.remastered &&
|
|
376
|
+
remasterYearsConflict(source.remasterYears, candidate.remasterYears)
|
|
377
|
+
) {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const titleExact = source.title.normalized === candidate.title.normalized;
|
|
382
|
+
const corroborated = albumMatch || durationMatch;
|
|
383
|
+
if (source.remastered !== candidate.remastered) {
|
|
384
|
+
if (!corroborated) return null;
|
|
385
|
+
return { rank: 1, quality: "alternate_master" };
|
|
386
|
+
}
|
|
387
|
+
if (
|
|
388
|
+
(source.materialTags.size > 0 || source.remastered) &&
|
|
389
|
+
!titleExact &&
|
|
390
|
+
!corroborated
|
|
391
|
+
) {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
rank: 2,
|
|
396
|
+
quality: corroborated ? "exact" : "standard",
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function bestCandidate(track, candidates) {
|
|
401
|
+
const eligible = [];
|
|
402
|
+
const sourceIdentity = recordingIdentity(track.title, track.release);
|
|
403
|
+
for (const candidate of candidates) {
|
|
404
|
+
if (!isPlainObject(candidate) || typeof candidate.uri !== "string") continue;
|
|
405
|
+
const candidateIdentity = recordingIdentity(
|
|
406
|
+
candidate.name ?? "",
|
|
407
|
+
candidate.album,
|
|
408
|
+
);
|
|
409
|
+
const titleMatch = candidateIdentity.title.core === sourceIdentity.title.core;
|
|
410
|
+
if (!titleMatch) continue;
|
|
411
|
+
if (
|
|
412
|
+
!artistsCompatible(
|
|
413
|
+
track,
|
|
414
|
+
candidate,
|
|
415
|
+
sourceIdentity.title,
|
|
416
|
+
candidateIdentity.title,
|
|
417
|
+
)
|
|
418
|
+
) {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
const albumMatch = albumMatches(track.release, candidate.album);
|
|
422
|
+
const durationMatch = durationMatches(
|
|
423
|
+
track.duration_ms,
|
|
424
|
+
candidate.duration_ms,
|
|
425
|
+
);
|
|
426
|
+
const recordingMatch = recordingCompatibility(
|
|
427
|
+
sourceIdentity,
|
|
428
|
+
candidateIdentity,
|
|
429
|
+
albumMatch,
|
|
430
|
+
durationMatch,
|
|
431
|
+
);
|
|
432
|
+
if (!recordingMatch) continue;
|
|
433
|
+
eligible.push({
|
|
434
|
+
candidate,
|
|
435
|
+
albumMatch,
|
|
436
|
+
durationMatch,
|
|
437
|
+
recordingRank: recordingMatch.rank,
|
|
438
|
+
quality: recordingMatch.quality,
|
|
439
|
+
score: (albumMatch ? 2 : 0) + (durationMatch ? 1 : 0),
|
|
440
|
+
popularity: Number.isInteger(candidate.popularity)
|
|
441
|
+
? candidate.popularity
|
|
442
|
+
: 0,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
if (eligible.length === 0) return null;
|
|
446
|
+
eligible.sort((left, right) => {
|
|
447
|
+
if (right.recordingRank !== left.recordingRank) {
|
|
448
|
+
return right.recordingRank - left.recordingRank;
|
|
449
|
+
}
|
|
450
|
+
if (right.score !== left.score) return right.score - left.score;
|
|
451
|
+
if (right.popularity !== left.popularity) {
|
|
452
|
+
return right.popularity - left.popularity;
|
|
453
|
+
}
|
|
454
|
+
return left.candidate.uri < right.candidate.uri
|
|
455
|
+
? -1
|
|
456
|
+
: left.candidate.uri > right.candidate.uri
|
|
457
|
+
? 1
|
|
458
|
+
: 0;
|
|
459
|
+
});
|
|
460
|
+
const winner = eligible[0];
|
|
461
|
+
return {
|
|
462
|
+
candidate: winner.candidate,
|
|
463
|
+
quality: winner.quality,
|
|
464
|
+
recordingRank: winner.recordingRank,
|
|
465
|
+
score: winner.score,
|
|
466
|
+
popularity: winner.popularity,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function entryFromMatch(match) {
|
|
471
|
+
const matched = {
|
|
472
|
+
title: match.candidate.name,
|
|
473
|
+
artists: match.candidate.artists ?? [],
|
|
474
|
+
};
|
|
475
|
+
if (typeof match.candidate.album === "string") {
|
|
476
|
+
matched.album = match.candidate.album;
|
|
477
|
+
}
|
|
478
|
+
if (Number.isInteger(match.candidate.duration_ms)) {
|
|
479
|
+
matched.duration_ms = match.candidate.duration_ms;
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
status: "resolved",
|
|
483
|
+
match_quality: match.quality,
|
|
484
|
+
matched,
|
|
485
|
+
spotify: {
|
|
486
|
+
track_id: match.candidate.id,
|
|
487
|
+
uri: match.candidate.uri,
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function entryFromProviderId(track) {
|
|
493
|
+
const matched = {
|
|
494
|
+
title: track.title,
|
|
495
|
+
artists: [track.artist_credit],
|
|
496
|
+
album: track.release,
|
|
497
|
+
};
|
|
498
|
+
if (Number.isInteger(track.duration_ms)) {
|
|
499
|
+
matched.duration_ms = track.duration_ms;
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
status: "resolved",
|
|
503
|
+
match_quality: "provider_id",
|
|
504
|
+
matched,
|
|
505
|
+
spotify: {
|
|
506
|
+
track_id: track.spotify_external_id,
|
|
507
|
+
uri: `spotify:track:${track.spotify_external_id}`,
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function resolutionKey(track) {
|
|
513
|
+
return track.spotify_external_id
|
|
514
|
+
? `spotify-id:${track.spotify_external_id}`
|
|
515
|
+
: spotifyResolutionCacheKey(track);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function createSpotifyCatalogResolver({ client, cache = null, now = Date.now } = {}) {
|
|
519
|
+
if (!client || typeof client !== "object") {
|
|
520
|
+
throw new TypeError("A Spotify Web API client is required.");
|
|
521
|
+
}
|
|
522
|
+
if (cache !== null && (typeof cache.get !== "function" || typeof cache.put !== "function")) {
|
|
523
|
+
throw new TypeError("The Spotify resolution cache interface is invalid.");
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function searchAndMatch(track) {
|
|
527
|
+
const queries = [
|
|
528
|
+
fieldedSearchQuery(track),
|
|
529
|
+
plainSearchQuery(track),
|
|
530
|
+
];
|
|
531
|
+
let fallback = null;
|
|
532
|
+
for (const query of queries) {
|
|
533
|
+
const result = await client.searchTracks({
|
|
534
|
+
query,
|
|
535
|
+
limit: SPOTIFY_CATALOG_RESOLVER_LIMITS.searchResultsMax,
|
|
536
|
+
});
|
|
537
|
+
const items = Array.isArray(result?.items) ? result.items : [];
|
|
538
|
+
const match = bestCandidate(track, items);
|
|
539
|
+
if (!match) continue;
|
|
540
|
+
if (match.recordingRank > 1) return entryFromMatch(match);
|
|
541
|
+
if (
|
|
542
|
+
!fallback ||
|
|
543
|
+
match.score > fallback.score ||
|
|
544
|
+
(match.score === fallback.score && match.popularity > fallback.popularity) ||
|
|
545
|
+
(match.score === fallback.score &&
|
|
546
|
+
match.popularity === fallback.popularity &&
|
|
547
|
+
match.candidate.uri < fallback.candidate.uri)
|
|
548
|
+
) {
|
|
549
|
+
fallback = match;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (fallback) return entryFromMatch(fallback);
|
|
553
|
+
return { status: "not_found" };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return Object.freeze({
|
|
557
|
+
async resolve(tracks) {
|
|
558
|
+
if (!Array.isArray(tracks) || tracks.length < 1) {
|
|
559
|
+
fail("invalid_tracks", "At least one track is required for resolution.");
|
|
560
|
+
}
|
|
561
|
+
if (tracks.length > SPOTIFY_CATALOG_RESOLVER_LIMITS.tracksPerCallMax) {
|
|
562
|
+
fail("invalid_tracks", "Too many tracks were submitted for resolution.");
|
|
563
|
+
}
|
|
564
|
+
const seenRefIds = new Set();
|
|
565
|
+
const prepared = tracks.map((track) => {
|
|
566
|
+
if (!isPlainObject(track)) {
|
|
567
|
+
fail("invalid_tracks", "A resolution track is invalid.");
|
|
568
|
+
}
|
|
569
|
+
const trackRefId = cleanInputText(
|
|
570
|
+
track.track_ref_id,
|
|
571
|
+
SPOTIFY_CATALOG_RESOLVER_LIMITS.trackRefIdLengthMax,
|
|
572
|
+
"invalid_track_ref",
|
|
573
|
+
"reference ID",
|
|
574
|
+
);
|
|
575
|
+
if (seenRefIds.has(trackRefId)) {
|
|
576
|
+
fail("invalid_tracks", "Duplicate track references cannot be resolved.");
|
|
577
|
+
}
|
|
578
|
+
seenRefIds.add(trackRefId);
|
|
579
|
+
const preparedTrack = {
|
|
580
|
+
track_ref_id: trackRefId,
|
|
581
|
+
title: cleanInputText(
|
|
582
|
+
track.title,
|
|
583
|
+
SPOTIFY_CATALOG_RESOLVER_LIMITS.titleLengthMax,
|
|
584
|
+
"invalid_track_title",
|
|
585
|
+
"title",
|
|
586
|
+
),
|
|
587
|
+
artist_credit: cleanInputText(
|
|
588
|
+
track.artist_credit,
|
|
589
|
+
SPOTIFY_CATALOG_RESOLVER_LIMITS.artistCreditLengthMax,
|
|
590
|
+
"invalid_track_artist",
|
|
591
|
+
"artist credit",
|
|
592
|
+
),
|
|
593
|
+
release: cleanInputText(
|
|
594
|
+
track.release,
|
|
595
|
+
SPOTIFY_CATALOG_RESOLVER_LIMITS.releaseLengthMax,
|
|
596
|
+
"invalid_track_release",
|
|
597
|
+
"release",
|
|
598
|
+
),
|
|
599
|
+
};
|
|
600
|
+
if (
|
|
601
|
+
Number.isInteger(track.duration_ms) &&
|
|
602
|
+
track.duration_ms >= 0 &&
|
|
603
|
+
track.duration_ms <= 86_400_000
|
|
604
|
+
) {
|
|
605
|
+
preparedTrack.duration_ms = track.duration_ms;
|
|
606
|
+
}
|
|
607
|
+
const spotifyExternalId = trustedSpotifyTrackId(track);
|
|
608
|
+
if (spotifyExternalId) {
|
|
609
|
+
preparedTrack.spotify_external_id = spotifyExternalId;
|
|
610
|
+
}
|
|
611
|
+
return preparedTrack;
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const uniqueKeys = new Map();
|
|
615
|
+
for (const track of prepared) {
|
|
616
|
+
const cacheKey = resolutionKey(track);
|
|
617
|
+
if (!uniqueKeys.has(cacheKey)) uniqueKeys.set(cacheKey, track);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const entriesByKey = new Map();
|
|
621
|
+
await Promise.all(
|
|
622
|
+
[...uniqueKeys.entries()].map(async ([cacheKey, track]) => {
|
|
623
|
+
if (track.spotify_external_id) {
|
|
624
|
+
entriesByKey.set(cacheKey, entryFromProviderId(track));
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
let entry = cache ? await cache.get(cacheKey) : null;
|
|
628
|
+
if (!entry) {
|
|
629
|
+
entry = await searchAndMatch(track);
|
|
630
|
+
if (entry.status === "resolved" && cache) {
|
|
631
|
+
await cache.put(cacheKey, entry, now());
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
entriesByKey.set(cacheKey, entry);
|
|
635
|
+
}),
|
|
636
|
+
);
|
|
637
|
+
|
|
638
|
+
const resolutions = prepared.map((track) => {
|
|
639
|
+
const entry = entriesByKey.get(resolutionKey(track));
|
|
640
|
+
return {
|
|
641
|
+
track_ref_id: track.track_ref_id,
|
|
642
|
+
...structuredClone(entry ?? { status: "not_found" }),
|
|
643
|
+
};
|
|
644
|
+
});
|
|
645
|
+
return {
|
|
646
|
+
provider: "spotify",
|
|
647
|
+
resolutions,
|
|
648
|
+
resolved_count: resolutions.filter(
|
|
649
|
+
(resolution) => resolution.status === "resolved",
|
|
650
|
+
).length,
|
|
651
|
+
not_found_count: resolutions.filter(
|
|
652
|
+
(resolution) => resolution.status !== "resolved",
|
|
653
|
+
).length,
|
|
654
|
+
};
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
}
|