@audiofool/moondog 0.1.0 → 0.2.0-beta.3

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 (125) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +156 -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 +253 -53
  10. package/src/core/application-update.mjs +320 -0
  11. package/src/core/capability-catalog.mjs +290 -55
  12. package/src/core/listener-avoids.mjs +101 -0
  13. package/src/core/listening-now.mjs +108 -0
  14. package/src/core/listening-profile-domain-services.mjs +189 -41
  15. package/src/core/listening-upkeep.mjs +31 -0
  16. package/src/core/moondog-application.mjs +2115 -107
  17. package/src/core/playback-preferences.mjs +97 -0
  18. package/src/core/spotify-listening-intent.mjs +124 -0
  19. package/src/core/synthetic-domain-services.mjs +24 -3
  20. package/src/core/track-title.mjs +107 -0
  21. package/src/demo/moondog-demo.mjs +4 -22
  22. package/src/i18n/index.mjs +285 -0
  23. package/src/i18n/messages/de.mjs +135 -0
  24. package/src/i18n/messages/en.mjs +136 -0
  25. package/src/i18n/messages/es.mjs +135 -0
  26. package/src/i18n/messages/fr.mjs +135 -0
  27. package/src/i18n/messages/it.mjs +135 -0
  28. package/src/i18n/messages/ja.mjs +135 -0
  29. package/src/i18n/messages/ko.mjs +135 -0
  30. package/src/i18n/messages/pt.mjs +135 -0
  31. package/src/i18n/messages/zh-Hant.mjs +135 -0
  32. package/src/i18n/messages/zh.mjs +135 -0
  33. package/src/i18n/preferences.mjs +34 -0
  34. package/src/i18n/screen/es.mjs +967 -0
  35. package/src/i18n/screen/ja.mjs +967 -0
  36. package/src/i18n/screen/pt.mjs +967 -0
  37. package/src/i18n/screen/zh.mjs +967 -0
  38. package/src/importers/apple-music-library/index.mjs +0 -16
  39. package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
  40. package/src/integrations/apple-music/catalog.mjs +166 -14
  41. package/src/integrations/apple-music/music-app.mjs +232 -0
  42. package/src/integrations/apple-music/storefront.mjs +28 -0
  43. package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
  44. package/src/integrations/lastfm/history.mjs +221 -0
  45. package/src/integrations/lastfm/settings.mjs +75 -0
  46. package/src/integrations/netease/ncm-cli-player.mjs +328 -0
  47. package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
  48. package/src/integrations/public-playlists.mjs +2 -1
  49. package/src/integrations/spotify/account-data-profile.mjs +14 -6
  50. package/src/integrations/spotify/authentication.mjs +149 -15
  51. package/src/integrations/spotify/catalog-resolver.mjs +9 -6
  52. package/src/integrations/spotify/connection.mjs +2 -1
  53. package/src/integrations/spotify/credential-store.mjs +4 -0
  54. package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
  55. package/src/integrations/spotify/history-archive.mjs +1 -1
  56. package/src/integrations/spotify/library-snapshot.mjs +227 -0
  57. package/src/integrations/spotify/recent-activity.mjs +2 -0
  58. package/src/integrations/spotify/service.mjs +873 -69
  59. package/src/integrations/spotify/web-api-client.mjs +410 -127
  60. package/src/integrations/web/brave-web.mjs +45 -0
  61. package/src/integrations/web/codex-web.mjs +15 -82
  62. package/src/integrations/web/deepseek-web.mjs +81 -0
  63. package/src/integrations/web/web-research.mjs +119 -0
  64. package/src/integrations/web/web-result.mjs +215 -0
  65. package/src/integrations/youtube-music/takeout.mjs +3 -1
  66. package/src/memory/local-memory-store.mjs +542 -57
  67. package/src/profile/history-import.mjs +42 -6
  68. package/src/profile/listener-corrections.mjs +22 -0
  69. package/src/profile/listener-profile-build.mjs +461 -0
  70. package/src/profile/listener-profile-store.mjs +130 -0
  71. package/src/profile/listener-profile-verification.mjs +68 -0
  72. package/src/profile/listener-profile-worker.mjs +179 -0
  73. package/src/profile/listening-history-store.mjs +640 -17
  74. package/src/profile/listening-profile-projection.mjs +454 -86
  75. package/src/profile/local-music-data.mjs +1 -1
  76. package/src/profile/moondog-suggestions.mjs +184 -0
  77. package/src/profile/music-providers.mjs +11 -7
  78. package/src/profile/profile-exploration.mjs +92 -0
  79. package/src/profile/recorded-measures.mjs +24 -0
  80. package/src/profile/spotify-archive-taste.mjs +0 -4
  81. package/src/runtime/pi/agent-runtime.mjs +2536 -692
  82. package/src/runtime/pi/authentication.mjs +34 -1
  83. package/src/runtime/pi/configured-runtime.mjs +22 -6
  84. package/src/runtime/pi/conversation-name.mjs +53 -0
  85. package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
  86. package/src/runtime/pi/model-catalog.mjs +28 -5
  87. package/src/runtime/pi/model-settings.mjs +202 -0
  88. package/src/runtime/pi/model-transport.mjs +10 -0
  89. package/src/runtime/pi/persistent-credential-store.mjs +67 -31
  90. package/src/runtime/pi/profile-build-runtime.mjs +161 -0
  91. package/src/runtime/pi/public-model-catalog.mjs +288 -0
  92. package/src/runtime/pi/runtime-settings.mjs +10 -2
  93. package/src/runtime/pi/skills/listener-profile/SKILL.md +48 -0
  94. package/src/runtime/pi/tool-trace.mjs +37 -0
  95. package/src/surfaces/cli/auth-command.mjs +12 -4
  96. package/src/surfaces/cli/brand-components.mjs +310 -132
  97. package/src/surfaces/cli/brand-theme.mjs +51 -28
  98. package/src/surfaces/cli/command-completions.mjs +58 -42
  99. package/src/surfaces/cli/format-output.mjs +199 -388
  100. package/src/surfaces/cli/history-import-view.mjs +292 -139
  101. package/src/surfaces/cli/import-guides.mjs +2 -0
  102. package/src/surfaces/cli/listener-profile-command.mjs +92 -0
  103. package/src/surfaces/cli/listening-menu.mjs +7 -4
  104. package/src/surfaces/cli/profile-command.mjs +30 -21
  105. package/src/surfaces/cli/seasons-chart.mjs +87 -0
  106. package/src/surfaces/cli/spotify-command.mjs +54 -6
  107. package/src/surfaces/cli/taste-profile-model.mjs +78 -79
  108. package/src/surfaces/cli/taste-profile-view.mjs +33 -26
  109. package/src/surfaces/cli/terminal-art.mjs +140 -35
  110. package/src/surfaces/cli/tui.mjs +1066 -357
  111. package/src/surfaces/cli/update-command.mjs +32 -0
  112. package/src/surfaces/cli/web-command.mjs +53 -14
  113. package/src/surfaces/html/tasteprint.mjs +43 -70
  114. package/contracts/v1/capability-policy.schema.json +0 -572
  115. package/contracts/v1/profile-evidence.schema.json +0 -237
  116. package/contracts/v1/tool-invocation.schema.json +0 -318
  117. package/contracts/v1/tool-receipt.schema.json +0 -1066
  118. package/scripts/demo-studio.mjs +0 -58
  119. package/src/core/apple-data-migration.mjs +0 -50
  120. package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
  121. package/src/demo/moondog-tasteprint-demo.mjs +0 -631
  122. package/src/integrations/spotify/index.mjs +0 -80
  123. package/src/surfaces/web/showcase.mjs +0 -272
  124. package/src/surfaces/web/studio-page.mjs +0 -4148
  125. 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",
@@ -407,6 +452,15 @@ export function createAppleMusicCatalogClient({
407
452
  });
408
453
  }
409
454
 
455
+ // One artist's songs as Apple lists them. The keyword search has no entry for some albums, such as
456
+ // 刘森's 华北浪革, and their album lookup lists no songs, but the artist's lookup has them.
457
+ async function artistSongResults(artistId) {
458
+ const catalogId = cleanArtistCatalogId(artistId);
459
+ const key = `artist-songs|${storefront()}|${catalogId}`;
460
+ return cachedRequest(cache, key, cacheTtlMs, now, () =>
461
+ request("/lookup", { id: catalogId, country: storefront().toLowerCase(), entity: "song", limit: 200 }));
462
+ }
463
+
410
464
  return Object.freeze({
411
465
  async searchTracks(query) {
412
466
  const cleaned = cleanInputText(
@@ -415,11 +469,11 @@ export function createAppleMusicCatalogClient({
415
469
  "apple_music_catalog_query_invalid",
416
470
  "catalog query",
417
471
  );
418
- const key = `tracks|US|${normalizeForMatch(cleaned)}`;
472
+ const key = `tracks|${storefront()}|${normalizeForMatch(cleaned)}`;
419
473
  return cachedRequest(cache, key, cacheTtlMs, now, async () => {
420
474
  const results = await request("/search", {
421
475
  term: cleaned,
422
- country: "us",
476
+ country: storefront().toLowerCase(),
423
477
  media: "music",
424
478
  entity: "song",
425
479
  limit: APPLE_MUSIC_CATALOG_LIMITS.trackSearchResultsMax,
@@ -429,6 +483,36 @@ export function createAppleMusicCatalogClient({
429
483
  });
430
484
  },
431
485
 
486
+ async searchAlbums(query) {
487
+ const cleaned = cleanInputText(query, APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax, "apple_music_catalog_query_invalid", "album query");
488
+ const key = `albums|${storefront()}|${normalizeForMatch(cleaned)}`;
489
+ return cachedRequest(cache, key, cacheTtlMs, now, async () => {
490
+ const results = await request("/search", { term: cleaned, country: storefront().toLowerCase(), media: "music", entity: "album", limit: 10 });
491
+ return results.map(normalizeAlbum).filter(Boolean);
492
+ });
493
+ },
494
+
495
+ // Every song on one album, in disc and track order.
496
+ async albumTracks(albumId) {
497
+ const catalogId = typeof albumId === "string" && /^\d{1,20}$/u.test(albumId) ? albumId : null;
498
+ if (!catalogId) fail("apple_music_catalog_query_invalid", "The album identity is invalid.");
499
+ const key = `album-tracks|${storefront()}|${catalogId}`;
500
+ return cachedRequest(cache, key, cacheTtlMs, now, async () => {
501
+ const results = await request("/lookup", { id: catalogId, country: storefront().toLowerCase(), entity: "song", limit: 200 });
502
+ const order = (raw) => (Number.isSafeInteger(raw?.discNumber) ? raw.discNumber : 1) * 1000 + (Number.isSafeInteger(raw?.trackNumber) ? raw.trackNumber : 999);
503
+ const song = (raw) => isPlainObject(raw) && raw.kind === "song";
504
+ let songs = results.filter(song);
505
+ const artistId = safeCatalogId(results.find((raw) => isPlainObject(raw) && raw.wrapperType === "collection")?.artistId);
506
+ if (!songs.length && artistId) {
507
+ songs = (await artistSongResults(artistId)).filter((raw) => song(raw) && safeCatalogId(raw.collectionId) === catalogId);
508
+ }
509
+ return songs
510
+ .sort((left, right) => order(left) - order(right))
511
+ .map(normalizeTrack)
512
+ .filter(Boolean);
513
+ });
514
+ },
515
+
432
516
  async searchArtists(artistName) {
433
517
  const cleaned = cleanInputText(
434
518
  artistName,
@@ -436,11 +520,11 @@ export function createAppleMusicCatalogClient({
436
520
  "apple_music_catalog_artist_invalid",
437
521
  "artist name",
438
522
  );
439
- const key = `artists|US|${normalizeForMatch(cleaned)}`;
523
+ const key = `artists|${storefront()}|${normalizeForMatch(cleaned)}`;
440
524
  return cachedRequest(cache, key, cacheTtlMs, now, async () => {
441
525
  const results = await request("/search", {
442
526
  term: cleaned,
443
- country: "us",
527
+ country: storefront().toLowerCase(),
444
528
  media: "music",
445
529
  entity: "musicArtist",
446
530
  attribute: "artistTerm",
@@ -458,6 +542,10 @@ export function createAppleMusicCatalogClient({
458
542
  async artistReleases(artistId) {
459
543
  return (await artistCatalog(artistId)).releases;
460
544
  },
545
+
546
+ async artistSongs(artistId) {
547
+ return (await artistSongResults(artistId)).map(normalizeTrack).filter(Boolean);
548
+ },
461
549
  });
462
550
  }
463
551
 
@@ -472,7 +560,8 @@ function candidateSummary(artist, releases = []) {
472
560
  }
473
561
 
474
562
  export function createAppleMusicCatalog({
475
- client = createAppleMusicCatalogClient(),
563
+ storefront = appleMusicStorefront,
564
+ client = createAppleMusicCatalogClient({ storefront }),
476
565
  now = Date.now,
477
566
  } = {}) {
478
567
  if (
@@ -484,6 +573,14 @@ export function createAppleMusicCatalog({
484
573
  throw new TypeError("The Apple Music catalog client is invalid.");
485
574
  }
486
575
 
576
+ // The artists with exactly this name, whose own catalog has what the keyword search misses.
577
+ async function namedArtists(name) {
578
+ const wanted = normalizeForMatch(name);
579
+ return (await client.searchArtists(name))
580
+ .filter((candidate) => normalizeForMatch(candidate.name) === wanted)
581
+ .slice(0, APPLE_MUSIC_CATALOG_LIMITS.exactArtistCandidatesMax);
582
+ }
583
+
487
584
  return Object.freeze({
488
585
  async searchTracks({ queries, limit = 8 } = {}) {
489
586
  if (
@@ -526,9 +623,9 @@ export function createAppleMusicCatalog({
526
623
  resultSets.push(await client.searchTracks(query));
527
624
  }
528
625
  const source = {
529
- ...sourceMetadata(now),
626
+ ...sourceMetadata(now, storefront),
530
627
  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.",
628
+ `Keyword relevance from the Apple Music ${storefront()} storefront. Results are external catalog candidates, not proof of personal fit, novelty, or cross-platform availability.`,
532
629
  };
533
630
  const tracks = interleaveTrackResults(
534
631
  resultSets,
@@ -544,6 +641,61 @@ export function createAppleMusicCatalog({
544
641
  };
545
642
  },
546
643
 
644
+ // One named recording with a playable preview: the exact title first, then a version of it.
645
+ async findPreview({ title, artist } = {}) {
646
+ const cleanedTitle = cleanInputText(title, APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax, "apple_music_catalog_query_invalid", "track title");
647
+ const cleanedArtist = cleanInputText(artist, APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax, "apple_music_catalog_query_invalid", "artist");
648
+ const wantedTitle = normalizeForMatch(cleanedTitle);
649
+ const wantedArtist = normalizeForMatch(cleanedArtist);
650
+ const pick = (tracks) => {
651
+ const results = tracks.filter((track) => track.preview_url && normalizeForMatch(track.artist_credit).includes(wantedArtist));
652
+ return results.find((candidate) => normalizeForMatch(candidate.title) === wantedTitle) ??
653
+ results.find((candidate) => normalizeForMatch(candidate.title).startsWith(wantedTitle));
654
+ };
655
+ let track = pick(await client.searchTracks(`${cleanedArtist} ${cleanedTitle}`));
656
+ if (!track && typeof client.artistSongs === "function") {
657
+ for (const named of await namedArtists(cleanedArtist)) {
658
+ track = pick(await client.artistSongs(named.catalog_id));
659
+ if (track) break;
660
+ }
661
+ }
662
+ return {
663
+ state: track ? "resolved" : "not_found",
664
+ source: sourceMetadata(now, storefront),
665
+ ...(track ? { track } : {}),
666
+ };
667
+ },
668
+
669
+ // One named album's previews, in order: the exact title first, then an edition of it.
670
+ async findAlbumPreviews({ title, artist } = {}) {
671
+ if (typeof client.searchAlbums !== "function" || typeof client.albumTracks !== "function") {
672
+ fail("apple_music_catalog_unavailable", "Album previews are unavailable.");
673
+ }
674
+ const cleanedTitle = cleanInputText(title, APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax, "apple_music_catalog_query_invalid", "album title");
675
+ const cleanedArtist = cleanInputText(artist, APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax, "apple_music_catalog_query_invalid", "artist");
676
+ const wantedTitle = normalizeForMatch(cleanedTitle);
677
+ const wantedArtist = normalizeForMatch(cleanedArtist);
678
+ const pick = (albums) => albums.find((candidate) => normalizeForMatch(candidate.title) === wantedTitle) ??
679
+ albums.find((candidate) => normalizeForMatch(candidate.title).startsWith(wantedTitle));
680
+ let album = pick((await client.searchAlbums(`${cleanedArtist} ${cleanedTitle}`))
681
+ .filter((candidate) => normalizeForMatch(candidate.artist_credit).includes(wantedArtist)));
682
+ if (!album) {
683
+ for (const named of await namedArtists(cleanedArtist)) {
684
+ const release = pick(await client.artistReleases(named.catalog_id));
685
+ if (!release) continue;
686
+ album = { catalog_id: release.catalog_id, title: release.title, artist_credit: release.artist_name,
687
+ ...(release.catalog_url ? { catalog_url: release.catalog_url } : {}) };
688
+ break;
689
+ }
690
+ }
691
+ const tracks = album ? (await client.albumTracks(album.catalog_id)).filter((track) => track.preview_url).slice(0, 30) : [];
692
+ return {
693
+ state: tracks.length ? "resolved" : "not_found",
694
+ source: sourceMetadata(now, storefront),
695
+ ...(tracks.length ? { album, tracks } : {}),
696
+ };
697
+ },
698
+
547
699
  async findArtistReleasesByCatalogId({
548
700
  artistCatalogId,
549
701
  limit = 6,
@@ -565,7 +717,7 @@ export function createAppleMusicCatalog({
565
717
  "Explicit Apple Music artist lookup is unavailable.",
566
718
  );
567
719
  }
568
- const source = sourceMetadata(now);
720
+ const source = sourceMetadata(now, storefront);
569
721
  const artist = await client.artistIdentity(catalogId);
570
722
  if (!artist) {
571
723
  return {
@@ -635,7 +787,7 @@ export function createAppleMusicCatalog({
635
787
  );
636
788
  }
637
789
 
638
- const source = sourceMetadata(now);
790
+ const source = sourceMetadata(now, storefront);
639
791
  const normalizedArtist = normalizeForMatch(artist);
640
792
  let selected = null;
641
793
  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
+ }