@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,1126 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
assertRequiredPropertiesDeclared,
|
|
5
|
+
contractSchemaIds,
|
|
6
|
+
createContractValidator,
|
|
7
|
+
formatValidationErrors,
|
|
8
|
+
} from "../../../scripts/contract-lib.mjs";
|
|
9
|
+
import {
|
|
10
|
+
canonicalizeJson,
|
|
11
|
+
findUnsafePersistedData,
|
|
12
|
+
sha256Hex,
|
|
13
|
+
validateTrackRefSemantics,
|
|
14
|
+
} from "../../../scripts/contract-semantics.mjs";
|
|
15
|
+
import { AppleMusicImportError } from "./parse-plist.mjs";
|
|
16
|
+
import {
|
|
17
|
+
APPLE_LIBRARY_BATCH_NAMESPACE,
|
|
18
|
+
APPLE_LIBRARY_SNAPSHOT_NAMESPACE,
|
|
19
|
+
APPLE_LIBRARY_TRACK_NAMESPACE,
|
|
20
|
+
APPLE_MUSIC_LIBRARY_IMPORTER_NAME,
|
|
21
|
+
APPLE_MUSIC_LIBRARY_IMPORTER_VERSION,
|
|
22
|
+
isUuid,
|
|
23
|
+
uuidV5,
|
|
24
|
+
} from "./stable-ids.mjs";
|
|
25
|
+
|
|
26
|
+
const snapshotSchema = JSON.parse(
|
|
27
|
+
await readFile(new URL("./track-snapshot.schema.json", import.meta.url), "utf8"),
|
|
28
|
+
);
|
|
29
|
+
assertRequiredPropertiesDeclared(snapshotSchema, "track-snapshot.schema.json#");
|
|
30
|
+
|
|
31
|
+
let validatorPromise;
|
|
32
|
+
|
|
33
|
+
async function validators() {
|
|
34
|
+
validatorPromise ??= createContractValidator().then(({ ajv }) => {
|
|
35
|
+
ajv.addSchema(snapshotSchema);
|
|
36
|
+
return {
|
|
37
|
+
trackRef: ajv.getSchema(contractSchemaIds.get("track-ref")),
|
|
38
|
+
snapshot: ajv.getSchema(snapshotSchema.$id),
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
return validatorPromise;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fail(code, message) {
|
|
45
|
+
throw new AppleMusicImportError(code, message);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isPlainObject(value) {
|
|
49
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
50
|
+
const prototype = Object.getPrototypeOf(value);
|
|
51
|
+
return prototype === Object.prototype || prototype === null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sourceKeyOrder([left], [right]) {
|
|
55
|
+
if (/^\d+$/.test(left) && /^\d+$/.test(right)) {
|
|
56
|
+
const leftNumber = BigInt(left);
|
|
57
|
+
const rightNumber = BigInt(right);
|
|
58
|
+
if (leftNumber < rightNumber) return -1;
|
|
59
|
+
if (leftNumber > rightNumber) return 1;
|
|
60
|
+
}
|
|
61
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function cleanText(value, field, warnings, sourceRecordKeySha256, maxLength = 512) {
|
|
65
|
+
if (value === undefined) return undefined;
|
|
66
|
+
if (typeof value !== "string") {
|
|
67
|
+
warnings.push({
|
|
68
|
+
code: "field_type_invalid",
|
|
69
|
+
scope: "track",
|
|
70
|
+
field,
|
|
71
|
+
source_record_key_sha256: sourceRecordKeySha256,
|
|
72
|
+
});
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
const cleaned = value.trim();
|
|
76
|
+
if (cleaned === "") return undefined;
|
|
77
|
+
if (Array.from(cleaned).length > maxLength) {
|
|
78
|
+
warnings.push({
|
|
79
|
+
code: "field_length_invalid",
|
|
80
|
+
scope: "track",
|
|
81
|
+
field,
|
|
82
|
+
source_record_key_sha256: sourceRecordKeySha256,
|
|
83
|
+
});
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
return cleaned;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function displayLabel(title, artist, fallback, warnings, sourceRecordKeySha256) {
|
|
90
|
+
const label = title && artist ? `${title} - ${artist}` : title ?? artist ?? fallback;
|
|
91
|
+
const codePoints = Array.from(label);
|
|
92
|
+
if (codePoints.length <= 512) return label;
|
|
93
|
+
warnings.push({
|
|
94
|
+
code: "display_label_truncated",
|
|
95
|
+
scope: "track",
|
|
96
|
+
field: "display_label",
|
|
97
|
+
source_record_key_sha256: sourceRecordKeySha256,
|
|
98
|
+
});
|
|
99
|
+
return codePoints.slice(0, 512).join("");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function nonnegativeInteger(value, field, warnings, sourceRecordKeySha256) {
|
|
103
|
+
if (value === undefined) return undefined;
|
|
104
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
105
|
+
warnings.push({
|
|
106
|
+
code: "field_value_invalid",
|
|
107
|
+
scope: "track",
|
|
108
|
+
field,
|
|
109
|
+
source_record_key_sha256: sourceRecordKeySha256,
|
|
110
|
+
});
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function boundedInteger(
|
|
117
|
+
value,
|
|
118
|
+
field,
|
|
119
|
+
warnings,
|
|
120
|
+
sourceRecordKeySha256,
|
|
121
|
+
minimum,
|
|
122
|
+
maximum,
|
|
123
|
+
) {
|
|
124
|
+
const parsed = nonnegativeInteger(
|
|
125
|
+
value,
|
|
126
|
+
field,
|
|
127
|
+
warnings,
|
|
128
|
+
sourceRecordKeySha256,
|
|
129
|
+
);
|
|
130
|
+
if (parsed === undefined) return undefined;
|
|
131
|
+
if (parsed < minimum || parsed > maximum) {
|
|
132
|
+
warnings.push({
|
|
133
|
+
code: "field_value_out_of_range",
|
|
134
|
+
scope: "track",
|
|
135
|
+
field,
|
|
136
|
+
source_record_key_sha256: sourceRecordKeySha256,
|
|
137
|
+
});
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
return parsed;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function optionalBoolean(value, field, warnings, sourceRecordKeySha256) {
|
|
144
|
+
if (value === undefined) return undefined;
|
|
145
|
+
if (typeof value !== "boolean") {
|
|
146
|
+
warnings.push({
|
|
147
|
+
code: "field_type_invalid",
|
|
148
|
+
scope: "track",
|
|
149
|
+
field,
|
|
150
|
+
source_record_key_sha256: sourceRecordKeySha256,
|
|
151
|
+
});
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function optionalTimestamp(value, field, warnings, sourceRecordKeySha256) {
|
|
158
|
+
if (value === undefined) return undefined;
|
|
159
|
+
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
|
|
160
|
+
warnings.push({
|
|
161
|
+
code: "field_date_invalid",
|
|
162
|
+
scope: "track",
|
|
163
|
+
field,
|
|
164
|
+
source_record_key_sha256: sourceRecordKeySha256,
|
|
165
|
+
});
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
return value.toISOString();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function releaseDate(track, warnings, sourceRecordKeySha256) {
|
|
172
|
+
const released = optionalTimestamp(
|
|
173
|
+
track["Release Date"],
|
|
174
|
+
"release_date",
|
|
175
|
+
warnings,
|
|
176
|
+
sourceRecordKeySha256,
|
|
177
|
+
);
|
|
178
|
+
if (released) return released.slice(0, 10);
|
|
179
|
+
|
|
180
|
+
if (track.Year === undefined) return undefined;
|
|
181
|
+
if (
|
|
182
|
+
!Number.isSafeInteger(track.Year) ||
|
|
183
|
+
track.Year < 1000 ||
|
|
184
|
+
track.Year > 9999
|
|
185
|
+
) {
|
|
186
|
+
warnings.push({
|
|
187
|
+
code: "field_value_invalid",
|
|
188
|
+
scope: "track",
|
|
189
|
+
field: "year",
|
|
190
|
+
source_record_key_sha256: sourceRecordKeySha256,
|
|
191
|
+
});
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
return String(track.Year).padStart(4, "0");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function ratingState(
|
|
198
|
+
value,
|
|
199
|
+
computed,
|
|
200
|
+
field,
|
|
201
|
+
computedField,
|
|
202
|
+
warnings,
|
|
203
|
+
sourceRecordKeySha256,
|
|
204
|
+
) {
|
|
205
|
+
const rating = boundedInteger(
|
|
206
|
+
value,
|
|
207
|
+
field,
|
|
208
|
+
warnings,
|
|
209
|
+
sourceRecordKeySha256,
|
|
210
|
+
0,
|
|
211
|
+
100,
|
|
212
|
+
);
|
|
213
|
+
if (rating === undefined) return undefined;
|
|
214
|
+
const result = { value: rating };
|
|
215
|
+
const computedValue = optionalBoolean(
|
|
216
|
+
computed,
|
|
217
|
+
computedField,
|
|
218
|
+
warnings,
|
|
219
|
+
sourceRecordKeySha256,
|
|
220
|
+
);
|
|
221
|
+
if (computedValue !== undefined) result.computed = computedValue;
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function setIfDefined(target, key, value) {
|
|
226
|
+
if (value !== undefined) target[key] = value;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function safeCoverage(parsed) {
|
|
230
|
+
const coverage = {
|
|
231
|
+
title: 0,
|
|
232
|
+
artist: 0,
|
|
233
|
+
album: 0,
|
|
234
|
+
persistent_identity: 0,
|
|
235
|
+
aggregate_play_count: 0,
|
|
236
|
+
last_played_at: 0,
|
|
237
|
+
aggregate_skip_count: 0,
|
|
238
|
+
last_skipped_at: 0,
|
|
239
|
+
rating: 0,
|
|
240
|
+
loved_or_favorited: 0,
|
|
241
|
+
date_added: 0,
|
|
242
|
+
local_file_reference: 0,
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
for (const track of Object.values(parsed.root.Tracks)) {
|
|
246
|
+
if (!isPlainObject(track)) continue;
|
|
247
|
+
if (typeof track.Name === "string" && track.Name.trim()) coverage.title += 1;
|
|
248
|
+
if (typeof track.Artist === "string" && track.Artist.trim()) {
|
|
249
|
+
coverage.artist += 1;
|
|
250
|
+
}
|
|
251
|
+
if (typeof track.Album === "string" && track.Album.trim()) coverage.album += 1;
|
|
252
|
+
if (
|
|
253
|
+
typeof track["Persistent ID"] === "string" &&
|
|
254
|
+
track["Persistent ID"].trim()
|
|
255
|
+
) {
|
|
256
|
+
coverage.persistent_identity += 1;
|
|
257
|
+
}
|
|
258
|
+
if (Number.isSafeInteger(track["Play Count"]) && track["Play Count"] >= 0) {
|
|
259
|
+
coverage.aggregate_play_count += 1;
|
|
260
|
+
}
|
|
261
|
+
if (track["Play Date UTC"] instanceof Date) coverage.last_played_at += 1;
|
|
262
|
+
if (Number.isSafeInteger(track["Skip Count"]) && track["Skip Count"] >= 0) {
|
|
263
|
+
coverage.aggregate_skip_count += 1;
|
|
264
|
+
}
|
|
265
|
+
if (track["Skip Date"] instanceof Date) coverage.last_skipped_at += 1;
|
|
266
|
+
if (Number.isSafeInteger(track.Rating)) coverage.rating += 1;
|
|
267
|
+
if (track.Loved === true || track.Favorited === true) {
|
|
268
|
+
coverage.loved_or_favorited += 1;
|
|
269
|
+
}
|
|
270
|
+
if (track["Date Added"] instanceof Date) coverage.date_added += 1;
|
|
271
|
+
if (typeof track.Location === "string" && track.Location) {
|
|
272
|
+
coverage.local_file_reference += 1;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return coverage;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function summarizeAppleMusicLibrary(parsed) {
|
|
280
|
+
return {
|
|
281
|
+
ok: true,
|
|
282
|
+
mode: "inspect",
|
|
283
|
+
writes_performed: false,
|
|
284
|
+
source_format: "apple_music_library_xml_plist",
|
|
285
|
+
source_bytes: parsed.sourceBytes,
|
|
286
|
+
captured_at: parsed.capturedAt,
|
|
287
|
+
counts: {
|
|
288
|
+
tracks: parsed.trackCount,
|
|
289
|
+
playlists: parsed.playlistCount,
|
|
290
|
+
playlist_item_references: parsed.playlistItemCount,
|
|
291
|
+
provisional_track_candidates: parsed.trackCount,
|
|
292
|
+
aggregate_track_snapshots: parsed.trackCount,
|
|
293
|
+
core_listening_events: 0,
|
|
294
|
+
core_taste_events: 0,
|
|
295
|
+
},
|
|
296
|
+
coverage: safeCoverage(parsed),
|
|
297
|
+
semantics: {
|
|
298
|
+
source_is_library_snapshot: true,
|
|
299
|
+
source_is_complete_listening_history: false,
|
|
300
|
+
track_refs_are_batch_candidates: true,
|
|
301
|
+
cross_batch_identity_uses_hashed_external_refs: true,
|
|
302
|
+
aggregate_counts_expanded_into_events: false,
|
|
303
|
+
explicit_states_projected_into_taste_events: false,
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function warningCounts(warnings) {
|
|
309
|
+
const counts = {};
|
|
310
|
+
for (const warning of warnings) {
|
|
311
|
+
counts[warning.code] = (counts[warning.code] ?? 0) + 1;
|
|
312
|
+
}
|
|
313
|
+
return Object.fromEntries(
|
|
314
|
+
Object.entries(counts).sort(([left], [right]) =>
|
|
315
|
+
left < right ? -1 : left > right ? 1 : 0,
|
|
316
|
+
),
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function identityMetadata(trackRef) {
|
|
321
|
+
return Object.fromEntries(
|
|
322
|
+
Object.entries({
|
|
323
|
+
title: trackRef.title,
|
|
324
|
+
artist_credits: trackRef.artist_credits,
|
|
325
|
+
release: trackRef.release,
|
|
326
|
+
duration_ms: trackRef.duration_ms,
|
|
327
|
+
isrc: trackRef.isrc,
|
|
328
|
+
extensions: trackRef.extensions,
|
|
329
|
+
}).filter(([, value]) => value !== undefined),
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function collectOutputStrings(value, strings = new Set()) {
|
|
334
|
+
if (typeof value === "string") {
|
|
335
|
+
strings.add(value);
|
|
336
|
+
} else if (Array.isArray(value)) {
|
|
337
|
+
for (const child of value) collectOutputStrings(child, strings);
|
|
338
|
+
} else if (isPlainObject(value)) {
|
|
339
|
+
for (const child of Object.values(value)) collectOutputStrings(child, strings);
|
|
340
|
+
}
|
|
341
|
+
return strings;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function assertRawSourceIdentifiersExcluded(parsed, records) {
|
|
345
|
+
const excluded = new Set();
|
|
346
|
+
const add = (value) => {
|
|
347
|
+
if (typeof value === "string" && value.length >= 8) excluded.add(value);
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
add(parsed.libraryPersistentId);
|
|
351
|
+
add(parsed.root["Music Folder"]);
|
|
352
|
+
for (const track of Object.values(parsed.root.Tracks)) {
|
|
353
|
+
if (!isPlainObject(track)) continue;
|
|
354
|
+
add(track["Persistent ID"]);
|
|
355
|
+
add(track.Location);
|
|
356
|
+
}
|
|
357
|
+
for (const playlist of parsed.root.Playlists ?? []) {
|
|
358
|
+
if (!isPlainObject(playlist)) continue;
|
|
359
|
+
add(playlist["Playlist Persistent ID"]);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const outputStrings = collectOutputStrings(records);
|
|
363
|
+
for (const value of excluded) {
|
|
364
|
+
if (outputStrings.has(value)) {
|
|
365
|
+
fail(
|
|
366
|
+
"privacy_raw_source_value",
|
|
367
|
+
"Normalized output contains a forbidden raw source identifier",
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function assertRecordSafe(record, type, validate) {
|
|
374
|
+
if (!validate(record)) {
|
|
375
|
+
fail(
|
|
376
|
+
"normalized_schema_invalid",
|
|
377
|
+
`${type} failed validation: ${formatValidationErrors(validate.errors)}`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
const unsafeIssues = findUnsafePersistedData(record);
|
|
381
|
+
if (unsafeIssues.length > 0) {
|
|
382
|
+
fail("normalized_record_unsafe", `${type} failed persisted-data inspection`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function assertExactKeys(value, expectedKeys, code) {
|
|
387
|
+
if (!isPlainObject(value)) {
|
|
388
|
+
fail(code, "Import record structure is invalid");
|
|
389
|
+
}
|
|
390
|
+
const actualKeys = Object.keys(value).sort();
|
|
391
|
+
const expected = [...expectedKeys].sort();
|
|
392
|
+
if (canonicalizeJson(actualKeys) !== canonicalizeJson(expected)) {
|
|
393
|
+
fail(code, "Import record contains unexpected or missing fields");
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export async function validateAppleMusicImportRecords(records) {
|
|
398
|
+
assertExactKeys(
|
|
399
|
+
records,
|
|
400
|
+
["manifest", "trackRefs", "trackSnapshots", "warnings"],
|
|
401
|
+
"import_records_invalid",
|
|
402
|
+
);
|
|
403
|
+
if (
|
|
404
|
+
!Array.isArray(records.trackRefs) ||
|
|
405
|
+
!Array.isArray(records.trackSnapshots) ||
|
|
406
|
+
!Array.isArray(records.warnings)
|
|
407
|
+
) {
|
|
408
|
+
fail("import_records_invalid", "Import record collections must be arrays");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const { trackRef: validateTrackRef, snapshot: validateSnapshot } =
|
|
412
|
+
await validators();
|
|
413
|
+
const { manifest } = records;
|
|
414
|
+
assertExactKeys(
|
|
415
|
+
manifest,
|
|
416
|
+
[
|
|
417
|
+
"schema_version",
|
|
418
|
+
"importer",
|
|
419
|
+
"subject_id",
|
|
420
|
+
"import_batch_id",
|
|
421
|
+
"source",
|
|
422
|
+
"counts",
|
|
423
|
+
"coverage",
|
|
424
|
+
"warning_counts",
|
|
425
|
+
"semantics",
|
|
426
|
+
],
|
|
427
|
+
"manifest_invalid",
|
|
428
|
+
);
|
|
429
|
+
if (
|
|
430
|
+
manifest.schema_version !== "apple-music-library-import-manifest/1" ||
|
|
431
|
+
!isUuid(manifest.subject_id) ||
|
|
432
|
+
manifest.subject_id !== manifest.subject_id.toLowerCase() ||
|
|
433
|
+
!isUuid(manifest.import_batch_id)
|
|
434
|
+
) {
|
|
435
|
+
fail("manifest_invalid", "Import manifest identity is invalid");
|
|
436
|
+
}
|
|
437
|
+
const expectedImportBatchId = uuidV5(
|
|
438
|
+
`v1\n${APPLE_MUSIC_LIBRARY_IMPORTER_NAME}@${APPLE_MUSIC_LIBRARY_IMPORTER_VERSION}\n${manifest.subject_id}\n${manifest.source?.sha256}`,
|
|
439
|
+
APPLE_LIBRARY_BATCH_NAMESPACE,
|
|
440
|
+
);
|
|
441
|
+
if (expectedImportBatchId !== manifest.import_batch_id) {
|
|
442
|
+
fail("manifest_invalid", "Import manifest deterministic identity is invalid");
|
|
443
|
+
}
|
|
444
|
+
assertExactKeys(manifest.importer, ["name", "version"], "manifest_invalid");
|
|
445
|
+
if (
|
|
446
|
+
manifest.importer.name !== APPLE_MUSIC_LIBRARY_IMPORTER_NAME ||
|
|
447
|
+
manifest.importer.version !== APPLE_MUSIC_LIBRARY_IMPORTER_VERSION
|
|
448
|
+
) {
|
|
449
|
+
fail("manifest_invalid", "Import manifest version is invalid");
|
|
450
|
+
}
|
|
451
|
+
assertExactKeys(
|
|
452
|
+
manifest.source,
|
|
453
|
+
[
|
|
454
|
+
"format",
|
|
455
|
+
"sha256",
|
|
456
|
+
"bytes",
|
|
457
|
+
"captured_at",
|
|
458
|
+
"library_fingerprint_sha256",
|
|
459
|
+
],
|
|
460
|
+
"manifest_invalid",
|
|
461
|
+
);
|
|
462
|
+
if (
|
|
463
|
+
manifest.source.format !== "apple_music_library_xml_plist" ||
|
|
464
|
+
!/^[0-9a-f]{64}$/.test(manifest.source.sha256) ||
|
|
465
|
+
!/^[0-9a-f]{64}$/.test(manifest.source.library_fingerprint_sha256) ||
|
|
466
|
+
!Number.isSafeInteger(manifest.source.bytes) ||
|
|
467
|
+
manifest.source.bytes < 1 ||
|
|
468
|
+
!/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z$/.test(
|
|
469
|
+
manifest.source.captured_at,
|
|
470
|
+
) ||
|
|
471
|
+
Number.isNaN(Date.parse(manifest.source.captured_at))
|
|
472
|
+
) {
|
|
473
|
+
fail("manifest_invalid", "Import manifest source summary is invalid");
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const countKeys = [
|
|
477
|
+
"source_tracks",
|
|
478
|
+
"source_playlists",
|
|
479
|
+
"source_playlist_item_references",
|
|
480
|
+
"track_refs",
|
|
481
|
+
"aggregate_track_snapshots",
|
|
482
|
+
"core_listening_events",
|
|
483
|
+
"core_taste_events",
|
|
484
|
+
"warnings",
|
|
485
|
+
];
|
|
486
|
+
assertExactKeys(manifest.counts, countKeys, "manifest_invalid");
|
|
487
|
+
if (
|
|
488
|
+
countKeys.some(
|
|
489
|
+
(key) =>
|
|
490
|
+
!Number.isSafeInteger(manifest.counts[key]) ||
|
|
491
|
+
manifest.counts[key] < 0,
|
|
492
|
+
) ||
|
|
493
|
+
manifest.counts.track_refs !== records.trackRefs.length ||
|
|
494
|
+
manifest.counts.aggregate_track_snapshots !==
|
|
495
|
+
records.trackSnapshots.length ||
|
|
496
|
+
manifest.counts.warnings !== records.warnings.length ||
|
|
497
|
+
manifest.counts.core_listening_events !== 0 ||
|
|
498
|
+
manifest.counts.core_taste_events !== 0 ||
|
|
499
|
+
manifest.counts.track_refs > manifest.counts.source_tracks
|
|
500
|
+
) {
|
|
501
|
+
fail("manifest_invalid", "Import manifest record counts are invalid");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const coverageKeys = [
|
|
505
|
+
"title",
|
|
506
|
+
"artist",
|
|
507
|
+
"album",
|
|
508
|
+
"persistent_identity",
|
|
509
|
+
"aggregate_play_count",
|
|
510
|
+
"last_played_at",
|
|
511
|
+
"aggregate_skip_count",
|
|
512
|
+
"last_skipped_at",
|
|
513
|
+
"rating",
|
|
514
|
+
"loved_or_favorited",
|
|
515
|
+
"date_added",
|
|
516
|
+
"local_file_reference",
|
|
517
|
+
];
|
|
518
|
+
assertExactKeys(manifest.coverage, coverageKeys, "manifest_invalid");
|
|
519
|
+
if (
|
|
520
|
+
coverageKeys.some(
|
|
521
|
+
(key) =>
|
|
522
|
+
!Number.isSafeInteger(manifest.coverage[key]) ||
|
|
523
|
+
manifest.coverage[key] < 0 ||
|
|
524
|
+
manifest.coverage[key] > manifest.counts.source_tracks,
|
|
525
|
+
)
|
|
526
|
+
) {
|
|
527
|
+
fail("manifest_invalid", "Import manifest coverage is invalid");
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const semanticKeys = [
|
|
531
|
+
"source_is_library_snapshot",
|
|
532
|
+
"source_is_complete_listening_history",
|
|
533
|
+
"track_refs_are_batch_candidates",
|
|
534
|
+
"cross_batch_identity_uses_hashed_external_refs",
|
|
535
|
+
"aggregate_counts_expanded_into_events",
|
|
536
|
+
"explicit_states_projected_into_taste_events",
|
|
537
|
+
];
|
|
538
|
+
assertExactKeys(manifest.semantics, semanticKeys, "manifest_invalid");
|
|
539
|
+
const expectedSemantics = {
|
|
540
|
+
source_is_library_snapshot: true,
|
|
541
|
+
source_is_complete_listening_history: false,
|
|
542
|
+
track_refs_are_batch_candidates: true,
|
|
543
|
+
cross_batch_identity_uses_hashed_external_refs: true,
|
|
544
|
+
aggregate_counts_expanded_into_events: false,
|
|
545
|
+
explicit_states_projected_into_taste_events: false,
|
|
546
|
+
};
|
|
547
|
+
if (
|
|
548
|
+
canonicalizeJson(manifest.semantics) !==
|
|
549
|
+
canonicalizeJson(expectedSemantics)
|
|
550
|
+
) {
|
|
551
|
+
fail("manifest_invalid", "Import manifest semantics are invalid");
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const trackIds = new Set();
|
|
555
|
+
const trackRefsById = new Map();
|
|
556
|
+
const externalIdentities = new Set();
|
|
557
|
+
for (const trackRef of records.trackRefs) {
|
|
558
|
+
assertRecordSafe(trackRef, "TrackRef", validateTrackRef);
|
|
559
|
+
if (!validateTrackRefSemantics(trackRef).ok) {
|
|
560
|
+
fail("normalized_semantics_invalid", "TrackRef failed semantic validation");
|
|
561
|
+
}
|
|
562
|
+
if (
|
|
563
|
+
trackRef.identity_status !== "provisional" ||
|
|
564
|
+
trackRef.revision !== 1 ||
|
|
565
|
+
trackRef.created_at !== manifest.source.captured_at ||
|
|
566
|
+
trackIds.has(trackRef.track_ref_id)
|
|
567
|
+
) {
|
|
568
|
+
fail("import_records_invalid", "TrackRef batch invariants are invalid");
|
|
569
|
+
}
|
|
570
|
+
trackIds.add(trackRef.track_ref_id);
|
|
571
|
+
trackRefsById.set(trackRef.track_ref_id, trackRef);
|
|
572
|
+
|
|
573
|
+
const appleRefs = trackRef.external_refs.filter(
|
|
574
|
+
(externalRef) =>
|
|
575
|
+
externalRef.system === "apple-music-library" &&
|
|
576
|
+
externalRef.entity_type === "library.track" &&
|
|
577
|
+
/^[0-9a-f]{64}$/.test(externalRef.external_id),
|
|
578
|
+
);
|
|
579
|
+
if (appleRefs.length !== 1 || trackRef.external_refs.length !== 1) {
|
|
580
|
+
fail("import_records_invalid", "TrackRef external identity is invalid");
|
|
581
|
+
}
|
|
582
|
+
if (
|
|
583
|
+
trackRef.extensions &&
|
|
584
|
+
Object.keys(trackRef.extensions).some(
|
|
585
|
+
(key) =>
|
|
586
|
+
!new Set([
|
|
587
|
+
"apple_music.genre",
|
|
588
|
+
"apple_music.composer",
|
|
589
|
+
"apple_music.album_artist",
|
|
590
|
+
]).has(key),
|
|
591
|
+
)
|
|
592
|
+
) {
|
|
593
|
+
fail("import_records_invalid", "TrackRef extensions are invalid");
|
|
594
|
+
}
|
|
595
|
+
const externalIdentity = appleRefs[0].external_id;
|
|
596
|
+
if (externalIdentities.has(externalIdentity)) {
|
|
597
|
+
fail("import_records_invalid", "TrackRef external identity is duplicated");
|
|
598
|
+
}
|
|
599
|
+
externalIdentities.add(externalIdentity);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const snapshotTrackIds = new Set();
|
|
603
|
+
for (const snapshot of records.trackSnapshots) {
|
|
604
|
+
assertRecordSafe(snapshot, "TrackSnapshot", validateSnapshot);
|
|
605
|
+
if (
|
|
606
|
+
snapshot.import_batch_id !== manifest.import_batch_id ||
|
|
607
|
+
snapshot.subject_id !== manifest.subject_id ||
|
|
608
|
+
snapshot.captured_at !== manifest.source.captured_at ||
|
|
609
|
+
!trackIds.has(snapshot.track_ref_id) ||
|
|
610
|
+
snapshotTrackIds.has(snapshot.track_ref_id)
|
|
611
|
+
) {
|
|
612
|
+
fail("import_records_invalid", "Track snapshot batch invariants are invalid");
|
|
613
|
+
}
|
|
614
|
+
const trackRef = trackRefsById.get(snapshot.track_ref_id);
|
|
615
|
+
const expectedTrackRefId = uuidV5(
|
|
616
|
+
`candidate-v1\n${manifest.import_batch_id}\n${snapshot.source_record_key_sha256}\n${trackRef.external_refs[0].external_id}`,
|
|
617
|
+
APPLE_LIBRARY_TRACK_NAMESPACE,
|
|
618
|
+
);
|
|
619
|
+
const expectedSnapshotId = uuidV5(
|
|
620
|
+
`v1\n${manifest.import_batch_id}\n${snapshot.source_record_key_sha256}`,
|
|
621
|
+
APPLE_LIBRARY_SNAPSHOT_NAMESPACE,
|
|
622
|
+
);
|
|
623
|
+
if (
|
|
624
|
+
expectedTrackRefId !== trackRef.track_ref_id ||
|
|
625
|
+
expectedSnapshotId !== snapshot.track_snapshot_id
|
|
626
|
+
) {
|
|
627
|
+
fail(
|
|
628
|
+
"import_records_invalid",
|
|
629
|
+
"Apple import record deterministic identity is invalid",
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
snapshotTrackIds.add(snapshot.track_ref_id);
|
|
633
|
+
}
|
|
634
|
+
if (snapshotTrackIds.size !== trackIds.size) {
|
|
635
|
+
fail("import_records_invalid", "Every TrackRef must have one track snapshot");
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const warningCountsFromRecords = {};
|
|
639
|
+
for (const warning of records.warnings) {
|
|
640
|
+
if (!isPlainObject(warning)) {
|
|
641
|
+
fail("warning_invalid", "Import warning must be an object");
|
|
642
|
+
}
|
|
643
|
+
const allowedKeys = new Set([
|
|
644
|
+
"code",
|
|
645
|
+
"scope",
|
|
646
|
+
"field",
|
|
647
|
+
"source_record_key_sha256",
|
|
648
|
+
]);
|
|
649
|
+
if (
|
|
650
|
+
Object.keys(warning).some((key) => !allowedKeys.has(key)) ||
|
|
651
|
+
!/^[a-z][a-z0-9_]{0,127}$/.test(warning.code) ||
|
|
652
|
+
!new Set(["library", "track"]).has(warning.scope) ||
|
|
653
|
+
(warning.field !== undefined &&
|
|
654
|
+
!/^[a-z][a-z0-9_]{0,127}$/.test(warning.field)) ||
|
|
655
|
+
(warning.source_record_key_sha256 !== undefined &&
|
|
656
|
+
!/^[0-9a-f]{64}$/.test(warning.source_record_key_sha256)) ||
|
|
657
|
+
(warning.scope === "track" &&
|
|
658
|
+
warning.source_record_key_sha256 === undefined) ||
|
|
659
|
+
(warning.scope === "library" &&
|
|
660
|
+
warning.source_record_key_sha256 !== undefined)
|
|
661
|
+
) {
|
|
662
|
+
fail("warning_invalid", "Import warning is invalid");
|
|
663
|
+
}
|
|
664
|
+
warningCountsFromRecords[warning.code] =
|
|
665
|
+
(warningCountsFromRecords[warning.code] ?? 0) + 1;
|
|
666
|
+
if (findUnsafePersistedData(warning).length > 0) {
|
|
667
|
+
fail("normalized_record_unsafe", "Import warning failed data inspection");
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (
|
|
671
|
+
!isPlainObject(manifest.warning_counts) ||
|
|
672
|
+
Object.values(manifest.warning_counts).some(
|
|
673
|
+
(value) => !Number.isSafeInteger(value) || value < 1,
|
|
674
|
+
)
|
|
675
|
+
) {
|
|
676
|
+
fail("manifest_invalid", "Import manifest warning counts are invalid");
|
|
677
|
+
}
|
|
678
|
+
if (
|
|
679
|
+
canonicalizeJson(manifest.warning_counts) !==
|
|
680
|
+
canonicalizeJson(
|
|
681
|
+
Object.fromEntries(
|
|
682
|
+
Object.entries(warningCountsFromRecords).sort(([left], [right]) =>
|
|
683
|
+
left < right ? -1 : left > right ? 1 : 0,
|
|
684
|
+
),
|
|
685
|
+
),
|
|
686
|
+
)
|
|
687
|
+
) {
|
|
688
|
+
fail("manifest_invalid", "Import manifest warning counts are invalid");
|
|
689
|
+
}
|
|
690
|
+
if (findUnsafePersistedData(manifest).length > 0) {
|
|
691
|
+
fail("manifest_unsafe", "Import manifest failed persisted-data inspection");
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
export async function normalizeAppleMusicLibrary(parsed, { subjectId }) {
|
|
696
|
+
if (!isUuid(subjectId)) {
|
|
697
|
+
fail("subject_id_invalid", "Import requires a valid stable subject UUID");
|
|
698
|
+
}
|
|
699
|
+
const normalizedSubjectId = subjectId.toLowerCase();
|
|
700
|
+
const { trackRef: validateTrackRef, snapshot: validateSnapshot } =
|
|
701
|
+
await validators();
|
|
702
|
+
|
|
703
|
+
const importBatchId = uuidV5(
|
|
704
|
+
`v1\n${APPLE_MUSIC_LIBRARY_IMPORTER_NAME}@${APPLE_MUSIC_LIBRARY_IMPORTER_VERSION}\n${normalizedSubjectId}\n${parsed.sourceSha256}`,
|
|
705
|
+
APPLE_LIBRARY_BATCH_NAMESPACE,
|
|
706
|
+
);
|
|
707
|
+
const libraryIdentityMaterial = parsed.libraryPersistentId
|
|
708
|
+
? `v1\n${normalizedSubjectId}\n${parsed.libraryPersistentId}`
|
|
709
|
+
: `fallback-v1\n${normalizedSubjectId}\n${parsed.sourceSha256}`;
|
|
710
|
+
const libraryFingerprintSha256 = sha256Hex({
|
|
711
|
+
kind: "apple_music_library_identity",
|
|
712
|
+
identity_material: libraryIdentityMaterial,
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
const warnings = [];
|
|
716
|
+
if (!parsed.libraryPersistentId) {
|
|
717
|
+
warnings.push({
|
|
718
|
+
code: "library_persistent_id_missing",
|
|
719
|
+
scope: "library",
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const trackRefs = [];
|
|
724
|
+
const trackSnapshots = [];
|
|
725
|
+
const seenPersistentIds = new Map();
|
|
726
|
+
|
|
727
|
+
for (const [sourceKey, track] of Object.entries(parsed.root.Tracks).sort(
|
|
728
|
+
sourceKeyOrder,
|
|
729
|
+
)) {
|
|
730
|
+
if (!isPlainObject(track)) {
|
|
731
|
+
fail("plist_track_invalid", "Apple Music track entry must be a dictionary");
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const sourceRecordKeySha256 = sha256Hex({
|
|
735
|
+
kind: "apple_music_library_source_record",
|
|
736
|
+
source_sha256: parsed.sourceSha256,
|
|
737
|
+
source_key: sourceKey,
|
|
738
|
+
});
|
|
739
|
+
const persistentId = cleanText(
|
|
740
|
+
track["Persistent ID"],
|
|
741
|
+
"persistent_id",
|
|
742
|
+
warnings,
|
|
743
|
+
sourceRecordKeySha256,
|
|
744
|
+
512,
|
|
745
|
+
);
|
|
746
|
+
if (!persistentId) {
|
|
747
|
+
warnings.push({
|
|
748
|
+
code: "identity_fallback_unstable",
|
|
749
|
+
scope: "track",
|
|
750
|
+
source_record_key_sha256: sourceRecordKeySha256,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const externalIdentityMaterial =
|
|
755
|
+
parsed.libraryPersistentId && persistentId
|
|
756
|
+
? `v1\n${normalizedSubjectId}\n${parsed.libraryPersistentId}\n${persistentId}`
|
|
757
|
+
: `fallback-v1\n${normalizedSubjectId}\n${parsed.sourceSha256}\n${sourceKey}`;
|
|
758
|
+
const externalIdentitySha256 = sha256Hex({
|
|
759
|
+
kind: "apple_music_library_track_identity",
|
|
760
|
+
identity_material: externalIdentityMaterial,
|
|
761
|
+
});
|
|
762
|
+
const trackCandidateMaterial =
|
|
763
|
+
`candidate-v1\n${importBatchId}\n${sourceRecordKeySha256}\n` +
|
|
764
|
+
externalIdentitySha256;
|
|
765
|
+
const trackRefId = uuidV5(
|
|
766
|
+
trackCandidateMaterial,
|
|
767
|
+
APPLE_LIBRARY_TRACK_NAMESPACE,
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
const title = cleanText(
|
|
771
|
+
track.Name,
|
|
772
|
+
"title",
|
|
773
|
+
warnings,
|
|
774
|
+
sourceRecordKeySha256,
|
|
775
|
+
);
|
|
776
|
+
let artist = cleanText(
|
|
777
|
+
track.Artist,
|
|
778
|
+
"artist",
|
|
779
|
+
warnings,
|
|
780
|
+
sourceRecordKeySha256,
|
|
781
|
+
);
|
|
782
|
+
const albumArtist = cleanText(
|
|
783
|
+
track["Album Artist"],
|
|
784
|
+
"album_artist",
|
|
785
|
+
warnings,
|
|
786
|
+
sourceRecordKeySha256,
|
|
787
|
+
);
|
|
788
|
+
if (!artist && albumArtist) {
|
|
789
|
+
artist = albumArtist;
|
|
790
|
+
warnings.push({
|
|
791
|
+
code: "artist_fell_back_to_album_artist",
|
|
792
|
+
scope: "track",
|
|
793
|
+
source_record_key_sha256: sourceRecordKeySha256,
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
const album = cleanText(
|
|
797
|
+
track.Album,
|
|
798
|
+
"album",
|
|
799
|
+
warnings,
|
|
800
|
+
sourceRecordKeySha256,
|
|
801
|
+
);
|
|
802
|
+
const durationMs = nonnegativeInteger(
|
|
803
|
+
track["Total Time"],
|
|
804
|
+
"duration_ms",
|
|
805
|
+
warnings,
|
|
806
|
+
sourceRecordKeySha256,
|
|
807
|
+
);
|
|
808
|
+
const released = releaseDate(track, warnings, sourceRecordKeySha256);
|
|
809
|
+
const rawIsrc = cleanText(
|
|
810
|
+
track.ISRC,
|
|
811
|
+
"isrc",
|
|
812
|
+
warnings,
|
|
813
|
+
sourceRecordKeySha256,
|
|
814
|
+
64,
|
|
815
|
+
);
|
|
816
|
+
const isrc = rawIsrc?.toUpperCase();
|
|
817
|
+
const validIsrc = isrc && /^[A-Z]{2}[A-Z0-9]{3}[0-9]{7}$/.test(isrc);
|
|
818
|
+
if (isrc && !validIsrc) {
|
|
819
|
+
warnings.push({
|
|
820
|
+
code: "isrc_invalid",
|
|
821
|
+
scope: "track",
|
|
822
|
+
source_record_key_sha256: sourceRecordKeySha256,
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const trackRef = {
|
|
827
|
+
schema_version: 1,
|
|
828
|
+
track_ref_id: trackRefId,
|
|
829
|
+
revision: 1,
|
|
830
|
+
identity_status: "provisional",
|
|
831
|
+
display_label: displayLabel(
|
|
832
|
+
title,
|
|
833
|
+
artist,
|
|
834
|
+
`Apple Music library track ${externalIdentitySha256.slice(0, 12)}`,
|
|
835
|
+
warnings,
|
|
836
|
+
sourceRecordKeySha256,
|
|
837
|
+
),
|
|
838
|
+
external_refs: [
|
|
839
|
+
{
|
|
840
|
+
system: "apple-music-library",
|
|
841
|
+
entity_type: "library.track",
|
|
842
|
+
external_id: externalIdentitySha256,
|
|
843
|
+
},
|
|
844
|
+
],
|
|
845
|
+
created_at: parsed.capturedAt,
|
|
846
|
+
};
|
|
847
|
+
setIfDefined(trackRef, "title", title);
|
|
848
|
+
if (artist) {
|
|
849
|
+
trackRef.artist_credits = [{ name: artist, role: "primary" }];
|
|
850
|
+
}
|
|
851
|
+
if (album) {
|
|
852
|
+
trackRef.release = { title: album };
|
|
853
|
+
setIfDefined(trackRef.release, "release_date", released);
|
|
854
|
+
}
|
|
855
|
+
setIfDefined(trackRef, "duration_ms", durationMs);
|
|
856
|
+
if (validIsrc) trackRef.isrc = isrc;
|
|
857
|
+
|
|
858
|
+
const extensions = {};
|
|
859
|
+
setIfDefined(
|
|
860
|
+
extensions,
|
|
861
|
+
"apple_music.genre",
|
|
862
|
+
cleanText(track.Genre, "genre", warnings, sourceRecordKeySha256),
|
|
863
|
+
);
|
|
864
|
+
setIfDefined(
|
|
865
|
+
extensions,
|
|
866
|
+
"apple_music.composer",
|
|
867
|
+
cleanText(track.Composer, "composer", warnings, sourceRecordKeySha256),
|
|
868
|
+
);
|
|
869
|
+
setIfDefined(extensions, "apple_music.album_artist", albumArtist);
|
|
870
|
+
if (Object.keys(extensions).length > 0) trackRef.extensions = extensions;
|
|
871
|
+
|
|
872
|
+
assertRecordSafe(trackRef, "TrackRef", validateTrackRef);
|
|
873
|
+
const semanticResult = validateTrackRefSemantics(trackRef);
|
|
874
|
+
if (!semanticResult.ok) {
|
|
875
|
+
fail("normalized_semantics_invalid", "TrackRef failed semantic validation");
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const aggregateState = {};
|
|
879
|
+
setIfDefined(
|
|
880
|
+
aggregateState,
|
|
881
|
+
"play_count",
|
|
882
|
+
nonnegativeInteger(
|
|
883
|
+
track["Play Count"],
|
|
884
|
+
"play_count",
|
|
885
|
+
warnings,
|
|
886
|
+
sourceRecordKeySha256,
|
|
887
|
+
),
|
|
888
|
+
);
|
|
889
|
+
setIfDefined(
|
|
890
|
+
aggregateState,
|
|
891
|
+
"last_played_at",
|
|
892
|
+
optionalTimestamp(
|
|
893
|
+
track["Play Date UTC"],
|
|
894
|
+
"last_played_at",
|
|
895
|
+
warnings,
|
|
896
|
+
sourceRecordKeySha256,
|
|
897
|
+
),
|
|
898
|
+
);
|
|
899
|
+
if (track["Play Date"] !== undefined && !track["Play Date UTC"]) {
|
|
900
|
+
warnings.push({
|
|
901
|
+
code: "legacy_play_date_not_projected",
|
|
902
|
+
scope: "track",
|
|
903
|
+
source_record_key_sha256: sourceRecordKeySha256,
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
setIfDefined(
|
|
907
|
+
aggregateState,
|
|
908
|
+
"skip_count",
|
|
909
|
+
nonnegativeInteger(
|
|
910
|
+
track["Skip Count"],
|
|
911
|
+
"skip_count",
|
|
912
|
+
warnings,
|
|
913
|
+
sourceRecordKeySha256,
|
|
914
|
+
),
|
|
915
|
+
);
|
|
916
|
+
setIfDefined(
|
|
917
|
+
aggregateState,
|
|
918
|
+
"last_skipped_at",
|
|
919
|
+
optionalTimestamp(
|
|
920
|
+
track["Skip Date"],
|
|
921
|
+
"last_skipped_at",
|
|
922
|
+
warnings,
|
|
923
|
+
sourceRecordKeySha256,
|
|
924
|
+
),
|
|
925
|
+
);
|
|
926
|
+
setIfDefined(
|
|
927
|
+
aggregateState,
|
|
928
|
+
"rating",
|
|
929
|
+
ratingState(
|
|
930
|
+
track.Rating,
|
|
931
|
+
track["Rating Computed"],
|
|
932
|
+
"rating",
|
|
933
|
+
"rating_computed",
|
|
934
|
+
warnings,
|
|
935
|
+
sourceRecordKeySha256,
|
|
936
|
+
),
|
|
937
|
+
);
|
|
938
|
+
setIfDefined(
|
|
939
|
+
aggregateState,
|
|
940
|
+
"album_rating",
|
|
941
|
+
ratingState(
|
|
942
|
+
track["Album Rating"],
|
|
943
|
+
track["Album Rating Computed"],
|
|
944
|
+
"album_rating",
|
|
945
|
+
"album_rating_computed",
|
|
946
|
+
warnings,
|
|
947
|
+
sourceRecordKeySha256,
|
|
948
|
+
),
|
|
949
|
+
);
|
|
950
|
+
setIfDefined(
|
|
951
|
+
aggregateState,
|
|
952
|
+
"loved",
|
|
953
|
+
optionalBoolean(
|
|
954
|
+
track.Loved,
|
|
955
|
+
"loved",
|
|
956
|
+
warnings,
|
|
957
|
+
sourceRecordKeySha256,
|
|
958
|
+
),
|
|
959
|
+
);
|
|
960
|
+
setIfDefined(
|
|
961
|
+
aggregateState,
|
|
962
|
+
"favorited",
|
|
963
|
+
optionalBoolean(
|
|
964
|
+
track.Favorited,
|
|
965
|
+
"favorited",
|
|
966
|
+
warnings,
|
|
967
|
+
sourceRecordKeySha256,
|
|
968
|
+
),
|
|
969
|
+
);
|
|
970
|
+
|
|
971
|
+
const libraryState = {};
|
|
972
|
+
setIfDefined(
|
|
973
|
+
libraryState,
|
|
974
|
+
"date_added",
|
|
975
|
+
optionalTimestamp(
|
|
976
|
+
track["Date Added"],
|
|
977
|
+
"date_added",
|
|
978
|
+
warnings,
|
|
979
|
+
sourceRecordKeySha256,
|
|
980
|
+
),
|
|
981
|
+
);
|
|
982
|
+
setIfDefined(
|
|
983
|
+
libraryState,
|
|
984
|
+
"date_modified",
|
|
985
|
+
optionalTimestamp(
|
|
986
|
+
track["Date Modified"],
|
|
987
|
+
"date_modified",
|
|
988
|
+
warnings,
|
|
989
|
+
sourceRecordKeySha256,
|
|
990
|
+
),
|
|
991
|
+
);
|
|
992
|
+
for (const [sourceField, outputField] of [
|
|
993
|
+
["Apple Music", "apple_music"],
|
|
994
|
+
["Purchased", "purchased"],
|
|
995
|
+
["Playlist Only", "playlist_only"],
|
|
996
|
+
["Compilation", "compilation"],
|
|
997
|
+
["Unplayed", "unplayed"],
|
|
998
|
+
["Disabled", "disabled"],
|
|
999
|
+
]) {
|
|
1000
|
+
setIfDefined(
|
|
1001
|
+
libraryState,
|
|
1002
|
+
outputField,
|
|
1003
|
+
optionalBoolean(
|
|
1004
|
+
track[sourceField],
|
|
1005
|
+
outputField,
|
|
1006
|
+
warnings,
|
|
1007
|
+
sourceRecordKeySha256,
|
|
1008
|
+
),
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const trackSnapshot = {
|
|
1013
|
+
schema_version: "1",
|
|
1014
|
+
track_snapshot_id: uuidV5(
|
|
1015
|
+
`v1\n${importBatchId}\n${sourceRecordKeySha256}`,
|
|
1016
|
+
APPLE_LIBRARY_SNAPSHOT_NAMESPACE,
|
|
1017
|
+
),
|
|
1018
|
+
import_batch_id: importBatchId,
|
|
1019
|
+
subject_id: normalizedSubjectId,
|
|
1020
|
+
track_ref_id: trackRefId,
|
|
1021
|
+
captured_at: parsed.capturedAt,
|
|
1022
|
+
source_record_key_sha256: sourceRecordKeySha256,
|
|
1023
|
+
snapshot_kind: "aggregate_library_state",
|
|
1024
|
+
aggregate_state: aggregateState,
|
|
1025
|
+
library_state: libraryState,
|
|
1026
|
+
};
|
|
1027
|
+
assertRecordSafe(trackSnapshot, "TrackSnapshot", validateSnapshot);
|
|
1028
|
+
|
|
1029
|
+
if (persistentId) {
|
|
1030
|
+
const comparison = {
|
|
1031
|
+
identity_metadata: identityMetadata(trackRef),
|
|
1032
|
+
aggregate_state: aggregateState,
|
|
1033
|
+
library_state: libraryState,
|
|
1034
|
+
};
|
|
1035
|
+
const prior = seenPersistentIds.get(persistentId);
|
|
1036
|
+
if (prior) {
|
|
1037
|
+
if (canonicalizeJson(prior) !== canonicalizeJson(comparison)) {
|
|
1038
|
+
fail(
|
|
1039
|
+
"duplicate_persistent_id_conflict",
|
|
1040
|
+
"A persistent track identity has conflicting metadata or state",
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
warnings.push({
|
|
1044
|
+
code: "duplicate_persistent_id_deduplicated",
|
|
1045
|
+
scope: "track",
|
|
1046
|
+
source_record_key_sha256: sourceRecordKeySha256,
|
|
1047
|
+
});
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1050
|
+
seenPersistentIds.set(persistentId, comparison);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
trackRefs.push(trackRef);
|
|
1054
|
+
trackSnapshots.push(trackSnapshot);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
trackRefs.sort((left, right) =>
|
|
1058
|
+
left.track_ref_id < right.track_ref_id
|
|
1059
|
+
? -1
|
|
1060
|
+
: left.track_ref_id > right.track_ref_id
|
|
1061
|
+
? 1
|
|
1062
|
+
: 0,
|
|
1063
|
+
);
|
|
1064
|
+
trackSnapshots.sort((left, right) =>
|
|
1065
|
+
left.track_ref_id < right.track_ref_id
|
|
1066
|
+
? -1
|
|
1067
|
+
: left.track_ref_id > right.track_ref_id
|
|
1068
|
+
? 1
|
|
1069
|
+
: 0,
|
|
1070
|
+
);
|
|
1071
|
+
warnings.sort((left, right) =>
|
|
1072
|
+
canonicalizeJson(left) < canonicalizeJson(right)
|
|
1073
|
+
? -1
|
|
1074
|
+
: canonicalizeJson(left) > canonicalizeJson(right)
|
|
1075
|
+
? 1
|
|
1076
|
+
: 0,
|
|
1077
|
+
);
|
|
1078
|
+
|
|
1079
|
+
const manifest = {
|
|
1080
|
+
schema_version: "apple-music-library-import-manifest/1",
|
|
1081
|
+
importer: {
|
|
1082
|
+
name: APPLE_MUSIC_LIBRARY_IMPORTER_NAME,
|
|
1083
|
+
version: APPLE_MUSIC_LIBRARY_IMPORTER_VERSION,
|
|
1084
|
+
},
|
|
1085
|
+
subject_id: normalizedSubjectId,
|
|
1086
|
+
import_batch_id: importBatchId,
|
|
1087
|
+
source: {
|
|
1088
|
+
format: "apple_music_library_xml_plist",
|
|
1089
|
+
sha256: parsed.sourceSha256,
|
|
1090
|
+
bytes: parsed.sourceBytes,
|
|
1091
|
+
captured_at: parsed.capturedAt,
|
|
1092
|
+
library_fingerprint_sha256: libraryFingerprintSha256,
|
|
1093
|
+
},
|
|
1094
|
+
counts: {
|
|
1095
|
+
source_tracks: parsed.trackCount,
|
|
1096
|
+
source_playlists: parsed.playlistCount,
|
|
1097
|
+
source_playlist_item_references: parsed.playlistItemCount,
|
|
1098
|
+
track_refs: trackRefs.length,
|
|
1099
|
+
aggregate_track_snapshots: trackSnapshots.length,
|
|
1100
|
+
core_listening_events: 0,
|
|
1101
|
+
core_taste_events: 0,
|
|
1102
|
+
warnings: warnings.length,
|
|
1103
|
+
},
|
|
1104
|
+
coverage: safeCoverage(parsed),
|
|
1105
|
+
warning_counts: warningCounts(warnings),
|
|
1106
|
+
semantics: {
|
|
1107
|
+
source_is_library_snapshot: true,
|
|
1108
|
+
source_is_complete_listening_history: false,
|
|
1109
|
+
track_refs_are_batch_candidates: true,
|
|
1110
|
+
cross_batch_identity_uses_hashed_external_refs: true,
|
|
1111
|
+
aggregate_counts_expanded_into_events: false,
|
|
1112
|
+
explicit_states_projected_into_taste_events: false,
|
|
1113
|
+
},
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
const records = { manifest, trackRefs, trackSnapshots, warnings };
|
|
1117
|
+
await validateAppleMusicImportRecords(records);
|
|
1118
|
+
assertRawSourceIdentifiersExcluded(parsed, records);
|
|
1119
|
+
for (const value of [manifest, ...warnings]) {
|
|
1120
|
+
if (findUnsafePersistedData(value).length > 0) {
|
|
1121
|
+
fail("normalized_record_unsafe", "Import metadata failed persisted-data inspection");
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
return records;
|
|
1126
|
+
}
|