@audiofool/moondog 0.1.0 → 0.2.0-beta.2

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 (121) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +153 -68
  3. package/contracts/v1/definitions.schema.json +1 -169
  4. package/package.json +7 -23
  5. package/scripts/contract-lib.mjs +0 -7
  6. package/scripts/contract-semantics.mjs +0 -1904
  7. package/scripts/moondog.mjs +285 -74
  8. package/src/core/apple-library-source-status.mjs +0 -16
  9. package/src/core/apple-projection-domain-services.mjs +168 -36
  10. package/src/core/application-update.mjs +320 -0
  11. package/src/core/capability-catalog.mjs +278 -55
  12. package/src/core/listening-now.mjs +108 -0
  13. package/src/core/listening-profile-domain-services.mjs +153 -41
  14. package/src/core/listening-upkeep.mjs +31 -0
  15. package/src/core/moondog-application.mjs +1908 -104
  16. package/src/core/playback-preferences.mjs +97 -0
  17. package/src/core/spotify-listening-intent.mjs +121 -0
  18. package/src/core/synthetic-domain-services.mjs +24 -3
  19. package/src/demo/moondog-demo.mjs +4 -22
  20. package/src/i18n/index.mjs +285 -0
  21. package/src/i18n/messages/de.mjs +128 -0
  22. package/src/i18n/messages/en.mjs +129 -0
  23. package/src/i18n/messages/es.mjs +128 -0
  24. package/src/i18n/messages/fr.mjs +128 -0
  25. package/src/i18n/messages/it.mjs +128 -0
  26. package/src/i18n/messages/ja.mjs +128 -0
  27. package/src/i18n/messages/ko.mjs +128 -0
  28. package/src/i18n/messages/pt.mjs +128 -0
  29. package/src/i18n/messages/zh-Hant.mjs +128 -0
  30. package/src/i18n/messages/zh.mjs +128 -0
  31. package/src/i18n/preferences.mjs +34 -0
  32. package/src/i18n/screen/es.mjs +966 -0
  33. package/src/i18n/screen/ja.mjs +966 -0
  34. package/src/i18n/screen/pt.mjs +966 -0
  35. package/src/i18n/screen/zh.mjs +966 -0
  36. package/src/importers/apple-music-library/index.mjs +0 -16
  37. package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
  38. package/src/integrations/apple-music/catalog.mjs +123 -14
  39. package/src/integrations/apple-music/music-app.mjs +232 -0
  40. package/src/integrations/apple-music/storefront.mjs +28 -0
  41. package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
  42. package/src/integrations/lastfm/history.mjs +221 -0
  43. package/src/integrations/lastfm/settings.mjs +75 -0
  44. package/src/integrations/netease/ncm-cli-player.mjs +321 -0
  45. package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
  46. package/src/integrations/public-playlists.mjs +2 -1
  47. package/src/integrations/spotify/account-data-profile.mjs +14 -6
  48. package/src/integrations/spotify/authentication.mjs +149 -15
  49. package/src/integrations/spotify/catalog-resolver.mjs +8 -4
  50. package/src/integrations/spotify/connection.mjs +2 -1
  51. package/src/integrations/spotify/credential-store.mjs +4 -0
  52. package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
  53. package/src/integrations/spotify/history-archive.mjs +1 -1
  54. package/src/integrations/spotify/library-snapshot.mjs +227 -0
  55. package/src/integrations/spotify/recent-activity.mjs +2 -0
  56. package/src/integrations/spotify/service.mjs +873 -69
  57. package/src/integrations/spotify/web-api-client.mjs +407 -127
  58. package/src/integrations/web/brave-web.mjs +45 -0
  59. package/src/integrations/web/codex-web.mjs +15 -82
  60. package/src/integrations/web/deepseek-web.mjs +81 -0
  61. package/src/integrations/web/web-research.mjs +119 -0
  62. package/src/integrations/web/web-result.mjs +215 -0
  63. package/src/integrations/youtube-music/takeout.mjs +3 -1
  64. package/src/memory/local-memory-store.mjs +469 -41
  65. package/src/profile/history-import.mjs +42 -6
  66. package/src/profile/listener-profile-build.mjs +378 -0
  67. package/src/profile/listener-profile-store.mjs +130 -0
  68. package/src/profile/listener-profile-verification.mjs +68 -0
  69. package/src/profile/listener-profile-worker.mjs +172 -0
  70. package/src/profile/listening-history-store.mjs +424 -8
  71. package/src/profile/listening-profile-projection.mjs +242 -76
  72. package/src/profile/local-music-data.mjs +1 -1
  73. package/src/profile/music-providers.mjs +11 -7
  74. package/src/profile/profile-exploration.mjs +84 -0
  75. package/src/profile/recorded-measures.mjs +24 -0
  76. package/src/profile/spotify-archive-taste.mjs +0 -4
  77. package/src/runtime/pi/agent-runtime.mjs +2204 -605
  78. package/src/runtime/pi/authentication.mjs +34 -1
  79. package/src/runtime/pi/configured-runtime.mjs +22 -6
  80. package/src/runtime/pi/conversation-name.mjs +53 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
  82. package/src/runtime/pi/model-catalog.mjs +28 -5
  83. package/src/runtime/pi/model-settings.mjs +202 -0
  84. package/src/runtime/pi/model-transport.mjs +10 -0
  85. package/src/runtime/pi/persistent-credential-store.mjs +67 -31
  86. package/src/runtime/pi/profile-build-runtime.mjs +160 -0
  87. package/src/runtime/pi/public-model-catalog.mjs +288 -0
  88. package/src/runtime/pi/runtime-settings.mjs +10 -2
  89. package/src/runtime/pi/skills/listener-profile/SKILL.md +46 -0
  90. package/src/runtime/pi/tool-trace.mjs +37 -0
  91. package/src/surfaces/cli/auth-command.mjs +12 -4
  92. package/src/surfaces/cli/brand-components.mjs +310 -132
  93. package/src/surfaces/cli/brand-theme.mjs +51 -28
  94. package/src/surfaces/cli/command-completions.mjs +58 -42
  95. package/src/surfaces/cli/format-output.mjs +199 -388
  96. package/src/surfaces/cli/history-import-view.mjs +292 -139
  97. package/src/surfaces/cli/import-guides.mjs +2 -0
  98. package/src/surfaces/cli/listener-profile-command.mjs +92 -0
  99. package/src/surfaces/cli/listening-menu.mjs +7 -4
  100. package/src/surfaces/cli/profile-command.mjs +30 -21
  101. package/src/surfaces/cli/seasons-chart.mjs +87 -0
  102. package/src/surfaces/cli/spotify-command.mjs +54 -6
  103. package/src/surfaces/cli/taste-profile-model.mjs +78 -79
  104. package/src/surfaces/cli/taste-profile-view.mjs +33 -26
  105. package/src/surfaces/cli/terminal-art.mjs +140 -35
  106. package/src/surfaces/cli/tui.mjs +1066 -357
  107. package/src/surfaces/cli/update-command.mjs +32 -0
  108. package/src/surfaces/cli/web-command.mjs +53 -14
  109. package/src/surfaces/html/tasteprint.mjs +43 -70
  110. package/contracts/v1/capability-policy.schema.json +0 -572
  111. package/contracts/v1/profile-evidence.schema.json +0 -237
  112. package/contracts/v1/tool-invocation.schema.json +0 -318
  113. package/contracts/v1/tool-receipt.schema.json +0 -1066
  114. package/scripts/demo-studio.mjs +0 -58
  115. package/src/core/apple-data-migration.mjs +0 -50
  116. package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
  117. package/src/demo/moondog-tasteprint-demo.mjs +0 -631
  118. package/src/integrations/spotify/index.mjs +0 -80
  119. package/src/surfaces/web/showcase.mjs +0 -272
  120. package/src/surfaces/web/studio-page.mjs +0 -4148
  121. package/src/surfaces/web/studio.mjs +0 -2288
@@ -3,7 +3,6 @@ import {
3
3
  normalizeAppleMusicLibrary,
4
4
  summarizeAppleMusicLibrary,
5
5
  } from "./normalize.mjs";
6
- import { writeAppleMusicImportBatch } from "./import-batch.mjs";
7
6
 
8
7
  export {
9
8
  AppleMusicImportError,
@@ -33,7 +32,6 @@ export {
33
32
  computeAppleSqliteLogicalDigest,
34
33
  defaultAppleMusicProjectionPath,
35
34
  defaultAppleMusicProjectionPathFor,
36
- legacyAppleMusicProjectionPath,
37
35
  openAppleMusicSqliteProjection,
38
36
  rebuildAppleMusicSqliteProjection,
39
37
  } from "./sqlite-projection.mjs";
@@ -60,17 +58,3 @@ export async function buildAppleMusicLibraryImport(
60
58
  const parsed = await readAppleMusicLibrary(inputPath, { limits });
61
59
  return normalizeAppleMusicLibrary(parsed, { subjectId });
62
60
  }
63
-
64
- export async function importAppleMusicLibraryFile(
65
- inputPath,
66
- { subjectId, outputRoot, outputBoundary, limits } = {},
67
- ) {
68
- const records = await buildAppleMusicLibraryImport(inputPath, {
69
- subjectId,
70
- limits,
71
- });
72
- return writeAppleMusicImportBatch(records, {
73
- outputRoot,
74
- boundaryRoot: outputBoundary,
75
- });
76
- }
@@ -9,13 +9,13 @@ import {
9
9
  unlink,
10
10
  } from "node:fs/promises";
11
11
  import path from "node:path";
12
- import { fileURLToPath } from "node:url";
13
12
 
14
13
  import {
15
14
  canonicalizeJson,
16
15
  sha256Hex,
17
16
  } from "../../../scripts/contract-semantics.mjs";
18
17
  import { resolveMoondogStateDirectory } from "../../core/state-directory.mjs";
18
+ import { uuidV5 } from "../../core/uuid-v5.mjs";
19
19
  import { AppleMusicImportError } from "./parse-plist.mjs";
20
20
  import { listAppleMusicImportBatches } from "./read-batch.mjs";
21
21
  import { promoteAppleMusicImportBatches } from "./projection-records.mjs";
@@ -25,14 +25,6 @@ export const APPLE_SQLITE_PROJECTION_VERSION = 1;
25
25
  export const APPLE_LIBRARY_SEARCH_HARD_LIMIT = 25;
26
26
  export const APPLE_LIBRARY_SEARCH_HARD_OFFSET = 10_000;
27
27
  export const APPLE_PROFILE_SUMMARY_HARD_LIMIT = 50;
28
- // Earlier versions kept the projection inside the source checkout.
29
- export const legacyAppleMusicProjectionPath = fileURLToPath(
30
- new URL(
31
- "../../../data/private/apple-music-library/projection.sqlite",
32
- import.meta.url,
33
- ),
34
- );
35
-
36
28
  export function defaultAppleMusicProjectionPathFor(environment = process.env) {
37
29
  return path.join(resolveMoondogStateDirectory(environment), "apple-music-library", "projection.sqlite");
38
30
  }
@@ -1188,6 +1180,56 @@ class AppleMusicSqliteProjection {
1188
1180
  };
1189
1181
  }
1190
1182
 
1183
+ getProfileCatalog({ excludePreference = () => false } = {}) {
1184
+ this.#assertOpen();
1185
+ // Aggregate preference evidence per track before joining, so a loved and
1186
+ // rated track contributes once to each facet's track count.
1187
+ const tracks = this.#database.prepare(`SELECT s.track_ref_id, s.title AS label,
1188
+ s.artist_credit, s.release_title AS release, s.genre_label AS genre,
1189
+ s.play_count, s.loved, s.favorited, s.rating_value, s.rating_computed,
1190
+ COALESCE(p.strength, 0) AS preference_strength, p.evidence_id
1191
+ FROM library_search s LEFT JOIN (
1192
+ SELECT subject_id, track_ref_id, MAX(strength) AS strength, MIN(evidence_id) AS evidence_id
1193
+ FROM profile_evidence WHERE claim_dimension = 'taste.track_preference' AND direction = 'supports'
1194
+ GROUP BY subject_id, track_ref_id
1195
+ ) p ON p.subject_id = s.subject_id AND p.track_ref_id = s.track_ref_id
1196
+ WHERE s.subject_id = ? ORDER BY preference_strength DESC, s.play_count DESC, s.track_ref_id`)
1197
+ .all(this.#subjectId).map((row) => ({
1198
+ ...Object.fromEntries(Object.entries(row).filter(([, value]) => value !== null)),
1199
+ preference_strength: excludePreference(row) ? 0 : row.preference_strength,
1200
+ })).sort((a, b) => b.preference_strength - a.preference_strength ||
1201
+ (b.play_count ?? -1) - (a.play_count ?? -1) || a.track_ref_id.localeCompare(b.track_ref_id));
1202
+ const facets = (field) => {
1203
+ const grouped = new Map();
1204
+ for (const track of tracks) {
1205
+ if (!track[field]?.trim()) continue;
1206
+ const key = normalizedText(track[field]);
1207
+ const item = grouped.get(key) ?? {
1208
+ name: track[field], library_tracks: 0, preferred_tracks: 0, play_count: 0,
1209
+ tracks_with_play_count: 0, preference_strength: 0,
1210
+ evidence_id: uuidV5(`${this.#subjectId}\0apple-${field}\0${key}`, "5d9a8b8d-c66c-547f-98c2-c8a0559f56be"),
1211
+ };
1212
+ item.library_tracks++;
1213
+ if (track.preference_strength > 0) item.preferred_tracks++;
1214
+ item.preference_strength += track.preference_strength;
1215
+ if (track.play_count !== undefined) { item.play_count += track.play_count; item.tracks_with_play_count++; }
1216
+ grouped.set(key, item);
1217
+ }
1218
+ return [...grouped.values()].sort((a, b) => b.preference_strength - a.preference_strength ||
1219
+ b.library_tracks - a.library_tracks || a.name.localeCompare(b.name));
1220
+ };
1221
+ return {
1222
+ coverage: {
1223
+ apple_library_tracks: tracks.length,
1224
+ apple_preferred_tracks: tracks.filter((track) => track.preference_strength > 0).length,
1225
+ apple_tracks_with_play_count: tracks.filter((track) => track.play_count !== undefined).length,
1226
+ apple_aggregate_plays: tracks.reduce((sum, track) => sum + (track.play_count ?? 0), 0),
1227
+ apple_snapshot_at: readMeta(this.#database, "projection.source_timestamp"),
1228
+ },
1229
+ sections: { apple_tracks: tracks, apple_artists: facets("artist_credit"), apple_genres: facets("genre") },
1230
+ };
1231
+ }
1232
+
1191
1233
  explainProfileEvidence({ evidenceId } = {}) {
1192
1234
  this.#assertOpen();
1193
1235
  if (!isUuid(evidenceId)) {
@@ -1,4 +1,5 @@
1
1
  import { uuidV5 } from "../../core/uuid-v5.mjs";
2
+ import { appleMusicStorefront } from "./storefront.mjs";
2
3
 
3
4
  export const APPLE_MUSIC_CATALOG_TRACK_NAMESPACE =
4
5
  "2837a09e-e5f0-5aa5-b271-bb99d8b0de20";
@@ -115,6 +116,45 @@ function safeAppleMusicUrl(value) {
115
116
  }
116
117
  }
117
118
 
119
+ // Apple serves 30-second previews only from its own audio host.
120
+ function safePreviewUrl(value) {
121
+ const cleaned = safeText(value, 2_048);
122
+ if (!cleaned) return null;
123
+ try {
124
+ const url = new URL(cleaned);
125
+ return url.protocol === "https:" && url.hostname === "audio-ssl.itunes.apple.com" ? url.toString() : null;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ // Cover art comes only from Apple's image hosts, at the size a small player needs.
132
+ function safeArtworkUrl(value) {
133
+ const cleaned = safeText(value, 2_048);
134
+ if (!cleaned) return null;
135
+ try {
136
+ const url = new URL(cleaned);
137
+ if (url.protocol !== "https:" || !url.hostname.endsWith(".mzstatic.com")) return null;
138
+ return url.toString().replace(/\/100x100bb\./u, "/300x300bb.");
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ function normalizeAlbum(raw) {
145
+ if (!isPlainObject(raw) || raw.wrapperType !== "collection") return null;
146
+ const catalogId = safeCatalogId(raw.collectionId);
147
+ const title = safeText(raw.collectionName, 512);
148
+ const artistCredit = safeText(raw.artistName, 256);
149
+ if (!catalogId || !title || !artistCredit) return null;
150
+ const album = { catalog_id: catalogId, title, artist_credit: artistCredit };
151
+ const catalogUrl = safeAppleMusicUrl(raw.collectionViewUrl);
152
+ const artworkUrl = safeArtworkUrl(raw.artworkUrl100);
153
+ if (catalogUrl) album.catalog_url = catalogUrl;
154
+ if (artworkUrl) album.artwork_url = artworkUrl;
155
+ return album;
156
+ }
157
+
118
158
  function releaseDate(value) {
119
159
  if (typeof value !== "string") return null;
120
160
  const matched = /^(\d{4}-\d{2}-\d{2})T/u.exec(value);
@@ -214,6 +254,10 @@ function normalizeTrack(raw) {
214
254
  if (primaryGenre) track.primary_genre = primaryGenre;
215
255
  if (catalogUrl) track.catalog_url = catalogUrl;
216
256
  if (date) track.release_date = date;
257
+ const previewUrl = safePreviewUrl(raw.previewUrl);
258
+ const artworkUrl = safeArtworkUrl(raw.artworkUrl100);
259
+ if (previewUrl) track.preview_url = previewUrl;
260
+ if (artworkUrl) track.artwork_url = artworkUrl;
217
261
  return track;
218
262
  }
219
263
 
@@ -237,7 +281,7 @@ function deduplicateAndSortReleases(releases) {
237
281
  });
238
282
  }
239
283
 
240
- function sourceMetadata(now) {
284
+ function sourceMetadata(now, storefront) {
241
285
  const current = now();
242
286
  if (!Number.isFinite(current)) {
243
287
  fail("apple_music_catalog_clock_invalid", "The catalog clock is invalid.");
@@ -245,10 +289,10 @@ function sourceMetadata(now) {
245
289
  return {
246
290
  provider: "apple_music",
247
291
  catalog: "itunes_search_api",
248
- storefront: "US",
292
+ storefront: storefront(),
249
293
  retrieved_at: new Date(current).toISOString(),
250
294
  coverage:
251
- "Apple Music US storefront catalog only. This is not a claim about every music platform.",
295
+ `Apple Music ${storefront()} storefront catalog only. This is not a claim about every music platform.`,
252
296
  };
253
297
  }
254
298
 
@@ -353,6 +397,7 @@ export function createAppleMusicCatalogClient({
353
397
  fetchImpl = globalThis.fetch,
354
398
  now = Date.now,
355
399
  cacheTtlMs = APPLE_MUSIC_CATALOG_LIMITS.cacheTtlMs,
400
+ storefront = appleMusicStorefront,
356
401
  } = {}) {
357
402
  if (typeof fetchImpl !== "function") {
358
403
  throw new TypeError("A fetch implementation is required.");
@@ -386,11 +431,11 @@ export function createAppleMusicCatalogClient({
386
431
 
387
432
  async function artistCatalog(artistId) {
388
433
  const catalogId = cleanArtistCatalogId(artistId);
389
- const key = `artist-catalog|US|${catalogId}`;
434
+ const key = `artist-catalog|${storefront()}|${catalogId}`;
390
435
  return cachedRequest(cache, key, cacheTtlMs, now, async () => {
391
436
  const results = await request("/lookup", {
392
437
  id: catalogId,
393
- country: "us",
438
+ country: storefront().toLowerCase(),
394
439
  entity: "album",
395
440
  limit: APPLE_MUSIC_CATALOG_LIMITS.releasesPerArtistMax,
396
441
  sort: "recent",
@@ -415,11 +460,11 @@ export function createAppleMusicCatalogClient({
415
460
  "apple_music_catalog_query_invalid",
416
461
  "catalog query",
417
462
  );
418
- const key = `tracks|US|${normalizeForMatch(cleaned)}`;
463
+ const key = `tracks|${storefront()}|${normalizeForMatch(cleaned)}`;
419
464
  return cachedRequest(cache, key, cacheTtlMs, now, async () => {
420
465
  const results = await request("/search", {
421
466
  term: cleaned,
422
- country: "us",
467
+ country: storefront().toLowerCase(),
423
468
  media: "music",
424
469
  entity: "song",
425
470
  limit: APPLE_MUSIC_CATALOG_LIMITS.trackSearchResultsMax,
@@ -429,6 +474,31 @@ export function createAppleMusicCatalogClient({
429
474
  });
430
475
  },
431
476
 
477
+ async searchAlbums(query) {
478
+ const cleaned = cleanInputText(query, APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax, "apple_music_catalog_query_invalid", "album query");
479
+ const key = `albums|${storefront()}|${normalizeForMatch(cleaned)}`;
480
+ return cachedRequest(cache, key, cacheTtlMs, now, async () => {
481
+ const results = await request("/search", { term: cleaned, country: storefront().toLowerCase(), media: "music", entity: "album", limit: 10 });
482
+ return results.map(normalizeAlbum).filter(Boolean);
483
+ });
484
+ },
485
+
486
+ // Every song on one album, in disc and track order.
487
+ async albumTracks(albumId) {
488
+ const catalogId = typeof albumId === "string" && /^\d{1,20}$/u.test(albumId) ? albumId : null;
489
+ if (!catalogId) fail("apple_music_catalog_query_invalid", "The album identity is invalid.");
490
+ const key = `album-tracks|${storefront()}|${catalogId}`;
491
+ return cachedRequest(cache, key, cacheTtlMs, now, async () => {
492
+ const results = await request("/lookup", { id: catalogId, country: storefront().toLowerCase(), entity: "song", limit: 200 });
493
+ const order = (raw) => (Number.isSafeInteger(raw?.discNumber) ? raw.discNumber : 1) * 1000 + (Number.isSafeInteger(raw?.trackNumber) ? raw.trackNumber : 999);
494
+ return results
495
+ .filter((raw) => isPlainObject(raw) && raw.kind === "song")
496
+ .sort((left, right) => order(left) - order(right))
497
+ .map(normalizeTrack)
498
+ .filter(Boolean);
499
+ });
500
+ },
501
+
432
502
  async searchArtists(artistName) {
433
503
  const cleaned = cleanInputText(
434
504
  artistName,
@@ -436,11 +506,11 @@ export function createAppleMusicCatalogClient({
436
506
  "apple_music_catalog_artist_invalid",
437
507
  "artist name",
438
508
  );
439
- const key = `artists|US|${normalizeForMatch(cleaned)}`;
509
+ const key = `artists|${storefront()}|${normalizeForMatch(cleaned)}`;
440
510
  return cachedRequest(cache, key, cacheTtlMs, now, async () => {
441
511
  const results = await request("/search", {
442
512
  term: cleaned,
443
- country: "us",
513
+ country: storefront().toLowerCase(),
444
514
  media: "music",
445
515
  entity: "musicArtist",
446
516
  attribute: "artistTerm",
@@ -472,7 +542,8 @@ function candidateSummary(artist, releases = []) {
472
542
  }
473
543
 
474
544
  export function createAppleMusicCatalog({
475
- client = createAppleMusicCatalogClient(),
545
+ storefront = appleMusicStorefront,
546
+ client = createAppleMusicCatalogClient({ storefront }),
476
547
  now = Date.now,
477
548
  } = {}) {
478
549
  if (
@@ -526,9 +597,9 @@ export function createAppleMusicCatalog({
526
597
  resultSets.push(await client.searchTracks(query));
527
598
  }
528
599
  const source = {
529
- ...sourceMetadata(now),
600
+ ...sourceMetadata(now, storefront),
530
601
  coverage:
531
- "Keyword relevance from the Apple Music US storefront. Results are external catalog candidates, not proof of personal fit, novelty, or cross-platform availability.",
602
+ `Keyword relevance from the Apple Music ${storefront()} storefront. Results are external catalog candidates, not proof of personal fit, novelty, or cross-platform availability.`,
532
603
  };
533
604
  const tracks = interleaveTrackResults(
534
605
  resultSets,
@@ -544,6 +615,44 @@ export function createAppleMusicCatalog({
544
615
  };
545
616
  },
546
617
 
618
+ // One named recording with a playable preview: the exact title first, then a version of it.
619
+ async findPreview({ title, artist } = {}) {
620
+ const cleanedTitle = cleanInputText(title, APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax, "apple_music_catalog_query_invalid", "track title");
621
+ const cleanedArtist = cleanInputText(artist, APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax, "apple_music_catalog_query_invalid", "artist");
622
+ const wantedTitle = normalizeForMatch(cleanedTitle);
623
+ const wantedArtist = normalizeForMatch(cleanedArtist);
624
+ const results = (await client.searchTracks(`${cleanedArtist} ${cleanedTitle}`))
625
+ .filter((track) => track.preview_url && normalizeForMatch(track.artist_credit).includes(wantedArtist));
626
+ const track = results.find((candidate) => normalizeForMatch(candidate.title) === wantedTitle) ??
627
+ results.find((candidate) => normalizeForMatch(candidate.title).startsWith(wantedTitle));
628
+ return {
629
+ state: track ? "resolved" : "not_found",
630
+ source: sourceMetadata(now, storefront),
631
+ ...(track ? { track } : {}),
632
+ };
633
+ },
634
+
635
+ // One named album's previews, in order: the exact title first, then an edition of it.
636
+ async findAlbumPreviews({ title, artist } = {}) {
637
+ if (typeof client.searchAlbums !== "function" || typeof client.albumTracks !== "function") {
638
+ fail("apple_music_catalog_unavailable", "Album previews are unavailable.");
639
+ }
640
+ const cleanedTitle = cleanInputText(title, APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax, "apple_music_catalog_query_invalid", "album title");
641
+ const cleanedArtist = cleanInputText(artist, APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax, "apple_music_catalog_query_invalid", "artist");
642
+ const wantedTitle = normalizeForMatch(cleanedTitle);
643
+ const wantedArtist = normalizeForMatch(cleanedArtist);
644
+ const albums = (await client.searchAlbums(`${cleanedArtist} ${cleanedTitle}`))
645
+ .filter((candidate) => normalizeForMatch(candidate.artist_credit).includes(wantedArtist));
646
+ const album = albums.find((candidate) => normalizeForMatch(candidate.title) === wantedTitle) ??
647
+ albums.find((candidate) => normalizeForMatch(candidate.title).startsWith(wantedTitle));
648
+ const tracks = album ? (await client.albumTracks(album.catalog_id)).filter((track) => track.preview_url).slice(0, 30) : [];
649
+ return {
650
+ state: tracks.length ? "resolved" : "not_found",
651
+ source: sourceMetadata(now, storefront),
652
+ ...(tracks.length ? { album, tracks } : {}),
653
+ };
654
+ },
655
+
547
656
  async findArtistReleasesByCatalogId({
548
657
  artistCatalogId,
549
658
  limit = 6,
@@ -565,7 +674,7 @@ export function createAppleMusicCatalog({
565
674
  "Explicit Apple Music artist lookup is unavailable.",
566
675
  );
567
676
  }
568
- const source = sourceMetadata(now);
677
+ const source = sourceMetadata(now, storefront);
569
678
  const artist = await client.artistIdentity(catalogId);
570
679
  if (!artist) {
571
680
  return {
@@ -635,7 +744,7 @@ export function createAppleMusicCatalog({
635
744
  );
636
745
  }
637
746
 
638
- const source = sourceMetadata(now);
747
+ const source = sourceMetadata(now, storefront);
639
748
  const normalizedArtist = normalizeForMatch(artist);
640
749
  let selected = null;
641
750
  let selectedReleases = [];
@@ -0,0 +1,232 @@
1
+ // The Music app on this Mac, read and controlled through Apple Events (osascript). Moondog sees what
2
+ // the listener plays there themselves, and the playlist it comes from. Nothing is sent until the app
3
+ // is running, so reading never opens Music. The first read asks the listener to let Moondog (or the
4
+ // terminal it runs in) control Music; until they allow it, reads answer "not_allowed".
5
+ import { execFile } from "node:child_process";
6
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import path from "node:path";
9
+
10
+ // The first read waits while macOS asks the listener; later ones answer in well under a second.
11
+ const READ_TIMEOUT_MS = 15_000;
12
+ // A playlist longer than this is read as a window around the playing song.
13
+ const PLAYLIST_WINDOW = 50;
14
+ const PLAYLIST_BEFORE = 5;
15
+ const COLUMN_READ_MAX = 2_000;
16
+ // Covers already drawn, by album, or by song for one without an album.
17
+ const KNOWN_COVERS = 40;
18
+ const COVER_SIZE = 600;
19
+ // After a refusal, Moondog asks macOS again only this often.
20
+ const REFUSED_RETRY_MS = 60_000;
21
+
22
+ const failure = (code, message) => Object.assign(new Error(message), { code });
23
+
24
+ // One JSON answer per run. argv: the action, then its argument.
25
+ const SCRIPT = String.raw`
26
+ function run(argv) {
27
+ const action = argv[0];
28
+ const music = Application("Music");
29
+ if (!music.running()) return JSON.stringify({ running: false });
30
+ if (action === "pause") music.pause();
31
+ else if (action === "resume") music.play();
32
+ else if (action === "next") music.nextTrack();
33
+ else if (action === "previous") music.backTrack();
34
+ else if (action === "seek") music.playerPosition = Number(argv[1]);
35
+ const state = music.playerState();
36
+ const answer = { running: true, state: state };
37
+ if (state === "stopped") return JSON.stringify(answer);
38
+ try {
39
+ const track = music.currentTrack.properties();
40
+ answer.track = { id: track.persistentID, title: track.name, artist: track.artist, album: track.album,
41
+ albumArtist: track.albumArtist, duration: track.duration };
42
+ } catch (error) {}
43
+ try { answer.stream = music.currentStreamTitle(); } catch (error) {}
44
+ try { answer.position = music.playerPosition(); } catch (error) {}
45
+ try { answer.shuffle = music.shuffleEnabled(); answer.repeat = music.songRepeat(); } catch (error) {}
46
+ try {
47
+ const playlist = music.currentPlaylist;
48
+ answer.playlist = { id: playlist.persistentID(), name: playlist.name() };
49
+ try { answer.playlist.special = playlist.specialKind(); } catch (error) {}
50
+ if (action === "tracks") {
51
+ const tracks = playlist.tracks;
52
+ const ids = tracks.persistentID();
53
+ answer.playlist.count = ids.length;
54
+ const at = answer.track ? ids.indexOf(answer.track.id) : -1;
55
+ const start = ids.length <= ${PLAYLIST_WINDOW} || at < 0 ? 0 : Math.max(0, Math.min(at - ${PLAYLIST_BEFORE}, ids.length - ${PLAYLIST_WINDOW}));
56
+ const end = Math.min(ids.length, start + ${PLAYLIST_WINDOW});
57
+ answer.playlist.index = at;
58
+ answer.playlist.start = start;
59
+ answer.playlist.tracks = [];
60
+ // A playlist of ordinary length comes back fastest a column at a time; a long one, song by song.
61
+ if (ids.length <= ${COLUMN_READ_MAX}) {
62
+ const titles = tracks.name(), artists = tracks.artist(), albums = tracks.album(), durations = tracks.duration();
63
+ for (let index = start; index < end; index++) {
64
+ answer.playlist.tracks.push({ id: ids[index], title: titles[index], artist: artists[index], album: albums[index], duration: durations[index] });
65
+ }
66
+ } else {
67
+ for (let index = start; index < end; index++) {
68
+ const entry = tracks[index].properties();
69
+ answer.playlist.tracks.push({ id: entry.persistentID, title: entry.name, artist: entry.artist, album: entry.album, duration: entry.duration });
70
+ }
71
+ }
72
+ }
73
+ } catch (error) {}
74
+ return JSON.stringify(answer);
75
+ }`;
76
+
77
+ // Music hands a cover over only as AppleScript data, which JavaScript for Automation cannot write.
78
+ // argv: the file to write, then the persistent id of the playing song or of one in its playlist.
79
+ const COVER_SCRIPT = String.raw`
80
+ on run argv
81
+ if application "Music" is not running then return "none"
82
+ set wanted to item 2 of argv
83
+ tell application "Music"
84
+ try
85
+ if persistent ID of current track is wanted then
86
+ set chosen to current track
87
+ else
88
+ set chosen to first track of current playlist whose persistent ID is wanted
89
+ end if
90
+ set cover to raw data of artwork 1 of chosen
91
+ on error
92
+ return "none"
93
+ end try
94
+ end tell
95
+ set target to open for access (POSIX file (item 1 of argv)) with write permission
96
+ set eof target to 0
97
+ write cover to target
98
+ close access target
99
+ return "ok"
100
+ end run`;
101
+
102
+ function osascript(args, { timeout = READ_TIMEOUT_MS } = {}) {
103
+ return new Promise((resolve, reject) => {
104
+ execFile("/usr/bin/osascript", args, { timeout, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
105
+ if (!error) return resolve(String(stdout).trim());
106
+ // -1743: the listener has not let this app control Music (System Settings, Privacy & Security, Automation).
107
+ if (/-1743|Not authori[sz]ed/iu.test(String(stderr))) {
108
+ return reject(failure("music_app_not_allowed", "Moondog is not allowed to control the Music app. Turn it on in System Settings, Privacy & Security, Automation."));
109
+ }
110
+ reject(failure("music_app_failed", `The Music app did not answer: ${String(stderr || error.message).trim().slice(0, 200)}`));
111
+ });
112
+ });
113
+ }
114
+
115
+ const text = (value) => (typeof value === "string" && value.trim() ? value.trim() : null);
116
+ const milliseconds = (seconds) => (Number.isFinite(seconds) && seconds >= 0 ? Math.round(seconds * 1000) : null);
117
+
118
+ function songOf(track) {
119
+ return {
120
+ id: text(track.id),
121
+ title: text(track.title) ?? "",
122
+ artists: text(track.artist) ? [text(track.artist)] : [],
123
+ album: text(track.album),
124
+ duration_ms: milliseconds(track.duration),
125
+ };
126
+ }
127
+
128
+ // A radio station names its song only in the stream's title, "Artist - Title".
129
+ function streamSongOf(answer) {
130
+ const stream = text(answer.stream);
131
+ if (!stream) return answer.track ? songOf(answer.track) : null;
132
+ const [artist, ...title] = stream.split(" - ");
133
+ return title.length ? { id: null, title: title.join(" - "), artists: [artist], album: text(answer.track?.title), duration_ms: null }
134
+ : { id: null, title: stream, artists: [], album: text(answer.track?.title), duration_ms: null };
135
+ }
136
+
137
+ /**
138
+ * What the Music app plays: { state, song, position_ms, shuffle, repeat, playlist }.
139
+ * state is "playing", "paused" or "stopped"; "not_running" while the app is closed,
140
+ * "not_allowed" until the listener lets Moondog control it, "unavailable" off macOS.
141
+ * playlist.tracks, with the playing song's place among them, comes only when asked for.
142
+ */
143
+ function nowPlayingOf(answer) {
144
+ if (!answer?.running) return { state: "not_running" };
145
+ const state = answer.state === "playing" || answer.state === "fast forwarding" || answer.state === "rewinding" ? "playing"
146
+ : answer.state === "paused" ? "paused" : "stopped";
147
+ const song = state === "stopped" ? null : streamSongOf(answer);
148
+ if (!song?.title) return { state: "stopped" };
149
+ const playlist = answer.playlist && text(answer.playlist.name) ? {
150
+ id: text(answer.playlist.id),
151
+ name: text(answer.playlist.name),
152
+ // "Library" or "Music" when the song plays from the whole library rather than a playlist.
153
+ library: ["Library", "Music"].includes(answer.playlist.special),
154
+ ...(Number.isInteger(answer.playlist.count) ? { tracks_total: answer.playlist.count } : {}),
155
+ ...(Array.isArray(answer.playlist.tracks) ? {
156
+ position: answer.playlist.index >= 0 ? answer.playlist.index + 1 : null,
157
+ first_position: answer.playlist.start + 1,
158
+ tracks: answer.playlist.tracks.map(songOf),
159
+ } : {}),
160
+ } : null;
161
+ return {
162
+ state,
163
+ song,
164
+ position_ms: milliseconds(answer.position) ?? 0,
165
+ shuffle: answer.shuffle === true,
166
+ repeat: answer.repeat === "one" ? "track" : answer.repeat === "all" ? "context" : "off",
167
+ playlist,
168
+ };
169
+ }
170
+
171
+ export function createMusicAppPlayer({ platform = process.platform, run = osascript, now = Date.now } = {}) {
172
+ const available = platform === "darwin";
173
+ let refusedAt = null;
174
+ const covers = new Map();
175
+
176
+ async function ask(action, argument) {
177
+ if (!available) return null;
178
+ if (refusedAt !== null && now() - refusedAt < REFUSED_RETRY_MS) throw failure("music_app_not_allowed", "Moondog is not allowed to control the Music app. Turn it on in System Settings, Privacy & Security, Automation.");
179
+ try {
180
+ const answer = JSON.parse(await run(["-l", "JavaScript", "-e", SCRIPT, action, ...(argument === undefined ? [] : [String(argument)])]));
181
+ refusedAt = null;
182
+ return answer;
183
+ } catch (error) {
184
+ if (error?.code === "music_app_not_allowed") refusedAt = now();
185
+ throw error;
186
+ }
187
+ }
188
+ const read = async (action, argument) => {
189
+ try {
190
+ return available ? nowPlayingOf(await ask(action, argument)) : { state: "unavailable" };
191
+ } catch (error) {
192
+ if (error?.code === "music_app_not_allowed") return { state: "not_allowed", reason: error.message };
193
+ throw error;
194
+ }
195
+ };
196
+
197
+ return {
198
+ available,
199
+ nowPlaying: ({ tracks = false } = {}) => read(tracks ? "tracks" : "read"),
200
+ pause: () => read("pause"),
201
+ resume: () => read("resume"),
202
+ next: () => read("next"),
203
+ previous: () => read("previous"),
204
+ seek: ({ positionMs = 0 } = {}) => read("seek", Math.max(0, positionMs) / 1000),
205
+
206
+ // The cover of the playing song, or of one in its playlist, as a data URL. It is read from Music
207
+ // and scaled down once per album.
208
+ async cover(song) {
209
+ if (!available || !song?.id) return null;
210
+ const key = song.album ? `${song.artists?.[0] ?? ""}\n${song.album}` : song.id;
211
+ if (covers.has(key)) return covers.get(key);
212
+ const folder = await mkdtemp(path.join(tmpdir(), "moondog-cover-"));
213
+ let url = null;
214
+ try {
215
+ const original = path.join(folder, "cover");
216
+ const scaled = path.join(folder, "cover.jpg");
217
+ if (await run(["-e", COVER_SCRIPT, original, song.id]) === "ok") {
218
+ await new Promise((resolve, reject) => execFile("/usr/bin/sips", ["-s", "format", "jpeg", "-Z", String(COVER_SIZE), original, "--out", scaled],
219
+ { timeout: READ_TIMEOUT_MS }, (error) => (error ? reject(error) : resolve())));
220
+ url = `data:image/jpeg;base64,${(await readFile(scaled)).toString("base64")}`;
221
+ }
222
+ } catch {
223
+ url = null;
224
+ } finally {
225
+ await rm(folder, { recursive: true, force: true });
226
+ }
227
+ covers.set(key, url);
228
+ if (covers.size > KNOWN_COVERS) covers.delete(covers.keys().next().value);
229
+ return url;
230
+ },
231
+ };
232
+ }
@@ -0,0 +1,28 @@
1
+ // The Apple Music storefront the catalog searches: the listener's own account's when the desktop
2
+ // app's MusicKit helper reports it, otherwise the Mac's region as the desktop app passes it, otherwise
3
+ // the region of the process's locale, otherwise the US.
4
+ // Catalog ids and previews differ between storefronts, so whole songs need the account's one.
5
+
6
+ const code = (value) => (typeof value === "string" && /^[a-z]{2}$/iu.test(value) ? value.toUpperCase() : null);
7
+
8
+ function localeRegion() {
9
+ try {
10
+ return code(new Intl.Locale(Intl.DateTimeFormat().resolvedOptions().locale).maximize().region);
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ let account = null;
17
+
18
+ /** The storefront's two-letter country code, upper case, such as "US". */
19
+ export function appleMusicStorefront() {
20
+ return account ?? code(process.env.MOONDOG_SYSTEM_REGION) ?? localeRegion() ?? "US";
21
+ }
22
+
23
+ /** Records the listener's account storefront; an invalid code leaves the current one. */
24
+ export function setAppleMusicStorefront(value) {
25
+ const valid = code(value);
26
+ if (valid) account = valid;
27
+ return Boolean(valid);
28
+ }