@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,357 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
import { parse } from "plist";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_APPLE_LIBRARY_LIMITS = Object.freeze({
|
|
7
|
+
maxBytes: 64 * 1024 * 1024,
|
|
8
|
+
maxNodes: 2_000_000,
|
|
9
|
+
maxDepth: 64,
|
|
10
|
+
maxTextBytes: 1024 * 1024,
|
|
11
|
+
maxTracks: 100_000,
|
|
12
|
+
maxPlaylists: 10_000,
|
|
13
|
+
maxPlaylistItems: 1_000_000,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const standardAppleDoctype =
|
|
17
|
+
/<!DOCTYPE\s+plist\s+PUBLIC\s+["']-\/\/Apple(?:\s+Computer)?\/\/DTD\s+PLIST\s+1\.0\/\/EN["']\s+["']http:\/\/www\.apple\.com\/DTDs\/PropertyList-1\.0\.dtd["']\s*>/i;
|
|
18
|
+
|
|
19
|
+
export class AppleMusicImportError extends Error {
|
|
20
|
+
constructor(code, message, options) {
|
|
21
|
+
super(message, options);
|
|
22
|
+
this.name = "AppleMusicImportError";
|
|
23
|
+
this.code = code;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fail(code, message, options) {
|
|
28
|
+
throw new AppleMusicImportError(code, message, options);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isPlainObject(value) {
|
|
32
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
33
|
+
const prototype = Object.getPrototypeOf(value);
|
|
34
|
+
return prototype === Object.prototype || prototype === null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mergedLimits(overrides = {}) {
|
|
38
|
+
const limits = { ...DEFAULT_APPLE_LIBRARY_LIMITS, ...overrides };
|
|
39
|
+
for (const [name, value] of Object.entries(limits)) {
|
|
40
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
41
|
+
throw new TypeError(`Apple Music parser limit ${name} must be positive`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return limits;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function decodeXmlKey(source) {
|
|
48
|
+
const decoded = source.replace(
|
|
49
|
+
/&(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-f]+);/gi,
|
|
50
|
+
(entity) => {
|
|
51
|
+
const body = entity.slice(1, -1).toLowerCase();
|
|
52
|
+
const named = new Map([
|
|
53
|
+
["amp", "&"],
|
|
54
|
+
["lt", "<"],
|
|
55
|
+
["gt", ">"],
|
|
56
|
+
["quot", '"'],
|
|
57
|
+
["apos", "'"],
|
|
58
|
+
]);
|
|
59
|
+
if (named.has(body)) return named.get(body);
|
|
60
|
+
const codePoint = body.startsWith("#x")
|
|
61
|
+
? Number.parseInt(body.slice(2), 16)
|
|
62
|
+
: Number.parseInt(body.slice(1), 10);
|
|
63
|
+
if (!Number.isInteger(codePoint) || codePoint > 0x10ffff) {
|
|
64
|
+
fail("xml_invalid_key_entity", "XML key contains an invalid entity");
|
|
65
|
+
}
|
|
66
|
+
return String.fromCodePoint(codePoint);
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
if (/&[^;\s]+;/.test(decoded)) {
|
|
71
|
+
fail("xml_unsupported_key_entity", "XML key contains an unsupported entity");
|
|
72
|
+
}
|
|
73
|
+
return decoded;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function stripAndValidateMarkup(xml) {
|
|
77
|
+
if (/<!ENTITY\b/i.test(xml)) {
|
|
78
|
+
fail("xml_entity_forbidden", "Custom XML entities are not allowed");
|
|
79
|
+
}
|
|
80
|
+
if (/<!\[CDATA\[/i.test(xml)) {
|
|
81
|
+
fail("xml_cdata_unsupported", "CDATA sections are not supported");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const withoutStandardDoctype = xml.replace(standardAppleDoctype, "");
|
|
85
|
+
if (/<!DOCTYPE\b/i.test(withoutStandardDoctype)) {
|
|
86
|
+
fail("xml_doctype_forbidden", "Only the standard Apple plist doctype is allowed");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const commentStarts = withoutStandardDoctype.match(/<!--/g)?.length ?? 0;
|
|
90
|
+
const commentEnds = withoutStandardDoctype.match(/-->/g)?.length ?? 0;
|
|
91
|
+
if (commentStarts !== commentEnds) {
|
|
92
|
+
fail("xml_malformed_comment", "XML contains an unclosed comment");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return withoutStandardDoctype.replace(/<!--[\s\S]*?-->/g, "");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function validateDocumentEnvelope(xml) {
|
|
99
|
+
const declaration =
|
|
100
|
+
/^\s*<\?xml\s+version=["']1\.0["'](?:\s+encoding=["']UTF-8["'])?\s*\?>/i;
|
|
101
|
+
const declarationMatch = xml.match(declaration);
|
|
102
|
+
if (!declarationMatch && /<\?xml\b/i.test(xml)) {
|
|
103
|
+
fail(
|
|
104
|
+
"plist_envelope_invalid",
|
|
105
|
+
"XML declaration must appear before the plist document",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const withoutDeclaration = declarationMatch
|
|
109
|
+
? xml.slice(declarationMatch[0].length)
|
|
110
|
+
: xml;
|
|
111
|
+
if (/<\?xml\b/i.test(withoutDeclaration) || /<\?(?!xml\b)/i.test(xml)) {
|
|
112
|
+
fail(
|
|
113
|
+
"xml_processing_instruction_forbidden",
|
|
114
|
+
"XML contains an unsupported processing instruction",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const openings = [...withoutDeclaration.matchAll(/<plist\b[^>]*>/gi)];
|
|
119
|
+
const closings = [...withoutDeclaration.matchAll(/<\/plist\s*>/gi)];
|
|
120
|
+
if (openings.length !== 1 || closings.length !== 1) {
|
|
121
|
+
fail("plist_envelope_invalid", "XML must contain exactly one plist document");
|
|
122
|
+
}
|
|
123
|
+
if (
|
|
124
|
+
!/^<plist(?:\s+version=["']1\.0["'])?\s*>$/i.test(openings[0][0])
|
|
125
|
+
) {
|
|
126
|
+
fail("plist_element_invalid", "plist root attributes are not supported");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const before = withoutDeclaration.slice(0, openings[0].index);
|
|
130
|
+
const closingEnd = closings[0].index + closings[0][0].length;
|
|
131
|
+
const after = withoutDeclaration.slice(closingEnd);
|
|
132
|
+
if (before.trim() !== "" || after.trim() !== "") {
|
|
133
|
+
fail(
|
|
134
|
+
"plist_envelope_invalid",
|
|
135
|
+
"XML cannot contain content outside the plist document",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function preflightStructure(xml, limits) {
|
|
141
|
+
for (const match of xml.matchAll(
|
|
142
|
+
/<(?!\/)(dict|array|key|string|integer|real|date|data|true|false)\b([^>]*)>/gi,
|
|
143
|
+
)) {
|
|
144
|
+
const tagName = match[1].toLowerCase();
|
|
145
|
+
const suffix = match[2].trim();
|
|
146
|
+
if (suffix !== "" && suffix !== "/") {
|
|
147
|
+
fail("xml_tag_attributes_forbidden", "plist value tags cannot have attributes");
|
|
148
|
+
}
|
|
149
|
+
if (tagName === "key" && suffix === "/") {
|
|
150
|
+
fail("xml_empty_key_forbidden", "Empty plist dictionary keys are not allowed");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const nodeMatches = xml.match(
|
|
155
|
+
/<(?:dict|array|key|string|integer|real|date|data|true|false)\b/gi,
|
|
156
|
+
);
|
|
157
|
+
if ((nodeMatches?.length ?? 0) > limits.maxNodes) {
|
|
158
|
+
fail("xml_node_limit", "XML exceeds the configured node limit");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (const tagName of ["key", "string", "data", "integer", "real", "date"]) {
|
|
162
|
+
const pattern = new RegExp(
|
|
163
|
+
`<${tagName}\\s*>([\\s\\S]*?)<\\/${tagName}\\s*>`,
|
|
164
|
+
"gi",
|
|
165
|
+
);
|
|
166
|
+
for (const match of xml.matchAll(pattern)) {
|
|
167
|
+
if (Buffer.byteLength(match[1], "utf8") > limits.maxTextBytes) {
|
|
168
|
+
fail("xml_text_limit", "XML contains a text value above the size limit");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const stack = [];
|
|
174
|
+
const tokenPattern =
|
|
175
|
+
/<(dict|array)\s*>|<\/(dict|array)\s*>|<key\s*>([\s\S]*?)<\/key\s*>/gi;
|
|
176
|
+
|
|
177
|
+
for (const match of xml.matchAll(tokenPattern)) {
|
|
178
|
+
if (match[1]) {
|
|
179
|
+
stack.push({ type: match[1].toLowerCase(), keys: new Set() });
|
|
180
|
+
if (stack.length > limits.maxDepth) {
|
|
181
|
+
fail("xml_depth_limit", "XML exceeds the configured nesting depth");
|
|
182
|
+
}
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (match[2]) {
|
|
187
|
+
const expected = match[2].toLowerCase();
|
|
188
|
+
const current = stack.pop();
|
|
189
|
+
if (!current || current.type !== expected) {
|
|
190
|
+
fail("xml_structure_invalid", "XML containers are not balanced");
|
|
191
|
+
}
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const current = stack.at(-1);
|
|
196
|
+
if (!current || current.type !== "dict") {
|
|
197
|
+
fail("xml_key_outside_dict", "XML key appears outside a dictionary");
|
|
198
|
+
}
|
|
199
|
+
const key = decodeXmlKey(match[3]);
|
|
200
|
+
if (current.keys.has(key)) {
|
|
201
|
+
fail("xml_duplicate_dict_key", "XML contains a duplicate dictionary key");
|
|
202
|
+
}
|
|
203
|
+
current.keys.add(key);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (stack.length !== 0) {
|
|
207
|
+
fail("xml_structure_invalid", "XML containers are not balanced");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isoTimestamp(value, fieldName) {
|
|
212
|
+
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
|
|
213
|
+
fail("plist_required_date_invalid", `${fieldName} must be a valid plist date`);
|
|
214
|
+
}
|
|
215
|
+
return value.toISOString();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function validateParsedRoot(root, limits) {
|
|
219
|
+
if (!isPlainObject(root)) {
|
|
220
|
+
fail("plist_root_invalid", "Apple Music plist root must be a dictionary");
|
|
221
|
+
}
|
|
222
|
+
if (!isPlainObject(root.Tracks)) {
|
|
223
|
+
fail("plist_tracks_invalid", "Apple Music plist must contain a Tracks dictionary");
|
|
224
|
+
}
|
|
225
|
+
if (root.Playlists !== undefined && !Array.isArray(root.Playlists)) {
|
|
226
|
+
fail("plist_playlists_invalid", "Apple Music Playlists must be an array");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const trackCount = Object.keys(root.Tracks).length;
|
|
230
|
+
const playlists = root.Playlists ?? [];
|
|
231
|
+
if (trackCount > limits.maxTracks) {
|
|
232
|
+
fail("plist_track_limit", "Apple Music library exceeds the track limit");
|
|
233
|
+
}
|
|
234
|
+
if (playlists.length > limits.maxPlaylists) {
|
|
235
|
+
fail("plist_playlist_limit", "Apple Music library exceeds the playlist limit");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let playlistItemCount = 0;
|
|
239
|
+
for (const playlist of playlists) {
|
|
240
|
+
if (!isPlainObject(playlist)) {
|
|
241
|
+
fail("plist_playlist_invalid", "Apple Music playlist entry must be a dictionary");
|
|
242
|
+
}
|
|
243
|
+
const items = playlist["Playlist Items"];
|
|
244
|
+
if (items !== undefined && !Array.isArray(items)) {
|
|
245
|
+
fail("plist_playlist_items_invalid", "Playlist Items must be an array");
|
|
246
|
+
}
|
|
247
|
+
playlistItemCount += items?.length ?? 0;
|
|
248
|
+
if (playlistItemCount > limits.maxPlaylistItems) {
|
|
249
|
+
fail(
|
|
250
|
+
"plist_playlist_item_limit",
|
|
251
|
+
"Apple Music library exceeds the playlist item limit",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const libraryPersistentId = root["Library Persistent ID"];
|
|
257
|
+
if (
|
|
258
|
+
libraryPersistentId !== undefined &&
|
|
259
|
+
(typeof libraryPersistentId !== "string" ||
|
|
260
|
+
libraryPersistentId.trim() === "")
|
|
261
|
+
) {
|
|
262
|
+
fail(
|
|
263
|
+
"plist_library_identity_invalid",
|
|
264
|
+
"Library Persistent ID must be a non-empty string when present",
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
capturedAt: isoTimestamp(root.Date, "Apple Music export Date"),
|
|
270
|
+
libraryPersistentId: libraryPersistentId?.trim(),
|
|
271
|
+
trackCount,
|
|
272
|
+
playlistCount: playlists.length,
|
|
273
|
+
playlistItemCount,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function parseAppleMusicLibraryBuffer(buffer, options = {}) {
|
|
278
|
+
if (!Buffer.isBuffer(buffer)) {
|
|
279
|
+
throw new TypeError("Apple Music XML input must be a Buffer");
|
|
280
|
+
}
|
|
281
|
+
const limits = mergedLimits(options.limits);
|
|
282
|
+
if (buffer.length > limits.maxBytes) {
|
|
283
|
+
fail("input_byte_limit", "Apple Music XML exceeds the configured byte limit");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let xml;
|
|
287
|
+
try {
|
|
288
|
+
xml = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
|
289
|
+
} catch (cause) {
|
|
290
|
+
fail("input_encoding_invalid", "Apple Music XML must be valid UTF-8", {
|
|
291
|
+
cause,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
if (xml.codePointAt(0) === 0xfeff) xml = xml.slice(1);
|
|
295
|
+
if (xml.includes("\0")) {
|
|
296
|
+
fail("input_nul_forbidden", "Apple Music XML cannot contain NUL bytes");
|
|
297
|
+
}
|
|
298
|
+
if (!/<plist(?:\s[^>]*)?>/i.test(xml)) {
|
|
299
|
+
fail("plist_element_missing", "Input does not contain a plist root element");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const sanitizedXml = stripAndValidateMarkup(xml);
|
|
303
|
+
validateDocumentEnvelope(sanitizedXml);
|
|
304
|
+
preflightStructure(sanitizedXml, limits);
|
|
305
|
+
|
|
306
|
+
let root;
|
|
307
|
+
let parserDiagnostic = false;
|
|
308
|
+
const originalError = console.error;
|
|
309
|
+
const originalWarn = console.warn;
|
|
310
|
+
console.error = () => {
|
|
311
|
+
parserDiagnostic = true;
|
|
312
|
+
};
|
|
313
|
+
console.warn = () => {
|
|
314
|
+
parserDiagnostic = true;
|
|
315
|
+
};
|
|
316
|
+
try {
|
|
317
|
+
root = parse(sanitizedXml);
|
|
318
|
+
} catch {
|
|
319
|
+
fail("plist_parse_failed", "Apple Music XML could not be parsed");
|
|
320
|
+
} finally {
|
|
321
|
+
console.error = originalError;
|
|
322
|
+
console.warn = originalWarn;
|
|
323
|
+
}
|
|
324
|
+
if (parserDiagnostic) {
|
|
325
|
+
fail("plist_parse_diagnostic", "Apple Music XML contains invalid markup");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const rootSummary = validateParsedRoot(root, limits);
|
|
329
|
+
return {
|
|
330
|
+
root,
|
|
331
|
+
sourceSha256: createHash("sha256").update(buffer).digest("hex"),
|
|
332
|
+
sourceBytes: buffer.length,
|
|
333
|
+
...rootSummary,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function readAppleMusicLibrary(inputPath, options = {}) {
|
|
338
|
+
if (typeof inputPath !== "string" || inputPath.trim() === "") {
|
|
339
|
+
throw new TypeError("Apple Music XML input path is required");
|
|
340
|
+
}
|
|
341
|
+
const limits = mergedLimits(options.limits);
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
const metadata = await stat(inputPath);
|
|
345
|
+
if (!metadata.isFile()) {
|
|
346
|
+
fail("input_not_file", "Apple Music XML input must be a regular file");
|
|
347
|
+
}
|
|
348
|
+
if (metadata.size > limits.maxBytes) {
|
|
349
|
+
fail("input_byte_limit", "Apple Music XML exceeds the configured byte limit");
|
|
350
|
+
}
|
|
351
|
+
const buffer = await readFile(inputPath);
|
|
352
|
+
return parseAppleMusicLibraryBuffer(buffer, { limits });
|
|
353
|
+
} catch (cause) {
|
|
354
|
+
if (cause instanceof AppleMusicImportError) throw cause;
|
|
355
|
+
fail("input_unreadable", "Apple Music XML input could not be read", { cause });
|
|
356
|
+
}
|
|
357
|
+
}
|