@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
@@ -1,4 +1,5 @@
1
1
  import { sanitizeTerminalText } from "./format-output.mjs";
2
+ import { intlLocale, screenTranslator } from "../../i18n/index.mjs";
2
3
 
3
4
  const MAX_ITEMS_PER_SOURCE = 10;
4
5
 
@@ -27,9 +28,9 @@ function count(value) {
27
28
  : undefined;
28
29
  }
29
30
 
30
- function amount(value) {
31
+ function amount(value, locale = "en") {
31
32
  return Number.isFinite(value) && value >= 0
32
- ? value.toLocaleString("en-US", { maximumFractionDigits: 1 })
33
+ ? value.toLocaleString(intlLocale(locale), { maximumFractionDigits: 1 })
33
34
  : undefined;
34
35
  }
35
36
 
@@ -57,59 +58,56 @@ function subjectKey(target) {
57
58
  ]);
58
59
  }
59
60
 
60
- function plural(value, one, many = `${one}s`) {
61
- return `${count(value)} ${value === 1 ? one : many}`;
62
- }
63
-
64
- function itemMetrics(item) {
61
+ function itemMetrics(item, tr) {
65
62
  const values = [];
66
63
  const known = (field) => count(item?.[field]) !== undefined;
67
- if (known("play_count")) values.push(plural(item.play_count, "play"));
64
+ if (known("play_count")) values.push(tr.n(item.play_count, "{count} play", "{count} plays"));
68
65
  if (known("engaged_play_count") && item.engaged_play_count !== item.play_count) {
69
- values.push(`${count(item.engaged_play_count)} heard properly`);
66
+ values.push(tr("{count} heard properly", { count: tr.number(item.engaged_play_count) }));
70
67
  }
71
- if (known("distinct_tracks")) values.push(plural(item.distinct_tracks, "track"));
72
- if (known("explicit_skips") && item.explicit_skips > 0) values.push(`skipped ${plural(item.explicit_skips, "time")}`);
73
- if (known("playlist_count")) values.push(`on ${plural(item.playlist_count, "playlist")}`);
74
- if (known("quiet_days")) values.push(`quiet for ${plural(item.quiet_days, "day")}`);
75
- if (known("return_count")) values.push(`came back ${item.return_count === 1 ? "once" : `${count(item.return_count)} times`}`);
76
- if (known("longest_gap_days")) values.push(`longest time away ${plural(item.longest_gap_days, "day")}`);
77
- if (known("maximum_consecutive_plays")) values.push(`up to ${count(item.maximum_consecutive_plays)} in a row`);
78
- if (known("burst_count") && item.burst_count > 1) values.push(`${count(item.burst_count)} separate runs`);
79
- const minutes = amount(item?.listening_minutes);
80
- if (minutes !== undefined) values.push(`${minutes} min`);
68
+ if (known("distinct_tracks")) values.push(tr.n(item.distinct_tracks, "{count} track", "{count} tracks"));
69
+ if (known("explicit_skips") && item.explicit_skips > 0) values.push(tr.n(item.explicit_skips, "skipped {count} time", "skipped {count} times"));
70
+ if (known("playlist_count")) values.push(tr.n(item.playlist_count, "on {count} playlist", "on {count} playlists"));
71
+ if (known("quiet_days")) values.push(tr.n(item.quiet_days, "quiet for {count} day", "quiet for {count} days"));
72
+ if (known("return_count")) values.push(item.return_count === 1 ? tr("came back once") : tr.n(item.return_count, "came back {count} time", "came back {count} times"));
73
+ if (known("longest_gap_days")) values.push(tr.n(item.longest_gap_days, "longest time away {count} day", "longest time away {count} days"));
74
+ if (known("maximum_consecutive_plays")) values.push(tr("up to {count} in a row", { count: tr.number(item.maximum_consecutive_plays) }));
75
+ if (known("burst_count") && item.burst_count > 1) values.push(tr("{count} separate runs", { count: tr.number(item.burst_count) }));
76
+ const minutes = amount(item?.listening_minutes, tr.locale);
77
+ if (minutes !== undefined) values.push(tr("{minutes} min", { minutes }));
81
78
  const lastPlayed = date(item?.last_played_at);
82
- if (lastPlayed) values.push(`last played ${lastPlayed}`);
79
+ if (lastPlayed) values.push(tr("last played {date}", { date: lastPlayed }));
83
80
  if (Number.isFinite(item?.confidence) && item.confidence >= 0 && item.confidence <= 1) {
84
- values.push(`${Math.round(item.confidence * 100)}% sure`);
81
+ values.push(tr("{percent}% sure", { percent: Math.round(item.confidence * 100) }));
85
82
  }
86
83
  return values;
87
84
  }
88
85
 
89
- function overview(profile) {
86
+ function overview(profile, tr) {
90
87
  const coverage = profile?.coverage ?? {};
91
88
  const lines = [];
92
89
  const durationUnavailable = coverage.effective_listening_events > 0 && coverage.events_with_played_duration === 0;
90
+ const hours = durationUnavailable ? undefined : amount(coverage.listening_hours, tr.locale);
93
91
  const listening = [
94
- [count(coverage.effective_listening_events), coverage.effective_listening_events === 1 ? "play" : "plays"],
95
- [durationUnavailable ? undefined : amount(coverage.listening_hours), "h"],
96
- [count(coverage.listening_tracks), coverage.listening_tracks === 1 ? "track" : "tracks"],
97
- ].filter(([value]) => value !== undefined).map(([value, label]) => `${value} ${label}`);
98
- if (durationUnavailable) listening.push("listening time unknown");
92
+ ...(count(coverage.effective_listening_events) !== undefined ? [tr.n(coverage.effective_listening_events, "{count} play", "{count} plays")] : []),
93
+ ...(hours !== undefined ? [tr("{hours} h", { hours })] : []),
94
+ ...(count(coverage.listening_tracks) !== undefined ? [tr.n(coverage.listening_tracks, "{count} track", "{count} tracks")] : []),
95
+ ];
96
+ if (durationUnavailable) listening.push(tr("listening time unknown"));
99
97
  if (listening.length && !(coverage.effective_listening_events === 0 && (coverage.tracks_observed > 0 || coverage.collection_tracks > 0))) {
100
98
  lines.push(listening.join(" · "));
101
99
  }
102
100
  if (Number.isSafeInteger(coverage.tracks_observed) && coverage.tracks_observed > 0) {
103
101
  const loved = count(coverage.loved_or_favorited);
104
- lines.push(`Apple Music library: ${plural(coverage.tracks_observed, "track")}${
105
- loved === undefined ? "" : ` · ${loved} loved`
106
- }`);
102
+ const tracks = tr.n(coverage.tracks_observed, "{count} track", "{count} tracks");
103
+ lines.push(loved === undefined ? tr("Apple Music library: {tracks}", { tracks })
104
+ : tr("Apple Music library: {tracks} · {loved} loved", { tracks, loved: tr.number(coverage.loved_or_favorited) }));
107
105
  }
108
106
  const source = profile?.listening_source ?? {};
109
107
  const collections = items(coverage.collection_sources).filter((collection) => collection.tracks > 0);
110
- if (collections.length > 1) lines.push(`${count(coverage.collection_tracks)} songs across ${collections.length} services`);
108
+ if (collections.length > 1) lines.push(tr("{songs} across {count} services", { songs: tr.n(coverage.collection_tracks, "{count} song", "{count} songs"), count: collections.length }));
111
109
  for (const collection of collections) {
112
- if (collection.tracks > 0) lines.push(`${inline(collection.label)}: ${plural(collection.tracks, "song")}`);
110
+ if (collection.tracks > 0) lines.push(`${inline(collection.label)}: ${tr.n(collection.tracks, "{count} song", "{count} songs")}`);
113
111
  }
114
112
  const providers = items(source.providers).map(inline).filter(Boolean).map((provider) => ({
115
113
  spotify: "Spotify",
@@ -120,21 +118,26 @@ function overview(profile) {
120
118
  })[provider] ?? provider);
121
119
  const earliest = date(source.listening_range?.earliest);
122
120
  const latest = date(source.listening_range?.latest);
123
- const range = earliest && latest ? `${earliest} to ${latest}` : earliest || latest;
121
+ const range = earliest && latest ? tr("{first} to {last}", { first: earliest, last: latest }) : earliest || latest;
124
122
  if (providers.length || range) {
125
- lines.push([providers.length ? `${providers.join(", ")} history` : "History", range]
123
+ lines.push([providers.length ? tr("{services} history", { services: providers.join(", ") }) : tr("History"), range]
126
124
  .filter(Boolean).join(" · "));
127
125
  }
128
126
  const captured = date(profile?.source?.captured_at);
129
- if (captured) lines.push(`Library as of ${captured}`);
130
- const active = count(coverage.active_listener_assertions);
131
- if (coverage.active_listener_assertions > 0) lines.push(`${plural(coverage.active_listener_assertions, "choice")} you've made`);
127
+ if (captured) lines.push(tr("Library as of {date}", { date: captured }));
128
+ if (coverage.active_listener_assertions > 0) lines.push(tr.n(coverage.active_listener_assertions, "{count} choice you've made", "{count} choices you've made"));
129
+ if (profile?.listener_model) {
130
+ const saved = profile.listener_model;
131
+ lines.splice(1, 0, saved.state === "missing" ? tr("Build a saved reading: /profile build")
132
+ : saved.state === "stale" ? tr("Saved reading v{version} needs update: /profile build", { version: saved.sequence })
133
+ : tr("Saved reading v{version}: /profile saved", { version: saved.sequence }));
134
+ }
132
135
  return lines;
133
136
  }
134
137
 
135
138
  const sourceNames = {
136
- "Apple Music library preference": "In your Apple Music library",
137
- "Apple Music aggregate play count": "Apple Music play count",
139
+ "Apple Music library preference": tr => tr("In your Apple Music library"),
140
+ "Apple Music aggregate play count": tr => tr("Apple Music play count"),
138
141
  };
139
142
 
140
143
  function explicitKind(item) {
@@ -145,9 +148,10 @@ function explicitKind(item) {
145
148
  }
146
149
 
147
150
  /** Build a private, bounded review view from structured profile evidence. */
148
- export function buildTasteProfileModel(profile = {}) {
151
+ export function buildTasteProfileModel(profile = {}, tr = screenTranslator("en")) {
149
152
  const metrics = (item) => itemMetrics(profile.coverage?.events_with_played_duration === 0
150
- ? { ...item, listening_minutes: undefined } : item);
153
+ ? { ...item, listening_minutes: undefined } : item, tr);
154
+ const withDetails = (source, details) => details.length ? tr("{source}: {details}", { source, details: details.join(" · ") }) : source;
151
155
  const subjects = new Map();
152
156
  const seenDetails = new Map();
153
157
  const assertions = profile?.listener_assertions ?? {};
@@ -163,7 +167,7 @@ export function buildTasteProfileModel(profile = {}) {
163
167
  subject = {
164
168
  key,
165
169
  label: inline(target.label),
166
- subtitle: kind === "track" ? inline(target.artistCredit) : "Artist",
170
+ subtitle: kind === "track" ? inline(target.artistCredit) : tr("Artist"),
167
171
  kind,
168
172
  detailLines: [],
169
173
  target,
@@ -204,26 +208,22 @@ export function buildTasteProfileModel(profile = {}) {
204
208
  seenAssertions.add(key);
205
209
  const when = date(item.asserted_at);
206
210
  const note = inline(item.note);
207
- add(item, item.entity_type, "Your choice", `You said: ${
208
- item.stance === "like" ? "I like this" : "keep this out"
209
- }${when ? ` · ${when}` : ""}${note ? ` · "${note}"` : ""}`, { assertion: true });
211
+ add(item, item.entity_type, tr("Your choice"), [item.stance === "like" ? tr("You said: I like this") : tr("You said: keep this out"),
212
+ ...(when ? [when] : []), ...(note ? [`"${note}"`] : [])].join(" · "), { assertion: true });
210
213
  }
211
214
 
212
215
  const recentDays = count(behavior.context?.recent_window_days);
213
- const recentSource = recentDays ? `Lately (last ${recentDays} days)` : "Lately";
216
+ const recentSource = recentDays ? tr("Lately (last {count} days)", { count: recentDays }) : tr("Lately");
214
217
  for (const [field, kind, source] of [
215
- ["repeat_tracks", "track", "Most played"],
216
- ["enduring_artists", "artist", "Across the years"],
218
+ ["repeat_tracks", "track", tr("Most played")],
219
+ ["enduring_artists", "artist", tr("Across the years")],
217
220
  ["recent_tracks", "track", recentSource],
218
221
  ["recent_artists", "artist", recentSource],
219
- ["rediscovery_tracks", "track", "Gone quiet"],
220
- ["historical_return_tracks", "track", "Came back"],
221
- ["back_to_back_tracks", "track", "Played in a row"],
222
+ ["rediscovery_tracks", "track", tr("Gone quiet")],
223
+ ["historical_return_tracks", "track", tr("Came back")],
224
+ ["back_to_back_tracks", "track", tr("Played in a row")],
222
225
  ]) {
223
- for (const item of items(behavior[field])) {
224
- const details = metrics(item);
225
- add(item, kind, source, `${source}${details.length ? `: ${details.join(" · ")}` : ""}`);
226
- }
226
+ for (const item of items(behavior[field])) add(item, kind, source, withDetails(source, metrics(item)));
227
227
  }
228
228
  for (const item of items(behavior.time_capsule_tracks)) {
229
229
  const year = count(item?.capsule_year);
@@ -233,35 +233,32 @@ export function buildTasteProfileModel(profile = {}) {
233
233
  listening_minutes: item?.year_listening_minutes,
234
234
  explicit_skips: item?.year_explicit_skips,
235
235
  });
236
- add(item, "track", "Song of the year", `${year ? `Your song of ${item.capsule_year}` : "Song of the year"}${
237
- details.length ? `: ${details.join(" · ")}` : ""
238
- }`);
236
+ add(item, "track", tr("Song of the year"), withDetails(year ? tr("Your song of {year}", { year: item.capsule_year }) : tr("Song of the year"), details));
239
237
  }
240
238
  for (const [field, kind, source] of [
241
- ["saved_tracks", "track", "Saved in your Spotify library"],
242
- ["playlist_anchors", "track", "On your Spotify playlists"],
243
- ["followed_artists", "artist", "Followed on Spotify"],
239
+ ["saved_tracks", "track", service => tr("Saved in your {service} library", { service })],
240
+ ["playlist_anchors", "track", service => tr("On your {service} playlists", { service })],
241
+ ["followed_artists", "artist", service => tr("Followed on {service}", { service })],
244
242
  ]) {
245
243
  for (const item of items(curated[field])) {
246
- const details = metrics(item);
247
- const label = source.replace("Spotify", inline(item.source_label) || "Spotify");
248
- add(item, kind, label, `${label}${details.length ? `: ${details.join(" · ")}` : ""}`);
244
+ const label = source(inline(item.source_label) || "Spotify");
245
+ add(item, kind, label, withDetails(label, metrics(item)));
249
246
  }
250
247
  }
251
248
  for (const item of items(curated.avoids)) {
252
249
  const target = targetFor(item, item?.entity_type);
253
250
  if (target && !seenAssertions.has(subjectKey(target))) {
254
- add(item, item.entity_type, "Disliked in your music service", "You disliked this in your music service. You can still change it here.");
251
+ add(item, item.entity_type, tr("Disliked in your music service"), tr("You disliked this in your music service. You can still change it here."));
255
252
  }
256
253
  }
257
254
  for (const [field, kind] of [["artists", "artist"], ["tracks", "track"]]) {
258
255
  for (const item of items(profile?.provider_signals?.[field])) {
259
256
  const rank = count(item?.best_rank);
260
- const details = [...(rank ? [`best at #${rank}`] : []), ...metrics(item)];
261
- add(item, kind, "Service top list", `In your service's top list${details.length ? `: ${details.join(" · ")}` : ""}`);
257
+ const details = [...(rank ? [tr("best at #{rank}", { rank })] : []), ...metrics(item)];
258
+ add(item, kind, tr("Service top list"), withDetails(tr("In your service's top list"), details));
262
259
  }
263
260
  }
264
- for (const [field, fallback] of [["strong_preferences", "From your library"], ["familiarity", "Familiar from your library"]]) {
261
+ for (const [field, fallback] of [["strong_preferences", tr("From your library")], ["familiarity", tr("Familiar from your library")]]) {
265
262
  for (const item of items(profile?.[field])) {
266
263
  const kind = explicitKind(item);
267
264
  if (!kind) continue;
@@ -269,10 +266,10 @@ export function buildTasteProfileModel(profile = {}) {
269
266
  if (!target) continue;
270
267
  const existing = subjects.get(subjectKey(target));
271
268
  if (item.evidence_id && existing?.evidence.some((evidence) => evidence.id === item.evidence_id)) continue;
272
- const source = sourceNames[inline(item.source)] ?? (inline(item.source) || fallback);
269
+ const source = sourceNames[inline(item.source)]?.(tr) ?? (inline(item.source) || fallback);
273
270
  const observed = date(item.observed_at);
274
- const details = [inline(item.signal).replace(/,? non-computed rating/u, ", rated"), ...metrics(item), ...(observed ? [`as of ${observed}`] : [])].filter(Boolean);
275
- add(item, kind, source, `${source}${details.length ? `: ${details.join(" · ")}` : ""}`);
271
+ const details = [inline(item.signal).replace(/,? non-computed rating/u, ", rated"), ...metrics(item), ...(observed ? [tr("as of {date}", { date: observed })] : [])].filter(Boolean);
272
+ add(item, kind, source, withDetails(source, details));
276
273
  }
277
274
  }
278
275
  for (const item of items(profile?.artist_facets)) {
@@ -280,27 +277,29 @@ export function buildTasteProfileModel(profile = {}) {
280
277
  if (!target) continue;
281
278
  const existing = subjects.get(subjectKey(target));
282
279
  if (item.evidence_id && existing?.evidence.some((evidence) => evidence.id === item.evidence_id)) continue;
283
- add(item, "artist", "Through a track", "Shows up through one of their tracks.");
280
+ add(item, "artist", tr("Library evidence"), Number.isSafeInteger(item.library_tracks)
281
+ ? tr("Across your Apple library: {tracks} · {preferred} with positive preferences.", { tracks: tr.n(item.library_tracks, "{count} track", "{count} tracks"), preferred: tr.number(item.preferred_tracks) })
282
+ : tr("Shows up through one of their tracks."));
284
283
  }
285
284
 
286
285
  for (const subject of subjects.values()) {
287
286
  if (subject.kind !== "track") continue;
288
287
  const artist = subjects.get(subjectKey({ entityType: "artist", label: subject.target.artistCredit }));
289
288
  if (artist?.stance !== "avoid") continue;
290
- subject.detailLines.unshift(`You asked me to keep ${artist.label} out.${
291
- subject.stance === "like" ? " That still applies, even though you like this track." : ""
292
- }`);
289
+ subject.detailLines.unshift(subject.stance === "like"
290
+ ? tr("You asked me to keep {artist} out. That still applies, even though you like this track.", { artist: artist.label })
291
+ : tr("You asked me to keep {artist} out.", { artist: artist.label }));
293
292
  if (artist.evidenceId && !subject.evidence.some((evidence) => evidence.id === artist.evidenceId)) {
294
- subject.evidence.push({ id: artist.evidenceId, source: "Your choice for the artist" });
293
+ subject.evidence.push({ id: artist.evidenceId, source: tr("Your choice for the artist") });
295
294
  }
296
295
  }
297
296
 
298
297
  return {
299
- summaryLines: overview(profile),
298
+ summaryLines: overview(profile, tr),
300
299
  subjects: [...subjects.values()],
301
300
  emptyLines: [
302
- "Nothing to look at yet.",
303
- "Import your history or library and it will show up here.",
301
+ tr("Nothing to look at yet."),
302
+ tr("Import your history or library and it will show up here."),
304
303
  ],
305
304
  };
306
305
  }
@@ -9,12 +9,13 @@ import {
9
9
  } from "@earendil-works/pi-tui";
10
10
 
11
11
  import { sanitizeTerminalText } from "./format-output.mjs";
12
+ import { N_, screenTranslator } from "../../i18n/index.mjs";
12
13
 
13
14
  const categories = [
14
- { id: "all", label: "All" },
15
- { id: "track", label: "Tracks" },
16
- { id: "artist", label: "Artists" },
17
- { id: "choices", label: "Your choices" },
15
+ { id: "all", label: N_("All") },
16
+ { id: "track", label: N_("Tracks") },
17
+ { id: "artist", label: N_("Artists") },
18
+ { id: "choices", label: N_("Your choices") },
18
19
  ];
19
20
 
20
21
  function clean(value) {
@@ -41,14 +42,15 @@ function ends(left, right, width) {
41
42
  return head + " ".repeat(width - visibleWidth(head) - tailWidth) + right;
42
43
  }
43
44
 
44
- function stanceLabel(subject) {
45
- return subject?.stance === "like" ? "You like this" : subject?.stance === "avoid" ? "You asked me to keep this out" : "";
45
+ function stanceLabel(subject, tr) {
46
+ return subject?.stance === "like" ? tr("You like this") : subject?.stance === "avoid" ? tr("You asked me to keep this out") : "";
46
47
  }
47
48
 
48
49
  /** A terminal-native, searchable profile with a live reading beside its subjects. */
49
50
  export class TasteProfileView {
50
- constructor({ model, getTheme, getRows }) {
51
+ constructor({ model, getTheme, getRows, getText = () => screenTranslator("en") }) {
51
52
  this.getTheme = getTheme;
53
+ this.getText = getText;
52
54
  this.getRows = getRows;
53
55
  this.input = new Input();
54
56
  this.category = "all";
@@ -76,7 +78,7 @@ export class TasteProfileView {
76
78
  const subjects = Array.isArray(this.model.subjects) ? this.model.subjects : [];
77
79
  this.entries = subjects.filter((subject) => subject && typeof subject === "object").map((subject) => ({
78
80
  subject,
79
- search: [subject.label, subject.subtitle, subject.kind, stanceLabel(subject), ...(subject.detailLines ?? [])]
81
+ search: [subject.label, subject.subtitle, subject.kind, stanceLabel(subject, this.getText()), ...(subject.detailLines ?? [])]
80
82
  .map(inline).join(" ").toLowerCase(),
81
83
  }));
82
84
  this.filterSubjects();
@@ -150,13 +152,14 @@ export class TasteProfileView {
150
152
  }
151
153
 
152
154
  renderCategories(width, theme) {
155
+ const tr = this.getText();
153
156
  if (width < 34) {
154
- const label = categories.find((category) => category.id === this.category)?.label ?? "All";
155
- return theme.bold(`‹ ${label} ›`) + (width >= 24 ? theme.faint(" tab change") : "");
157
+ const label = tr.marked(categories.find((category) => category.id === this.category)?.label ?? "All");
158
+ return theme.bold(`‹ ${label} ›`) + (width >= 24 ? theme.faint(` ${tr("tab change")}`) : "");
156
159
  }
157
160
  return categories.map(({ id, label }) => {
158
- const text = width < 38 && id === "choices" ? "Choices" : label;
159
- return id === this.category ? theme.inverse(theme.bold(` ${text} `)) : theme.muted(text);
161
+ const text = width < 38 && id === "choices" ? tr("Choices") : tr.marked(label);
162
+ return id === this.category ? theme.underline(theme.selected(text)) : theme.muted(text);
160
163
  }).join(width >= 34 ? " " : " ");
161
164
  }
162
165
 
@@ -167,14 +170,15 @@ export class TasteProfileView {
167
170
  const selectedIndex = this.filtered.findIndex((subject) => subject.key === this.selectedKey);
168
171
  const start = Math.max(0, Math.min(selectedIndex - Math.floor(capacity / 2), this.filtered.length - capacity));
169
172
  const lines = [];
173
+ const tr = this.getText();
170
174
  for (const subject of this.filtered.slice(start, start + capacity)) {
171
175
  const selected = subject.key === this.selectedKey;
172
- const marker = selected ? "› " : " ";
173
- const label = inline(subject.label) || (subject.kind === "artist" ? "Artist" : "Track");
176
+ const marker = selected ? "◉ " : " ";
177
+ const label = inline(subject.label) || (subject.kind === "artist" ? tr("Artist") : tr("Track"));
174
178
  const stance = subject.stance === "like" ? "+" : subject.stance === "avoid" ? "−" : "";
175
179
  const main = ends(marker + label, stance, width);
176
- lines.push(selected ? theme.inverse(theme.bold(fit(main, width))) : theme.text(main));
177
- if (!compact && lines.length < height) lines.push(theme.muted(` ${inline(subject.subtitle) || (subject.kind === "artist" ? "Artist" : "Track")}`));
180
+ lines.push(selected ? theme.selected(main) : theme.text(main));
181
+ if (!compact && lines.length < height) lines.push(theme.muted(` ${inline(subject.subtitle) || (subject.kind === "artist" ? tr("Artist") : tr("Track"))}`));
178
182
  }
179
183
  return Array.from({ length: height }, (_, index) => lines[index] ?? "");
180
184
  }
@@ -183,16 +187,17 @@ export class TasteProfileView {
183
187
  if (height <= 0) return [];
184
188
  const subject = this.getSelectedItem();
185
189
  if (!subject) return Array(height).fill("");
186
- const heading = theme.bold(inline(subject.label) || "Selected");
190
+ const tr = this.getText();
191
+ const heading = theme.bold(inline(subject.label) || tr("Selected"));
187
192
  const subtitle = inline(subject.subtitle);
188
- const stance = stanceLabel(subject);
193
+ const stance = stanceLabel(subject, tr);
189
194
  const prefix = [heading];
190
195
  if (subtitle && height >= 4) prefix.push(theme.muted(subtitle));
191
196
  if (stance && height >= 6) prefix.push(theme.accent(stance));
192
197
  if (height >= 8) prefix.push("");
193
198
  const sourceLines = Array.isArray(subject.detailLines) && subject.detailLines.length
194
199
  ? subject.detailLines
195
- : ["Nothing more to show for this one yet."];
200
+ : [tr("Nothing more to show for this one yet.")];
196
201
  const detail = sourceLines.flatMap((line) => wrapTextWithAnsi(clean(line), Math.max(1, width)));
197
202
  const available = Math.max(0, height - prefix.length);
198
203
  const scrolls = detail.length > available;
@@ -204,7 +209,7 @@ export class TasteProfileView {
204
209
  const lines = [...prefix, ...visible];
205
210
  if (scrolls && available >= 2) {
206
211
  const range = `${this.detailOffset + 1}–${Math.min(detail.length, this.detailOffset + contentHeight)}/${detail.length}`;
207
- lines.push(theme.faint(ends("PgUp/PgDn to scroll", range, width)));
212
+ lines.push(theme.faint(ends(tr("PgUp/PgDn to scroll"), range, width)));
208
213
  }
209
214
  return Array.from({ length: height }, (_, index) => lines[index] ?? "");
210
215
  }
@@ -213,11 +218,12 @@ export class TasteProfileView {
213
218
  this.detailOffset = 0;
214
219
  this.detailMaxOffset = 0;
215
220
  const filtered = this.entries.length > 0;
221
+ const tr = this.getText();
216
222
  const lines = filtered
217
- ? ["Nothing matches that.", "", "Try fewer words, or press Tab for another list."]
223
+ ? [tr("Nothing matches that."), "", tr("Try fewer words, or press Tab for another list.")]
218
224
  : (Array.isArray(this.model.emptyLines) && this.model.emptyLines.length
219
225
  ? this.model.emptyLines
220
- : ["Nothing to look at yet.", "", "Import your history or library and it will show up here."]);
226
+ : [tr("Nothing to look at yet."), "", tr("Import your history or library and it will show up here.")]);
221
227
  const wrapped = lines.flatMap((line) => wrapTextWithAnsi(clean(line), width));
222
228
  return Array.from({ length: height }, (_, index) => index === 0
223
229
  ? theme.bold(wrapped[index] ?? "") : theme.muted(wrapped[index] ?? ""));
@@ -233,18 +239,19 @@ export class TasteProfileView {
233
239
  const inner = Math.max(1, columns - inset * 2);
234
240
  const position = this.filtered.findIndex((subject) => subject.key === this.selectedKey) + 1;
235
241
  const count = `${Math.max(0, position)}/${this.filtered.length}`;
236
- const heading = theme.bold(inner >= 24 + count.length ? "Your listening profile" : "Your profile");
242
+ const tr = this.getText();
243
+ const heading = theme.bold(inner >= 24 + count.length ? tr("Your listening profile") : tr("Your profile"));
237
244
  const lines = [ends(heading, theme.muted(count), inner)];
238
245
  if (height >= 10) {
239
246
  const summaries = Array.isArray(this.model.summaryLines) ? this.model.summaryLines : [];
240
- const maxSummary = height >= 22 ? 2 : 1;
247
+ const maxSummary = height >= 22 ? 3 : 1;
241
248
  lines.push(...summaries.slice(0, maxSummary).map((line) => theme.muted(inline(line))));
242
249
  }
243
250
  if (height >= 5) lines.push(inner >= 76
244
- ? ends(this.renderCategories(inner, theme), theme.faint("Ctrl+R refresh · Ctrl+O report"), inner)
251
+ ? ends(this.renderCategories(inner, theme), theme.faint(tr("Ctrl+R refresh · Ctrl+O report")), inner)
245
252
  : this.renderCategories(inner, theme));
246
253
  if (height >= 2) {
247
- const searchLabel = inner >= 26 ? theme.faint("Search ") : "";
254
+ const searchLabel = inner >= 26 ? theme.faint(`${tr("Search")} `) : "";
248
255
  lines.push(searchLabel + this.input.render(Math.max(1, inner - visibleWidth(searchLabel)))[0]);
249
256
  }
250
257
  if (height >= 7) lines.push(theme.faint("─".repeat(inner)));