@audiofool/moondog 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +125 -0
  2. package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
  3. package/contracts/v1/capability-policy.schema.json +572 -0
  4. package/contracts/v1/definitions.schema.json +365 -0
  5. package/contracts/v1/library-track-observation.schema.json +138 -0
  6. package/contracts/v1/listening-event.schema.json +111 -0
  7. package/contracts/v1/profile-evidence.schema.json +237 -0
  8. package/contracts/v1/taste-event.schema.json +258 -0
  9. package/contracts/v1/tool-invocation.schema.json +318 -0
  10. package/contracts/v1/tool-receipt.schema.json +1066 -0
  11. package/contracts/v1/track-ref.schema.json +275 -0
  12. package/contracts/v2/profile-evidence.schema.json +284 -0
  13. package/package.json +105 -0
  14. package/scripts/contract-lib.mjs +361 -0
  15. package/scripts/contract-semantics.mjs +2630 -0
  16. package/scripts/demo-studio.mjs +58 -0
  17. package/scripts/moondog +52 -0
  18. package/scripts/moondog.mjs +954 -0
  19. package/src/core/apple-data-migration.mjs +50 -0
  20. package/src/core/apple-library-source-status.mjs +220 -0
  21. package/src/core/apple-projection-domain-services.mjs +2356 -0
  22. package/src/core/capability-catalog.mjs +577 -0
  23. package/src/core/listening-profile-domain-services.mjs +1010 -0
  24. package/src/core/lyric-library.mjs +121 -0
  25. package/src/core/lyric-profile.mjs +50 -0
  26. package/src/core/lyric-service.mjs +41 -0
  27. package/src/core/moondog-application.mjs +1827 -0
  28. package/src/core/state-directory.mjs +19 -0
  29. package/src/core/synthetic-domain-services.mjs +1485 -0
  30. package/src/core/uuid-v5.mjs +42 -0
  31. package/src/demo/fictional-spotify-history.mjs +400 -0
  32. package/src/demo/moondog-demo.mjs +355 -0
  33. package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
  34. package/src/demo/moondog-tasteprint-demo.mjs +631 -0
  35. package/src/importers/apple-music-library/import-batch.mjs +290 -0
  36. package/src/importers/apple-music-library/index.mjs +76 -0
  37. package/src/importers/apple-music-library/normalize.mjs +1126 -0
  38. package/src/importers/apple-music-library/parse-plist.mjs +357 -0
  39. package/src/importers/apple-music-library/projection-records.mjs +557 -0
  40. package/src/importers/apple-music-library/read-batch.mjs +276 -0
  41. package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
  42. package/src/importers/apple-music-library/stable-ids.mjs +19 -0
  43. package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
  44. package/src/integrations/apple-music/catalog.mjs +770 -0
  45. package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
  46. package/src/integrations/listenbrainz/history-file.mjs +560 -0
  47. package/src/integrations/lyrics/lrclib.mjs +55 -0
  48. package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
  49. package/src/integrations/public-playlists.mjs +104 -0
  50. package/src/integrations/spotify/account-data-history.mjs +545 -0
  51. package/src/integrations/spotify/account-data-profile.mjs +918 -0
  52. package/src/integrations/spotify/authentication.mjs +791 -0
  53. package/src/integrations/spotify/catalog-resolver.mjs +657 -0
  54. package/src/integrations/spotify/connection.mjs +125 -0
  55. package/src/integrations/spotify/credential-store.mjs +26 -0
  56. package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
  57. package/src/integrations/spotify/history-archive.mjs +59 -0
  58. package/src/integrations/spotify/index.mjs +80 -0
  59. package/src/integrations/spotify/recent-activity.mjs +198 -0
  60. package/src/integrations/spotify/resolution-cache.mjs +260 -0
  61. package/src/integrations/spotify/service.mjs +713 -0
  62. package/src/integrations/spotify/settings.mjs +89 -0
  63. package/src/integrations/spotify/web-api-client.mjs +761 -0
  64. package/src/integrations/web/codex-web.mjs +263 -0
  65. package/src/integrations/youtube-music/takeout.mjs +143 -0
  66. package/src/memory/local-memory-store.mjs +1705 -0
  67. package/src/memory/reflection-worker.mjs +87 -0
  68. package/src/profile/apple-library-import.mjs +28 -0
  69. package/src/profile/history-import.mjs +163 -0
  70. package/src/profile/listener-corrections.mjs +217 -0
  71. package/src/profile/listening-history-store.mjs +2055 -0
  72. package/src/profile/listening-profile-projection.mjs +2520 -0
  73. package/src/profile/local-music-data.mjs +812 -0
  74. package/src/profile/music-import-bundle.mjs +91 -0
  75. package/src/profile/music-providers.mjs +25 -0
  76. package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
  77. package/src/profile/spotify-archive-taste.mjs +482 -0
  78. package/src/runtime/pi/agent-runtime.mjs +6444 -0
  79. package/src/runtime/pi/authentication.mjs +104 -0
  80. package/src/runtime/pi/configured-runtime.mjs +98 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
  82. package/src/runtime/pi/model-catalog.mjs +62 -0
  83. package/src/runtime/pi/model-transport.mjs +117 -0
  84. package/src/runtime/pi/persistent-credential-store.mjs +670 -0
  85. package/src/runtime/pi/provider-registry.mjs +74 -0
  86. package/src/runtime/pi/runtime-settings.mjs +81 -0
  87. package/src/surfaces/cli/auth-command.mjs +355 -0
  88. package/src/surfaces/cli/brand-components.mjs +390 -0
  89. package/src/surfaces/cli/brand-theme.mjs +231 -0
  90. package/src/surfaces/cli/catalog-command.mjs +509 -0
  91. package/src/surfaces/cli/command-completions.mjs +113 -0
  92. package/src/surfaces/cli/data-command.mjs +214 -0
  93. package/src/surfaces/cli/format-output.mjs +750 -0
  94. package/src/surfaces/cli/history-import-view.mjs +445 -0
  95. package/src/surfaces/cli/import-guides.mjs +9 -0
  96. package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
  97. package/src/surfaces/cli/listening-menu.mjs +171 -0
  98. package/src/surfaces/cli/profile-command.mjs +290 -0
  99. package/src/surfaces/cli/spotify-command.mjs +849 -0
  100. package/src/surfaces/cli/taste-profile-model.mjs +306 -0
  101. package/src/surfaces/cli/taste-profile-view.mjs +272 -0
  102. package/src/surfaces/cli/terminal-art.mjs +295 -0
  103. package/src/surfaces/cli/tui.mjs +1714 -0
  104. package/src/surfaces/cli/web-command.mjs +52 -0
  105. package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
  106. package/src/surfaces/html/tasteprint.mjs +3575 -0
  107. package/src/surfaces/web/showcase.mjs +272 -0
  108. package/src/surfaces/web/studio-page.mjs +4148 -0
  109. package/src/surfaces/web/studio.mjs +2288 -0
@@ -0,0 +1,276 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile, readdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { canonicalizeJson } from "../../../scripts/contract-semantics.mjs";
6
+ import { validateAppleMusicImportRecords } from "./normalize.mjs";
7
+ import { AppleMusicImportError } from "./parse-plist.mjs";
8
+ import {
9
+ APPLE_LIBRARY_BATCH_NAMESPACE,
10
+ APPLE_MUSIC_LIBRARY_IMPORTER_NAME,
11
+ APPLE_MUSIC_LIBRARY_IMPORTER_VERSION,
12
+ isUuid,
13
+ uuidV5,
14
+ } from "./stable-ids.mjs";
15
+
16
+ const dataFileNames = [
17
+ "track-refs.ndjson",
18
+ "track-snapshots.ndjson",
19
+ "warnings.ndjson",
20
+ ];
21
+ const maximumManifestBytes = 1024 * 1024;
22
+ const maximumDataFileBytes = 128 * 1024 * 1024;
23
+
24
+ function fail(code, message, options) {
25
+ throw new AppleMusicImportError(code, message, options);
26
+ }
27
+
28
+ function sha256(buffer) {
29
+ return createHash("sha256").update(buffer).digest("hex");
30
+ }
31
+
32
+ function compareText(left, right) {
33
+ return left < right ? -1 : left > right ? 1 : 0;
34
+ }
35
+
36
+ function assertPrivateMode(metadata, kind) {
37
+ if (process.platform === "win32") return;
38
+ if ((metadata.mode & 0o077) !== 0) {
39
+ fail("batch_permissions_invalid", `Apple import batch ${kind} is not private`);
40
+ }
41
+ }
42
+
43
+ function parseCanonicalNdjson(buffer, expectedRecords) {
44
+ let source;
45
+ try {
46
+ source = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
47
+ } catch (cause) {
48
+ fail("batch_data_invalid", "Apple import batch data is not valid UTF-8", {
49
+ cause,
50
+ });
51
+ }
52
+ if (source === "") {
53
+ if (expectedRecords !== 0) {
54
+ fail("batch_data_invalid", "Apple import batch record count does not match");
55
+ }
56
+ return [];
57
+ }
58
+ if (!source.endsWith("\n")) {
59
+ fail("batch_data_invalid", "Apple import batch NDJSON is not canonical");
60
+ }
61
+
62
+ const lines = source.slice(0, -1).split("\n");
63
+ if (lines.length !== expectedRecords) {
64
+ fail("batch_data_invalid", "Apple import batch record count does not match");
65
+ }
66
+ return lines.map((line) => {
67
+ let record;
68
+ try {
69
+ record = JSON.parse(line);
70
+ } catch (cause) {
71
+ fail("batch_data_invalid", "Apple import batch contains invalid JSON", {
72
+ cause,
73
+ });
74
+ }
75
+ if (canonicalizeJson(record) !== line) {
76
+ fail("batch_data_invalid", "Apple import batch NDJSON is not canonical");
77
+ }
78
+ return record;
79
+ });
80
+ }
81
+
82
+ async function readPrivateFile(filePath, descriptor) {
83
+ const metadata = await lstat(filePath);
84
+ if (
85
+ !metadata.isFile() ||
86
+ metadata.isSymbolicLink() ||
87
+ metadata.size !== descriptor.bytes ||
88
+ metadata.size > maximumDataFileBytes
89
+ ) {
90
+ fail("batch_data_invalid", "Apple import batch data file is invalid");
91
+ }
92
+ assertPrivateMode(metadata, "file");
93
+ const buffer = await readFile(filePath);
94
+ if (sha256(buffer) !== descriptor.sha256) {
95
+ fail("batch_digest_mismatch", "Apple import batch data digest does not match");
96
+ }
97
+ return parseCanonicalNdjson(buffer, descriptor.records);
98
+ }
99
+
100
+ async function readAppleMusicImportManifest(
101
+ batchDirectory,
102
+ { expectedSubjectId } = {},
103
+ ) {
104
+ if (expectedSubjectId !== undefined && !isUuid(expectedSubjectId)) {
105
+ throw new TypeError("Expected Apple import subject must be a UUID");
106
+ }
107
+ let directoryMetadata;
108
+ try {
109
+ directoryMetadata = await lstat(batchDirectory);
110
+ } catch (cause) {
111
+ fail("batch_unreadable", "Apple import batch cannot be read", { cause });
112
+ }
113
+ if (
114
+ !directoryMetadata.isDirectory() ||
115
+ directoryMetadata.isSymbolicLink()
116
+ ) {
117
+ fail("batch_directory_invalid", "Apple import batch directory is invalid");
118
+ }
119
+ assertPrivateMode(directoryMetadata, "directory");
120
+
121
+ const batchDirectoryName = path.basename(batchDirectory);
122
+ if (!isUuid(batchDirectoryName)) {
123
+ fail("batch_directory_invalid", "Apple import batch directory name is invalid");
124
+ }
125
+
126
+ const entries = (await readdir(batchDirectory)).sort();
127
+ const expectedEntries = ["manifest.json", ...dataFileNames].sort();
128
+ if (canonicalizeJson(entries) !== canonicalizeJson(expectedEntries)) {
129
+ fail("batch_directory_invalid", "Apple import batch contains unexpected files");
130
+ }
131
+
132
+ const manifestPath = path.join(batchDirectory, "manifest.json");
133
+ const manifestMetadata = await lstat(manifestPath);
134
+ if (
135
+ !manifestMetadata.isFile() ||
136
+ manifestMetadata.isSymbolicLink() ||
137
+ manifestMetadata.size > maximumManifestBytes
138
+ ) {
139
+ fail("batch_manifest_invalid", "Apple import batch manifest is invalid");
140
+ }
141
+ assertPrivateMode(manifestMetadata, "manifest");
142
+
143
+ let persistedManifest;
144
+ try {
145
+ persistedManifest = JSON.parse(await readFile(manifestPath, "utf8"));
146
+ } catch (cause) {
147
+ fail("batch_manifest_invalid", "Apple import batch manifest is invalid", {
148
+ cause,
149
+ });
150
+ }
151
+ if (
152
+ persistedManifest.import_batch_id !== batchDirectoryName ||
153
+ (expectedSubjectId !== undefined &&
154
+ persistedManifest.subject_id !== expectedSubjectId.toLowerCase()) ||
155
+ !Array.isArray(persistedManifest.files) ||
156
+ persistedManifest.files.length !== dataFileNames.length
157
+ ) {
158
+ fail("batch_manifest_invalid", "Apple import batch manifest identity is invalid");
159
+ }
160
+ const expectedBatchId = uuidV5(
161
+ `v1\n${APPLE_MUSIC_LIBRARY_IMPORTER_NAME}@${APPLE_MUSIC_LIBRARY_IMPORTER_VERSION}\n${persistedManifest.subject_id}\n${persistedManifest.source?.sha256}`,
162
+ APPLE_LIBRARY_BATCH_NAMESPACE,
163
+ );
164
+ if (expectedBatchId !== persistedManifest.import_batch_id) {
165
+ fail(
166
+ "batch_manifest_invalid",
167
+ "Apple import batch deterministic identity is invalid",
168
+ );
169
+ }
170
+
171
+ const descriptors = new Map();
172
+ for (const descriptor of persistedManifest.files) {
173
+ if (
174
+ !descriptor ||
175
+ canonicalizeJson(Object.keys(descriptor).sort()) !==
176
+ canonicalizeJson(["bytes", "name", "records", "sha256"]) ||
177
+ !dataFileNames.includes(descriptor.name) ||
178
+ descriptors.has(descriptor.name) ||
179
+ !/^[0-9a-f]{64}$/.test(descriptor.sha256) ||
180
+ !Number.isSafeInteger(descriptor.bytes) ||
181
+ descriptor.bytes < 0 ||
182
+ !Number.isSafeInteger(descriptor.records) ||
183
+ descriptor.records < 0
184
+ ) {
185
+ fail("batch_manifest_invalid", "Apple import batch file descriptor is invalid");
186
+ }
187
+ descriptors.set(descriptor.name, descriptor);
188
+ }
189
+
190
+ return { persistedManifest, descriptors };
191
+ }
192
+
193
+ export async function readAppleMusicImportBatch(
194
+ batchDirectory,
195
+ { expectedSubjectId } = {},
196
+ ) {
197
+ const { persistedManifest, descriptors } =
198
+ await readAppleMusicImportManifest(batchDirectory, { expectedSubjectId });
199
+
200
+ const [trackRefs, trackSnapshots, warnings] = await Promise.all(
201
+ dataFileNames.map((fileName) =>
202
+ readPrivateFile(path.join(batchDirectory, fileName), descriptors.get(fileName)),
203
+ ),
204
+ );
205
+ const { files: ignoredFiles, ...manifest } = persistedManifest;
206
+ void ignoredFiles;
207
+ const records = { manifest, trackRefs, trackSnapshots, warnings };
208
+ await validateAppleMusicImportRecords(records);
209
+ return records;
210
+ }
211
+
212
+ export async function listAppleMusicImportBatches(
213
+ importsRoot,
214
+ { subjectId } = {},
215
+ ) {
216
+ if (subjectId !== undefined && !isUuid(subjectId)) {
217
+ throw new TypeError("Apple projection requires a valid subject UUID");
218
+ }
219
+ let rootMetadata;
220
+ try {
221
+ rootMetadata = await lstat(importsRoot);
222
+ } catch (cause) {
223
+ fail("imports_root_unreadable", "Apple import root cannot be read", { cause });
224
+ }
225
+ if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink()) {
226
+ fail("imports_root_invalid", "Apple import root is invalid");
227
+ }
228
+ assertPrivateMode(rootMetadata, "root");
229
+
230
+ const entries = await readdir(importsRoot, { withFileTypes: true });
231
+ const batchNames = entries
232
+ .map((entry) => entry.name)
233
+ .filter((name) => isUuid(name))
234
+ .sort();
235
+ const candidates = [];
236
+ for (const batchName of batchNames) {
237
+ const batchDirectory = path.join(importsRoot, batchName);
238
+ const { persistedManifest } = await readAppleMusicImportManifest(
239
+ batchDirectory,
240
+ );
241
+ candidates.push({
242
+ batchDirectory,
243
+ subjectId: persistedManifest.subject_id,
244
+ });
245
+ }
246
+ const subjects = new Set(candidates.map((candidate) => candidate.subjectId));
247
+ if (subjectId === undefined && subjects.size > 1) {
248
+ fail(
249
+ "projection_subject_mismatch",
250
+ "Apple projection cannot infer one subject across import batches",
251
+ );
252
+ }
253
+ const resolvedSubjectId =
254
+ subjectId?.toLowerCase() ?? [...subjects][0];
255
+ const batches = [];
256
+ for (const candidate of candidates) {
257
+ if (candidate.subjectId !== resolvedSubjectId) continue;
258
+ batches.push(
259
+ await readAppleMusicImportBatch(candidate.batchDirectory, {
260
+ expectedSubjectId: resolvedSubjectId,
261
+ }),
262
+ );
263
+ }
264
+ batches.sort((left, right) => {
265
+ const capturedOrder = compareText(
266
+ left.manifest.source.captured_at,
267
+ right.manifest.source.captured_at,
268
+ );
269
+ if (capturedOrder !== 0) return capturedOrder;
270
+ return compareText(
271
+ left.manifest.import_batch_id,
272
+ right.manifest.import_batch_id,
273
+ );
274
+ });
275
+ return batches;
276
+ }