@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
@@ -6,4 +6,6 @@ export const IMPORT_GUIDE_URLS = Object.freeze({
6
6
  youtube: "https://takeout.google.com/",
7
7
  qq: "https://y.qq.com/",
8
8
  netease: "https://music.163.com/",
9
+ lastfm: "https://www.last.fm/",
10
+ lastfmApi: "https://www.last.fm/api/account/create",
9
11
  });
@@ -0,0 +1,92 @@
1
+ import { sanitizeTerminalText } from "./format-output.mjs";
2
+ import { N_, screenTranslator } from "../../i18n/index.mjs";
3
+
4
+ export const savedProfileActions = new Set(["build", "saved", "explain"]);
5
+
6
+ const kindLabels = { observation: N_("measured"), hypothesis: N_("interpretation"), listener_assertion: N_("your choice") };
7
+
8
+ function builtLine(value, tr) {
9
+ const coverage = value.coverage?.coverage ?? {};
10
+ const concerns = value.verification?.state === "flagged" ? value.verification.checks.at(-1).issues.length : 0;
11
+ const check = value.verification?.state === "revised" ? tr("checked and revised once")
12
+ : concerns ? tr.n(concerns, "checked; one concern remains", "checked; {count} concerns remain") : tr("checked");
13
+ const built = [
14
+ tr("Built {date}", { date: value.built_at.slice(0, 10) }),
15
+ ...(Number.isInteger(coverage.effective_listening_events) ? [tr.n(coverage.effective_listening_events, "from {count} play", "from {count} plays")] : []),
16
+ ].join(" ");
17
+ const span = coverage.earliest_played_at && coverage.latest_played_at
18
+ ? `, ${tr("{first} to {last}", { first: coverage.earliest_played_at.slice(0, 10), last: coverage.latest_played_at.slice(0, 10) })}` : "";
19
+ return `${built}${span} · ${check}`;
20
+ }
21
+
22
+ function formatSaved(value, prefix, allFindings = false, tr) {
23
+ if (value.state === "missing") return tr("No saved reading yet. Use {prefix} build with a connected model.", { prefix });
24
+ const lines = [
25
+ `# ${tr("Your saved listening profile · v{version}", { version: value.sequence })}`,
26
+ value.state === "stale" ? tr("Evidence, choices or analysis changed. Use {prefix} build to update this reading.", { prefix }) : builtLine(value, tr),
27
+ "", value.summary, "",
28
+ ];
29
+ // The TUI renders this text as Markdown, which renumbers an ordered list from its
30
+ // first item. Ascending finding numbers keep the shown numbers valid for explain.
31
+ const displayed = allFindings ? value.claims : [...new Map([
32
+ ...(value.highlights ?? []), ...(value.listener_assertions ?? []),
33
+ ].map(claim => [claim.claim_id, claim])).values()].sort((a, b) => a.finding_number - b.finding_number);
34
+ for (const [index, claim] of displayed.entries()) {
35
+ lines.push(`${claim.finding_number ?? value.offset + index + 1}. ${claim.statement}`,
36
+ ` ${kindLabels[claim.kind] ? tr.marked(kindLabels[claim.kind]) : claim.kind.replaceAll("_", " ")} · ${claim.scope} · ${claim.uncertainty}`);
37
+ }
38
+ if (value.state === "current" && value.verification?.state === "flagged") {
39
+ lines.push("", tr("The final check still flagged:"), ...value.verification.checks.at(-1).issues.map(issue => `- ${issue.problem}`));
40
+ }
41
+ lines.push("", tr("Use {prefix} explain <number> for a finding's evidence.", { prefix }));
42
+ if (!allFindings && value.total_claims > displayed.length) lines.push(tr("All {count} findings: {prefix} saved 0", { count: value.total_claims, prefix }));
43
+ else if (allFindings && value.next_offset !== null) lines.push(tr("More findings: {prefix} saved {offset}", { prefix, offset: value.next_offset }));
44
+ return lines.join("\n");
45
+ }
46
+
47
+ function formatEvidence(entry) {
48
+ const { data } = entry;
49
+ const label = data.label ?? data.name ?? data.title ?? data.year ?? data.evidence_kind ?? entry.section;
50
+ const fields = Object.entries(data).filter(([key]) => !["label", "name", "title"].includes(key))
51
+ .map(([key, value]) => `${key.replaceAll("_", " ")}: ${typeof value === "object" ? JSON.stringify(value) : value}`);
52
+ return `- ${label} (${entry.section.replaceAll("_", " ")})\n ${fields.join(" · ")}`;
53
+ }
54
+
55
+ export async function runListenerProfileCommand({
56
+ application, args, json = false, output = process.stdout, commandPrefix = "moondog profile",
57
+ signal, onProgress, runtimeFactory, tr = screenTranslator("en"),
58
+ } = {}) {
59
+ const [action, ...values] = args ?? [];
60
+ let result;
61
+ let rendered;
62
+ if (action === "build") {
63
+ if (values.length > 1 || (values.length === 1 && values[0] !== "--force")) {
64
+ throw new Error(tr("Usage: {prefix} build [--force].", { prefix: commandPrefix }));
65
+ }
66
+ result = await application.buildListenerProfile({ force: values[0] === "--force", signal, onProgress, runtimeFactory });
67
+ rendered = result.state === "no_evidence" ? tr("Import your music first with /import.")
68
+ : `${result.state === "unchanged" ? tr("Your saved reading already matches the current evidence.") : tr("Saved your new listening profile.")}\n\n${formatSaved(result.revision, commandPrefix, false, tr)}`;
69
+ } else if (action === "saved") {
70
+ if (values.length > 1 || (values.length === 1 && !/^\d+$/u.test(values[0]))) {
71
+ throw new Error(tr("Usage: {prefix} saved [offset].", { prefix: commandPrefix }));
72
+ }
73
+ result = await application.getListenerProfile({ offset: Number(values[0] ?? 0) });
74
+ rendered = formatSaved(result, commandPrefix, values.length > 0, tr);
75
+ } else if (action === "explain") {
76
+ if (values.length !== 1 || !/^[1-9]\d*$/u.test(values[0]) || !Number.isSafeInteger(Number(values[0]))) {
77
+ throw new Error(tr("Usage: {prefix} explain <finding-number>.", { prefix: commandPrefix }));
78
+ }
79
+ const page = await application.getListenerProfile({ offset: Number(values[0]) - 1, limit: 1 });
80
+ if (!page.claims.length) throw new Error(tr("That finding is not in your saved profile. Open /profile saved first."));
81
+ result = await application.getListenerProfile({ claimId: page.claims[0].claim_id, revisionId: page.revision_id });
82
+ rendered = [
83
+ `# ${result.state === "stale" ? tr("Finding {number} · profile v{version} · needs update", { number: values[0], version: result.sequence })
84
+ : tr("Finding {number} · profile v{version}", { number: values[0], version: result.sequence })}`,
85
+ result.claim.statement, "", tr("Scope: {scope}", { scope: result.claim.scope }), tr("Uncertainty: {uncertainty}", { uncertainty: result.claim.uncertainty }),
86
+ "", tr("Supporting evidence:"), ...result.supporting_evidence.map(formatEvidence),
87
+ ...(result.contradicting_evidence.length ? ["", tr("Conflicting evidence:"), ...result.contradicting_evidence.map(formatEvidence)] : []),
88
+ ].join("\n");
89
+ } else throw new Error("Unknown saved profile command");
90
+ output.write(`${sanitizeTerminalText(json ? JSON.stringify(result, null, 2) : rendered)}\n`);
91
+ return result;
92
+ }
@@ -5,6 +5,7 @@ import {
5
5
  truncateToWidth,
6
6
  visibleWidth,
7
7
  } from "@earendil-works/pi-tui";
8
+ import { screenTranslator } from "../../i18n/index.mjs";
8
9
 
9
10
  function paintLine(value, width, theme) {
10
11
  const clipped = truncateToWidth(value, width, "");
@@ -16,7 +17,8 @@ function paintLine(value, width, theme) {
16
17
 
17
18
  /** A searchable Pi menu that keeps its own contents inside the viewport. */
18
19
  export class ListeningMenu {
19
- constructor({ items, title, getTheme, getRows, currentValue }) {
20
+ constructor({ items, title, getTheme, getRows, currentValue, getText = () => screenTranslator("en") }) {
21
+ this.getText = getText;
20
22
  this.items = items;
21
23
  this.title = title;
22
24
  this.getTheme = getTheme;
@@ -125,9 +127,10 @@ export class ListeningMenu {
125
127
  const title = theme.bold(truncateToWidth(this.title, titleWidth, ""));
126
128
  const heading = title + " ".repeat(Math.max(1, inner - visibleWidth(title) - count.length)) + theme.muted(count);
127
129
  const search = this.input.render(inner)[0];
130
+ const tr = this.getText();
128
131
  const hint = theme.muted(inner >= 55
129
- ? "type to filter ↑↓ choose enter select esc back"
130
- : inner >= 29 ? "↑↓ choose enter ↵ esc back" : "↑↓ ↵ esc");
132
+ ? tr("type to filter ↑↓ choose enter select esc back")
133
+ : inner >= 29 ? tr("↑↓ choose enter ↵ esc back") : "↑↓ ↵ esc");
131
134
  let lines;
132
135
  if (budget < 4) {
133
136
  // An exceptionally short terminal has no room for separate chrome rows.
@@ -144,7 +147,7 @@ export class ListeningMenu {
144
147
  // The title owns the item count; omit SelectList's additional scroll-info row.
145
148
  const candidates = this.filteredItems.length
146
149
  ? this.select.render(inner).slice(0, this.capacity)
147
- : [theme.muted("Nothing matches. Backspace to widen the search.")];
150
+ : [theme.muted(tr("Nothing matches. Backspace to widen the search."))];
148
151
  let labels = [heading, search];
149
152
  if (compactBox) {
150
153
  const labelWidth = Math.max(1, Math.floor(inner / 2));
@@ -1,3 +1,4 @@
1
+ import { screenTranslator } from "../../i18n/index.mjs";
1
2
  import { access } from "node:fs/promises";
2
3
 
3
4
  import {
@@ -11,6 +12,10 @@ export function profileCorrectionHelpText(commandPrefix = "moondog profile") {
11
12
 
12
13
  Commands:
13
14
 
15
+ - \`${commandPrefix} build [--force]\` - build or resume a saved reading with your connected model
16
+ - \`${commandPrefix} saved [offset]\` - read saved findings, including after a restart
17
+ - \`${commandPrefix} explain <number>\` - inspect a saved finding's evidence
18
+
14
19
  - \`${commandPrefix} corrections [--all] [--json]\` - everything you've told me; add --all to include earlier choices
15
20
  - \`${commandPrefix} correct --artist <name> (--like|--avoid) [--note <text>] [--json]\` - like an artist, or keep them out
16
21
  - \`${commandPrefix} correct --track <title> --by <artist> (--like|--avoid) [--note <text>] [--json]\` - like a track, or keep it out
@@ -107,14 +112,14 @@ function writeLine(output, value = "") {
107
112
  output.write(`${sanitizeTerminalText(value)}\n`);
108
113
  }
109
114
 
110
- function formatCorrections(value) {
115
+ function formatCorrections(value, tr) {
111
116
  const lines = [
112
- "# What you've told me",
117
+ `# ${tr("What you've told me")}`,
113
118
  "",
114
- `${value.active} ${value.active === 1 ? "choice" : "choices"} in effect.`,
119
+ tr.n(value.active, "{count} choice in effect.", "{count} choices in effect."),
115
120
  ];
116
121
  if (value.corrections.length === 0) {
117
- lines.push("", "Nothing yet. Open a song or artist in /taste to like it or keep it out.");
122
+ lines.push("", tr("Nothing yet. Open a song or artist in /taste to like it or keep it out."));
118
123
  return lines.join("\n");
119
124
  }
120
125
  lines.push("");
@@ -122,8 +127,11 @@ function formatCorrections(value) {
122
127
  const target = item.artist_credit
123
128
  ? `${item.label} - ${item.artist_credit}`
124
129
  : item.label;
130
+ const choice = item.entity_type === "track"
131
+ ? item.stance === "avoid" ? tr("Keep out track") : tr("Like track")
132
+ : item.stance === "avoid" ? tr("Keep out artist") : tr("Like artist");
125
133
  lines.push(
126
- `- ${item.stance === "avoid" ? "Keep out" : "Like"} ${item.entity_type}: ${target}${item.state === "active" ? "" : ` (${item.state})`}`,
134
+ `- ${choice}: ${target}${item.state === "active" ? "" : ` (${item.state})`}`,
127
135
  ` ${String(item.occurred_at).slice(0, 10)} · \`${item.correction_id}\``,
128
136
  );
129
137
  if (item.note) lines.push(` "${item.note}"`);
@@ -131,22 +139,22 @@ function formatCorrections(value) {
131
139
  return lines.join("\n");
132
140
  }
133
141
 
134
- function formatCorrection(value, commandPrefix) {
142
+ function formatCorrection(value, commandPrefix, tr) {
135
143
  const target = value.artist_credit
136
144
  ? `${value.label} - ${value.artist_credit}`
137
145
  : value.label;
138
146
  return [
139
- value.stance === "avoid" ? `Noted. I'll keep ${target} out.` : `Noted. You like ${target}.`,
140
- ...(value.superseded_correction_id ? ["This replaces what you told me before about it."] : []),
141
- "Your profile is updated. Your listening history stays as it was.",
142
- `To undo it: ${commandPrefix} retract ${value.correction_id}`,
147
+ value.stance === "avoid" ? tr("Noted. I'll keep {name} out.", { name: target }) : tr("Noted. You like {name}.", { name: target }),
148
+ ...(value.superseded_correction_id ? [tr("This replaces what you told me before about it.")] : []),
149
+ tr("Your profile is updated. Your listening history stays as it was."),
150
+ tr("To undo it: {command}", { command: `${commandPrefix} retract ${value.correction_id}` }),
143
151
  ].join("\n");
144
152
  }
145
153
 
146
- function formatRetraction(value) {
154
+ function formatRetraction(value, tr) {
147
155
  return [
148
- `Undone. I'll read ${value.label} from your listening again.`,
149
- "Your profile is updated. Your listening history stays as it was.",
156
+ tr("Undone. I'll read {name} from your listening again.", { name: value.label }),
157
+ tr("Your profile is updated. Your listening history stays as it was."),
150
158
  ].join("\n");
151
159
  }
152
160
 
@@ -169,6 +177,7 @@ export async function runProfileCommand({
169
177
  openStore = openListeningHistoryStore,
170
178
  now = () => new Date(),
171
179
  commandPrefix = "moondog profile",
180
+ tr = screenTranslator("en"),
172
181
  } = {}) {
173
182
  const options = parseProfileArguments(args ?? []);
174
183
  if (!options.action || options.action === "help") {
@@ -193,11 +202,11 @@ export async function runProfileCommand({
193
202
  corrections: [],
194
203
  writes: "none",
195
204
  };
196
- writeLine(output, json ? JSON.stringify(empty, null, 2) : formatCorrections(empty));
205
+ writeLine(output, json ? JSON.stringify(empty, null, 2) : formatCorrections(empty, tr));
197
206
  return empty;
198
207
  }
199
208
  if (options.action === "retract" && !databasePresent) {
200
- throw new Error("There's nothing to undo yet.");
209
+ throw new Error(tr("There's nothing to undo yet."));
201
210
  }
202
211
  if (
203
212
  options.action === "correct" &&
@@ -206,7 +215,7 @@ export async function runProfileCommand({
206
215
  preferredSubjectId = await resolvePreferredSubjectId();
207
216
  if (!preferredSubjectId) {
208
217
  throw new Error(
209
- "There's no profile to change yet. Bring in your music with /import first.",
218
+ tr("There's no profile to change yet. Bring in your music with /import first."),
210
219
  );
211
220
  }
212
221
  }
@@ -224,7 +233,7 @@ export async function runProfileCommand({
224
233
  };
225
234
  writeLine(
226
235
  output,
227
- json ? JSON.stringify(empty, null, 2) : formatCorrections(empty),
236
+ json ? JSON.stringify(empty, null, 2) : formatCorrections(empty, tr),
228
237
  );
229
238
  return empty;
230
239
  }
@@ -236,7 +245,7 @@ export async function runProfileCommand({
236
245
  }
237
246
  if (!subjectId) {
238
247
  throw new Error(
239
- "There's no profile to change yet. Bring in your music with /import first.",
248
+ tr("There's no profile to change yet. Bring in your music with /import first."),
240
249
  );
241
250
  }
242
251
  let value;
@@ -278,10 +287,10 @@ export async function runProfileCommand({
278
287
  json
279
288
  ? JSON.stringify(value, null, 2)
280
289
  : options.action === "corrections"
281
- ? formatCorrections(value)
290
+ ? formatCorrections(value, tr)
282
291
  : options.action === "correct"
283
- ? formatCorrection(value, commandPrefix)
284
- : formatRetraction(value),
292
+ ? formatCorrection(value, commandPrefix, tr)
293
+ : formatRetraction(value, tr),
285
294
  );
286
295
  return value;
287
296
  } finally {
@@ -0,0 +1,87 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ import { formatListeningTime, sanitizeTerminalText } from "./format-output.mjs";
4
+
5
+ // The last twelve quarters as bars: listening time per quarter, or plays when the history
6
+ // didn't record how long every play lasted, with its new songs and leading artist beside
7
+ // each bar. Every terminal draws the bars with block characters.
8
+ // In Moondog's own window (MOONDOG_HOST=app), each bar row also carries an invisible
9
+ // OSC 7717 mark: block id, row, bar width, and the chart's numbers, so any row that is
10
+ // in view is enough. The window paints graphics over exactly those cells; the labels
11
+ // and details stay terminal text. A marked region keeps a blank cell on each side,
12
+ // which the graphics may bleed into.
13
+ export const BLOCK_MARK = 7717;
14
+ const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
15
+ let charts = 0;
16
+
17
+ function textBar(fraction, width) {
18
+ const eighths = Math.round(Math.max(0, Math.min(1, fraction)) * width * 8);
19
+ return "█".repeat(Math.floor(eighths / 8)) + EIGHTHS[eighths % 8];
20
+ }
21
+
22
+ function inline(value) {
23
+ return sanitizeTerminalText(String(value ?? "")).replace(/\s+/gu, " ").trim();
24
+ }
25
+
26
+ export class SeasonsChart {
27
+ constructor(seasons, { getTheme, tr, environment = process.env }) {
28
+ this.seasons = seasons.filter((season) => /^\d{4}-Q[1-4]$/u.test(season?.key ?? "")).slice(-12);
29
+ this.getTheme = getTheme;
30
+ this.tr = tr;
31
+ this.marked = environment.MOONDOG_HOST === "app";
32
+ this.id = `seasons-${(charts += 1)}`;
33
+ }
34
+
35
+ invalidate() {}
36
+
37
+ render(width) {
38
+ const theme = this.getTheme();
39
+ const tr = this.tr;
40
+ // Minutes compare quarters only when every quarter with plays has them. Otherwise every
41
+ // bar counts plays: Last.fm and ListenBrainz record no play lengths, and unknown isn't zero.
42
+ const timed = this.seasons.every((season) => !(season.event_count > 0) || Number.isFinite(season.listening_minutes));
43
+ const amounts = this.seasons.map((season) => {
44
+ const amount = timed ? season.listening_minutes : season.event_count;
45
+ return Number.isFinite(amount) ? amount : 0;
46
+ });
47
+ const most = Math.max(1, ...amounts);
48
+ const details = this.seasons.map((season) => {
49
+ if (!Number.isInteger(season.event_count) || season.event_count === 0) return tr("nothing in your history");
50
+ const artist = inline(season.leading_artist?.name);
51
+ return [
52
+ timed ? formatListeningTime(season.listening_minutes, tr) : tr.n(season.event_count, "{count} play", "{count} plays"),
53
+ season.first_observed_tracks > 0 ? tr("{count} new", { count: tr.number(season.first_observed_tracks) }) : null,
54
+ artist || null,
55
+ ].filter(Boolean).join(" · ");
56
+ });
57
+ const detailWidth = Math.min(28, Math.max(...details.map((detail) => visibleWidth(detail))));
58
+ // " 2024 Q3 bars details": the bars take what the label and a short detail leave.
59
+ const barWidth = Math.max(8, Math.min(30, width - 1 - 7 - 2 - 2 - detailWidth));
60
+ const payload = btoa(JSON.stringify({
61
+ kind: "seasons",
62
+ measure: timed ? "minutes" : "plays",
63
+ rows: this.seasons.map((season, row) => ({
64
+ quarter: Number(season.key.slice(-1)),
65
+ amount: amounts[row],
66
+ fraction: amounts[row] / most,
67
+ newSongs: Number.isInteger(season.first_observed_tracks) ? season.first_observed_tracks : 0,
68
+ empty: !(season.event_count > 0),
69
+ })),
70
+ colors: {
71
+ ink: theme.palette.foreground,
72
+ muted: theme.palette.muted,
73
+ faint: theme.palette.faint,
74
+ paper: theme.palette.background,
75
+ },
76
+ }));
77
+ return this.seasons.map((season, row) => {
78
+ const [year, quarter] = season.key.split("-");
79
+ // The year shows where it changes, so the column reads as a calendar.
80
+ const label = row === 0 || quarter === "Q1" ? `${year} ${quarter}` : ` ${quarter}`;
81
+ const bar = amounts[row] > 0 ? textBar(amounts[row] / most, barWidth) : "·";
82
+ const mark = this.marked ? `\x1b]${BLOCK_MARK};${this.id};${row};${barWidth};${payload}\x07` : "";
83
+ const line = ` ${theme.muted(label)} ${mark}${theme.text(bar)}${" ".repeat(Math.max(0, barWidth - visibleWidth(bar)))} ${theme.muted(details[row])}`;
84
+ return truncateToWidth(line, width, "");
85
+ });
86
+ }
87
+ }
@@ -16,9 +16,10 @@ import {
16
16
  projectSpotifyRecentActivity,
17
17
  } from "../../integrations/spotify/recent-activity.mjs";
18
18
  import { readSpotifyHistoryArchive } from "../../integrations/spotify/history-archive.mjs";
19
+ import { projectSpotifyLibrarySnapshot } from "../../integrations/spotify/library-snapshot.mjs";
19
20
  import { sanitizeTerminalText } from "./format-output.mjs";
20
21
 
21
- const readCommands = new Set(["account", "now", "devices", "queue", "recent"]);
22
+ const readCommands = new Set(["account", "now", "devices", "queue", "recent", "library"]);
22
23
  const writeCommands = new Set([
23
24
  "play",
24
25
  "pause",
@@ -50,6 +51,8 @@ Commands:
50
51
  - \`moondog spotify account|now|devices|queue [--json]\`
51
52
  - \`moondog spotify recent [--limit <1-50>] [--after <ms>|--before <ms>] [--json]\`
52
53
  - \`moondog spotify sync-recent [--limit <1-50>] [--json]\`
54
+ - \`moondog spotify library [--json]\`
55
+ - \`moondog spotify sync-library [--json]\`
53
56
  - \`moondog spotify import-history <spotify-history.zip> [--json]\`
54
57
  - \`moondog spotify play [spotify-uri] [--json]\`
55
58
  - \`moondog spotify pause|next|previous [--json]\`
@@ -260,7 +263,7 @@ function formatPlain(action, value) {
260
263
  `Redirect URI: ${SPOTIFY_DEFAULT_REDIRECT_URI}`,
261
264
  ];
262
265
  if (value.state === "stored") {
263
- const purpose = value.scope_purpose === "recent_listening" ? "reading recent plays" : "playback and playlists";
266
+ const purpose = value.scope_purpose === "recent_listening" ? "reading recent plays" : "playback, playlists and top artists/tracks";
264
267
  lines.push(
265
268
  value.scopes_sufficient
266
269
  ? `Permissions for ${purpose}: all set`
@@ -351,6 +354,26 @@ function formatPlain(action, value) {
351
354
  `- ${value.inserted_track_refs ?? 0} songs new to your profile`,
352
355
  ].join("\n");
353
356
  }
357
+ if (action === "library") {
358
+ return [
359
+ "# Your Spotify library",
360
+ `- ${value.saved_tracks?.length ?? 0} saved songs, ${value.saved_albums?.length ?? 0} saved albums`,
361
+ `- ${value.followed_artists?.length ?? 0} followed artists`,
362
+ `- ${value.playlists?.length ?? 0} of your own playlists`,
363
+ `- Top artists and tracks for the last four weeks, six months and year`,
364
+ ].join("\n");
365
+ }
366
+ if (action === "sync-library") {
367
+ if (value.already_imported) return "Your Spotify library hasn't changed since the last sync.";
368
+ return [
369
+ "# Your Spotify library is in",
370
+ `- ${value.preview.savedTracks} saved songs, ${value.preview.savedAlbums} saved albums, ${value.preview.followedArtists} followed artists`,
371
+ `- ${value.preview.playlistTracks} songs across ${value.preview.playlists} of your own playlists`,
372
+ `- ${value.preview.topArtists} top artists and ${value.preview.topTracks} top tracks from Spotify`,
373
+ ...(value.preview.truncated.length ? [`- Very large library: stopped reading ${value.preview.truncated.join(", ").replaceAll("_", " ")} at Moondog's limit`] : []),
374
+ ...(value.preview.skippedPlaylists ? [`- ${value.preview.skippedPlaylists} playlists couldn't be read and were skipped`] : []),
375
+ ].join("\n");
376
+ }
354
377
  if (action === "import-history") {
355
378
  const extended =
356
379
  value.data_scope === "lifetime_extended_streaming_history";
@@ -478,7 +501,8 @@ function createRuntimeResolver({
478
501
  const auth = await this.authentication();
479
502
  client = createSpotifyWebApiClient({
480
503
  fetchImpl,
481
- tokenProvider: () => auth.getAccessToken(),
504
+ tokenProvider: (options) => auth.getAccessToken(options),
505
+ refreshAccessToken: (options) => auth.refreshAccessToken(options),
482
506
  });
483
507
  return client;
484
508
  },
@@ -507,11 +531,12 @@ function createRuntimeResolver({
507
531
  };
508
532
  }
509
533
 
510
- async function runReadCommand(action, service, input) {
534
+ async function runReadCommand(action, service, input, { signal, onProgress } = {}) {
511
535
  if (action === "account") return service.account();
512
536
  if (action === "now") return service.currentPlayer();
513
537
  if (action === "devices") return service.devices();
514
538
  if (action === "recent") return service.recentActivity(input);
539
+ if (action === "library") return service.librarySnapshot({ signal, onProgress });
515
540
  return service.queue();
516
541
  }
517
542
 
@@ -601,6 +626,7 @@ export async function runSpotifyCommand({
601
626
  spotify,
602
627
  signalTarget = process,
603
628
  signal,
629
+ onProgress,
604
630
  } = {}) {
605
631
  const options = parseArguments(args, json);
606
632
  const action = options.action;
@@ -617,6 +643,7 @@ export async function runSpotifyCommand({
617
643
  "logout",
618
644
  "resolve",
619
645
  "sync-recent",
646
+ "sync-library",
620
647
  "import-history",
621
648
  ...readCommands,
622
649
  ...writeCommands,
@@ -793,7 +820,7 @@ export async function runSpotifyCommand({
793
820
  : (exactArguments(action, options.rest, 0), undefined);
794
821
  const service = await runtime.service();
795
822
  const result = await externalCall(
796
- () => runReadCommand(action, service, input),
823
+ () => runReadCommand(action, service, input, { signal, onProgress }),
797
824
  `Spotify ${action} could not be read.`,
798
825
  );
799
826
  emit(stdout, action, result, options.json);
@@ -814,10 +841,12 @@ export async function runSpotifyCommand({
814
841
  const service = await runtime.service();
815
842
  const page = await externalCall(
816
843
  () =>
844
+ // Read Spotify's whole window: the cursor moves past whatever a shorter read leaves out.
817
845
  service.recentActivity({
846
+ limit: 50,
818
847
  ...input,
819
848
  ...(cursor !== null ? { after: cursor } : {}),
820
- }),
849
+ }, { signal }),
821
850
  "Spotify recent activity could not be read.",
822
851
  );
823
852
  const currentTime = now();
@@ -839,6 +868,25 @@ export async function runSpotifyCommand({
839
868
  return result;
840
869
  }
841
870
 
871
+ if (action === "sync-library") {
872
+ exactArguments(action, options.rest, 0);
873
+ if (!recentActivityStore || typeof recentActivityStore.ingestImport !== "function") {
874
+ throw commandError("Spotify library storage is unavailable.");
875
+ }
876
+ if (typeof subjectId !== "string" || !subjectId.trim()) {
877
+ throw commandError("A trusted local music subject is required for a library sync.");
878
+ }
879
+ const service = await runtime.service();
880
+ const snapshot = await externalCall(() => service.librarySnapshot({ signal, onProgress }), "Your Spotify library could not be read.");
881
+ const currentTime = now();
882
+ if (!Number.isFinite(currentTime)) throw commandError("Spotify library capture time is unavailable.");
883
+ const projected = projectSpotifyLibrarySnapshot({ subjectId, snapshot, capturedAt: new Date(currentTime).toISOString() });
884
+ const receipt = await externalCall(() => recentActivityStore.ingestImport(projected.bundle), "Your Spotify library could not be stored.");
885
+ const result = { ...receipt, preview: projected.preview };
886
+ emit(stdout, action, result, options.json);
887
+ return result;
888
+ }
889
+
842
890
  const service = await runtime.service();
843
891
  const result = await externalCall(
844
892
  () => runWriteCommand(action, options.rest, service),