@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,509 @@
1
+ import { sanitizeTerminalText } from "./format-output.mjs";
2
+ import { recoverArtistReleasesWithCrossCatalogIdentity } from "../../integrations/cross-catalog-artist-identity.mjs";
3
+
4
+ const usage =
5
+ "Usage: moondog catalog latest-single --artist <name> [--artist-page <Apple Music artist URL> | --known-release <title> | --from <spotify-history.zip>] [--json].";
6
+
7
+ export function catalogHelpText() {
8
+ return `# Moondog public catalog
9
+
10
+ - \`moondog catalog latest-single --artist <name> [--artist-page <Apple Music artist URL> | --known-release <title> | --from <spotify-history.zip>] [--json]\` - read the newest already released single from the Apple Music US storefront without a model
11
+
12
+ Use \`--artist-page\` to select one public artist page returned by an ambiguous result.
13
+ Use \`--known-release\` only to distinguish artists that share the same exact name.
14
+ Use \`--from\` to derive up to three withheld release hints from one explicitly supplied Spotify history ZIP without persistence.
15
+ When Apple name search remains ambiguous or finds no exact name, Moondog can follow one exact Wikidata label or alias through linked MusicBrainz and Apple Music artist identities.
16
+ The result includes the retrieval time and storefront boundary, makes no provider write, and does not send a personal profile or private archive.`;
17
+ }
18
+
19
+ function fail(message = usage) {
20
+ throw new Error(message);
21
+ }
22
+
23
+ function cleanText(value, maximum, field) {
24
+ if (typeof value !== "string") fail(`The catalog ${field} is invalid.`);
25
+ const cleaned = value
26
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "")
27
+ .replace(/[\u202a-\u202e\u2066-\u2069]/gu, "")
28
+ .replace(/\s+/gu, " ")
29
+ .trim();
30
+ if (!cleaned || Array.from(cleaned).length > maximum) {
31
+ fail(`The catalog ${field} is invalid.`);
32
+ }
33
+ return cleaned;
34
+ }
35
+
36
+ function optionalText(value, maximum, field) {
37
+ return value === undefined || value === null
38
+ ? undefined
39
+ : cleanText(value, maximum, field);
40
+ }
41
+
42
+ function appleCatalogUrl(value, field) {
43
+ const cleaned = optionalText(value, 2_048, field);
44
+ if (!cleaned) return undefined;
45
+ let url;
46
+ try {
47
+ url = new URL(cleaned);
48
+ } catch {
49
+ fail(`The catalog ${field} is invalid.`);
50
+ }
51
+ if (
52
+ url.protocol !== "https:" ||
53
+ !new Set(["music.apple.com", "itunes.apple.com"]).has(url.hostname)
54
+ ) {
55
+ fail(`The catalog ${field} is invalid.`);
56
+ }
57
+ return url.toString();
58
+ }
59
+
60
+ function artistCatalogIdFromPage(value) {
61
+ const cleaned = cleanText(value, 2_048, "artist page");
62
+ let url;
63
+ try {
64
+ url = new URL(cleaned);
65
+ } catch {
66
+ fail("The catalog artist page is invalid.");
67
+ }
68
+ if (
69
+ url.protocol !== "https:" ||
70
+ url.username ||
71
+ url.password ||
72
+ !new Set(["music.apple.com", "itunes.apple.com"]).has(url.hostname)
73
+ ) {
74
+ fail("The catalog artist page is invalid.");
75
+ }
76
+ const segments = url.pathname.split("/").filter(Boolean);
77
+ const artistIndex = segments.indexOf("artist");
78
+ const rawCatalogId = segments.at(-1)?.replace(/^id(?=\d)/u, "");
79
+ if (
80
+ artistIndex < 0 ||
81
+ artistIndex >= segments.length - 1 ||
82
+ !/^\d{1,20}$/u.test(rawCatalogId ?? "")
83
+ ) {
84
+ fail("The catalog artist page is invalid.");
85
+ }
86
+ return rawCatalogId;
87
+ }
88
+
89
+ function parseLatestSingleArguments(args) {
90
+ const values = args.slice(1);
91
+ const parsed = {};
92
+ for (let index = 0; index < values.length; index += 2) {
93
+ const option = values[index];
94
+ const value = values[index + 1];
95
+ if (!new Set(["--artist", "--artist-page", "--known-release"]).has(option)) {
96
+ fail(usage);
97
+ }
98
+ if (value === undefined || value.startsWith("--")) {
99
+ fail(`The ${option} option requires a value.`);
100
+ }
101
+ const field = option === "--artist"
102
+ ? "artistName"
103
+ : option === "--artist-page"
104
+ ? "artistPage"
105
+ : "knownRelease";
106
+ if (parsed[field] !== undefined) {
107
+ fail(`The ${option} option may be provided only once.`);
108
+ }
109
+ parsed[field] = value;
110
+ }
111
+ if (parsed.artistName === undefined) {
112
+ fail("The --artist option is required.");
113
+ }
114
+ return parsed;
115
+ }
116
+
117
+ function projectSource(value) {
118
+ if (
119
+ !value ||
120
+ value.provider !== "apple_music" ||
121
+ value.catalog !== "itunes_search_api" ||
122
+ value.storefront !== "US"
123
+ ) {
124
+ fail("The public catalog source is invalid.");
125
+ }
126
+ const retrievedAt = cleanText(value.retrieved_at, 64, "retrieval time");
127
+ if (!Number.isFinite(Date.parse(retrievedAt))) {
128
+ fail("The public catalog retrieval time is invalid.");
129
+ }
130
+ return {
131
+ provider: "apple_music",
132
+ catalog: "itunes_search_api",
133
+ storefront: "US",
134
+ retrieved_at: retrievedAt,
135
+ coverage: cleanText(value.coverage, 256, "coverage boundary"),
136
+ };
137
+ }
138
+
139
+ function projectArtist(value) {
140
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
141
+ fail("The catalog artist is invalid.");
142
+ }
143
+ const artist = {
144
+ name: cleanText(value.name, 256, "artist name"),
145
+ };
146
+ const catalogUrl = appleCatalogUrl(value.catalog_url, "artist URL");
147
+ if (catalogUrl) artist.catalog_url = catalogUrl;
148
+ return artist;
149
+ }
150
+
151
+ function projectRelease(value) {
152
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
153
+ fail("The catalog release is invalid.");
154
+ }
155
+ const releaseType = cleanText(value.release_type, 16, "release type");
156
+ if (!new Set(["single", "ep", "album"]).has(releaseType)) {
157
+ fail("The catalog release type is invalid.");
158
+ }
159
+ const releaseDate = cleanText(value.release_date, 10, "release date");
160
+ if (
161
+ !/^\d{4}-\d{2}-\d{2}$/u.test(releaseDate) ||
162
+ !Number.isFinite(Date.parse(`${releaseDate}T00:00:00.000Z`))
163
+ ) {
164
+ fail("The catalog release date is invalid.");
165
+ }
166
+ const release = {
167
+ title: cleanText(value.title, 512, "release title"),
168
+ artist_name: cleanText(value.artist_name, 256, "release artist"),
169
+ release_type: releaseType,
170
+ release_date: releaseDate,
171
+ };
172
+ const catalogUrl = appleCatalogUrl(value.catalog_url, "release URL");
173
+ if (catalogUrl) release.catalog_url = catalogUrl;
174
+ return release;
175
+ }
176
+
177
+ function projectCandidate(value) {
178
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
179
+ fail("The catalog artist candidate is invalid.");
180
+ }
181
+ return {
182
+ artist: projectArtist(value.artist),
183
+ recent_releases: Array.isArray(value.recent_releases)
184
+ ? value.recent_releases.slice(0, 3).map(projectRelease)
185
+ : [],
186
+ };
187
+ }
188
+
189
+ function safeCount(value, field) {
190
+ if (!Number.isSafeInteger(value) || value < 0 || value > 500_000) {
191
+ fail(`The local archive ${field} is invalid.`);
192
+ }
193
+ return value;
194
+ }
195
+
196
+ function projectArchiveHints(value) {
197
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
198
+ fail("The local archive evidence is invalid.");
199
+ }
200
+ const sourceFormat = cleanText(
201
+ value.source_format,
202
+ 128,
203
+ "archive source format",
204
+ );
205
+ if (
206
+ !new Set([
207
+ "spotify_account_data_streaming_history_v1",
208
+ "spotify_extended_streaming_history_music_v1",
209
+ ]).has(sourceFormat)
210
+ ) {
211
+ fail("The local archive source format is invalid.");
212
+ }
213
+ if (!Array.isArray(value.release_titles) || value.release_titles.length > 3) {
214
+ fail("The local archive release hints are invalid.");
215
+ }
216
+ const releaseTitles = value.release_titles.map((title) =>
217
+ cleanText(title, 512, "archive release hint"),
218
+ );
219
+ const normalizedTitles = releaseTitles.map((title) =>
220
+ title.normalize("NFKC").toLocaleLowerCase("und"),
221
+ );
222
+ if (new Set(normalizedTitles).size !== releaseTitles.length) {
223
+ fail("The local archive release hints are invalid.");
224
+ }
225
+ safeCount(value.exact_artist_track_count, "exact-artist track count");
226
+ safeCount(value.exact_artist_event_count, "exact-artist event count");
227
+ return {
228
+ releaseTitles,
229
+ publicEvidence: {
230
+ source_format: sourceFormat,
231
+ private_values_withheld: true,
232
+ },
233
+ };
234
+ }
235
+
236
+ function boundedResult(
237
+ raw,
238
+ {
239
+ artistName,
240
+ artistPage,
241
+ knownRelease,
242
+ archiveHints,
243
+ releaseHintsConsidered = 0,
244
+ crossCatalogIdentity,
245
+ },
246
+ ) {
247
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
248
+ fail("The public catalog result is invalid.");
249
+ }
250
+ const source = projectSource(raw.source);
251
+ const base = {
252
+ command: "catalog.latest_single",
253
+ state: cleanText(raw.state, 32, "result state"),
254
+ source,
255
+ query: {
256
+ artist_name: cleanText(artistName, 256, "artist name"),
257
+ artist_page_provided: artistPage !== undefined,
258
+ known_release_provided: knownRelease !== undefined,
259
+ local_archive_provided: archiveHints !== undefined,
260
+ cross_catalog_identity_attempted: crossCatalogIdentity !== undefined,
261
+ },
262
+ model_required: false,
263
+ private_profile_read: false,
264
+ private_profile_sent: false,
265
+ private_archive_read: archiveHints !== undefined,
266
+ private_archive_sent: false,
267
+ external_effects: "none",
268
+ };
269
+ if (crossCatalogIdentity) {
270
+ base.cross_catalog_identity = crossCatalogIdentity;
271
+ }
272
+ if (archiveHints) {
273
+ base.local_archive_evidence = {
274
+ ...archiveHints.publicEvidence,
275
+ release_hints_considered: releaseHintsConsidered,
276
+ selected_release_withheld:
277
+ raw.state === "resolved" &&
278
+ raw.selection_basis === "exact_artist_name_and_known_release",
279
+ persisted: false,
280
+ };
281
+ }
282
+ if (raw.state === "not_found") return base;
283
+ if (raw.state === "artist_identity_mismatch") {
284
+ return {
285
+ ...base,
286
+ artist: projectArtist(raw.artist),
287
+ };
288
+ }
289
+ if (raw.state === "ambiguous_artist") {
290
+ const candidates = Array.isArray(raw.candidates)
291
+ ? raw.candidates.slice(0, 4).map(projectCandidate)
292
+ : [];
293
+ return {
294
+ ...base,
295
+ candidate_count: candidates.length,
296
+ candidates,
297
+ };
298
+ }
299
+ if (raw.state !== "resolved") fail("The public catalog result state is invalid.");
300
+ const artist = projectArtist(raw.artist);
301
+ const rawSelectionBasis = cleanText(
302
+ raw.selection_basis,
303
+ 64,
304
+ "identity selection basis",
305
+ );
306
+ const selectionBasis =
307
+ crossCatalogIdentity?.state === "resolved" &&
308
+ rawSelectionBasis === "explicit_artist_catalog_identity"
309
+ ? "wikidata_exact_label_or_alias_cross_catalog"
310
+ : archiveHints && rawSelectionBasis === "exact_artist_name_and_known_release"
311
+ ? "exact_artist_name_and_withheld_local_history_release"
312
+ : rawSelectionBasis;
313
+ if (raw.latest_released_single === null) {
314
+ return {
315
+ ...base,
316
+ state: "no_released_single",
317
+ artist,
318
+ identity_selection_basis: selectionBasis,
319
+ };
320
+ }
321
+ if (raw.latest_released_single === undefined) {
322
+ fail("The catalog did not provide an explicit latest released single.");
323
+ }
324
+ const latestReleasedSingle = projectRelease(raw.latest_released_single);
325
+ if (
326
+ latestReleasedSingle.release_type !== "single" ||
327
+ latestReleasedSingle.release_date > source.retrieved_at.slice(0, 10)
328
+ ) {
329
+ fail("The explicit latest released single is invalid.");
330
+ }
331
+ return {
332
+ ...base,
333
+ artist,
334
+ identity_selection_basis: selectionBasis,
335
+ latest_released_single: latestReleasedSingle,
336
+ };
337
+ }
338
+
339
+ function formatCandidate(candidate, index) {
340
+ const releases = candidate.recent_releases
341
+ .map((release) => `${release.title} (${release.release_date})`)
342
+ .join(", ");
343
+ const catalogPage = candidate.artist.catalog_url
344
+ ? `; catalog page: ${candidate.artist.catalog_url}`
345
+ : "";
346
+ return `- Candidate ${index + 1}: ${candidate.artist.name}${catalogPage}${
347
+ releases ? `; recent public releases: ${releases}` : ""
348
+ }`;
349
+ }
350
+
351
+ function formatCrossCatalogIdentity(identity) {
352
+ if (identity.state === "resolved") {
353
+ return `Cross-catalog identity: one exact Wikidata label or alias linked ${identity.canonical_name} through MusicBrainz ${identity.musicbrainz_artist_id} to Apple Music ${identity.apple_music_artist_id}.`;
354
+ }
355
+ if (identity.state === "not_found") {
356
+ return "Cross-catalog identity: Wikidata found no exact label or alias carrying a MusicBrainz artist identity, so Moondog did not guess.";
357
+ }
358
+ if (identity.state === "ambiguous") {
359
+ return "Cross-catalog identity: the exact Wikidata label or alias maps to more than one MusicBrainz artist identity, so Moondog did not guess.";
360
+ }
361
+ if (identity.state === "apple_music_identity_missing") {
362
+ return "Cross-catalog identity: the exact Wikidata label or alias resolves to one MusicBrainz artist identity but has no linked Apple Music artist identity, so Moondog did not guess.";
363
+ }
364
+ if (identity.state === "apple_music_identity_ambiguous") {
365
+ return "Cross-catalog identity: the exact Wikidata label or alias has more than one linked Apple Music artist identity, so Moondog did not guess.";
366
+ }
367
+ if (identity.state === "apple_music_identity_not_found") {
368
+ return "Cross-catalog identity: the uniquely linked Apple Music artist identity was not found in the US storefront, so Moondog retained the original catalog result.";
369
+ }
370
+ if (identity.state === "apple_music_identity_mismatch") {
371
+ return "Cross-catalog identity: the Apple lookup did not return the uniquely linked artist identity, so Moondog retained the original catalog result.";
372
+ }
373
+ return "Cross-catalog identity: Wikidata was unavailable, so Moondog retained the original catalog result instead of guessing.";
374
+ }
375
+
376
+ function formatResult(result) {
377
+ const lines = ["Apple Music US public catalog"];
378
+ if (result.state === "resolved") {
379
+ lines.push(
380
+ `${result.artist.name} latest released single: ${result.latest_released_single.title}`,
381
+ `Released: ${result.latest_released_single.release_date}`,
382
+ );
383
+ if (result.latest_released_single.catalog_url) {
384
+ lines.push(`Catalog page: ${result.latest_released_single.catalog_url}`);
385
+ }
386
+ lines.push(`Identity: ${result.identity_selection_basis}.`);
387
+ } else if (result.state === "ambiguous_artist") {
388
+ lines.push(
389
+ `The catalog returned multiple exact-name artist candidates (${result.candidate_count}), so Moondog did not guess.`,
390
+ ...result.candidates.map(formatCandidate),
391
+ "Next: rerun with --artist-page <one catalog page shown above>, or --known-release <one known title>.",
392
+ );
393
+ } else if (result.state === "artist_identity_mismatch") {
394
+ lines.push(
395
+ `The supplied artist page resolves to ${result.artist.name}, not ${result.query.artist_name}, so Moondog did not continue.`,
396
+ result.artist.catalog_url
397
+ ? `Catalog page: ${result.artist.catalog_url}`
398
+ : null,
399
+ );
400
+ } else if (result.state === "not_found") {
401
+ lines.push(
402
+ result.cross_catalog_identity?.state === "resolved"
403
+ ? `The linked Apple Music artist identity was not found in the US storefront for ${result.query.artist_name}.`
404
+ : `No exact artist match was found for ${result.query.artist_name}.`,
405
+ );
406
+ } else {
407
+ lines.push(
408
+ `No already released single was found for ${result.artist.name} in the bounded catalog lookup.`,
409
+ );
410
+ }
411
+ if (result.cross_catalog_identity) {
412
+ lines.push(formatCrossCatalogIdentity(result.cross_catalog_identity));
413
+ }
414
+ lines.push(
415
+ `Retrieved: ${result.source.retrieved_at}`,
416
+ `Boundary: ${result.source.coverage}`,
417
+ result.private_archive_read
418
+ ? "Privacy: This command read only the explicitly supplied Spotify history ZIP in memory, persisted none of it, and sent neither archive content nor derived release titles."
419
+ : "Privacy: This command did not read or send a personal profile.",
420
+ result.private_archive_read
421
+ ? "Moondog's persisted personal profile was not read."
422
+ : null,
423
+ "No model provider was used. No provider write occurred.",
424
+ );
425
+ return lines
426
+ .filter((line) => line !== null)
427
+ .map((line) => sanitizeTerminalText(line))
428
+ .join("\n");
429
+ }
430
+
431
+ export async function runCatalogCommand({
432
+ args = [],
433
+ json = false,
434
+ archivePath,
435
+ deriveArchiveHints,
436
+ catalog,
437
+ identityResolver,
438
+ stdout = process.stdout,
439
+ } = {}) {
440
+ if (
441
+ (args.length === 0 || (args.length === 1 && args[0] === "help")) &&
442
+ archivePath === undefined
443
+ ) {
444
+ stdout.write(`${catalogHelpText()}\n`);
445
+ return { state: "help" };
446
+ }
447
+ if (args[0] !== "latest-single") fail(usage);
448
+ const parsed = parseLatestSingleArguments(args);
449
+ const identityHintCount = [
450
+ parsed.artistPage,
451
+ parsed.knownRelease,
452
+ archivePath,
453
+ ].filter((value) => value !== undefined).length;
454
+ if (identityHintCount > 1) {
455
+ fail("Use only one identity hint: --artist-page, --known-release, or --from.");
456
+ }
457
+ const artistCatalogId = parsed.artistPage === undefined
458
+ ? undefined
459
+ : artistCatalogIdFromPage(parsed.artistPage);
460
+ if (!catalog || typeof catalog.findArtistReleases !== "function") {
461
+ fail("The public catalog is unavailable.");
462
+ }
463
+ let archiveHints;
464
+ if (archivePath !== undefined) {
465
+ if (typeof deriveArchiveHints !== "function") {
466
+ fail("The local Spotify archive reader is unavailable.");
467
+ }
468
+ archiveHints = projectArchiveHints(
469
+ await deriveArchiveHints({
470
+ archivePath,
471
+ artistName: parsed.artistName,
472
+ maximumHints: 3,
473
+ }),
474
+ );
475
+ }
476
+ const releaseHints = parsed.knownRelease
477
+ ? [parsed.knownRelease]
478
+ : (archiveHints?.releaseTitles ?? []);
479
+ const attempts = releaseHints.length > 0 ? releaseHints : [undefined];
480
+ let raw;
481
+ let releaseHintsConsidered = 0;
482
+ for (const releaseHint of attempts) {
483
+ if (releaseHint !== undefined) releaseHintsConsidered += 1;
484
+ raw = await catalog.findArtistReleases({
485
+ artistName: parsed.artistName,
486
+ ...(artistCatalogId === undefined ? {} : { artistCatalogId }),
487
+ ...(releaseHint === undefined ? {} : { knownRelease: releaseHint }),
488
+ limit: 8,
489
+ });
490
+ if (raw?.state !== "ambiguous_artist") break;
491
+ }
492
+ if (parsed.artistPage === undefined) {
493
+ raw = await recoverArtistReleasesWithCrossCatalogIdentity({
494
+ artistName: parsed.artistName,
495
+ currentResult: raw,
496
+ catalog,
497
+ identityResolver,
498
+ });
499
+ }
500
+ const crossCatalogIdentity = raw.cross_catalog_identity;
501
+ const result = boundedResult(raw, {
502
+ ...parsed,
503
+ archiveHints,
504
+ releaseHintsConsidered,
505
+ crossCatalogIdentity,
506
+ });
507
+ stdout.write(`${json ? JSON.stringify(result, null, 2) : formatResult(result)}\n`);
508
+ return result;
509
+ }
@@ -0,0 +1,113 @@
1
+ function choices(entries) {
2
+ return entries.map(([value, description]) => ({ value, label: value, description }));
3
+ }
4
+
5
+ const themeChoices = choices([
6
+ ["paper", "Warm ivory and charcoal ink"],
7
+ ["charcoal", "A quiet room, warm light"],
8
+ ["terminal", "Keep your terminal's colors"],
9
+ ["auto", "Follow MOONDOG_THEME, or guess from your terminal"],
10
+ ]);
11
+ const artChoices = choices([
12
+ ["braille", "Character artwork with fine detail"],
13
+ ["ascii", "Simple terminal characters"],
14
+ ["off", "A quiet opening, no artwork"],
15
+ ["auto", "Choose artwork for this terminal"],
16
+ ]);
17
+ const motionChoices = choices([["on", "Animate the character artwork"], ["off", "Keep the artwork still"]]);
18
+ const webChoices = choices([
19
+ ["search", "Search public music sites"],
20
+ ["read", "Read a public page"],
21
+ ["status", "Check whether web lookups work"],
22
+ ["help", "Web lookup commands"],
23
+ ]);
24
+ const spotifyChoices = choices([
25
+ ["status", "Check your Spotify connection"],
26
+ ["now", "What's playing now"],
27
+ ["devices", "Your Spotify devices"],
28
+ ["queue", "What's queued up"],
29
+ ["recent", "What you played lately"],
30
+ ["account", "Which account is connected"],
31
+ ["play", "Resume playback or play a Spotify URI"],
32
+ ["pause", "Pause playback"],
33
+ ["next", "Play the next track"],
34
+ ["previous", "Play the previous track"],
35
+ ["volume", "Set volume from 0 to 100"],
36
+ ["seek", "Seek to a position in milliseconds"],
37
+ ["shuffle", "Set shuffle on or off"],
38
+ ["repeat", "Repeat off, one track, or the context"],
39
+ ["transfer", "Move playback to another device"],
40
+ ["queue-add", "Add a track or episode URI to the queue"],
41
+ ["resolve", "Find a track by title and artist"],
42
+ ["sync-recent", "Add your latest plays to your profile"],
43
+ ["import-history", "Add a Spotify history ZIP to your profile"],
44
+ ["configure", "Save your Spotify app's Client ID"],
45
+ ["login", "Connect your Spotify account"],
46
+ ["logout", "Disconnect your Spotify account"],
47
+ ["help", "Spotify commands"],
48
+ ]);
49
+ const spotifyArguments = new Map([
50
+ ["shuffle", choices([["on", "Enable shuffle"], ["off", "Disable shuffle"]])],
51
+ ["repeat", choices([["off", "Disable repeat"], ["track", "Repeat this track"], ["context", "Repeat the album or playlist"]])],
52
+ ]);
53
+
54
+ function completeArgument(prefix, getChoices) {
55
+ // Enum completion leaves quoted, escaped, and multiline input to Pi's editor.
56
+ if (/["'\\\r\n]/u.test(prefix)) return null;
57
+ const [, beforeToken, token] = prefix.match(/^(.*?)(\S*)$/u);
58
+ const previous = beforeToken.trim().split(/\s+/u).filter(Boolean);
59
+ const available = getChoices(previous);
60
+ // A complete value should submit on Enter, even if it also prefixes another choice.
61
+ if (available.some((item) => item.value === token)) return null;
62
+ const matching = available.filter((item) => item.value.toLowerCase().startsWith(token.toLowerCase()));
63
+ // Pi replaces the entire argument prefix, including earlier arguments.
64
+ return matching.length ? matching.map((item) => ({ ...item, value: beforeToken + item.value })) : null;
65
+ }
66
+
67
+ export function withCommandCompletions(commands, {
68
+ providers = () => [],
69
+ models = () => [],
70
+ authProviderIds = [],
71
+ } = {}) {
72
+ const firstArgument = (items) => (previous) => previous.length === 0 ? items : [];
73
+ const specifications = {
74
+ theme: { argumentHint: "[paper|charcoal|terminal|auto]", choices: firstArgument(themeChoices) },
75
+ art: { argumentHint: "[braille|ascii|off|auto]", choices: firstArgument(artChoices) },
76
+ motion: { argumentHint: "[on|off]", choices: firstArgument(motionChoices) },
77
+ lyrics: { argumentHint: "[sync]", choices: firstArgument(choices([["sync", "Check profile songs for lyrics"]])) },
78
+ web: { argumentHint: "search <query> | read <url> | status", choices: firstArgument(webChoices) },
79
+ spotify: {
80
+ argumentHint: "<command> [arguments]",
81
+ choices: (previous) => previous.length === 0 ? spotifyChoices
82
+ : previous.length === 1 ? spotifyArguments.get(previous[0]) ?? [] : [],
83
+ },
84
+ auth: {
85
+ argumentHint: "[provider]",
86
+ choices: (previous) => previous.length === 0 ? providers()
87
+ .filter((provider) => authProviderIds.includes(provider.id))
88
+ .map((provider) => ({ value: provider.id, label: provider.id, description: provider.name })) : [],
89
+ },
90
+ model: {
91
+ argumentHint: "[provider] [model]",
92
+ choices: (previous) => {
93
+ if (previous.length > 1) return [];
94
+ const available = providers().filter((provider) => provider.modelCount > 0);
95
+ if (previous.length === 0) {
96
+ return available.map((provider) => ({ value: provider.id, label: provider.id, description: provider.name }));
97
+ }
98
+ const providerId = previous[0].toLowerCase();
99
+ if (!available.some((provider) => provider.id === providerId)) return [];
100
+ return models(providerId).map((model) => ({ value: model.id, label: model.id, description: model.name }));
101
+ },
102
+ },
103
+ };
104
+ return commands.map((command) => {
105
+ const specification = specifications[command.name];
106
+ if (!specification) return command;
107
+ return {
108
+ ...command,
109
+ argumentHint: command.argumentHint ?? specification.argumentHint,
110
+ getArgumentCompletions: command.getArgumentCompletions ?? ((prefix) => completeArgument(prefix, specification.choices)),
111
+ };
112
+ });
113
+ }